From 5cb0ca679934a2bea0ad6b72058429e2434f20b8 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 19:04:16 +0800 Subject: [PATCH 1/4] feat: leverage with paraswap adapter --- AGENTS.md | 1 + biome.jsonc | 3 + src/abis/bundlerV3.ts | 108 ++++ src/abis/morphoGeneralAdapterV1.ts | 78 +++ src/abis/paraswapAdapter.ts | 31 ++ src/hooks/leverage/types.ts | 9 +- src/hooks/useDeleverageQuote.ts | 4 +- src/hooks/useDeleverageTransaction.ts | 21 +- src/hooks/useLeverageQuote.ts | 121 ++++- src/hooks/useLeverageSupport.ts | 36 +- src/hooks/useLeverageTransaction.ts | 490 +++++++++++++----- .../add-collateral-and-leverage.tsx | 41 +- .../remove-collateral-and-deleverage.tsx | 2 +- src/modals/leverage/leverage-modal.tsx | 18 +- 14 files changed, 789 insertions(+), 174 deletions(-) create mode 100644 src/abis/bundlerV3.ts create mode 100644 src/abis/morphoGeneralAdapterV1.ts create mode 100644 src/abis/paraswapAdapter.ts diff --git a/AGENTS.md b/AGENTS.md index 4e762b8c..8231c3b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,7 @@ When touching transaction and position flows, validation MUST include all releva 12. **Aggregator API contract integrity**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders, normalize fetch/network failures into typed API errors, and verify returned route token addresses match requested canonical token addresses before using previews/tx payloads. 13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. 14. **Input/state integrity in tx-critical UIs**: never strip unsupported numeric syntax into a different value (e.g. `1e-6` must be rejected, not rewritten), and after any balance refetch re-derive selected token objects from refreshed data before allowing `Max`/submit. +15. **Swap leverage flashloan callback integrity**: Bundler3 swap leverage must use adapter flashloan callbacks (not pre-swap borrow gating), with `callbackHash`/`reenter` wiring and adapter token flows matching on-chain contracts; before submit, verify Velora quote/tx parity (route src amount + Paraswap calldata exact/min offsets) so previewed borrow/collateral amounts cannot drift from executed inputs. ### REQUIRED: Regression Rule Capture diff --git a/biome.jsonc b/biome.jsonc index 8c57af7b..48a90204 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -59,6 +59,9 @@ // Object shorthand - disable for gentle migration "useConsistentObjectDefinitions": "off", + // Catch mutable declarations that can be const + "useConst": "error", + // ENABLED - Safe, auto-fixable style improvements "useImportType": { "level": "warn", diff --git a/src/abis/bundlerV3.ts b/src/abis/bundlerV3.ts new file mode 100644 index 00000000..7d9395ef --- /dev/null +++ b/src/abis/bundlerV3.ts @@ -0,0 +1,108 @@ +import type { Abi } from 'viem'; + +/** + * Minimal Bundler3 ABI for multicall + callback reentry. + */ +export const bundlerV3Abi = [ + { + type: 'function', + stateMutability: 'view', + name: 'callbackHash', + inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + }, + { + type: 'function', + stateMutability: 'view', + name: 'initiator', + inputs: [], + outputs: [{ internalType: 'address', name: '', type: 'address' }], + }, + { + type: 'function', + stateMutability: 'view', + name: 'reenterHash', + inputs: [], + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + }, + { + type: 'function', + stateMutability: 'payable', + name: 'multicall', + inputs: [ + { + internalType: 'struct Call[]', + name: 'bundle', + type: 'tuple[]', + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + internalType: 'bool', + name: 'skipRevert', + type: 'bool', + }, + { + internalType: 'bytes32', + name: 'callbackHash', + type: 'bytes32', + }, + ], + }, + ], + outputs: [], + }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'reenter', + inputs: [ + { + internalType: 'struct Call[]', + name: 'bundle', + type: 'tuple[]', + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + internalType: 'bool', + name: 'skipRevert', + type: 'bool', + }, + { + internalType: 'bytes32', + name: 'callbackHash', + type: 'bytes32', + }, + ], + }, + ], + outputs: [], + }, +] as const satisfies Abi; diff --git a/src/abis/morphoGeneralAdapterV1.ts b/src/abis/morphoGeneralAdapterV1.ts new file mode 100644 index 00000000..462e0cec --- /dev/null +++ b/src/abis/morphoGeneralAdapterV1.ts @@ -0,0 +1,78 @@ +import type { Abi } from 'viem'; + +const marketParamsTuple = { + internalType: 'struct MarketParams', + name: 'marketParams', + type: 'tuple', + 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' }, + ], +} as const; + +/** + * Minimal GeneralAdapter1 ABI needed for swap-backed leverage. + */ +export const morphoGeneralAdapterV1Abi = [ + { + type: 'function', + stateMutability: 'nonpayable', + name: 'erc20Transfer', + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + outputs: [], + }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'erc20TransferFrom', + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + outputs: [], + }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'morphoSupplyCollateral', + inputs: [ + marketParamsTuple, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'address', name: 'onBehalf', type: 'address' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'morphoBorrow', + inputs: [ + marketParamsTuple, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'uint256', name: 'minSharePriceE27', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + ], + outputs: [], + }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'morphoFlashLoan', + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + outputs: [], + }, +] as const satisfies Abi; diff --git a/src/abis/paraswapAdapter.ts b/src/abis/paraswapAdapter.ts new file mode 100644 index 00000000..2708a8dd --- /dev/null +++ b/src/abis/paraswapAdapter.ts @@ -0,0 +1,31 @@ +import type { Abi } from 'viem'; + +/** + * Minimal ParaswapAdapter ABI for Bundler3 swap legs. + */ +export const paraswapAdapterAbi = [ + { + type: 'function', + stateMutability: 'nonpayable', + name: 'sell', + inputs: [ + { internalType: 'address', name: 'augustus', type: 'address' }, + { internalType: 'bytes', name: 'callData', type: 'bytes' }, + { internalType: 'address', name: 'srcToken', type: 'address' }, + { internalType: 'address', name: 'destToken', type: 'address' }, + { internalType: 'bool', name: 'sellEntireBalance', type: 'bool' }, + { + components: [ + { internalType: 'uint256', name: 'exactAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'limitAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'quotedAmount', type: 'uint256' }, + ], + internalType: 'struct Offsets', + name: 'offsets', + type: 'tuple', + }, + { internalType: 'address', name: 'receiver', type: 'address' }, + ], + outputs: [], + }, +] as const satisfies Abi; diff --git a/src/hooks/leverage/types.ts b/src/hooks/leverage/types.ts index d1b8f816..c0e1ca9b 100644 --- a/src/hooks/leverage/types.ts +++ b/src/hooks/leverage/types.ts @@ -6,7 +6,14 @@ export type Erc4626LeverageRoute = { underlyingLoanToken: Address; }; -export type LeverageRoute = Erc4626LeverageRoute; +export type SwapLeverageRoute = { + kind: 'swap'; + bundler3Address: Address; + generalAdapterAddress: Address; + paraswapAdapterAddress: Address; +}; + +export type LeverageRoute = Erc4626LeverageRoute | SwapLeverageRoute; export type LeverageSupport = { isSupported: boolean; diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index 5523a552..deceb7a9 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -2,11 +2,11 @@ import { useMemo } from 'react'; import { useReadContract } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; import { withSlippageCeil } from './leverage/math'; -import type { LeverageRoute } from './leverage/types'; +import type { Erc4626LeverageRoute } from './leverage/types'; type UseDeleverageQuoteParams = { chainId: number; - route: LeverageRoute | null; + route: Erc4626LeverageRoute | null; withdrawCollateralAmount: bigint; currentBorrowAssets: bigint; }; diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index 2eeb8641..41a5b571 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -10,15 +10,16 @@ import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; import { withSlippageFloor } from './leverage/math'; -import type { LeverageRoute } from './leverage/types'; +import type { Erc4626LeverageRoute } from './leverage/types'; export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx' | 'execute'; type UseDeleverageTransactionProps = { market: Market; - route: LeverageRoute | null; + route: Erc4626LeverageRoute | null; withdrawCollateralAmount: bigint; flashLoanAmount: bigint; repayBySharesAmount: bigint; @@ -244,8 +245,9 @@ export function useDeleverageTransaction({ } catch (error: unknown) { tracking.fail(); console.error('Error during deleverage execution:', error); - if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { - toast.error('Deleverage Failed', 'An unexpected error occurred during deleverage.'); + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'An unexpected error occurred during deleverage.'); + if (userFacingMessage !== 'User rejected transaction.') { + toast.error('Deleverage Failed', userFacingMessage); } } }, [ @@ -289,14 +291,9 @@ export function useDeleverageTransaction({ } catch (error: unknown) { console.error('Error in authorizeAndDeleverage:', error); tracking.fail(); - if (error instanceof Error) { - if (error.message.includes('User rejected')) { - toast.error('Transaction rejected', 'Transaction rejected by user'); - } else { - toast.error('Error', 'Failed to process deleverage transaction'); - } - } else { - toast.error('Error', 'An unexpected error occurred'); + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process deleverage transaction'); + if (userFacingMessage !== 'User rejected transaction.') { + toast.error('Error', userFacingMessage); } } }, [account, usePermit2Setting, tracking, getStepsForFlow, market, withdrawCollateralAmount, executeDeleverage, toast]); diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts index 0944658f..908026cf 100644 --- a/src/hooks/useLeverageQuote.ts +++ b/src/hooks/useLeverageQuote.ts @@ -1,7 +1,9 @@ import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useReadContract } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; -import { computeFlashCollateralAmount } from './leverage/math'; +import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora'; +import { computeFlashCollateralAmount, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; type UseLeverageQuoteParams = { @@ -9,6 +11,11 @@ type UseLeverageQuoteParams = { route: LeverageRoute | null; userCollateralAmount: bigint; multiplierBps: bigint; + loanTokenAddress: string; + loanTokenDecimals: number; + collateralTokenAddress: string; + collateralTokenDecimals: number; + userAddress?: `0x${string}`; }; export type LeverageQuote = { @@ -17,6 +24,7 @@ export type LeverageQuote = { totalAddedCollateral: bigint; isLoading: boolean; error: string | null; + swapPriceRoute: VeloraPriceRoute | null; }; /** @@ -25,7 +33,17 @@ export type LeverageQuote = { * - `flashCollateralAmount`: extra collateral target sourced via the flash leg * - `flashLoanAmount`: debt token flash amount needed to mint that extra collateral */ -export function useLeverageQuote({ chainId, route, userCollateralAmount, multiplierBps }: UseLeverageQuoteParams): LeverageQuote { +export function useLeverageQuote({ + chainId, + route, + userCollateralAmount, + multiplierBps, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + userAddress, +}: UseLeverageQuoteParams): LeverageQuote { const targetFlashCollateralAmount = useMemo( () => computeFlashCollateralAmount(userCollateralAmount, multiplierBps), [userCollateralAmount, multiplierBps], @@ -36,34 +54,114 @@ export function useLeverageQuote({ chainId, route, userCollateralAmount, multipl isLoading: isLoadingErc4626, error: erc4626Error, } = useReadContract({ - address: route?.collateralVault, + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, abi: erc4626Abi, functionName: 'previewMint', args: [targetFlashCollateralAmount], chainId, query: { - enabled: !!route && targetFlashCollateralAmount > 0n, + enabled: route?.kind === 'erc4626' && targetFlashCollateralAmount > 0n, }, }); + const swapQuoteQuery = useQuery({ + queryKey: [ + 'leverage-swap-quote', + chainId, + route?.kind === 'swap' ? route.paraswapAdapterAddress : null, + route?.kind === 'swap' ? route.generalAdapterAddress : null, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + targetFlashCollateralAmount.toString(), + userAddress ?? null, + ], + enabled: route?.kind === 'swap' && targetFlashCollateralAmount > 0n && !!userAddress, + queryFn: async () => { + const buyRoute = await fetchVeloraPriceRoute({ + srcToken: loanTokenAddress, + srcDecimals: loanTokenDecimals, + destToken: collateralTokenAddress, + destDecimals: collateralTokenDecimals, + amount: targetFlashCollateralAmount, + network: chainId, + userAddress: userAddress as `0x${string}`, + side: 'BUY', + }); + + const borrowAssets = BigInt(buyRoute.srcAmount); + if (borrowAssets <= 0n) { + return { + flashLoanAmount: 0n, + flashCollateralAmount: 0n, + priceRoute: null, + }; + } + + const sellRoute = await fetchVeloraPriceRoute({ + srcToken: loanTokenAddress, + srcDecimals: loanTokenDecimals, + destToken: collateralTokenAddress, + destDecimals: collateralTokenDecimals, + amount: borrowAssets, + network: chainId, + userAddress: userAddress as `0x${string}`, + side: 'SELL', + }); + + return { + flashLoanAmount: borrowAssets, + flashCollateralAmount: withSlippageFloor(BigInt(sellRoute.destAmount)), + priceRoute: sellRoute, + }; + }, + }); + + const swapQuote = useMemo(() => { + if (route?.kind !== 'swap') { + return { + flashLoanAmount: 0n, + flashCollateralAmount: 0n, + priceRoute: null, + }; + } + + return ( + swapQuoteQuery.data ?? { + flashLoanAmount: 0n, + flashCollateralAmount: 0n, + priceRoute: null, + } + ); + }, [route, swapQuoteQuery.data]); + const flashCollateralAmount = useMemo(() => { if (!route) return 0n; + if (route.kind === 'swap') return swapQuote.flashCollateralAmount; return targetFlashCollateralAmount; - }, [route, targetFlashCollateralAmount]); + }, [route, targetFlashCollateralAmount, swapQuote.flashCollateralAmount]); const flashLoanAmount = useMemo(() => { if (!route) return 0n; + if (route.kind === 'swap') return swapQuote.flashLoanAmount; return (erc4626PreviewMint as bigint | undefined) ?? 0n; - }, [route, erc4626PreviewMint]); + }, [route, swapQuote.flashLoanAmount, erc4626PreviewMint]); const error = useMemo(() => { if (!route) return null; - const e = erc4626Error; - if (!e) return null; - return e instanceof Error ? e.message : 'Failed to quote leverage route'; - }, [route, erc4626Error]); + if (route.kind === 'swap') { + if (!userAddress && targetFlashCollateralAmount > 0n) return 'Connect wallet to fetch swap-backed leverage route.'; + const routeError = swapQuoteQuery.error; + if (!routeError) return null; + return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for leverage.'; + } + const erc4626RouteError = erc4626Error; + if (!erc4626RouteError) return null; + return erc4626RouteError instanceof Error ? erc4626RouteError.message : 'Failed to quote leverage route'; + }, [route, userAddress, targetFlashCollateralAmount, swapQuoteQuery.error, erc4626Error]); - const isLoading = !!route && isLoadingErc4626; + const isLoading = !!route && (route.kind === 'swap' ? swapQuoteQuery.isLoading || swapQuoteQuery.isFetching : isLoadingErc4626); return { flashCollateralAmount, @@ -71,5 +169,6 @@ export function useLeverageQuote({ chainId, route, userCollateralAmount, multipl totalAddedCollateral: userCollateralAmount + flashCollateralAmount, isLoading, error, + swapPriceRoute: route?.kind === 'swap' ? swapQuote.priceRoute : null, }; } diff --git a/src/hooks/useLeverageSupport.ts b/src/hooks/useLeverageSupport.ts index 05f64c30..f25cc6d3 100644 --- a/src/hooks/useLeverageSupport.ts +++ b/src/hooks/useLeverageSupport.ts @@ -1,9 +1,10 @@ import { useMemo } from 'react'; +import { getChainAddresses } from '@morpho-org/blue-sdk'; import { type Address, isAddressEqual, zeroAddress } from 'viem'; import { useReadContracts } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; import type { Market } from '@/utils/types'; -import type { Erc4626LeverageRoute, LeverageSupport } from './leverage/types'; +import type { Erc4626LeverageRoute, LeverageSupport, SwapLeverageRoute } from './leverage/types'; type UseLeverageSupportParams = { market: Market; @@ -57,13 +58,42 @@ export function useLeverageSupport({ market }: UseLeverageSupportParams): Levera }; } + if (!isLoading && !isRefetching) { + try { + // Bundler3 adapter addresses are sourced from the canonical blue-sdk chain registry. + // They are not hardcoded in this repository. + const chainAddresses = getChainAddresses(chainId); + const bundler3Addresses = chainAddresses?.bundler3; + + if (bundler3Addresses?.bundler3 && bundler3Addresses.generalAdapter1 && bundler3Addresses.paraswapAdapter) { + const route: SwapLeverageRoute = { + kind: 'swap', + bundler3Address: bundler3Addresses.bundler3 as Address, + generalAdapterAddress: bundler3Addresses.generalAdapter1 as Address, + paraswapAdapterAddress: bundler3Addresses.paraswapAdapter as Address, + }; + + return { + isSupported: true, + supportsLeverage: true, + supportsDeleverage: false, + isLoading: false, + route, + reason: 'Deleverage is not yet available for swap-backed leverage routes.', + }; + } + } catch { + // Unsupported chain in the blue-sdk addresses registry. + } + } + return { isSupported: false, supportsLeverage: false, supportsDeleverage: false, isLoading: isLoading || isRefetching, route: null, - reason: 'Leverage is currently available only for ERC4626-underlying routes on Bundler V2.', + reason: 'Leverage is currently available for ERC4626 routes on Bundler V2, or swap routes where Bundler3 + Paraswap adapter are deployed.', }; - }, [collateralToken, loanToken, data, isLoading, isRefetching]); + }, [chainId, collateralToken, loanToken, data, isLoading, isRefetching]); } diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 290b5cbe..af520d8b 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -1,7 +1,12 @@ -import { useCallback } from 'react'; -import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256 } from 'viem'; +import { useCallback, useMemo } from 'react'; +import { type Address, encodeAbiParameters, encodeFunctionData, keccak256, maxUint256, zeroHash } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; +import { bundlerV3Abi } from '@/abis/bundlerV3'; +import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; +import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; +import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora'; +import { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; import { useERC20Approval } from '@/hooks/useERC20Approval'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { usePermit2 } from '@/hooks/usePermit2'; @@ -12,6 +17,7 @@ import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; import { computeBorrowSharesWithBuffer, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; @@ -31,17 +37,70 @@ type UseLeverageTransactionProps = { collateralAmountInCollateralToken: bigint; flashCollateralAmount: bigint; flashLoanAmount: bigint; + swapPriceRoute: VeloraPriceRoute | null; useLoanAssetAsInput: boolean; onSuccess?: () => void; }; +const LEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); +const PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR = '0xe3ead59e'; +const PARASWAP_SELL_EXACT_AMOUNT_OFFSET = 100n; +const PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET = 132n; +const PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET = 164n; + +type Bundler3Call = { + to: Address; + data: `0x${string}`; + value: bigint; + skipRevert: boolean; + callbackHash: `0x${string}`; +}; + +const BUNDLER3_CALLS_ABI_PARAMS = [ + { + type: 'tuple[]', + components: [ + { type: 'address', name: 'to' }, + { type: 'bytes', name: 'data' }, + { type: 'uint256', name: 'value' }, + { type: 'bool', name: 'skipRevert' }, + { type: 'bytes32', name: 'callbackHash' }, + ], + }, +] as const; + +const encodeBundler3Calls = (bundle: Bundler3Call[]): `0x${string}` => { + return encodeAbiParameters(BUNDLER3_CALLS_ABI_PARAMS, [bundle]); +}; + +const getParaswapSellOffsets = (augustusCallData: `0x${string}`) => { + const selector = augustusCallData.slice(0, 10).toLowerCase(); + if (selector !== PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR) { + throw new Error('Unsupported Velora swap method for Paraswap adapter route.'); + } + + return { + exactAmount: PARASWAP_SELL_EXACT_AMOUNT_OFFSET, + limitAmount: PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET, + quotedAmount: PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET, + } as const; +}; + +const readCalldataUint256 = (callData: `0x${string}`, offset: bigint): bigint => { + const byteOffset = Number(offset); + const start = 2 + byteOffset * 2; + const end = start + 64; + if (callData.length < end) { + throw new Error('Invalid Paraswap calldata for swap-backed leverage.'); + } + + return BigInt(`0x${callData.slice(start, end)}`); +}; + /** - * Executes an ERC4626 leverage loop in Bundler V2. - * - * Flow: - * 1) transfer user input token (collateral shares or loan-asset underlying) - * 2) optionally deposit upfront underlying into ERC4626 collateral shares - * 3) flash-loan loan token, mint more collateral shares, supply collateral, then borrow back the flash amount + * Executes leverage transactions for: + * - ERC4626 deterministic loops on Bundler V2 + * - generalized swap-backed loops on Bundler3 + adapters */ export function useLeverageTransaction({ market, @@ -50,6 +109,7 @@ export function useLeverageTransaction({ collateralAmountInCollateralToken, flashCollateralAmount, flashLoanAmount, + swapPriceRoute, useLoanAssetAsInput, onSuccess, }: UseLeverageTransactionProps) { @@ -57,18 +117,34 @@ export function useLeverageTransaction({ const tracking = useTransactionTracking('leverage'); const { address: account, chainId } = useConnection(); const toast = useStyledToast(); - const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id); + const isSwapRoute = route?.kind === 'swap'; + const usePermit2ForRoute = usePermit2Setting && !isSwapRoute; + + const bundlerAddress = useMemo
(() => { + if (route?.kind === 'swap') { + return route.bundler3Address; + } + return getBundlerV2(market.morphoBlue.chain.id) as Address; + }, [route, market.morphoBlue.chain.id]); + const authorizationTarget = useMemo
(() => { + if (route?.kind === 'swap') { + return route.generalAdapterAddress; + } + return bundlerAddress; + }, [route, bundlerAddress]); + const { batchAddUserMarkets } = useUserMarketsCache(account); - const isLoanAssetInput = useLoanAssetAsInput; + const isLoanAssetInput = !isSwapRoute && useLoanAssetAsInput; const inputTokenAddress = isLoanAssetInput ? (market.loanAsset.address as Address) : (market.collateralAsset.address as Address); const inputTokenSymbol = isLoanAssetInput ? market.loanAsset.symbol : market.collateralAsset.symbol; const inputTokenDecimals = isLoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; const inputTokenAmountForTransfer = isLoanAssetInput ? collateralAmount : collateralAmountInCollateralToken; + const approvalSpender = route?.kind === 'swap' ? route.generalAdapterAddress : bundlerAddress; const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( { chainId: market.morphoBlue.chain.id, - bundlerAddress: bundlerAddress as Address, + bundlerAddress: authorizationTarget, }, ); @@ -79,17 +155,17 @@ export function useLeverageTransaction({ signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: bundlerAddress, + spender: approvalSpender, token: inputTokenAddress as `0x${string}`, refetchInterval: 10_000, chainId: market.morphoBlue.chain.id, tokenSymbol: inputTokenSymbol, - amount: inputTokenAmountForTransfer, + amount: usePermit2ForRoute ? inputTokenAmountForTransfer : 0n, }); const { isApproved, approve, isApproving } = useERC20Approval({ token: inputTokenAddress, - spender: bundlerAddress, + spender: approvalSpender, amount: inputTokenAmountForTransfer, tokenSymbol: inputTokenSymbol, chainId: market.morphoBlue.chain.id, @@ -110,7 +186,27 @@ export function useLeverageTransaction({ }); const getStepsForFlow = useCallback( - (isPermit2: boolean) => { + (isPermit2: boolean, isSwap: boolean) => { + if (isSwap) { + return [ + { + id: 'authorize_bundler_tx', + title: 'Authorize Morpho Adapter', + description: 'Submit one transaction authorizing Morpho adapter actions on your position.', + }, + { + id: 'approve_token', + title: `Approve ${inputTokenSymbol}`, + description: `Approve ${inputTokenSymbol} transfer for the leverage flow.`, + }, + { + id: 'execute', + title: 'Confirm Leverage', + description: 'Confirm the Bundler3 leverage transaction in your wallet.', + }, + ]; + } + if (isPermit2) { return [ { @@ -176,7 +272,7 @@ export function useLeverageTransaction({ try { const txs: `0x${string}`[] = []; - if (usePermit2Setting) { + if (usePermit2ForRoute) { if (!permit2Authorized) { tracking.update('approve_permit2'); await authorizePermit2(); @@ -215,105 +311,241 @@ export function useLeverageTransaction({ } } - if (inputTokenAmountForTransfer > 0n) { - txs.push( + 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), + }; + + if (route.kind === 'swap') { + if (!swapPriceRoute) { + throw new Error('Missing Velora swap quote for leverage.'); + } + + const activePriceRoute = swapPriceRoute; + const swapTxPayload = await (async () => { + try { + return await buildVeloraTransactionPayload({ + srcToken: market.loanAsset.address, + srcDecimals: market.loanAsset.decimals, + destToken: market.collateralAsset.address, + destDecimals: market.collateralAsset.decimals, + srcAmount: flashLoanAmount, + network: market.morphoBlue.chain.id, + userAddress: account as Address, + priceRoute: activePriceRoute, + slippageBps: LEVERAGE_SWAP_SLIPPAGE_BPS, + side: 'SELL', + }); + } catch (buildError: unknown) { + if (isVeloraRateChangedError(buildError)) { + throw new Error('Leverage quote changed. Please review the updated preview and try again.'); + } + throw buildError; + } + })(); + + const minCollateralOut = withSlippageFloor(BigInt(activePriceRoute.destAmount)); + if (minCollateralOut <= 0n) { + throw new Error('Velora returned zero collateral output for leverage swap.'); + } + + const sellOffsets = getParaswapSellOffsets(swapTxPayload.data); + const quotedBorrowAssets = BigInt(activePriceRoute.srcAmount); + const calldataSellAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount); + const calldataMinCollateralOut = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount); + if (quotedBorrowAssets !== flashLoanAmount || calldataSellAmount !== flashLoanAmount || calldataMinCollateralOut !== minCollateralOut) { + throw new Error('Leverage quote changed. Please review the updated preview and try again.'); + } + + const callbackBundle: Bundler3Call[] = [ + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'erc20Transfer', + args: [market.loanAsset.address as Address, route.paraswapAdapterAddress, flashLoanAmount], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + { + to: route.paraswapAdapterAddress, + data: encodeFunctionData({ + abi: paraswapAdapterAbi, + functionName: 'sell', + args: [ + swapTxPayload.to, + swapTxPayload.data, + market.loanAsset.address as Address, + market.collateralAsset.address as Address, + false, + sellOffsets, + route.generalAdapterAddress, + ], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoSupplyCollateral', + args: [marketParams, maxUint256, account as Address, '0x'], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoBorrow', + args: [marketParams, flashLoanAmount, 0n, 0n, route.generalAdapterAddress], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + ]; + const callbackBundleData = encodeBundler3Calls(callbackBundle); + + const bundleCalls: Bundler3Call[] = []; + + if (inputTokenAmountForTransfer > 0n) { + bundleCalls.push({ + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'erc20TransferFrom', + args: [inputTokenAddress, route.generalAdapterAddress, inputTokenAmountForTransfer], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }); + bundleCalls.push({ + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoSupplyCollateral', + args: [marketParams, inputTokenAmountForTransfer, account as Address, '0x'], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }); + } + + bundleCalls.push({ + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoFlashLoan', + args: [market.loanAsset.address as Address, flashLoanAmount, callbackBundleData], + }), + value: 0n, + skipRevert: false, + callbackHash: keccak256(callbackBundleData), + }); + + tracking.update('execute'); + await new Promise((resolve) => setTimeout(resolve, 800)); + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: (encodeFunctionData({ + abi: bundlerV3Abi, + functionName: 'multicall', + args: [bundleCalls], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: 0n, + }); + } else { + const maxBorrowShares = computeBorrowSharesWithBuffer({ + borrowAssets: flashLoanAmount, + totalBorrowAssets: BigInt(market.state.borrowAssets), + totalBorrowShares: BigInt(market.state.borrowShares), + }); + + if (inputTokenAmountForTransfer > 0n) { + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: usePermit2ForRoute ? 'transferFrom2' : 'erc20TransferFrom', + args: [inputTokenAddress, inputTokenAmountForTransfer], + }), + ); + } + + if (isLoanAssetInput) { + // WHY: this lets users start with loan-token underlying for ERC4626 markets. + // We mint shares first so all leverage math and downstream Morpho collateral is in share units. + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Deposit', + args: [ + route.collateralVault, + collateralAmount, + withSlippageFloor(collateralAmountInCollateralToken), + bundlerAddress as Address, + ], + }), + ); + } + + const callbackTxs: `0x${string}`[] = [ encodeFunctionData({ abi: morphoBundlerAbi, - functionName: usePermit2Setting ? 'transferFrom2' : 'erc20TransferFrom', - args: [inputTokenAddress, inputTokenAmountForTransfer], + functionName: 'erc4626Deposit', + args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address], + }), + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupplyCollateral', + args: [marketParams, maxUint256, account as Address, '0x'], + }), + ]; + + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoBorrow', + args: [marketParams, flashLoanAmount, 0n, maxBorrowShares, bundlerAddress as Address], }), ); - } - if (isLoanAssetInput) { - // WHY: this lets users start with loan-token underlying for ERC4626 markets. - // We mint shares first so all leverage math and downstream Morpho collateral is in share units. + const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); txs.push( encodeFunctionData({ abi: morphoBundlerAbi, - functionName: 'erc4626Deposit', - args: [ - route.collateralVault, - collateralAmount, - withSlippageFloor(collateralAmountInCollateralToken), - bundlerAddress as Address, - ], + functionName: 'morphoFlashLoan', + args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData], }), ); - } - - const callbackTxs: `0x${string}`[] = [ - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc4626Deposit', - args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address], - }), - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoSupplyCollateral', - 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), - }, - maxUint256, - account as Address, - '0x', - ], - }), - ]; - - const maxBorrowShares = computeBorrowSharesWithBuffer({ - borrowAssets: flashLoanAmount, - totalBorrowAssets: BigInt(market.state.borrowAssets), - totalBorrowShares: BigInt(market.state.borrowShares), - }); - - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoBorrow', - 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), - }, - flashLoanAmount, - 0n, - maxBorrowShares, - bundlerAddress as Address, - ], - }), - ); - - const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); - txs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoFlashLoan', - args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData], - }), - ); - tracking.update('execute'); - await new Promise((resolve) => setTimeout(resolve, 800)); + tracking.update('execute'); + 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}`, - value: 0n, - }); + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: (encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [txs], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: 0n, + }); + } batchAddUserMarkets([ { @@ -326,8 +558,9 @@ export function useLeverageTransaction({ } catch (error: unknown) { tracking.fail(); console.error('Error during leverage execution:', error); - if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { - toast.error('Leverage Failed', 'An unexpected error occurred during leverage.'); + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'An unexpected error occurred during leverage.'); + if (userFacingMessage !== 'User rejected transaction.') { + toast.error('Leverage Failed', userFacingMessage); } } }, [ @@ -341,7 +574,8 @@ export function useLeverageTransaction({ isLoanAssetInput, flashCollateralAmount, flashLoanAmount, - usePermit2Setting, + swapPriceRoute, + usePermit2ForRoute, permit2Authorized, isBundlerAuthorized, authorizePermit2, @@ -363,9 +597,9 @@ export function useLeverageTransaction({ } try { - const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + const initialStep = usePermit2ForRoute ? 'approve_permit2' : 'authorize_bundler_tx'; tracking.start( - getStepsForFlow(usePermit2Setting), + getStepsForFlow(usePermit2ForRoute, isSwapRoute), { title: 'Leverage', description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, @@ -380,19 +614,19 @@ export function useLeverageTransaction({ } catch (error: unknown) { console.error('Error in approveAndLeverage:', error); tracking.fail(); - if (error instanceof Error) { - if (error.message.includes('User rejected')) { - toast.error('Transaction rejected', 'Transaction rejected by user'); - } else { - toast.error('Error', 'Failed to process leverage transaction'); - } - } else { - toast.error('Error', 'An unexpected error occurred'); + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process leverage transaction'); + if (userFacingMessage !== 'User rejected transaction.') { + toast.error('Error', userFacingMessage); } } - }, [account, usePermit2Setting, tracking, getStepsForFlow, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); + }, [account, usePermit2ForRoute, tracking, getStepsForFlow, isSwapRoute, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); const signAndLeverage = useCallback(async () => { + if (!usePermit2ForRoute) { + await approveAndLeverage(); + return; + } + if (!account) { toast.info('No account connected', 'Please connect your wallet.'); return; @@ -406,7 +640,7 @@ export function useLeverageTransaction({ : 'approve_permit2'; tracking.start( - getStepsForFlow(usePermit2Setting), + getStepsForFlow(true, false), { title: 'Leverage', description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, @@ -421,21 +655,17 @@ export function useLeverageTransaction({ } catch (error: unknown) { console.error('Error in signAndLeverage:', 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 leverage transaction'); - } - } else { - toast.error('Transaction Error', 'An unexpected error occurred'); + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process leverage transaction'); + if (userFacingMessage !== 'User rejected transaction.') { + toast.error('Transaction Error', userFacingMessage); } } }, [ + usePermit2ForRoute, + approveAndLeverage, account, tracking, getStepsForFlow, - usePermit2Setting, permit2Authorized, isBundlerAuthorized, market, @@ -445,15 +675,15 @@ export function useLeverageTransaction({ toast, ]); - const isLoading = leveragePending || isLoadingPermit2 || isApproving || isAuthorizingBundler; + const isLoading = leveragePending || (usePermit2ForRoute && isLoadingPermit2) || isApproving || isAuthorizingBundler; return { transaction: tracking.transaction, dismiss: tracking.dismiss, currentStep: tracking.currentStep as LeverageStepType | null, - isLoadingPermit2, + isLoadingPermit2: usePermit2ForRoute ? isLoadingPermit2 : false, isApproved, - permit2Authorized, + permit2Authorized: usePermit2ForRoute ? permit2Authorized : false, leveragePending, isLoading, isBundlerAuthorized, diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index bf5b2874..04591b75 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -59,6 +59,7 @@ export function AddCollateralAndLeverage({ const multiplierBps = useMemo(() => clampMultiplierBps(parseMultiplierToBps(multiplierInput)), [multiplierInput]); const isErc4626Route = route?.kind === 'erc4626'; + const isSwapRoute = route?.kind === 'swap'; const { data: loanTokenBalance } = useReadContract({ address: market.loanAsset.address as `0x${string}`, @@ -89,7 +90,7 @@ export function AddCollateralAndLeverage({ } = useReadContract({ // WHY: for ERC4626 "start with loan asset" mode, user input is underlying assets. // We convert to collateral shares first so multiplier/flash math stays in collateral units. - address: route?.collateralVault, + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, abi: erc4626Abi, functionName: 'previewDeposit', args: [collateralAmount], @@ -116,6 +117,11 @@ export function AddCollateralAndLeverage({ route, userCollateralAmount: collateralAmountForLeverageQuote, multiplierBps, + loanTokenAddress: market.loanAsset.address, + loanTokenDecimals: market.loanAsset.decimals, + collateralTokenAddress: market.collateralAsset.address, + collateralTokenDecimals: market.collateralAsset.decimals, + userAddress: account as `0x${string}` | undefined, }); const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); @@ -171,7 +177,7 @@ export function AddCollateralAndLeverage({ if (onSuccess) onSuccess(); }, [onSuccess]); - const { transaction, isLoadingPermit2, isApproved, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = + const { transaction, isLoadingPermit2, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = useLeverageTransaction({ market, route, @@ -179,6 +185,7 @@ export function AddCollateralAndLeverage({ collateralAmountInCollateralToken: collateralAmountForLeverageQuote, flashCollateralAmount: quote.flashCollateralAmount, flashLoanAmount: quote.flashLoanAmount, + swapPriceRoute: quote.swapPriceRoute, useLoanAssetAsInput: useLoanAssetInput, onSuccess: handleTransactionSuccess, }); @@ -194,16 +201,15 @@ export function AddCollateralAndLeverage({ }, [multiplierInput]); const handleLeverage = useCallback(() => { - if (usePermit2Setting && permit2Authorized) { + const usePermit2Flow = usePermit2Setting && isErc4626Route; + + if (usePermit2Flow && permit2Authorized) { void signAndLeverage(); return; } - if (!usePermit2Setting && isApproved) { - void approveAndLeverage(); - return; - } + void approveAndLeverage(); - }, [usePermit2Setting, permit2Authorized, signAndLeverage, isApproved, approveAndLeverage]); + }, [usePermit2Setting, isErc4626Route, permit2Authorized, signAndLeverage, approveAndLeverage]); const projectedOverLimit = projectedLTV >= lltv; const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity; @@ -221,6 +227,11 @@ export function AddCollateralAndLeverage({ () => formatTokenAmountPreview(quote.totalAddedCollateral, market.collateralAsset.decimals), [quote.totalAddedCollateral, market.collateralAsset.decimals], ); + const swapCollateralOutPreview = useMemo( + () => formatTokenAmountPreview(quote.flashCollateralAmount, market.collateralAsset.decimals), + [quote.flashCollateralAmount, market.collateralAsset.decimals], + ); + const collateralPreviewForDisplay = isSwapRoute ? swapCollateralOutPreview : totalCollateralAddedPreview; const renderRateValue = useCallback( (apy: number | null): JSX.Element => { if (apy == null || !Number.isFinite(apy)) return -; @@ -336,7 +347,7 @@ export function AddCollateralAndLeverage({

Transaction Preview

- Flash Borrow + {isSwapRoute ? 'Flash Borrow Required' : 'Flash Borrow'} {flashBorrowPreview.full}}> {flashBorrowPreview.compact} @@ -351,10 +362,10 @@ export function AddCollateralAndLeverage({
- Total Collateral Added + {isSwapRoute ? 'Collateral From Swap (Est.)' : 'Total Collateral Added'} - {totalCollateralAddedPreview.full}}> - {totalCollateralAddedPreview.compact} + {collateralPreviewForDisplay.full}}> + {collateralPreviewForDisplay.compact} )} + {isSwapRoute && ( +
+ Route + Bundler3 + Velora +
+ )}
{conversionErrorMessage &&

{conversionErrorMessage}

} {quote.error &&

{quote.error}

} diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx index 610c59bf..65057b61 100644 --- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -30,7 +30,7 @@ export function RemoveCollateralAndDeleverage({ onSuccess, isRefreshing = false, }: RemoveCollateralAndDeleverageProps): JSX.Element { - const route = support.route; + const route = support.route?.kind === 'erc4626' ? support.route : null; const [withdrawCollateralAmount, setWithdrawCollateralAmount] = useState(0n); const [withdrawInputError, setWithdrawInputError] = useState(null); diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index 6a979afe..ff06eaff 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -35,6 +35,7 @@ export function LeverageModal({ const { address: account } = useConnection(); const support = useLeverageSupport({ market }); const isErc4626Route = support.route?.kind === 'erc4626'; + const isSwapRoute = support.route?.kind === 'swap'; const effectiveMode = mode; const modeOptions: { value: string; label: string }[] = toggleLeverageDeleverage @@ -119,16 +120,29 @@ export function LeverageModal({ #ERC4626 )} + {isSwapRoute && ( + + #SWAP + + )}
} description={ effectiveMode === 'leverage' ? isErc4626Route ? `Leverage ERC4626 vault exposure by looping ${market.loanAsset.symbol} into ${market.collateralAsset.symbol}.` - : `Leverage your ${market.collateralAsset.symbol} exposure by looping.` + : isSwapRoute + ? `Leverage ${market.collateralAsset.symbol} exposure through Bundler3 + Velora swap routing.` + : `Leverage your ${market.collateralAsset.symbol} exposure by looping.` : isErc4626Route ? `Reduce ERC4626 leveraged exposure by unwinding your ${market.collateralAsset.symbol} loop.` - : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` + : isSwapRoute + ? `Deleverage is not yet available for the swap route on this market.` + : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` } /> From e4a48271cd4f7b2b4e7c32515bde21af031d6dbb Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 22:54:55 +0800 Subject: [PATCH 2/4] feat: leverage amd deleverage --- AGENTS.md | 4 +- src/abis/morphoGeneralAdapterV1.ts | 25 ++ src/features/swap/components/SwapModal.tsx | 14 +- src/hooks/leverage/bundler3.ts | 55 +++ src/hooks/leverage/types.ts | 9 - src/hooks/useDeleverageQuote.ts | 148 ++++++- src/hooks/useDeleverageTransaction.ts | 396 +++++++++++++----- src/hooks/useLeverageSupport.ts | 99 ----- src/hooks/useLeverageTransaction.ts | 109 +++-- src/hooks/useMultiMarketSupply.ts | 4 +- src/modals/borrow/borrow-modal.tsx | 5 +- .../add-collateral-and-leverage.tsx | 15 +- .../remove-collateral-and-deleverage.tsx | 23 +- src/modals/leverage/leverage-modal.tsx | 118 +++++- 14 files changed, 693 insertions(+), 331 deletions(-) create mode 100644 src/hooks/leverage/bundler3.ts delete mode 100644 src/hooks/useLeverageSupport.ts diff --git a/AGENTS.md b/AGENTS.md index 8231c3b3..f42adad7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,9 @@ When touching transaction and position flows, validation MUST include all releva 12. **Aggregator API contract integrity**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders, normalize fetch/network failures into typed API errors, and verify returned route token addresses match requested canonical token addresses before using previews/tx payloads. 13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. 14. **Input/state integrity in tx-critical UIs**: never strip unsupported numeric syntax into a different value (e.g. `1e-6` must be rejected, not rewritten), and after any balance refetch re-derive selected token objects from refreshed data before allowing `Max`/submit. -15. **Swap leverage flashloan callback integrity**: Bundler3 swap leverage must use adapter flashloan callbacks (not pre-swap borrow gating), with `callbackHash`/`reenter` wiring and adapter token flows matching on-chain contracts; before submit, verify Velora quote/tx parity (route src amount + Paraswap calldata exact/min offsets) so previewed borrow/collateral amounts cannot drift from executed inputs. +15. **Swap leverage/deleverage flashloan callback integrity**: Bundler3 swap leverage/deleverage must use adapter flashloan callbacks (not pre-swap borrow gating), with `callbackHash`/`reenter` wiring and adapter token flows matching on-chain contracts; before submit, verify Velora quote/tx parity (route src amount + Paraswap calldata exact/min offsets) so previewed borrow/repay/collateral amounts cannot drift from executed inputs. +16. **Multi-leg quote completeness for swap previews**: when a preview depends on multiple aggregator legs (e.g. SELL repay quote + BUY max-collateral bound), surface failures from every required leg and use conservative fallbacks (`0`, disable submit) instead of optimistic defaults so partial quote failures cannot overstate safe unwind amounts. +17. **Adapter-executed aggregator build integrity**: for Bundler3 adapter swaps, never hard-require wallet-level allowance checks from aggregator build endpoints; attempt normal build first, retry with endpoint `ignoreChecks` only for allowance-specific failures, and fail closed unless the built transaction target matches trusted addresses from the quoted route. ### REQUIRED: Regression Rule Capture diff --git a/src/abis/morphoGeneralAdapterV1.ts b/src/abis/morphoGeneralAdapterV1.ts index 462e0cec..084004a5 100644 --- a/src/abis/morphoGeneralAdapterV1.ts +++ b/src/abis/morphoGeneralAdapterV1.ts @@ -64,6 +64,31 @@ export const morphoGeneralAdapterV1Abi = [ ], outputs: [], }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'morphoRepay', + inputs: [ + marketParamsTuple, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'uint256', name: 'maxSharePriceE27', type: 'uint256' }, + { internalType: 'address', name: 'onBehalf', type: 'address' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'function', + stateMutability: 'nonpayable', + name: 'morphoWithdrawCollateral', + inputs: [ + marketParamsTuple, + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + ], + outputs: [], + }, { type: 'function', stateMutability: 'nonpayable', diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 111e0a82..3923652c 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -331,23 +331,13 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp if (!quote || !sourceToken || !targetToken || error || !chainsMatch) return null; if (isRateInverted) { - const inverseRate = computeUnitRatePreviewAmount( - quote.buyAmount, - targetToken.decimals, - quote.sellAmount, - sourceToken.decimals, - ); + const inverseRate = computeUnitRatePreviewAmount(quote.buyAmount, targetToken.decimals, quote.sellAmount, sourceToken.decimals); if (!inverseRate) return null; const inverseRatePreview = formatTokenAmountPreview(inverseRate, RATE_PREVIEW_DECIMALS).compact; return `1 ${targetToken.symbol} ≈ ${inverseRatePreview} ${sourceToken.symbol}`; } - const forwardRate = computeUnitRatePreviewAmount( - quote.sellAmount, - sourceToken.decimals, - quote.buyAmount, - targetToken.decimals, - ); + const forwardRate = computeUnitRatePreviewAmount(quote.sellAmount, sourceToken.decimals, quote.buyAmount, targetToken.decimals); if (!forwardRate) return null; const forwardRatePreview = formatTokenAmountPreview(forwardRate, RATE_PREVIEW_DECIMALS).compact; return `1 ${sourceToken.symbol} ≈ ${forwardRatePreview} ${targetToken.symbol}`; diff --git a/src/hooks/leverage/bundler3.ts b/src/hooks/leverage/bundler3.ts new file mode 100644 index 00000000..1cfb3e0f --- /dev/null +++ b/src/hooks/leverage/bundler3.ts @@ -0,0 +1,55 @@ +import { type Address, encodeAbiParameters } from 'viem'; + +const PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR = '0xe3ead59e'; +const PARASWAP_SELL_EXACT_AMOUNT_OFFSET = 100n; +const PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET = 132n; +const PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET = 164n; + +export type Bundler3Call = { + to: Address; + data: `0x${string}`; + value: bigint; + skipRevert: boolean; + callbackHash: `0x${string}`; +}; + +const BUNDLER3_CALLS_ABI_PARAMS = [ + { + type: 'tuple[]', + components: [ + { type: 'address', name: 'to' }, + { type: 'bytes', name: 'data' }, + { type: 'uint256', name: 'value' }, + { type: 'bool', name: 'skipRevert' }, + { type: 'bytes32', name: 'callbackHash' }, + ], + }, +] as const; + +export const encodeBundler3Calls = (bundle: Bundler3Call[]): `0x${string}` => { + return encodeAbiParameters(BUNDLER3_CALLS_ABI_PARAMS, [bundle]); +}; + +export const getParaswapSellOffsets = (augustusCallData: `0x${string}`) => { + const selector = augustusCallData.slice(0, 10).toLowerCase(); + if (selector !== PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR) { + throw new Error('Unsupported Velora swap method for Paraswap adapter route.'); + } + + return { + exactAmount: PARASWAP_SELL_EXACT_AMOUNT_OFFSET, + limitAmount: PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET, + quotedAmount: PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET, + } as const; +}; + +export const readCalldataUint256 = (callData: `0x${string}`, offset: bigint): bigint => { + const byteOffset = Number(offset); + const start = 2 + byteOffset * 2; + const end = start + 64; + if (callData.length < end) { + throw new Error('Invalid Paraswap calldata for swap-backed route.'); + } + + return BigInt(`0x${callData.slice(start, end)}`); +}; diff --git a/src/hooks/leverage/types.ts b/src/hooks/leverage/types.ts index c0e1ca9b..313b182e 100644 --- a/src/hooks/leverage/types.ts +++ b/src/hooks/leverage/types.ts @@ -15,15 +15,6 @@ export type SwapLeverageRoute = { export type LeverageRoute = Erc4626LeverageRoute | SwapLeverageRoute; -export type LeverageSupport = { - isSupported: boolean; - supportsLeverage: boolean; - supportsDeleverage: boolean; - isLoading: boolean; - route: LeverageRoute | null; - reason: string | null; -}; - export const LEVERAGE_MULTIPLIER_SCALE_BPS = 10_000n; export const LEVERAGE_MIN_MULTIPLIER_BPS = 10_000n; // 1.00x export const LEVERAGE_DEFAULT_MULTIPLIER_BPS = 20_000n; // 2.00x diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index deceb7a9..86cafc96 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -1,14 +1,21 @@ import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useReadContract } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; -import { withSlippageCeil } from './leverage/math'; -import type { Erc4626LeverageRoute } from './leverage/types'; +import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora'; +import { withSlippageCeil, withSlippageFloor } from './leverage/math'; +import type { LeverageRoute } from './leverage/types'; type UseDeleverageQuoteParams = { chainId: number; - route: Erc4626LeverageRoute | null; + route: LeverageRoute | null; withdrawCollateralAmount: bigint; currentBorrowAssets: bigint; + loanTokenAddress: string; + loanTokenDecimals: number; + collateralTokenAddress: string; + collateralTokenDecimals: number; + userAddress?: `0x${string}`; }; export type DeleverageQuote = { @@ -17,19 +24,26 @@ export type DeleverageQuote = { maxCollateralForDebtRepay: bigint; isLoading: boolean; error: string | null; + swapPriceRoute: VeloraPriceRoute | null; }; /** * Quotes how much debt can be repaid when unwinding a given collateral amount. * - * We intentionally quote from `withdrawCollateralAmount -> loanAssets` using redeem - * side conversions so the callback consumes exactly the requested collateral amount. + * Routes: + * - ERC4626: `withdrawCollateralAmount -> previewRedeem` and `previewWithdraw(currentDebt)` + * - Swap: Velora SELL quote for repay preview and Velora BUY quote for max collateral to close debt */ export function useDeleverageQuote({ chainId, route, withdrawCollateralAmount, currentBorrowAssets, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + userAddress, }: UseDeleverageQuoteParams): DeleverageQuote { const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); @@ -38,13 +52,13 @@ export function useDeleverageQuote({ isLoading: isLoadingRedeem, error: redeemError, } = useReadContract({ - address: route?.collateralVault, + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, abi: erc4626Abi, functionName: 'previewRedeem', args: [withdrawCollateralAmount], chainId, query: { - enabled: !!route && withdrawCollateralAmount > 0n, + enabled: route?.kind === 'erc4626' && withdrawCollateralAmount > 0n, }, }); @@ -53,20 +67,98 @@ export function useDeleverageQuote({ isLoading: isLoadingWithdraw, error: withdrawError, } = useReadContract({ - address: route?.collateralVault, + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, abi: erc4626Abi, functionName: 'previewWithdraw', args: [bufferedBorrowAssets], chainId, query: { - enabled: !!route && bufferedBorrowAssets > 0n, + enabled: route?.kind === 'erc4626' && bufferedBorrowAssets > 0n, }, }); + const swapRepayQuoteQuery = useQuery({ + queryKey: [ + 'deleverage-swap-repay-quote', + chainId, + route?.kind === 'swap' ? route.paraswapAdapterAddress : null, + collateralTokenAddress, + collateralTokenDecimals, + loanTokenAddress, + loanTokenDecimals, + withdrawCollateralAmount.toString(), + userAddress ?? null, + ], + enabled: route?.kind === 'swap' && withdrawCollateralAmount > 0n && !!userAddress, + queryFn: async () => { + const sellRoute = await fetchVeloraPriceRoute({ + srcToken: collateralTokenAddress, + srcDecimals: collateralTokenDecimals, + destToken: loanTokenAddress, + destDecimals: loanTokenDecimals, + amount: withdrawCollateralAmount, + network: chainId, + userAddress: userAddress as `0x${string}`, + side: 'SELL', + }); + + return { + rawRouteRepayAmount: withSlippageFloor(BigInt(sellRoute.destAmount)), + priceRoute: sellRoute, + }; + }, + }); + + const swapMaxCollateralForDebtQuery = useQuery({ + queryKey: [ + 'deleverage-swap-max-collateral', + chainId, + route?.kind === 'swap' ? route.paraswapAdapterAddress : null, + collateralTokenAddress, + collateralTokenDecimals, + loanTokenAddress, + loanTokenDecimals, + bufferedBorrowAssets.toString(), + userAddress ?? null, + ], + enabled: route?.kind === 'swap' && bufferedBorrowAssets > 0n && !!userAddress, + queryFn: async () => { + const buyRoute = await fetchVeloraPriceRoute({ + srcToken: collateralTokenAddress, + srcDecimals: collateralTokenDecimals, + destToken: loanTokenAddress, + destDecimals: loanTokenDecimals, + amount: bufferedBorrowAssets, + network: chainId, + userAddress: userAddress as `0x${string}`, + side: 'BUY', + }); + + return BigInt(buyRoute.srcAmount); + }, + }); + + const swapRepayQuote = useMemo(() => { + if (route?.kind !== 'swap') { + return { + rawRouteRepayAmount: 0n, + priceRoute: null, + }; + } + + return ( + swapRepayQuoteQuery.data ?? { + rawRouteRepayAmount: 0n, + priceRoute: null, + } + ); + }, [route, swapRepayQuoteQuery.data]); + const rawRouteRepayAmount = useMemo(() => { if (!route || withdrawCollateralAmount <= 0n) return 0n; + if (route.kind === 'swap') return swapRepayQuote.rawRouteRepayAmount; return (erc4626PreviewRedeem as bigint | undefined) ?? 0n; - }, [route, withdrawCollateralAmount, erc4626PreviewRedeem]); + }, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, erc4626PreviewRedeem]); const repayAmount = useMemo(() => { if (rawRouteRepayAmount <= 0n) return 0n; @@ -75,17 +167,46 @@ export function useDeleverageQuote({ const maxCollateralForDebtRepay = useMemo(() => { if (!route || currentBorrowAssets <= 0n) return 0n; + if (route.kind === 'swap') { + if (!userAddress) return 0n; + return swapMaxCollateralForDebtQuery.data ?? 0n; + } return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n; - }, [route, currentBorrowAssets, erc4626PreviewWithdrawForDebt]); + }, [route, currentBorrowAssets, swapMaxCollateralForDebtQuery.data, userAddress, erc4626PreviewWithdrawForDebt]); const error = useMemo(() => { if (!route) return null; + if (route.kind === 'swap') { + if (!userAddress && withdrawCollateralAmount > 0n) { + return 'Connect wallet to fetch swap-backed deleverage route.'; + } + const routeError = + (withdrawCollateralAmount > 0n ? swapRepayQuoteQuery.error : null) ?? + (withdrawCollateralAmount > 0n ? swapMaxCollateralForDebtQuery.error : null); + if (!routeError) return null; + return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for deleverage.'; + } const routeError = redeemError ?? withdrawError; if (!routeError) return null; return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route'; - }, [route, redeemError, withdrawError]); + }, [ + route, + userAddress, + withdrawCollateralAmount, + swapRepayQuoteQuery.error, + swapMaxCollateralForDebtQuery.error, + redeemError, + withdrawError, + ]); - const isLoading = !!route && (isLoadingRedeem || isLoadingWithdraw); + const isLoading = + !!route && + (route.kind === 'swap' + ? swapRepayQuoteQuery.isLoading || + swapRepayQuoteQuery.isFetching || + swapMaxCollateralForDebtQuery.isLoading || + swapMaxCollateralForDebtQuery.isFetching + : isLoadingRedeem || isLoadingWithdraw); return { repayAmount, @@ -93,5 +214,6 @@ export function useDeleverageQuote({ maxCollateralForDebtRepay, isLoading, error, + swapPriceRoute: route?.kind === 'swap' ? swapRepayQuote.priceRoute : null, }; } diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index 41a5b571..16a38deb 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -1,7 +1,12 @@ -import { useCallback } from 'react'; -import { type Address, encodeAbiParameters, encodeFunctionData } from 'viem'; +import { useCallback, useMemo } from 'react'; +import { type Address, encodeAbiParameters, encodeFunctionData, isAddress, isAddressEqual, keccak256, maxUint256, zeroHash } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; +import { bundlerV3Abi } from '@/abis/bundlerV3'; +import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; +import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; +import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora'; +import { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; @@ -12,31 +17,33 @@ import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; +import { type Bundler3Call, encodeBundler3Calls, getParaswapSellOffsets, readCalldataUint256 } from './leverage/bundler3'; import { withSlippageFloor } from './leverage/math'; -import type { Erc4626LeverageRoute } from './leverage/types'; +import type { LeverageRoute } from './leverage/types'; export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx' | 'execute'; type UseDeleverageTransactionProps = { market: Market; - route: Erc4626LeverageRoute | null; + route: LeverageRoute | null; withdrawCollateralAmount: bigint; flashLoanAmount: bigint; repayBySharesAmount: bigint; autoWithdrawCollateralAmount: bigint; + swapPriceRoute: VeloraPriceRoute | null; onSuccess?: () => void; }; +const DELEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); +const isVeloraAllowanceCheckError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return message.includes('allowance given to tokentransferproxy') || (message.includes('not enough') && message.includes('allowance')); +}; + /** - * Executes V2 deleverage for deterministic conversion routes. - * - * Flow: - * 1) flash-loan debt token - * 2) repay debt on behalf of user - * 3) withdraw requested collateral - * 4) convert withdrawn collateral back into debt token - * - * Morpho pulls the flash-loaned debt token back from bundler after callback. + * Executes deleverage transactions for: + * - ERC4626 deterministic loops on Bundler V2 + * - generalized swap-backed loops on Bundler3 + adapters */ export function useDeleverageTransaction({ market, @@ -45,19 +52,29 @@ export function useDeleverageTransaction({ flashLoanAmount, repayBySharesAmount, autoWithdrawCollateralAmount, + swapPriceRoute, onSuccess, }: UseDeleverageTransactionProps) { const { usePermit2: usePermit2Setting } = useAppSettings(); const tracking = useTransactionTracking('deleverage'); const { address: account, chainId } = useConnection(); const toast = useStyledToast(); - const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id); + const isSwapRoute = route?.kind === 'swap'; + const useSignatureAuthorization = usePermit2Setting && !isSwapRoute; + const bundlerAddress = useMemo
(() => { + if (route?.kind === 'swap') return route.bundler3Address; + return getBundlerV2(market.morphoBlue.chain.id) as Address; + }, [route, market.morphoBlue.chain.id]); + const authorizationTarget = useMemo
(() => { + if (route?.kind === 'swap') return route.generalAdapterAddress; + return bundlerAddress; + }, [route, bundlerAddress]); const { batchAddUserMarkets } = useUserMarketsCache(account); const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( { chainId: market.morphoBlue.chain.id, - bundlerAddress: bundlerAddress as Address, + bundlerAddress: authorizationTarget, }, ); @@ -75,7 +92,24 @@ export function useDeleverageTransaction({ }, }); - const getStepsForFlow = useCallback((isPermit2: boolean) => { + const getStepsForFlow = useCallback((isPermit2: boolean, isSwap: boolean) => { + if (isSwap) { + return [ + { + id: isPermit2 ? 'authorize_bundler_sig' : 'authorize_bundler_tx', + title: 'Authorize Morpho Adapter', + description: isPermit2 + ? 'Sign a message to authorize adapter actions on your position.' + : 'Submit one transaction authorizing adapter actions on your position.', + }, + { + id: 'execute', + title: 'Confirm Deleverage', + description: 'Confirm the Bundler3 deleverage transaction in your wallet.', + }, + ]; + } + if (isPermit2) { return [ { @@ -124,7 +158,7 @@ export function useDeleverageTransaction({ try { const txs: `0x${string}`[] = []; - if (usePermit2Setting) { + if (useSignatureAuthorization) { tracking.update('authorize_bundler_sig'); const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); if (authorizationTxData) { @@ -139,100 +173,255 @@ export function useDeleverageTransaction({ } } + 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 isRepayByShares = repayBySharesAmount > 0n; // WHY: when repaying by assets, Morpho expects a *minimum* shares bound. // Using an upper-bound style estimate causes false "slippage exceeded" reverts. const minRepayShares = 1n; - const callbackTxs: `0x${string}`[] = [ - 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), - }, - isRepayByShares ? 0n : flashLoanAmount, - isRepayByShares ? repayBySharesAmount : 0n, - isRepayByShares ? flashLoanAmount : minRepayShares, - account as Address, - '0x', - ], - }), - 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), - }, - withdrawCollateralAmount, - bundlerAddress as Address, - ], - }), - ]; + if (route.kind === 'swap') { + if (!swapPriceRoute) { + throw new Error('Missing Velora swap quote for deleverage.'); + } - const minAssetsOut = withSlippageFloor(flashLoanAmount); - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc4626Redeem', - args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address], - }), - ); + const activePriceRoute = swapPriceRoute; + const swapTxPayload = await (async () => { + const buildPayload = async (ignoreChecks: boolean) => + buildVeloraTransactionPayload({ + srcToken: market.collateralAsset.address, + srcDecimals: market.collateralAsset.decimals, + destToken: market.loanAsset.address, + destDecimals: market.loanAsset.decimals, + srcAmount: withdrawCollateralAmount, + network: market.morphoBlue.chain.id, + userAddress: account as Address, + priceRoute: activePriceRoute, + slippageBps: DELEVERAGE_SWAP_SLIPPAGE_BPS, + side: 'SELL', + ignoreChecks, + }); - if (autoWithdrawCollateralAmount > 0n) { - // WHY: if deleverage fully clears debt, keeping collateral locked in Morpho adds friction. - // We withdraw the remaining collateral in the same transaction so the position is closed. - callbackTxs.push( + try { + return await buildPayload(false); + } catch (buildError: unknown) { + if (isVeloraRateChangedError(buildError)) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + if (!isVeloraAllowanceCheckError(buildError)) { + throw buildError; + } + + try { + return await buildPayload(true); + } catch (fallbackBuildError: unknown) { + if (isVeloraRateChangedError(fallbackBuildError)) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + throw fallbackBuildError; + } + } + })(); + + const trustedVeloraTargets = [activePriceRoute.contractAddress, activePriceRoute.tokenTransferProxy].filter( + (candidate): candidate is Address => typeof candidate === 'string' && isAddress(candidate), + ); + if (trustedVeloraTargets.length === 0 || !trustedVeloraTargets.some((target) => isAddressEqual(swapTxPayload.to, target))) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + + const minLoanOut = withSlippageFloor(BigInt(activePriceRoute.destAmount)); + if (minLoanOut <= 0n) { + throw new Error('Velora returned zero loan output for deleverage swap.'); + } + + const sellOffsets = getParaswapSellOffsets(swapTxPayload.data); + const quotedSellCollateral = BigInt(activePriceRoute.srcAmount); + const calldataSellAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount); + const calldataMinLoanOut = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount); + if ( + quotedSellCollateral !== withdrawCollateralAmount || + calldataSellAmount !== withdrawCollateralAmount || + calldataMinLoanOut !== minLoanOut + ) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + + const callbackBundle: Bundler3Call[] = [ + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoRepay', + args: [ + marketParams, + isRepayByShares ? 0n : flashLoanAmount, + isRepayByShares ? repayBySharesAmount : 0n, + maxUint256, + account as Address, + '0x', + ], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoWithdrawCollateral', + args: [marketParams, withdrawCollateralAmount, route.paraswapAdapterAddress], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + { + to: route.paraswapAdapterAddress, + data: encodeFunctionData({ + abi: paraswapAdapterAbi, + functionName: 'sell', + args: [ + swapTxPayload.to, + swapTxPayload.data, + market.collateralAsset.address as Address, + market.loanAsset.address as Address, + false, + sellOffsets, + route.generalAdapterAddress, + ], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + ]; + + if (autoWithdrawCollateralAmount > 0n) { + callbackBundle.push({ + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoWithdrawCollateral', + args: [marketParams, autoWithdrawCollateralAmount, account as Address], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }); + } + + const callbackBundleData = encodeBundler3Calls(callbackBundle); + const bundleCalls: Bundler3Call[] = [ + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'morphoFlashLoan', + args: [market.loanAsset.address as Address, flashLoanAmount, callbackBundleData], + }), + value: 0n, + skipRevert: false, + callbackHash: keccak256(callbackBundleData), + }, + { + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: 'erc20Transfer', + args: [market.loanAsset.address as Address, account as Address, maxUint256], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + ]; + + tracking.update('execute'); + await new Promise((resolve) => setTimeout(resolve, 700)); + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: (encodeFunctionData({ + abi: bundlerV3Abi, + functionName: 'multicall', + args: [bundleCalls], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: 0n, + }); + } else { + const callbackTxs: `0x${string}`[] = [ encodeFunctionData({ abi: morphoBundlerAbi, - functionName: 'morphoWithdrawCollateral', + 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), - }, - autoWithdrawCollateralAmount, + marketParams, + isRepayByShares ? 0n : flashLoanAmount, + isRepayByShares ? repayBySharesAmount : 0n, + isRepayByShares ? flashLoanAmount : minRepayShares, account as Address, + '0x', ], }), + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdrawCollateral', + args: [marketParams, withdrawCollateralAmount, bundlerAddress as Address], + }), + ]; + + const minAssetsOut = withSlippageFloor(flashLoanAmount); + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Redeem', + args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address], + }), ); - } - const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); - txs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoFlashLoan', - args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData], - }), - ); + if (autoWithdrawCollateralAmount > 0n) { + // WHY: if deleverage fully clears debt, keeping collateral locked in Morpho adds friction. + // We withdraw the remaining collateral in the same transaction so the position is closed. + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdrawCollateral', + args: [marketParams, autoWithdrawCollateralAmount, account as Address], + }), + ); + } - tracking.update('execute'); - await new Promise((resolve) => setTimeout(resolve, 700)); + const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoFlashLoan', + args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData], + }), + ); - await sendTransactionAsync({ - account, - to: bundlerAddress, - data: (encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'multicall', - args: [txs], - }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, - value: 0n, - }); + tracking.update('execute'); + await new Promise((resolve) => setTimeout(resolve, 700)); + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: (encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [txs], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: 0n, + }); + } batchAddUserMarkets([ { @@ -258,7 +447,8 @@ export function useDeleverageTransaction({ flashLoanAmount, repayBySharesAmount, autoWithdrawCollateralAmount, - usePermit2Setting, + swapPriceRoute, + useSignatureAuthorization, ensureBundlerAuthorization, bundlerAddress, sendTransactionAsync, @@ -274,9 +464,9 @@ export function useDeleverageTransaction({ } try { - const initialStep = usePermit2Setting ? 'authorize_bundler_sig' : 'authorize_bundler_tx'; + const initialStep = useSignatureAuthorization ? 'authorize_bundler_sig' : 'authorize_bundler_tx'; tracking.start( - getStepsForFlow(usePermit2Setting), + getStepsForFlow(useSignatureAuthorization, isSwapRoute), { title: 'Deleverage', description: `${market.collateralAsset.symbol} unwound into ${market.loanAsset.symbol}`, @@ -296,7 +486,17 @@ export function useDeleverageTransaction({ toast.error('Error', userFacingMessage); } } - }, [account, usePermit2Setting, tracking, getStepsForFlow, market, withdrawCollateralAmount, executeDeleverage, toast]); + }, [ + account, + useSignatureAuthorization, + tracking, + getStepsForFlow, + isSwapRoute, + market, + withdrawCollateralAmount, + executeDeleverage, + toast, + ]); const isLoading = deleveragePending || isAuthorizingBundler; diff --git a/src/hooks/useLeverageSupport.ts b/src/hooks/useLeverageSupport.ts deleted file mode 100644 index f25cc6d3..00000000 --- a/src/hooks/useLeverageSupport.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { useMemo } from 'react'; -import { getChainAddresses } from '@morpho-org/blue-sdk'; -import { type Address, isAddressEqual, zeroAddress } from 'viem'; -import { useReadContracts } from 'wagmi'; -import { erc4626Abi } from '@/abis/erc4626'; -import type { Market } from '@/utils/types'; -import type { Erc4626LeverageRoute, LeverageSupport, SwapLeverageRoute } from './leverage/types'; - -type UseLeverageSupportParams = { - market: Market; -}; - -/** - * Detects whether a market can be levered/delevered with deterministic V2 routes. - * - * Supported route: - * - ERC4626 collateral where `vault.asset() == loanToken` - */ -export function useLeverageSupport({ market }: UseLeverageSupportParams): LeverageSupport { - const loanToken = market.loanAsset.address as Address; - const collateralToken = market.collateralAsset.address as Address; - const chainId = market.morphoBlue.chain.id; - - const { data, isLoading, isRefetching } = useReadContracts({ - contracts: [ - { - address: collateralToken, - abi: erc4626Abi, - functionName: 'asset', - args: [], - chainId, - }, - ], - allowFailure: true, - query: { - enabled: !!collateralToken && collateralToken !== zeroAddress, - }, - }); - - return useMemo((): LeverageSupport => { - const erc4626Asset = data?.[0]?.result as Address | undefined; - const hasErc4626Asset = !!erc4626Asset && erc4626Asset !== zeroAddress; - - if (hasErc4626Asset && isAddressEqual(erc4626Asset, loanToken)) { - const route: Erc4626LeverageRoute = { - kind: 'erc4626', - collateralVault: collateralToken, - underlyingLoanToken: loanToken, - }; - - return { - isSupported: true, - supportsLeverage: true, - supportsDeleverage: true, - isLoading: isLoading || isRefetching, - route, - reason: null, - }; - } - - if (!isLoading && !isRefetching) { - try { - // Bundler3 adapter addresses are sourced from the canonical blue-sdk chain registry. - // They are not hardcoded in this repository. - const chainAddresses = getChainAddresses(chainId); - const bundler3Addresses = chainAddresses?.bundler3; - - if (bundler3Addresses?.bundler3 && bundler3Addresses.generalAdapter1 && bundler3Addresses.paraswapAdapter) { - const route: SwapLeverageRoute = { - kind: 'swap', - bundler3Address: bundler3Addresses.bundler3 as Address, - generalAdapterAddress: bundler3Addresses.generalAdapter1 as Address, - paraswapAdapterAddress: bundler3Addresses.paraswapAdapter as Address, - }; - - return { - isSupported: true, - supportsLeverage: true, - supportsDeleverage: false, - isLoading: false, - route, - reason: 'Deleverage is not yet available for swap-backed leverage routes.', - }; - } - } catch { - // Unsupported chain in the blue-sdk addresses registry. - } - } - - return { - isSupported: false, - supportsLeverage: false, - supportsDeleverage: false, - isLoading: isLoading || isRefetching, - route: null, - reason: 'Leverage is currently available for ERC4626 routes on Bundler V2, or swap routes where Bundler3 + Paraswap adapter are deployed.', - }; - }, [chainId, collateralToken, loanToken, data, isLoading, isRefetching]); -} diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index af520d8b..30815651 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { type Address, encodeAbiParameters, encodeFunctionData, keccak256, maxUint256, zeroHash } from 'viem'; +import { type Address, encodeAbiParameters, encodeFunctionData, isAddress, isAddressEqual, keccak256, maxUint256, zeroHash } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { bundlerV3Abi } from '@/abis/bundlerV3'; @@ -19,6 +19,7 @@ import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; +import { type Bundler3Call, encodeBundler3Calls, getParaswapSellOffsets, readCalldataUint256 } from './leverage/bundler3'; import { computeBorrowSharesWithBuffer, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; @@ -43,58 +44,9 @@ type UseLeverageTransactionProps = { }; const LEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); -const PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR = '0xe3ead59e'; -const PARASWAP_SELL_EXACT_AMOUNT_OFFSET = 100n; -const PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET = 132n; -const PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET = 164n; - -type Bundler3Call = { - to: Address; - data: `0x${string}`; - value: bigint; - skipRevert: boolean; - callbackHash: `0x${string}`; -}; - -const BUNDLER3_CALLS_ABI_PARAMS = [ - { - type: 'tuple[]', - components: [ - { type: 'address', name: 'to' }, - { type: 'bytes', name: 'data' }, - { type: 'uint256', name: 'value' }, - { type: 'bool', name: 'skipRevert' }, - { type: 'bytes32', name: 'callbackHash' }, - ], - }, -] as const; - -const encodeBundler3Calls = (bundle: Bundler3Call[]): `0x${string}` => { - return encodeAbiParameters(BUNDLER3_CALLS_ABI_PARAMS, [bundle]); -}; - -const getParaswapSellOffsets = (augustusCallData: `0x${string}`) => { - const selector = augustusCallData.slice(0, 10).toLowerCase(); - if (selector !== PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR) { - throw new Error('Unsupported Velora swap method for Paraswap adapter route.'); - } - - return { - exactAmount: PARASWAP_SELL_EXACT_AMOUNT_OFFSET, - limitAmount: PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET, - quotedAmount: PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET, - } as const; -}; - -const readCalldataUint256 = (callData: `0x${string}`, offset: bigint): bigint => { - const byteOffset = Number(offset); - const start = 2 + byteOffset * 2; - const end = start + 64; - if (callData.length < end) { - throw new Error('Invalid Paraswap calldata for swap-backed leverage.'); - } - - return BigInt(`0x${callData.slice(start, end)}`); +const isVeloraAllowanceCheckError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return message.includes('allowance given to tokentransferproxy') || (message.includes('not enough') && message.includes('allowance')); }; /** @@ -119,7 +71,7 @@ export function useLeverageTransaction({ const toast = useStyledToast(); const isSwapRoute = route?.kind === 'swap'; const usePermit2ForRoute = usePermit2Setting && !isSwapRoute; - + const bundlerAddress = useMemo
(() => { if (route?.kind === 'swap') { return route.bundler3Address; @@ -132,7 +84,7 @@ export function useLeverageTransaction({ } return bundlerAddress; }, [route, bundlerAddress]); - + const { batchAddUserMarkets } = useUserMarketsCache(account); const isLoanAssetInput = !isSwapRoute && useLoanAssetAsInput; const inputTokenAddress = isLoanAssetInput ? (market.loanAsset.address as Address) : (market.collateralAsset.address as Address); @@ -326,8 +278,8 @@ export function useLeverageTransaction({ const activePriceRoute = swapPriceRoute; const swapTxPayload = await (async () => { - try { - return await buildVeloraTransactionPayload({ + const buildPayload = async (ignoreChecks: boolean) => + buildVeloraTransactionPayload({ srcToken: market.loanAsset.address, srcDecimals: market.loanAsset.decimals, destToken: market.collateralAsset.address, @@ -338,15 +290,37 @@ export function useLeverageTransaction({ priceRoute: activePriceRoute, slippageBps: LEVERAGE_SWAP_SLIPPAGE_BPS, side: 'SELL', + ignoreChecks, }); + + try { + return await buildPayload(false); } catch (buildError: unknown) { if (isVeloraRateChangedError(buildError)) { throw new Error('Leverage quote changed. Please review the updated preview and try again.'); } - throw buildError; + if (!isVeloraAllowanceCheckError(buildError)) { + throw buildError; + } + + try { + return await buildPayload(true); + } catch (fallbackBuildError: unknown) { + if (isVeloraRateChangedError(fallbackBuildError)) { + throw new Error('Leverage quote changed. Please review the updated preview and try again.'); + } + throw fallbackBuildError; + } } })(); + const trustedVeloraTargets = [activePriceRoute.contractAddress, activePriceRoute.tokenTransferProxy].filter( + (candidate): candidate is Address => typeof candidate === 'string' && isAddress(candidate), + ); + if (trustedVeloraTargets.length === 0 || !trustedVeloraTargets.some((target) => isAddressEqual(swapTxPayload.to, target))) { + throw new Error('Leverage quote changed. Please review the updated preview and try again.'); + } + const minCollateralOut = withSlippageFloor(BigInt(activePriceRoute.destAmount)); if (minCollateralOut <= 0n) { throw new Error('Velora returned zero collateral output for leverage swap.'); @@ -356,7 +330,11 @@ export function useLeverageTransaction({ const quotedBorrowAssets = BigInt(activePriceRoute.srcAmount); const calldataSellAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount); const calldataMinCollateralOut = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount); - if (quotedBorrowAssets !== flashLoanAmount || calldataSellAmount !== flashLoanAmount || calldataMinCollateralOut !== minCollateralOut) { + if ( + quotedBorrowAssets !== flashLoanAmount || + calldataSellAmount !== flashLoanAmount || + calldataMinCollateralOut !== minCollateralOut + ) { throw new Error('Leverage quote changed. Please review the updated preview and try again.'); } @@ -619,7 +597,18 @@ export function useLeverageTransaction({ toast.error('Error', userFacingMessage); } } - }, [account, usePermit2ForRoute, tracking, getStepsForFlow, isSwapRoute, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); + }, [ + account, + usePermit2ForRoute, + tracking, + getStepsForFlow, + isSwapRoute, + market, + inputTokenSymbol, + collateralAmount, + executeLeverage, + toast, + ]); const signAndLeverage = useCallback(async () => { if (!usePermit2ForRoute) { diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index 781e171f..ee11335f 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -35,9 +35,7 @@ export function useMultiMarketSupply( const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); const bundlerAddress = chainId ? getBundlerV2(chainId) : zeroAddress; const isBundlerAddressValid = chainId !== undefined && bundlerAddress !== zeroAddress; - const bundlerAddressErrorMessage = chainId - ? `No bundler configured for chain ${chainId}.` - : 'No chain selected for multi-market supply.'; + const bundlerAddressErrorMessage = chainId ? `No bundler configured for chain ${chainId}.` : 'No chain selected for multi-market supply.'; const { batchAddUserMarkets } = useUserMarketsCache(account); diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index d63b918f..e7f561d3 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -12,7 +12,6 @@ import { AddCollateralAndBorrow } from './components/add-collateral-and-borrow'; import { WithdrawCollateralAndRepay } from './components/withdraw-collateral-and-repay'; import { TokenIcon } from '@/components/shared/token-icon'; import { useModal } from '@/hooks/useModal'; -import { useLeverageSupport } from '@/hooks/useLeverageSupport'; type BorrowModalProps = { market: Market; @@ -40,15 +39,13 @@ export function BorrowModal({ const [mode, setMode] = useState<'borrow' | 'repay'>(() => defaultMode); const { address: account } = useConnection(); const { open: openModal } = useModal(); - const leverageSupport = useLeverageSupport({ market }); useEffect(() => { setMode(defaultMode); }, [defaultMode]); const leverageModalMode = mode === 'repay' ? 'deleverage' : 'leverage'; - const canOpenLeverageModal = - !leverageSupport.isLoading && (mode === 'borrow' ? leverageSupport.supportsLeverage : leverageSupport.supportsDeleverage); + const canOpenLeverageModal = true; const modeOptions: { value: string; label: string }[] = toggleBorrowRepay ? [ { value: 'borrow', label: `Borrow ${market.loanAsset.symbol}` }, diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 04591b75..7957986f 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -24,12 +24,12 @@ import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; import { convertApyToApr } from '@/utils/rateMath'; -import type { LeverageSupport } from '@/hooks/leverage/types'; +import type { LeverageRoute } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; type AddCollateralAndLeverageProps = { market: Market; - support: LeverageSupport; + route: LeverageRoute | null; currentPosition: MarketPosition | null; collateralTokenBalance: bigint | undefined; oraclePrice: bigint; @@ -41,14 +41,13 @@ const MULTIPLIER_INPUT_REGEX = /^\d*\.?\d*$/; export function AddCollateralAndLeverage({ market, - support, + route, currentPosition, collateralTokenBalance, oraclePrice, onSuccess, isRefreshing = false, }: AddCollateralAndLeverageProps): JSX.Element { - const route = support.route; const { address: account } = useConnection(); const { usePermit2: usePermit2Setting, isAprDisplay } = useAppSettings(); @@ -177,8 +176,8 @@ export function AddCollateralAndLeverage({ if (onSuccess) onSuccess(); }, [onSuccess]); - const { transaction, isLoadingPermit2, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = - useLeverageTransaction({ + const { transaction, isLoadingPermit2, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = useLeverageTransaction( + { market, route, collateralAmount, @@ -188,7 +187,8 @@ export function AddCollateralAndLeverage({ swapPriceRoute: quote.swapPriceRoute, useLoanAssetAsInput: useLoanAssetInput, onSuccess: handleTransactionSuccess, - }); + }, + ); const handleMultiplierInputChange = useCallback((value: string) => { const normalized = value.replace(',', '.'); @@ -425,7 +425,6 @@ export function AddCollateralAndLeverage({ onClick={handleLeverage} isLoading={isLoadingPermit2 || leveragePending || quote.isLoading || isLoadingInputConversion} disabled={ - !support.supportsLeverage || route == null || collateralInputError !== null || conversionErrorMessage !== null || diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx index 65057b61..bd4caff8 100644 --- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; +import { useConnection } from 'wagmi'; import Input from '@/components/Input/Input'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { Tooltip } from '@/components/ui/tooltip'; @@ -9,13 +10,13 @@ import { useDeleverageQuote } from '@/hooks/useDeleverageQuote'; import { useDeleverageTransaction } from '@/hooks/useDeleverageTransaction'; import { formatBalance } from '@/utils/balance'; import type { Market, MarketPosition } from '@/utils/types'; -import type { LeverageSupport } from '@/hooks/leverage/types'; +import type { LeverageRoute } from '@/hooks/leverage/types'; import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; type RemoveCollateralAndDeleverageProps = { market: Market; - support: LeverageSupport; + route: LeverageRoute | null; currentPosition: MarketPosition | null; oraclePrice: bigint; onSuccess?: () => void; @@ -24,13 +25,14 @@ type RemoveCollateralAndDeleverageProps = { export function RemoveCollateralAndDeleverage({ market, - support, + route, currentPosition, oraclePrice, onSuccess, isRefreshing = false, }: RemoveCollateralAndDeleverageProps): JSX.Element { - const route = support.route?.kind === 'erc4626' ? support.route : null; + const { address: account } = useConnection(); + const isSwapRoute = route?.kind === 'swap'; const [withdrawCollateralAmount, setWithdrawCollateralAmount] = useState(0n); const [withdrawInputError, setWithdrawInputError] = useState(null); @@ -44,6 +46,11 @@ export function RemoveCollateralAndDeleverage({ route, withdrawCollateralAmount, currentBorrowAssets, + loanTokenAddress: market.loanAsset.address, + loanTokenDecimals: market.loanAsset.decimals, + collateralTokenAddress: market.collateralAsset.address, + collateralTokenDecimals: market.collateralAsset.decimals, + userAddress: account as `0x${string}` | undefined, }); const projection = useMemo( @@ -102,6 +109,7 @@ export function RemoveCollateralAndDeleverage({ flashLoanAmount: projection.flashLoanAmountForTx, repayBySharesAmount: projection.repayBySharesAmount, autoWithdrawCollateralAmount: projection.autoWithdrawCollateralAmount, + swapPriceRoute: quote.swapPriceRoute, onSuccess: handleTransactionSuccess, }); @@ -204,6 +212,12 @@ export function RemoveCollateralAndDeleverage({ Projected LTV {formatLtvPercent(projectedLTV)}% + {isSwapRoute && ( +
+ Route + Bundler3 + Velora +
+ )} {quote.error &&

{quote.error}

} @@ -216,7 +230,6 @@ export function RemoveCollateralAndDeleverage({ onClick={handleDeleverage} isLoading={deleveragePending || quote.isLoading} disabled={ - !support.supportsDeleverage || route == null || withdrawInputError !== null || quote.error !== null || diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index ff06eaff..2b195d1e 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -1,11 +1,13 @@ -import { useCallback, useState } from 'react'; -import { erc20Abi } from 'viem'; -import { useConnection, useReadContract } from 'wagmi'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { getChainAddresses } from '@morpho-org/blue-sdk'; +import { type Address, erc20Abi, isAddressEqual, zeroAddress } from 'viem'; +import { useConnection, useReadContract, useReadContracts } from 'wagmi'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import { TokenIcon } from '@/components/shared/token-icon'; import { Badge } from '@/components/ui/badge'; -import { useLeverageSupport } from '@/hooks/useLeverageSupport'; +import { erc4626Abi } from '@/abis/erc4626'; +import type { LeverageRoute, SwapLeverageRoute } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; import { AddCollateralAndLeverage } from './components/add-collateral-and-leverage'; import { RemoveCollateralAndDeleverage } from './components/remove-collateral-and-deleverage'; @@ -32,10 +34,84 @@ export function LeverageModal({ toggleLeverageDeleverage = true, }: LeverageModalProps): JSX.Element { const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); + const [routeMode, setRouteMode] = useState<'swap' | 'erc4626'>('swap'); const { address: account } = useConnection(); - const support = useLeverageSupport({ market }); - const isErc4626Route = support.route?.kind === 'erc4626'; - const isSwapRoute = support.route?.kind === 'swap'; + + const swapRoute = useMemo(() => { + try { + const chainAddresses = getChainAddresses(market.morphoBlue.chain.id); + const bundler3Addresses = chainAddresses?.bundler3; + if (!bundler3Addresses?.bundler3 || !bundler3Addresses.generalAdapter1 || !bundler3Addresses.paraswapAdapter) { + return null; + } + + return { + kind: 'swap', + bundler3Address: bundler3Addresses.bundler3 as Address, + generalAdapterAddress: bundler3Addresses.generalAdapter1 as Address, + paraswapAdapterAddress: bundler3Addresses.paraswapAdapter as Address, + }; + } catch { + return null; + } + }, [market.morphoBlue.chain.id]); + + const { + data: erc4626ProbeData, + isLoading: isErc4626ProbeLoading, + isRefetching: isErc4626ProbeRefetching, + } = useReadContracts({ + contracts: [ + { + address: market.collateralAsset.address as Address, + abi: erc4626Abi, + functionName: 'asset', + args: [], + chainId: market.morphoBlue.chain.id, + }, + ], + allowFailure: true, + query: { + enabled: !!market.collateralAsset.address && market.collateralAsset.address !== zeroAddress, + }, + }); + + const isErc4626ModeAvailable = useMemo(() => { + const erc4626Asset = erc4626ProbeData?.[0]?.result as Address | undefined; + return !!erc4626Asset && erc4626Asset !== zeroAddress && isAddressEqual(erc4626Asset, market.loanAsset.address as Address); + }, [erc4626ProbeData, market.loanAsset.address]); + + const availableRouteModes = useMemo>(() => { + const modes: Array<'swap' | 'erc4626'> = []; + if (swapRoute) modes.push('swap'); + if (isErc4626ModeAvailable) modes.push('erc4626'); + return modes; + }, [swapRoute, isErc4626ModeAvailable]); + + useEffect(() => { + if (availableRouteModes.length === 0) return; + if (!availableRouteModes.includes(routeMode)) { + setRouteMode(availableRouteModes[0]); + } + }, [availableRouteModes, routeMode]); + + const route = useMemo(() => { + if (routeMode === 'erc4626' && isErc4626ModeAvailable) { + return { + kind: 'erc4626', + collateralVault: market.collateralAsset.address as Address, + underlyingLoanToken: market.loanAsset.address as Address, + }; + } + + return swapRoute; + }, [routeMode, isErc4626ModeAvailable, market.collateralAsset.address, market.loanAsset.address, swapRoute]); + const isErc4626Route = route?.kind === 'erc4626'; + const isSwapRoute = route?.kind === 'swap'; + const routeModeOptions: { value: string; label: string }[] = availableRouteModes.map((value) => ({ + value, + label: value === 'swap' ? 'Swap' : 'ERC4626', + })); const effectiveMode = mode; const modeOptions: { value: string; label: string }[] = toggleLeverageDeleverage @@ -129,6 +205,14 @@ export function LeverageModal({ #SWAP )} + {routeModeOptions.length > 1 && ( + setRouteMode(nextRouteMode as 'swap' | 'erc4626')} + className="text-xs text-secondary" + /> + )} } description={ @@ -141,41 +225,37 @@ export function LeverageModal({ : isErc4626Route ? `Reduce ERC4626 leveraged exposure by unwinding your ${market.collateralAsset.symbol} loop.` : isSwapRoute - ? `Deleverage is not yet available for the swap route on this market.` + ? `Reduce leveraged exposure by swapping withdrawn ${market.collateralAsset.symbol} back into ${market.loanAsset.symbol} via Bundler3 + Velora.` : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` } /> - {support.isSupported ? ( + {route ? ( effectiveMode === 'leverage' ? ( - ) : support.supportsDeleverage ? ( + ) : ( - ) : ( -
- {support.reason ?? 'Deleverage is not available for this route.'} -
) - ) : support.isLoading ? ( -
Checking leverage route support...
+ ) : isErc4626ProbeLoading || isErc4626ProbeRefetching ? ( +
Checking available leverage routes...
) : (
- {support.reason ?? 'This market is not supported by the V2 leverage routes.'} + Swap route configuration is unavailable for this network.
)}
From 2079e8f41aa2e84a4dc683103827a1a5a104109a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 28 Feb 2026 00:02:50 +0800 Subject: [PATCH 3/4] chore: self review fixes --- AGENTS.md | 1 + .../leverage/useLeverageRouteAvailability.ts | 93 ++++++++++++++++ src/hooks/useDeleverageQuote.ts | 3 +- src/modals/borrow/borrow-modal.tsx | 8 +- src/modals/leverage/leverage-modal.tsx | 105 +++++++----------- 5 files changed, 141 insertions(+), 69 deletions(-) create mode 100644 src/hooks/leverage/useLeverageRouteAvailability.ts diff --git a/AGENTS.md b/AGENTS.md index f42adad7..16cbad86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,7 @@ When touching transaction and position flows, validation MUST include all releva 15. **Swap leverage/deleverage flashloan callback integrity**: Bundler3 swap leverage/deleverage must use adapter flashloan callbacks (not pre-swap borrow gating), with `callbackHash`/`reenter` wiring and adapter token flows matching on-chain contracts; before submit, verify Velora quote/tx parity (route src amount + Paraswap calldata exact/min offsets) so previewed borrow/repay/collateral amounts cannot drift from executed inputs. 16. **Multi-leg quote completeness for swap previews**: when a preview depends on multiple aggregator legs (e.g. SELL repay quote + BUY max-collateral bound), surface failures from every required leg and use conservative fallbacks (`0`, disable submit) instead of optimistic defaults so partial quote failures cannot overstate safe unwind amounts. 17. **Adapter-executed aggregator build integrity**: for Bundler3 adapter swaps, never hard-require wallet-level allowance checks from aggregator build endpoints; attempt normal build first, retry with endpoint `ignoreChecks` only for allowance-specific failures, and fail closed unless the built transaction target matches trusted addresses from the quoted route. +18. **Route-selection and entrypoint consistency**: in leverage/deleverage UIs, selected route mode must never execute a different route while capability probes are in-flight; unsupported-entrypoint CTAs must be gated by executable route availability (not transient probe states) to avoid dead-end modal paths and false unsupported flashes. ### REQUIRED: Regression Rule Capture diff --git a/src/hooks/leverage/useLeverageRouteAvailability.ts b/src/hooks/leverage/useLeverageRouteAvailability.ts new file mode 100644 index 00000000..e55dc11a --- /dev/null +++ b/src/hooks/leverage/useLeverageRouteAvailability.ts @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { getChainAddresses } from '@morpho-org/blue-sdk'; +import { type Address, isAddressEqual, zeroAddress } from 'viem'; +import { useReadContracts } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +import type { SwapLeverageRoute } from './types'; + +type LeverageRouteMode = 'swap' | 'erc4626'; + +type UseLeverageRouteAvailabilityParams = { + chainId: number; + collateralTokenAddress: string; + loanTokenAddress: string; +}; + +type UseLeverageRouteAvailabilityResult = { + swapRoute: SwapLeverageRoute | null; + isErc4626ModeAvailable: boolean; + availableRouteModes: LeverageRouteMode[]; + isErc4626ProbeLoading: boolean; + isErc4626ProbeRefetching: boolean; + erc4626ProbeError: unknown; + hasAnyRoute: boolean; +}; + +export function useLeverageRouteAvailability({ + chainId, + collateralTokenAddress, + loanTokenAddress, +}: UseLeverageRouteAvailabilityParams): UseLeverageRouteAvailabilityResult { + const swapRoute = useMemo(() => { + try { + const chainAddresses = getChainAddresses(chainId); + const bundler3Addresses = chainAddresses?.bundler3; + if (!bundler3Addresses?.bundler3 || !bundler3Addresses.generalAdapter1 || !bundler3Addresses.paraswapAdapter) { + return null; + } + + return { + kind: 'swap', + bundler3Address: bundler3Addresses.bundler3 as Address, + generalAdapterAddress: bundler3Addresses.generalAdapter1 as Address, + paraswapAdapterAddress: bundler3Addresses.paraswapAdapter as Address, + }; + } catch { + return null; + } + }, [chainId]); + + const { + data: erc4626ProbeData, + isLoading: isErc4626ProbeLoading, + isRefetching: isErc4626ProbeRefetching, + error: erc4626ProbeError, + } = useReadContracts({ + contracts: [ + { + address: collateralTokenAddress as Address, + abi: erc4626Abi, + functionName: 'asset', + args: [], + chainId, + }, + ], + allowFailure: true, + query: { + enabled: !!collateralTokenAddress && collateralTokenAddress !== zeroAddress, + }, + }); + + const isErc4626ModeAvailable = useMemo(() => { + const erc4626Asset = erc4626ProbeData?.[0]?.result as Address | undefined; + return !!erc4626Asset && erc4626Asset !== zeroAddress && isAddressEqual(erc4626Asset, loanTokenAddress as Address); + }, [erc4626ProbeData, loanTokenAddress]); + + const availableRouteModes = useMemo(() => { + const modes: LeverageRouteMode[] = []; + // Prefer deterministic ERC4626 route by default when available. + if (isErc4626ModeAvailable) modes.push('erc4626'); + if (swapRoute) modes.push('swap'); + return modes; + }, [isErc4626ModeAvailable, swapRoute]); + + return { + swapRoute, + isErc4626ModeAvailable, + availableRouteModes, + isErc4626ProbeLoading, + isErc4626ProbeRefetching, + erc4626ProbeError, + hasAnyRoute: availableRouteModes.length > 0, + }; +} diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index 86cafc96..0fe1b9ff 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -182,7 +182,7 @@ export function useDeleverageQuote({ } const routeError = (withdrawCollateralAmount > 0n ? swapRepayQuoteQuery.error : null) ?? - (withdrawCollateralAmount > 0n ? swapMaxCollateralForDebtQuery.error : null); + (bufferedBorrowAssets > 0n ? swapMaxCollateralForDebtQuery.error : null); if (!routeError) return null; return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for deleverage.'; } @@ -193,6 +193,7 @@ export function useDeleverageQuote({ route, userAddress, withdrawCollateralAmount, + bufferedBorrowAssets, swapRepayQuoteQuery.error, swapMaxCollateralForDebtQuery.error, redeemError, diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index e7f561d3..377c3d81 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -11,6 +11,7 @@ import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing import { AddCollateralAndBorrow } from './components/add-collateral-and-borrow'; import { WithdrawCollateralAndRepay } from './components/withdraw-collateral-and-repay'; import { TokenIcon } from '@/components/shared/token-icon'; +import { useLeverageRouteAvailability } from '@/hooks/leverage/useLeverageRouteAvailability'; import { useModal } from '@/hooks/useModal'; type BorrowModalProps = { @@ -39,13 +40,18 @@ export function BorrowModal({ const [mode, setMode] = useState<'borrow' | 'repay'>(() => defaultMode); const { address: account } = useConnection(); const { open: openModal } = useModal(); + const { hasAnyRoute } = useLeverageRouteAvailability({ + chainId: market.morphoBlue.chain.id, + collateralTokenAddress: market.collateralAsset.address, + loanTokenAddress: market.loanAsset.address, + }); useEffect(() => { setMode(defaultMode); }, [defaultMode]); const leverageModalMode = mode === 'repay' ? 'deleverage' : 'leverage'; - const canOpenLeverageModal = true; + const canOpenLeverageModal = hasAnyRoute; const modeOptions: { value: string; label: string }[] = toggleBorrowRepay ? [ { value: 'borrow', label: `Borrow ${market.loanAsset.symbol}` }, diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index 2b195d1e..0d6d3709 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -1,13 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { getChainAddresses } from '@morpho-org/blue-sdk'; -import { type Address, erc20Abi, isAddressEqual, zeroAddress } from 'viem'; -import { useConnection, useReadContract, useReadContracts } from 'wagmi'; +import { type Address, erc20Abi } from 'viem'; +import { useConnection, useReadContract } from 'wagmi'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import { TokenIcon } from '@/components/shared/token-icon'; import { Badge } from '@/components/ui/badge'; -import { erc4626Abi } from '@/abis/erc4626'; -import type { LeverageRoute, SwapLeverageRoute } from '@/hooks/leverage/types'; +import { useLeverageRouteAvailability } from '@/hooks/leverage/useLeverageRouteAvailability'; +import type { LeverageRoute } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; import { AddCollateralAndLeverage } from './components/add-collateral-and-leverage'; import { RemoveCollateralAndDeleverage } from './components/remove-collateral-and-deleverage'; @@ -34,78 +33,50 @@ export function LeverageModal({ toggleLeverageDeleverage = true, }: LeverageModalProps): JSX.Element { const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); - const [routeMode, setRouteMode] = useState<'swap' | 'erc4626'>('swap'); + const [routeMode, setRouteMode] = useState<'swap' | 'erc4626'>('erc4626'); const { address: account } = useConnection(); - const swapRoute = useMemo(() => { - try { - const chainAddresses = getChainAddresses(market.morphoBlue.chain.id); - const bundler3Addresses = chainAddresses?.bundler3; - if (!bundler3Addresses?.bundler3 || !bundler3Addresses.generalAdapter1 || !bundler3Addresses.paraswapAdapter) { - return null; - } - - return { - kind: 'swap', - bundler3Address: bundler3Addresses.bundler3 as Address, - generalAdapterAddress: bundler3Addresses.generalAdapter1 as Address, - paraswapAdapterAddress: bundler3Addresses.paraswapAdapter as Address, - }; - } catch { - return null; - } - }, [market.morphoBlue.chain.id]); - - const { - data: erc4626ProbeData, - isLoading: isErc4626ProbeLoading, - isRefetching: isErc4626ProbeRefetching, - } = useReadContracts({ - contracts: [ - { - address: market.collateralAsset.address as Address, - abi: erc4626Abi, - functionName: 'asset', - args: [], - chainId: market.morphoBlue.chain.id, - }, - ], - allowFailure: true, - query: { - enabled: !!market.collateralAsset.address && market.collateralAsset.address !== zeroAddress, - }, - }); - - const isErc4626ModeAvailable = useMemo(() => { - const erc4626Asset = erc4626ProbeData?.[0]?.result as Address | undefined; - return !!erc4626Asset && erc4626Asset !== zeroAddress && isAddressEqual(erc4626Asset, market.loanAsset.address as Address); - }, [erc4626ProbeData, market.loanAsset.address]); - - const availableRouteModes = useMemo>(() => { - const modes: Array<'swap' | 'erc4626'> = []; - if (swapRoute) modes.push('swap'); - if (isErc4626ModeAvailable) modes.push('erc4626'); - return modes; - }, [swapRoute, isErc4626ModeAvailable]); + const { swapRoute, isErc4626ModeAvailable, availableRouteModes, isErc4626ProbeLoading, isErc4626ProbeRefetching } = + useLeverageRouteAvailability({ + chainId: market.morphoBlue.chain.id, + collateralTokenAddress: market.collateralAsset.address, + loanTokenAddress: market.loanAsset.address, + }); useEffect(() => { if (availableRouteModes.length === 0) return; - if (!availableRouteModes.includes(routeMode)) { - setRouteMode(availableRouteModes[0]); - } - }, [availableRouteModes, routeMode]); + if (availableRouteModes.includes(routeMode)) return; + + const waitingForErc4626Availability = + routeMode === 'erc4626' && !isErc4626ModeAvailable && (isErc4626ProbeLoading || isErc4626ProbeRefetching); + if (waitingForErc4626Availability) return; + + setRouteMode(availableRouteModes[0]); + }, [availableRouteModes, routeMode, isErc4626ModeAvailable, isErc4626ProbeLoading, isErc4626ProbeRefetching]); const route = useMemo(() => { - if (routeMode === 'erc4626' && isErc4626ModeAvailable) { - return { - kind: 'erc4626', - collateralVault: market.collateralAsset.address as Address, - underlyingLoanToken: market.loanAsset.address as Address, - }; + if (routeMode === 'erc4626') { + if (isErc4626ModeAvailable) { + return { + kind: 'erc4626', + collateralVault: market.collateralAsset.address as Address, + underlyingLoanToken: market.loanAsset.address as Address, + }; + } + if (isErc4626ProbeLoading || isErc4626ProbeRefetching) return null; + return swapRoute; } return swapRoute; - }, [routeMode, isErc4626ModeAvailable, market.collateralAsset.address, market.loanAsset.address, swapRoute]); + }, [ + routeMode, + isErc4626ModeAvailable, + isErc4626ProbeLoading, + isErc4626ProbeRefetching, + market.collateralAsset.address, + market.loanAsset.address, + swapRoute, + ]); const isErc4626Route = route?.kind === 'erc4626'; const isSwapRoute = route?.kind === 'swap'; const routeModeOptions: { value: string; label: string }[] = availableRouteModes.map((value) => ({ From 7cc34c7a63621f1e7e47562a94088bd8927012dd Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 28 Feb 2026 00:21:17 +0800 Subject: [PATCH 4/4] fix: sort and style --- src/features/markets/components/column-visibility.ts | 4 ++-- src/features/markets/components/markets-table-same-loan.tsx | 2 +- src/features/markets/markets-view.tsx | 4 ++-- src/hooks/useFilteredMarkets.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/markets/components/column-visibility.ts b/src/features/markets/components/column-visibility.ts index 51200f35..e80392ef 100644 --- a/src/features/markets/components/column-visibility.ts +++ b/src/features/markets/components/column-visibility.ts @@ -22,8 +22,8 @@ export const DEFAULT_COLUMN_VISIBILITY: ColumnVisibility = { totalBorrow: true, liquidity: false, supplyAPY: true, - borrowAPY: false, - rateAtTarget: false, + borrowAPY: true, + rateAtTarget: true, trustedBy: false, utilizationRate: false, dailySupplyAPY: false, diff --git a/src/features/markets/components/markets-table-same-loan.tsx b/src/features/markets/components/markets-table-same-loan.tsx index 4408d7b9..586cbfa9 100644 --- a/src/features/markets/components/markets-table-same-loan.tsx +++ b/src/features/markets/components/markets-table-same-loan.tsx @@ -505,7 +505,7 @@ export function MarketsTableWithSameLoanAsset({ [SortColumn.COLLATSYMBOL]: 'collateralAsset.symbol', [SortColumn.Supply]: 'state.supplyAssetsUsd', [SortColumn.APY]: 'state.supplyApy', - [SortColumn.Liquidity]: 'state.liquidityAssets', + [SortColumn.Liquidity]: 'state.liquidityAssetsUsd', [SortColumn.Borrow]: 'state.borrowAssetsUsd', [SortColumn.BorrowAPY]: 'state.borrowApy', [SortColumn.RateAtTarget]: 'state.apyAtTarget', diff --git a/src/features/markets/markets-view.tsx b/src/features/markets/markets-view.tsx index eab13ab3..e378ec9d 100644 --- a/src/features/markets/markets-view.tsx +++ b/src/features/markets/markets-view.tsx @@ -185,8 +185,8 @@ export default function Markets() { diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index 96910e16..391c30d1 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -115,7 +115,7 @@ export const useFilteredMarkets = (): Market[] => { [SortColumn.Supply]: 'state.supplyAssetsUsd', [SortColumn.Borrow]: 'state.borrowAssetsUsd', [SortColumn.SupplyAPY]: 'state.supplyApy', - [SortColumn.Liquidity]: 'state.liquidityAssets', + [SortColumn.Liquidity]: 'state.liquidityAssetsUsd', [SortColumn.BorrowAPY]: 'state.borrowApy', [SortColumn.RateAtTarget]: 'state.apyAtTarget', [SortColumn.TrustedBy]: '',