diff --git a/AGENTS.md b/AGENTS.md index 4e762b8c..2c8d5d59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,11 +142,14 @@ When touching transaction and position flows, validation MUST include all releva 7. **UI clarity and duplication checks**: remove duplicate/redundant/low-signal data and keep only decision-critical information. 8. **Null/data-corruption resilience**: guard null/undefined/stale API/contract fields so malformed data fails gracefully. 9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. -10. **Bundler authorization chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook. +10. **Bundler authorization and transfer-authority chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook; Permit2/ERC20 spender scope must target the contract that actually pulls the token (never Bundler3 unless it is the transfer executor), readiness must fail closed, and auth helpers must preserve original wallet/chain errors. 11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. -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. +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, verify returned route token addresses match requested canonical token addresses before using previews/tx payloads, and ensure aggregator `userAddress` / taker fields always match the actual on-chain swap executor (adapter contract for adapter-executed swaps, never an unrelated EOA). 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. **Bundler3 swap route 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 aggregator quote/tx parity (trusted target, exact/min calldata offsets, and same-pair combined-sell normalization) so previewed borrow/repay/collateral amounts cannot drift from executed inputs; prefer exact-in close executors that fully consume the withdrawn collateral over max-sell refund paths that can strand shared-adapter balances, and only relax build-time allowance checks for adapter-executed paths when the failure is allowance-specific. +16. **Quote, preview, and route-state integrity**: when a preview depends on one or more aggregator legs, surface failures from every required leg and use conservative fallbacks (`0`, disable submit) instead of optimistic defaults, but optional exact-close quote legs must not block still-valid partial execution paths; if a close-out path depends on a dedicated debt-close bound (for example BUY/max-close quoting) plus a separate execution preview, full-close / repay-by-shares intent must be driven by one explicit close-route flag shared by preview and tx building, the close executor must be satisfiable under the same slippage floor shown in UI, and if the current sell quote can fully close debt while the exact close bound is still unresolved the UI must fail closed rather than silently degrading to a dust-leaving partial path; for exact-in swap deleverage routes, the exact close bound is a threshold for switching into close mode, not a universal input cap, so valid oversell/refund paths must remain available and previews must continue to match the selected exact-in amount; preview rate/slippage must come from the executable quote/config, selected route mode must never execute a different route while capability probes are in-flight, and route controls/entry CTAs must stay consistent with capability probes without duplicate low-signal UI. +17. **Permit2 time-units and adapter balance hygiene**: Permit2 `expiration`/`sigDeadline` values must always be unix seconds (never milliseconds), and every adapter-executed swap leg must sweep leftover source tokens from the adapter before bundle exit so shared-adapter balances cannot accumulate between transactions. ### 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..21bb7f5f --- /dev/null +++ b/src/abis/morphoGeneralAdapterV1.ts @@ -0,0 +1,114 @@ +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: 'permit2TransferFrom', + 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: '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', + 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..9061026f --- /dev/null +++ b/src/abis/paraswapAdapter.ts @@ -0,0 +1,42 @@ +import type { Abi } from 'viem'; + +/** + * Minimal ParaswapAdapter ABI for Bundler3 swap legs. + */ +export const paraswapAdapterAbi = [ + { + 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: '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/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index 1cededd1..392e8b21 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -65,7 +65,6 @@ export type BuildVeloraTransactionPayloadParams = { userAddress: Address; priceRoute: VeloraPriceRoute; slippageBps: number; - side?: VeloraSwapSide; partner?: string; ignoreChecks?: boolean; }; @@ -174,6 +173,30 @@ const parseVeloraHexDataField = (value: unknown, fieldName: string): `0x${string return value as `0x${string}`; }; +const parseVeloraBigIntField = (value: unknown, fieldName: string, responsePayload: unknown): bigint => { + if (typeof value !== 'string' || value.length === 0) { + throw new VeloraApiError( + getVeloraApiErrorMessage(responsePayload, `Invalid ${fieldName} amount returned by Velora`), + 400, + responsePayload, + ); + } + + try { + const parsed = BigInt(value); + if (parsed < 0n) { + throw new Error('negative'); + } + return parsed; + } catch { + throw new VeloraApiError( + getVeloraApiErrorMessage(responsePayload, `Invalid ${fieldName} amount returned by Velora`), + 400, + responsePayload, + ); + } +}; + const REQUIRED_PRICE_ROUTE_STRING_FIELDS = ['srcToken', 'destToken', 'srcAmount', 'destAmount'] as const; const PRICE_ROUTE_SOURCE_TOKEN_FIELDS = ['fromTokenAddress', 'inputToken', 'srcToken', 'srcTokenAddress'] as const; const PRICE_ROUTE_DESTINATION_TOKEN_FIELDS = ['toTokenAddress', 'outputToken', 'destToken', 'destTokenAddress'] as const; @@ -304,10 +327,53 @@ export const buildVeloraTransactionPayload = async ({ userAddress, priceRoute, slippageBps, - side = 'SELL', partner = SWAP_PARTNER, ignoreChecks = false, }: BuildVeloraTransactionPayloadParams): Promise => { + if (srcAmount <= 0n) { + throw new VeloraApiError('SELL transaction payload requires a source amount greater than zero', 400, { srcAmount }); + } + if (slippageBps <= 0) { + throw new VeloraApiError('SELL transaction payload requires positive slippage', 400, { slippageBps }); + } + + const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken); + const requestedDestinationTokenAddress = toCanonicalTokenAddress(destToken); + if (!requestedSourceTokenAddress || !requestedDestinationTokenAddress) { + throw new VeloraApiError('Invalid source or destination token address provided for Velora transaction request', 400, { + srcToken, + destToken, + network, + }); + } + + const routeSourceTokenAddress = resolveCanonicalRouteTokenAddress(priceRoute, PRICE_ROUTE_SOURCE_TOKEN_FIELDS); + if (routeSourceTokenAddress && routeSourceTokenAddress !== requestedSourceTokenAddress) { + throw new VeloraApiError('Velora route source token does not match the requested source token', 400, { + requestedSourceTokenAddress, + routeSourceTokenAddress, + priceRoute, + }); + } + + const routeDestinationTokenAddress = resolveCanonicalRouteTokenAddress(priceRoute, PRICE_ROUTE_DESTINATION_TOKEN_FIELDS); + if (routeDestinationTokenAddress && routeDestinationTokenAddress !== requestedDestinationTokenAddress) { + throw new VeloraApiError('Velora route destination token does not match the requested destination token', 400, { + requestedDestinationTokenAddress, + routeDestinationTokenAddress, + priceRoute, + }); + } + + const quotedSourceAmount = parseVeloraBigIntField(priceRoute.srcAmount, 'priceRoute.srcAmount', priceRoute); + if (quotedSourceAmount !== srcAmount) { + throw new VeloraApiError('Velora route source amount does not match the requested transaction source amount', 400, { + requestedSourceAmount: srcAmount.toString(), + quotedSourceAmount: quotedSourceAmount.toString(), + priceRoute, + }); + } + const query = new URLSearchParams(); if (ignoreChecks) { query.set('ignoreChecks', 'true'); @@ -317,24 +383,25 @@ export const buildVeloraTransactionPayload = async ({ query.size > 0 ? `${VELORA_API_BASE_URL}/transactions/${network}?${query.toString()}` : `${VELORA_API_BASE_URL}/transactions/${network}`; + const requestBody = { + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount: srcAmount.toString(), + side: 'SELL' as const, + slippage: slippageBps, + priceRoute, + userAddress, + partner, + }; const response = await fetchVeloraJson(transactionUrl, { method: 'POST', headers: { 'content-type': 'application/json', }, - body: JSON.stringify({ - srcToken, - srcDecimals, - destToken, - destDecimals, - srcAmount: srcAmount.toString(), - side, - slippage: slippageBps, - priceRoute, - userAddress, - partner, - }), + body: JSON.stringify(requestBody), }); if (!response || typeof response !== 'object' || !response.to || !response.data) { @@ -368,6 +435,10 @@ export const prepareVeloraSwapPayload = async ({ partner = SWAP_PARTNER, ignoreChecks = false, }: PrepareVeloraSwapPayloadParams): Promise<{ priceRoute: VeloraPriceRoute; txPayload: VeloraTransactionPayload }> => { + if (side !== 'SELL') { + throw new VeloraApiError('Velora transaction payload preparation only supports SELL quotes', 400, { side }); + } + const priceRoute = await fetchVeloraPriceRoute({ srcToken, srcDecimals, @@ -376,7 +447,7 @@ export const prepareVeloraSwapPayload = async ({ amount, network, userAddress, - side, + side: 'SELL', partner, }); @@ -390,7 +461,6 @@ export const prepareVeloraSwapPayload = async ({ userAddress, priceRoute, slippageBps, - side, partner, ignoreChecks, }); diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 111e0a82..146e42ff 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -14,12 +14,13 @@ import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAllowance } from '@/hooks/useAllowance'; import { formatBalance } from '@/utils/balance'; import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; -import { formatCompactTokenAmount, formatTokenAmountPreview } from '@/utils/token-amount-format'; +import { formatCompactTokenAmount } from '@/utils/token-amount-format'; import { useVeloraSwap } from '../hooks/useVeloraSwap'; import { TokenNetworkDropdown } from './TokenNetworkDropdown'; import { SwapTokenAmountField } from './SwapTokenAmountField'; import { VELORA_SWAP_CHAINS, type SwapToken } from '../types'; import { DEFAULT_SLIPPAGE_PERCENT } from '../constants'; +import { formatSlippagePercent, formatSwapRatePreview } from '../utils/quote-preview'; type SwapModalProps = { isOpen: boolean; @@ -30,31 +31,10 @@ type SwapModalProps = { const MIN_SLIPPAGE_PERCENT = 0.1; const MAX_SLIPPAGE_PERCENT = 5; const DEFAULT_CHAIN_ID = 1; -const RATE_PREVIEW_DECIMALS = 8; - -const formatSlippagePercent = (value: number): string => { - return value.toFixed(2).replace(/\.?0+$/, ''); -}; - const clampSlippagePercent = (value: number): number => { return Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, value)); }; -const computeUnitRatePreviewAmount = ( - baseAmount: bigint, - baseTokenDecimals: number, - quoteAmount: bigint, - quoteTokenDecimals: number, -): bigint | null => { - if (baseAmount <= 0n || quoteAmount <= 0n) return null; - - const scaledNumerator = quoteAmount * 10n ** BigInt(baseTokenDecimals + RATE_PREVIEW_DECIMALS); - const scaledDenominator = baseAmount * 10n ** BigInt(quoteTokenDecimals); - if (scaledDenominator <= 0n) return null; - - return scaledNumerator / scaledDenominator; -}; - export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProps) { const { address: account } = useConnection(); const [sourceToken, setSourceToken] = useState(null); @@ -331,26 +311,24 @@ 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, - ); - if (!inverseRate) return null; - const inverseRatePreview = formatTokenAmountPreview(inverseRate, RATE_PREVIEW_DECIMALS).compact; - return `1 ${targetToken.symbol} ≈ ${inverseRatePreview} ${sourceToken.symbol}`; + return formatSwapRatePreview({ + baseAmount: quote.buyAmount, + baseTokenDecimals: targetToken.decimals, + baseTokenSymbol: targetToken.symbol, + quoteAmount: quote.sellAmount, + quoteTokenDecimals: sourceToken.decimals, + quoteTokenSymbol: sourceToken.symbol, + }); } - 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}`; + return formatSwapRatePreview({ + baseAmount: quote.sellAmount, + baseTokenDecimals: sourceToken.decimals, + baseTokenSymbol: sourceToken.symbol, + quoteAmount: quote.buyAmount, + quoteTokenDecimals: targetToken.decimals, + quoteTokenSymbol: targetToken.symbol, + }); }, [quote, sourceToken, targetToken, error, chainsMatch, isRateInverted]); return ( diff --git a/src/features/swap/utils/quote-preview.ts b/src/features/swap/utils/quote-preview.ts new file mode 100644 index 00000000..8d55ca3b --- /dev/null +++ b/src/features/swap/utils/quote-preview.ts @@ -0,0 +1,44 @@ +import { formatTokenAmountPreview } from '@/hooks/leverage/math'; + +const RATE_PREVIEW_DECIMALS = 8; + +export const formatSlippagePercent = (value: number): string => { + return value.toFixed(2).replace(/\.?0+$/, ''); +}; + +export const computeUnitRatePreviewAmount = ( + baseAmount: bigint, + baseTokenDecimals: number, + quoteAmount: bigint, + quoteTokenDecimals: number, +): bigint | null => { + if (baseAmount <= 0n || quoteAmount <= 0n) return null; + + const scaledNumerator = quoteAmount * 10n ** BigInt(baseTokenDecimals + RATE_PREVIEW_DECIMALS); + const scaledDenominator = baseAmount * 10n ** BigInt(quoteTokenDecimals); + if (scaledDenominator <= 0n) return null; + + return scaledNumerator / scaledDenominator; +}; + +export const formatSwapRatePreview = ({ + baseAmount, + baseTokenDecimals, + baseTokenSymbol, + quoteAmount, + quoteTokenDecimals, + quoteTokenSymbol, +}: { + baseAmount: bigint; + baseTokenDecimals: number; + baseTokenSymbol: string; + quoteAmount: bigint; + quoteTokenDecimals: number; + quoteTokenSymbol: string; +}): string | null => { + const ratePreviewAmount = computeUnitRatePreviewAmount(baseAmount, baseTokenDecimals, quoteAmount, quoteTokenDecimals); + if (ratePreviewAmount == null) return null; + + const formattedRatePreview = formatTokenAmountPreview(ratePreviewAmount, RATE_PREVIEW_DECIMALS).compact; + return `1 ${baseTokenSymbol} ≈ ${formattedRatePreview} ${quoteTokenSymbol}`; +}; 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/math.ts b/src/hooks/leverage/math.ts index 06195d19..91bc4c02 100644 --- a/src/hooks/leverage/math.ts +++ b/src/hooks/leverage/math.ts @@ -40,11 +40,15 @@ export const formatMultiplierBps = (value: bigint): string => { * Converts user collateral and desired multiplier into extra collateral required * via flash liquidity. */ -export const computeFlashCollateralAmount = (userCollateralAmount: bigint, multiplierBps: bigint): bigint => { - if (userCollateralAmount <= 0n) return 0n; +export const computeLeveragedExtraAmount = (baseAmount: bigint, multiplierBps: bigint): bigint => { + if (baseAmount <= 0n) return 0n; const safeMultiplier = clampMultiplierBps(multiplierBps); - const leveragedCollateral = (userCollateralAmount * safeMultiplier) / LEVERAGE_MULTIPLIER_SCALE_BPS; - return leveragedCollateral > userCollateralAmount ? leveragedCollateral - userCollateralAmount : 0n; + const leveragedAmount = (baseAmount * safeMultiplier) / LEVERAGE_MULTIPLIER_SCALE_BPS; + return leveragedAmount > baseAmount ? leveragedAmount - baseAmount : 0n; +}; + +export const computeFlashCollateralAmount = (userCollateralAmount: bigint, multiplierBps: bigint): bigint => { + return computeLeveragedExtraAmount(userCollateralAmount, multiplierBps); }; export const computeLeverageProjectedPosition = ({ @@ -63,7 +67,7 @@ export const computeLeverageProjectedPosition = ({ }); export type DeleverageProjectedPosition = { - closesDebt: boolean; + usesCloseRoute: boolean; repayBySharesAmount: bigint; flashLoanAmountForTx: bigint; autoWithdrawCollateralAmount: bigint; @@ -78,31 +82,49 @@ export const computeDeleverageProjectedPosition = ({ currentBorrowAssets, currentBorrowShares, withdrawCollateralAmount, - rawRouteRepayAmount, repayAmount, maxCollateralForDebtRepay, + closeRouteAvailable, + closeBoundIsInputCap, }: { currentCollateralAssets: bigint; currentBorrowAssets: bigint; currentBorrowShares: bigint; withdrawCollateralAmount: bigint; - rawRouteRepayAmount: bigint; repayAmount: bigint; maxCollateralForDebtRepay: bigint; + closeRouteAvailable: boolean; + closeBoundIsInputCap: boolean; }): DeleverageProjectedPosition => { - const maxWithdrawCollateral = minBigInt(maxCollateralForDebtRepay, currentCollateralAssets); + const maxWithdrawCollateral = + closeRouteAvailable && closeBoundIsInputCap ? minBigInt(maxCollateralForDebtRepay, currentCollateralAssets) : currentCollateralAssets; const boundedWithdrawCollateral = minBigInt(withdrawCollateralAmount, currentCollateralAssets); const projectedCollateralAfterInput = floorSub(currentCollateralAssets, boundedWithdrawCollateral); - const closesDebt = currentBorrowAssets > 0n && repayAmount >= currentBorrowAssets; - const repayBySharesAmount = closesDebt ? currentBorrowShares : 0n; - const flashLoanAmountForTx = closesDebt ? rawRouteRepayAmount : repayAmount; - const autoWithdrawCollateralAmount = closesDebt ? projectedCollateralAfterInput : 0n; - const projectedCollateralAssets = closesDebt ? 0n : projectedCollateralAfterInput; - const projectedBorrowAssets = floorSub(currentBorrowAssets, repayAmount); - const previewDebtRepaid = closesDebt ? currentBorrowAssets : repayAmount; + const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); + // WHY: full-close execution depends on the dedicated close bound, not the optimistic/pessimistic + // characteristics of the preview repay leg. This keeps repay-by-shares aligned with the real + // executable close path and avoids leaving debt dust on swap-backed unwinds. + const usesCloseRoute = + closeRouteAvailable && + currentBorrowAssets > 0n && + currentBorrowShares > 0n && + maxCollateralForDebtRepay > 0n && + boundedWithdrawCollateral >= maxCollateralForDebtRepay; + const projectedBorrowAssetsIfPartial = floorSub(currentBorrowAssets, repayAmount); + const preservesDebtDustPreview = !usesCloseRoute && currentBorrowAssets > 0n && projectedBorrowAssetsIfPartial === 0n && repayAmount > 0n; + const dustPreservingRepayAmount = preservesDebtDustPreview ? floorSub(currentBorrowAssets, 1n) : repayAmount; + // WHY: for a 1-unit debt, subtracting one unit would produce a zero flash amount and dead-end the flow. + // In that edge case we keep the original repay amount so users still have an executable fallback. + const partialRepayAmountForTx = dustPreservingRepayAmount > 0n ? dustPreservingRepayAmount : repayAmount; + const repayBySharesAmount = usesCloseRoute ? currentBorrowShares : 0n; + const flashLoanAmountForTx = usesCloseRoute ? bufferedBorrowAssets : partialRepayAmountForTx; + const autoWithdrawCollateralAmount = usesCloseRoute ? projectedCollateralAfterInput : 0n; + const projectedCollateralAssets = usesCloseRoute ? 0n : projectedCollateralAfterInput; + const projectedBorrowAssets = usesCloseRoute ? 0n : floorSub(currentBorrowAssets, partialRepayAmountForTx); + const previewDebtRepaid = usesCloseRoute ? currentBorrowAssets : partialRepayAmountForTx; return { - closesDebt, + usesCloseRoute, repayBySharesAmount, flashLoanAmountForTx, autoWithdrawCollateralAmount, diff --git a/src/hooks/leverage/types.ts b/src/hooks/leverage/types.ts index d1b8f816..313b182e 100644 --- a/src/hooks/leverage/types.ts +++ b/src/hooks/leverage/types.ts @@ -6,17 +6,15 @@ export type Erc4626LeverageRoute = { underlyingLoanToken: Address; }; -export type LeverageRoute = Erc4626LeverageRoute; - -export type LeverageSupport = { - isSupported: boolean; - supportsLeverage: boolean; - supportsDeleverage: boolean; - isLoading: boolean; - route: LeverageRoute | null; - reason: string | null; +export type SwapLeverageRoute = { + kind: 'swap'; + bundler3Address: Address; + generalAdapterAddress: Address; + paraswapAdapterAddress: Address; }; +export type LeverageRoute = Erc4626LeverageRoute | SwapLeverageRoute; + 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/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/leverage/velora-precheck.ts b/src/hooks/leverage/velora-precheck.ts new file mode 100644 index 00000000..c57b57da --- /dev/null +++ b/src/hooks/leverage/velora-precheck.ts @@ -0,0 +1,41 @@ +const SOURCE_TOKEN_LABEL_REGEX = /\b(src token|source token)\b/; +const SHARE_PRICE_SCALE_E27 = 10n ** 27n; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const hasWholeWord = (message: string, value: string): boolean => { + if (!value) return false; + const pattern = new RegExp(`\\b${escapeRegExp(value)}\\b`); + return pattern.test(message); +}; + +type VeloraBypassablePrecheckErrorParams = { + error: unknown; + sourceTokenAddress: string; + sourceTokenSymbol: string; +}; + +export const isVeloraBypassablePrecheckError = ({ + error, + sourceTokenAddress, + sourceTokenSymbol, +}: VeloraBypassablePrecheckErrorParams): boolean => { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + const isAllowancePrecheckError = message.includes('allowance given to tokentransferproxy'); + if (isAllowancePrecheckError) return true; + + if (!message.includes('not enough')) return false; + if (!message.includes('balance') && !message.includes('insufficient')) return false; + + const normalizedSourceAddress = sourceTokenAddress.toLowerCase(); + const normalizedSourceSymbol = sourceTokenSymbol.trim().toLowerCase(); + const referencesSourceToken = + message.includes(normalizedSourceAddress) || SOURCE_TOKEN_LABEL_REGEX.test(message) || hasWholeWord(message, normalizedSourceSymbol); + + return referencesSourceToken; +}; + +export const computeMaxSharePriceE27 = (maxAssets: bigint, shares: bigint): bigint => { + if (maxAssets <= 0n || shares <= 0n) return 0n; + return (maxAssets * SHARE_PRICE_SCALE_E27 + shares - 1n) / shares; +}; diff --git a/src/hooks/useBundlerAuthorizationStep.ts b/src/hooks/useBundlerAuthorizationStep.ts index a6eca0be..fee0c0ba 100644 --- a/src/hooks/useBundlerAuthorizationStep.ts +++ b/src/hooks/useBundlerAuthorizationStep.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import type { Address } from 'viem'; -import { useMorphoAuthorization } from './useMorphoAuthorization'; +import { type MorphoAuthorizationSignatureData, useMorphoAuthorization } from './useMorphoAuthorization'; type AuthorizationMode = 'signature' | 'transaction'; @@ -16,14 +16,22 @@ type UseBundlerAuthorizationStepParams = { type EnsureBundlerAuthorizationResult = { authorized: boolean; authorizationTxData: `0x${string}` | null; + authorizationSignatureData: MorphoAuthorizationSignatureData | null; }; export const useBundlerAuthorizationStep = ({ chainId, bundlerAddress }: UseBundlerAuthorizationStepParams) => { - const { isBundlerAuthorized, isAuthorizingBundler, authorizeBundlerWithSignature, authorizeWithTransaction, refetchIsBundlerAuthorized } = - useMorphoAuthorization({ - chainId, - authorized: bundlerAddress, - }); + const { + isBundlerAuthorized, + isBundlerAuthorizationStatusReady, + isBundlerAuthorizationReady, + isAuthorizingBundler, + authorizeBundlerWithSignature, + authorizeWithTransaction, + refetchIsBundlerAuthorized, + } = useMorphoAuthorization({ + chainId, + authorized: bundlerAddress, + }); const ensureBundlerAuthorization = useCallback( async ({ mode }: EnsureBundlerAuthorizationParams): Promise => { @@ -31,15 +39,20 @@ export const useBundlerAuthorizationStep = ({ chainId, bundlerAddress }: UseBund return { authorized: true, authorizationTxData: null, + authorizationSignatureData: null, }; } if (mode === 'signature') { - const authorizationTxData = await authorizeBundlerWithSignature(); - if (authorizationTxData) { + if (!isBundlerAuthorizationReady) { + throw new Error('Morpho authorization is still loading. Please wait a moment and try again.'); + } + const authorizationSignatureData = await authorizeBundlerWithSignature(); + if (authorizationSignatureData) { return { authorized: true, - authorizationTxData: authorizationTxData as `0x${string}`, + authorizationTxData: authorizationSignatureData.authorizationTxData, + authorizationSignatureData, }; } @@ -47,20 +60,35 @@ export const useBundlerAuthorizationStep = ({ chainId, bundlerAddress }: UseBund return { authorized: refreshedAuthorization.data === true, authorizationTxData: null, + authorizationSignatureData: null, }; } + if (!isBundlerAuthorizationStatusReady) { + throw new Error('Morpho authorization is still loading. Please wait a moment and try again.'); + } + const authorized = await authorizeWithTransaction(); return { authorized, authorizationTxData: null, + authorizationSignatureData: null, }; }, - [isBundlerAuthorized, authorizeBundlerWithSignature, authorizeWithTransaction, refetchIsBundlerAuthorized], + [ + isBundlerAuthorized, + isBundlerAuthorizationReady, + isBundlerAuthorizationStatusReady, + authorizeBundlerWithSignature, + authorizeWithTransaction, + refetchIsBundlerAuthorized, + ], ); return { isBundlerAuthorized, + isBundlerAuthorizationStatusReady, + isBundlerAuthorizationReady, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized, diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index 5523a552..f9775b78 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.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 { withSlippageCeil } from './leverage/math'; +import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora'; +import { withSlippageCeil, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; type UseDeleverageQuoteParams = { @@ -9,42 +11,60 @@ type UseDeleverageQuoteParams = { route: LeverageRoute | null; withdrawCollateralAmount: bigint; currentBorrowAssets: bigint; + currentBorrowShares: bigint; + loanTokenAddress: string; + loanTokenDecimals: number; + collateralTokenAddress: string; + collateralTokenDecimals: number; + userAddress?: `0x${string}`; }; export type DeleverageQuote = { repayAmount: bigint; rawRouteRepayAmount: bigint; maxCollateralForDebtRepay: bigint; + canCurrentSellCloseDebt: boolean; + closeRouteAvailable: boolean; + closeRouteRequiresResolution: boolean; isLoading: boolean; error: string | null; + swapSellPriceRoute: 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, + currentBorrowShares, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + userAddress, }: UseDeleverageQuoteParams): DeleverageQuote { const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); + const swapExecutionAddress = route?.kind === 'swap' ? route.paraswapAdapterAddress : null; const { data: erc4626PreviewRedeem, 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,45 +73,240 @@ 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, + swapExecutionAddress, + 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: swapExecutionAddress as `0x${string}`, + side: 'SELL', + }); + + const quotedSellCollateral = BigInt(sellRoute.srcAmount); + if (quotedSellCollateral !== withdrawCollateralAmount) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + + 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, + swapExecutionAddress, + 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: swapExecutionAddress as `0x${string}`, + side: 'BUY', + }); + const quotedDebtCloseAmount = BigInt(buyRoute.destAmount); + if (quotedDebtCloseAmount !== bufferedBorrowAssets) { + throw new Error('Failed to resolve the exact full-close collateral bound. Refresh the quote and try again.'); + } + + return { + maxCollateralForDebtRepay: BigInt(buyRoute.srcAmount), + priceRoute: buyRoute, + }; + }, + }); + + 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; return rawRouteRepayAmount > currentBorrowAssets ? currentBorrowAssets : rawRouteRepayAmount; }, [rawRouteRepayAmount, currentBorrowAssets]); + const canCurrentSellCloseDebt = useMemo(() => { + if (route?.kind !== 'swap' || withdrawCollateralAmount <= 0n || currentBorrowAssets <= 0n) return false; + return rawRouteRepayAmount >= bufferedBorrowAssets; + }, [route, withdrawCollateralAmount, currentBorrowAssets, rawRouteRepayAmount, bufferedBorrowAssets]); + const maxCollateralForDebtRepay = useMemo(() => { if (!route || currentBorrowAssets <= 0n) return 0n; + if (route.kind === 'swap') { + if (!userAddress || swapMaxCollateralForDebtQuery.error) return 0n; + return swapMaxCollateralForDebtQuery.data?.maxCollateralForDebtRepay ?? 0n; + } return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n; - }, [route, currentBorrowAssets, erc4626PreviewWithdrawForDebt]); + }, [ + route, + currentBorrowAssets, + swapMaxCollateralForDebtQuery.data, + swapMaxCollateralForDebtQuery.error, + userAddress, + erc4626PreviewWithdrawForDebt, + ]); + + const closeRouteRequiresResolution = useMemo(() => { + if (route?.kind !== 'swap') return false; + if (!canCurrentSellCloseDebt) return false; + if (currentBorrowShares <= 0n) return false; + if (!userAddress) return false; + if (swapMaxCollateralForDebtQuery.error) return false; + return maxCollateralForDebtRepay <= 0n && (swapMaxCollateralForDebtQuery.isLoading || swapMaxCollateralForDebtQuery.isFetching); + }, [ + route, + canCurrentSellCloseDebt, + currentBorrowShares, + userAddress, + swapMaxCollateralForDebtQuery.error, + swapMaxCollateralForDebtQuery.isLoading, + swapMaxCollateralForDebtQuery.isFetching, + maxCollateralForDebtRepay, + ]); + + const closeRouteAvailable = useMemo(() => { + if (!route || currentBorrowAssets <= 0n) return false; + + if (route.kind === 'swap') { + if (!userAddress || swapMaxCollateralForDebtQuery.error) return false; + return canCurrentSellCloseDebt && currentBorrowShares > 0n && maxCollateralForDebtRepay > 0n; + } + + return maxCollateralForDebtRepay > 0n; + }, [ + route, + currentBorrowAssets, + userAddress, + swapMaxCollateralForDebtQuery.error, + canCurrentSellCloseDebt, + currentBorrowShares, + maxCollateralForDebtRepay, + ]); + + const closeRouteResolutionFailed = useMemo(() => { + if (route?.kind !== 'swap') return false; + if (!canCurrentSellCloseDebt) return false; + if (currentBorrowShares <= 0n) return false; + if (!userAddress) return false; + if (swapMaxCollateralForDebtQuery.error || closeRouteRequiresResolution) return false; + return maxCollateralForDebtRepay <= 0n; + }, [ + route, + canCurrentSellCloseDebt, + currentBorrowShares, + userAddress, + swapMaxCollateralForDebtQuery.error, + closeRouteRequiresResolution, + maxCollateralForDebtRepay, + ]); const error = useMemo(() => { if (!route) return null; + if (route.kind === 'swap') { + if (!userAddress && withdrawCollateralAmount > 0n) { + return 'Connect wallet to fetch swap-backed deleverage route.'; + } + if (closeRouteResolutionFailed) { + return 'Failed to resolve the exact full-close collateral bound. Refresh the quote and try again.'; + } + if (canCurrentSellCloseDebt && currentBorrowShares > 0n && swapMaxCollateralForDebtQuery.error) { + return 'Failed to resolve the exact full-close collateral bound. Refresh the quote and try again.'; + } + const routeError = withdrawCollateralAmount > 0n ? swapRepayQuoteQuery.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, + closeRouteResolutionFailed, + canCurrentSellCloseDebt, + currentBorrowShares, + swapMaxCollateralForDebtQuery.error, + swapRepayQuoteQuery.error, + redeemError, + withdrawError, + ]); - const isLoading = !!route && (isLoadingRedeem || isLoadingWithdraw); + const isLoading = + !!route && + (route.kind === 'swap' + ? swapRepayQuoteQuery.isLoading || swapRepayQuoteQuery.isFetching || closeRouteRequiresResolution + : isLoadingRedeem || isLoadingWithdraw); return { repayAmount, rawRouteRepayAmount, maxCollateralForDebtRepay, + canCurrentSellCloseDebt, + closeRouteAvailable, + closeRouteRequiresResolution, isLoading, error, + swapSellPriceRoute: route?.kind === 'swap' ? swapRepayQuote.priceRoute : null, }; } diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index 2eeb8641..f1fa09e5 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'; @@ -10,9 +15,12 @@ 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 { type Bundler3Call, encodeBundler3Calls, getParaswapSellOffsets, readCalldataUint256 } from './leverage/bundler3'; import { withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; +import { computeMaxSharePriceE27, isVeloraBypassablePrecheckError } from './leverage/velora-precheck'; export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx' | 'execute'; @@ -20,45 +28,63 @@ type UseDeleverageTransactionProps = { market: Market; route: LeverageRoute | null; withdrawCollateralAmount: bigint; + maxWithdrawCollateralAmount: bigint; flashLoanAmount: bigint; repayBySharesAmount: bigint; + useCloseRoute: boolean; autoWithdrawCollateralAmount: bigint; + maxCollateralForDebtRepay: bigint; + swapSellPriceRoute: VeloraPriceRoute | null; onSuccess?: () => void; }; +const DELEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); + /** - * 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, route, withdrawCollateralAmount, + maxWithdrawCollateralAmount, flashLoanAmount, repayBySharesAmount, + useCloseRoute, autoWithdrawCollateralAmount, + maxCollateralForDebtRepay, + swapSellPriceRoute, 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, - }, - ); + const { + isBundlerAuthorized, + isBundlerAuthorizationReady, + isBundlerAuthorizationStatusReady, + isAuthorizingBundler, + ensureBundlerAuthorization, + refetchIsBundlerAuthorized, + } = useBundlerAuthorizationStep({ + chainId: market.morphoBlue.chain.id, + bundlerAddress: authorizationTarget, + }); const { isConfirming: deleveragePending, sendTransactionAsync } = useTransactionWithToast({ toastId: 'deleverage', @@ -74,7 +100,22 @@ export function useDeleverageTransaction({ }, }); - const getStepsForFlow = useCallback((isPermit2: boolean) => { + const getStepsForFlow = useCallback((isPermit2: boolean, isSwap: boolean) => { + if (isSwap) { + return [ + { + id: 'authorize_bundler_tx', + title: 'Authorize Morpho Adapter', + description: '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 [ { @@ -106,132 +147,345 @@ export function useDeleverageTransaction({ const executeDeleverage = useCallback(async () => { if (!account) { - toast.info('No account connected', 'Please connect your wallet.'); - return; + throw new Error('No account connected. Please connect your wallet.'); } if (!route) { - toast.info('Unsupported route', 'This market is not supported for deleverage.'); - return; + throw new Error('This market is not supported for deleverage.'); } if (withdrawCollateralAmount <= 0n || flashLoanAmount <= 0n) { - toast.info('Invalid deleverage inputs', 'Set a collateral unwind amount above zero.'); - return; + throw new Error('Invalid deleverage inputs. Set a collateral unwind amount above zero.'); + } + if (withdrawCollateralAmount > maxWithdrawCollateralAmount) { + throw new Error('Stale deleverage input. The maximum unwind amount changed. Please review and try again.'); } try { const txs: `0x${string}`[] = []; - if (usePermit2Setting) { - tracking.update('authorize_bundler_sig'); - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (useSignatureAuthorization) { + if (!isBundlerAuthorized) { + tracking.update('authorize_bundler_sig'); + } + const { authorized, authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via signature.'); + } + if (isBundlerAuthorized && authorizationTxData) { + throw new Error('Authorization state changed. Please retry deleverage.'); + } if (authorizationTxData) { txs.push(authorizationTxData); await new Promise((resolve) => setTimeout(resolve, 700)); } } else { - tracking.update('authorize_bundler_tx'); + if (!isBundlerAuthorized) { + tracking.update('authorize_bundler_tx'); + } const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); if (!authorized) { throw new Error('Failed to authorize Bundler via transaction.'); } } - const isRepayByShares = repayBySharesAmount > 0n; + 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 = useCloseRoute; + if (isRepayByShares && repayBySharesAmount <= 0n) { + throw new Error('Debt shares are unavailable for a full close. Refresh your position data and try again.'); + } // 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 bundlerV2RepaySlippageAmount = isRepayByShares ? flashLoanAmount : minRepayShares; + const generalAdapterMaxSharePriceE27 = isRepayByShares + ? computeMaxSharePriceE27(flashLoanAmount, repayBySharesAmount) + : computeMaxSharePriceE27(flashLoanAmount, minRepayShares); + if (generalAdapterMaxSharePriceE27 <= 0n) { + throw new Error('Invalid deleverage bounds for repay-by-shares. Refresh the quote and try again.'); + } - 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') { + const swapExecutionAddress = route.paraswapAdapterAddress; + if (useCloseRoute) { + if (maxCollateralForDebtRepay <= 0n) { + throw new Error('The exact close bound is unavailable. Refresh the quote and try again.'); + } + if (withdrawCollateralAmount < maxCollateralForDebtRepay) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + } - const minAssetsOut = withSlippageFloor(flashLoanAmount); - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc4626Redeem', - args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address], - }), - ); + const isCloseSwap = isRepayByShares; + const activePriceRoute = swapSellPriceRoute; + if (!activePriceRoute) { + throw new Error('Missing Velora swap quote for deleverage.'); + } - 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( + 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: swapExecutionAddress, + priceRoute: activePriceRoute, + slippageBps: DELEVERAGE_SWAP_SLIPPAGE_BPS, + ignoreChecks, + }); + + 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 ( + !isVeloraBypassablePrecheckError({ + error: buildError, + sourceTokenAddress: market.collateralAsset.address, + sourceTokenSymbol: market.collateralAsset.symbol, + }) + ) { + 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 sellOffsets = getParaswapSellOffsets(swapTxPayload.data); + const quotedSellCollateral = BigInt(activePriceRoute.srcAmount); + const quotedLoanOut = BigInt(activePriceRoute.destAmount); + const calldataSellAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount); + const calldataQuotedLoanOut = readCalldataUint256(swapTxPayload.data, sellOffsets.quotedAmount); + if ( + quotedSellCollateral !== withdrawCollateralAmount || + calldataSellAmount !== withdrawCollateralAmount || + calldataQuotedLoanOut !== quotedLoanOut + ) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + + const swapCallData = swapTxPayload.data; + const minLoanOut = withSlippageFloor(quotedLoanOut); + if (isCloseSwap) { + if (minLoanOut < flashLoanAmount) { + throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); + } + } else if (minLoanOut <= 0n) { + throw new Error('Velora returned zero loan output for deleverage swap.'); + } + + const calldataMinLoanOut = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount); + if (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, + generalAdapterMaxSharePriceE27, + 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, + swapCallData, + market.collateralAsset.address as Address, + market.loanAsset.address as Address, + false, + sellOffsets, + route.generalAdapterAddress, + ], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }, + { + to: route.paraswapAdapterAddress, + data: encodeFunctionData({ + abi: paraswapAdapterAbi, + functionName: 'erc20Transfer', + args: [market.collateralAsset.address as Address, account as Address, maxUint256], + }), + 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, + bundlerV2RepaySlippageAmount, 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([ { @@ -244,8 +498,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); } } }, [ @@ -253,10 +508,15 @@ export function useDeleverageTransaction({ market, route, withdrawCollateralAmount, + maxWithdrawCollateralAmount, flashLoanAmount, repayBySharesAmount, + useCloseRoute, autoWithdrawCollateralAmount, - usePermit2Setting, + maxCollateralForDebtRepay, + swapSellPriceRoute, + useSignatureAuthorization, + isBundlerAuthorized, ensureBundlerAuthorization, bundlerAddress, sendTransactionAsync, @@ -270,11 +530,19 @@ export function useDeleverageTransaction({ toast.info('No account connected', 'Please connect your wallet.'); return; } + if ((useSignatureAuthorization && !isBundlerAuthorizationReady) || (!useSignatureAuthorization && !isBundlerAuthorizationStatusReady)) { + toast.info('Authorization status loading', 'Please wait a moment and try again.'); + return; + } try { - const initialStep = usePermit2Setting ? 'authorize_bundler_sig' : 'authorize_bundler_tx'; + const initialStep: DeleverageStepType = isBundlerAuthorized + ? 'execute' + : 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}`, @@ -289,19 +557,29 @@ 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]); + }, [ + account, + isBundlerAuthorized, + isBundlerAuthorizationReady, + isBundlerAuthorizationStatusReady, + useSignatureAuthorization, + tracking, + getStepsForFlow, + isSwapRoute, + market, + withdrawCollateralAmount, + executeDeleverage, + toast, + ]); - const isLoading = deleveragePending || isAuthorizingBundler; + const isAuthorizationStatusLoading = + (useSignatureAuthorization && !isBundlerAuthorizationReady) || (!useSignatureAuthorization && !isBundlerAuthorizationStatusReady); + const isLoading = deleveragePending || isAuthorizingBundler || isAuthorizationStatusLoading; return { transaction: tracking.transaction, diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts index 0944658f..64469125 100644 --- a/src/hooks/useLeverageQuote.ts +++ b/src/hooks/useLeverageQuote.ts @@ -1,22 +1,32 @@ 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, computeLeveragedExtraAmount, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; type UseLeverageQuoteParams = { chainId: number; route: LeverageRoute | null; - userCollateralAmount: bigint; + userInputAmount: bigint; + inputMode: 'collateral' | 'loan'; multiplierBps: bigint; + loanTokenAddress: string; + loanTokenDecimals: number; + collateralTokenAddress: string; + collateralTokenDecimals: number; + userAddress?: `0x${string}`; }; export type LeverageQuote = { + initialCollateralAmount: bigint; flashCollateralAmount: bigint; flashLoanAmount: bigint; totalAddedCollateral: bigint; isLoading: boolean; error: string | null; + swapPriceRoute: VeloraPriceRoute | null; }; /** @@ -25,51 +35,258 @@ 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, + userInputAmount, + inputMode, + multiplierBps, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + userAddress, +}: UseLeverageQuoteParams): LeverageQuote { + const isLoanAssetInput = inputMode === 'loan'; + const isSwapLoanAssetInput = route?.kind === 'swap' && isLoanAssetInput; + const swapExecutionAddress = route?.kind === 'swap' ? route.paraswapAdapterAddress : null; + + const { + data: erc4626PreviewDeposit, + isLoading: isLoadingErc4626Deposit, + error: erc4626DepositError, + } = useReadContract({ + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, + abi: erc4626Abi, + functionName: 'previewDeposit', + args: [userInputAmount], + chainId, + query: { + enabled: route?.kind === 'erc4626' && isLoanAssetInput && userInputAmount > 0n, + }, + }); + + const initialCollateralAmount = useMemo(() => { + if (!route) return 0n; + if (isSwapLoanAssetInput) return 0n; + if (!isLoanAssetInput) return userInputAmount; + if (route.kind === 'erc4626') return (erc4626PreviewDeposit as bigint | undefined) ?? 0n; + return 0n; + }, [route, isSwapLoanAssetInput, isLoanAssetInput, userInputAmount, erc4626PreviewDeposit]); + const targetFlashCollateralAmount = useMemo( - () => computeFlashCollateralAmount(userCollateralAmount, multiplierBps), - [userCollateralAmount, multiplierBps], + () => (isSwapLoanAssetInput ? 0n : computeFlashCollateralAmount(initialCollateralAmount, multiplierBps)), + [isSwapLoanAssetInput, initialCollateralAmount, multiplierBps], ); const { data: erc4626PreviewMint, - isLoading: isLoadingErc4626, - error: erc4626Error, + isLoading: isLoadingErc4626Mint, + error: erc4626MintError, } = 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 swapCollateralInputQuoteQuery = useQuery({ + queryKey: [ + 'leverage-swap-collateral-input-quote', + chainId, + route?.kind === 'swap' ? route.paraswapAdapterAddress : null, + route?.kind === 'swap' ? route.generalAdapterAddress : null, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + swapExecutionAddress, + targetFlashCollateralAmount.toString(), + userAddress ?? null, + ], + enabled: route?.kind === 'swap' && !isLoanAssetInput && targetFlashCollateralAmount > 0n && !!userAddress, + queryFn: async () => { + const buyRoute = await fetchVeloraPriceRoute({ + srcToken: loanTokenAddress, + srcDecimals: loanTokenDecimals, + destToken: collateralTokenAddress, + destDecimals: collateralTokenDecimals, + amount: targetFlashCollateralAmount, + network: chainId, + userAddress: swapExecutionAddress 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: swapExecutionAddress as `0x${string}`, + side: 'SELL', + }); + if (BigInt(sellRoute.srcAmount) !== borrowAssets) { + throw new Error('Failed to quote stable Velora swap route for leverage.'); + } + + return { + flashLoanAmount: borrowAssets, + flashCollateralAmount: withSlippageFloor(BigInt(sellRoute.destAmount)), + priceRoute: sellRoute, + }; + }, + }); + + const swapLoanInputCombinedQuoteQuery = useQuery({ + queryKey: [ + 'leverage-swap-loan-input-combined-quote', + chainId, + route?.kind === 'swap' ? route.paraswapAdapterAddress : null, + route?.kind === 'swap' ? route.generalAdapterAddress : null, + loanTokenAddress, + loanTokenDecimals, + collateralTokenAddress, + collateralTokenDecimals, + swapExecutionAddress, + userInputAmount.toString(), + multiplierBps.toString(), + userAddress ?? null, + ], + enabled: route?.kind === 'swap' && isLoanAssetInput && userInputAmount > 0n && !!userAddress, + queryFn: async () => { + const flashLoanAmount = computeLeveragedExtraAmount(userInputAmount, multiplierBps); + if (flashLoanAmount <= 0n) { + return { + flashLoanAmount: 0n, + flashCollateralAmount: 0n, + totalAddedCollateral: 0n, + priceRoute: null, + }; + } + + const totalLoanSellAmount = userInputAmount + flashLoanAmount; + const sellRoute = await fetchVeloraPriceRoute({ + srcToken: loanTokenAddress, + srcDecimals: loanTokenDecimals, + destToken: collateralTokenAddress, + destDecimals: collateralTokenDecimals, + amount: totalLoanSellAmount, + network: chainId, + userAddress: swapExecutionAddress as `0x${string}`, + side: 'SELL', + }); + if (BigInt(sellRoute.srcAmount) !== totalLoanSellAmount) { + throw new Error('Failed to quote stable Velora swap route for leverage.'); + } + + const totalAddedCollateral = withSlippageFloor(BigInt(sellRoute.destAmount)); + + return { + flashLoanAmount, + flashCollateralAmount: 0n, + totalAddedCollateral, + priceRoute: sellRoute, + }; }, }); const flashCollateralAmount = useMemo(() => { if (!route) return 0n; + if (route.kind === 'swap') { + if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashCollateralAmount ?? 0n; + return swapCollateralInputQuoteQuery.data?.flashCollateralAmount ?? 0n; + } return targetFlashCollateralAmount; - }, [route, targetFlashCollateralAmount]); + }, [ + route, + isLoanAssetInput, + targetFlashCollateralAmount, + swapLoanInputCombinedQuoteQuery.data?.flashCollateralAmount, + swapCollateralInputQuoteQuery.data?.flashCollateralAmount, + ]); const flashLoanAmount = useMemo(() => { if (!route) return 0n; + if (route.kind === 'swap') { + if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashLoanAmount ?? 0n; + return swapCollateralInputQuoteQuery.data?.flashLoanAmount ?? 0n; + } return (erc4626PreviewMint as bigint | undefined) ?? 0n; - }, [route, erc4626PreviewMint]); + }, [ + route, + isLoanAssetInput, + swapLoanInputCombinedQuoteQuery.data?.flashLoanAmount, + swapCollateralInputQuoteQuery.data?.flashLoanAmount, + erc4626PreviewMint, + ]); + + const totalAddedCollateral = useMemo(() => { + if (!route) return 0n; + if (route.kind === 'swap') { + if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.totalAddedCollateral ?? 0n; + return initialCollateralAmount + flashCollateralAmount; + } + return initialCollateralAmount + flashCollateralAmount; + }, [route, isLoanAssetInput, initialCollateralAmount, flashCollateralAmount, swapLoanInputCombinedQuoteQuery.data?.totalAddedCollateral]); + + const swapPriceRoute = useMemo(() => { + if (route?.kind !== 'swap') return null; + if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.priceRoute ?? null; + return swapCollateralInputQuoteQuery.data?.priceRoute ?? null; + }, [route, isLoanAssetInput, swapLoanInputCombinedQuoteQuery.data?.priceRoute, swapCollateralInputQuoteQuery.data?.priceRoute]); 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 && userInputAmount > 0n) return 'Connect wallet to fetch swap-backed leverage route.'; + const routeError = isLoanAssetInput ? swapLoanInputCombinedQuoteQuery.error : swapCollateralInputQuoteQuery.error; + if (!routeError) return null; + return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for leverage.'; + } + const erc4626RouteError = erc4626DepositError ?? erc4626MintError; + if (!erc4626RouteError) return null; + return erc4626RouteError instanceof Error ? erc4626RouteError.message : 'Failed to quote leverage route'; + }, [ + route, + swapExecutionAddress, + userAddress, + userInputAmount, + isLoanAssetInput, + swapLoanInputCombinedQuoteQuery.error, + swapCollateralInputQuoteQuery.error, + erc4626DepositError, + erc4626MintError, + ]); - const isLoading = !!route && isLoadingErc4626; + const isLoading = + !!route && + (route.kind === 'swap' + ? (isLoanAssetInput && (swapLoanInputCombinedQuoteQuery.isLoading || swapLoanInputCombinedQuoteQuery.isFetching)) || + (!isLoanAssetInput && (swapCollateralInputQuoteQuery.isLoading || swapCollateralInputQuoteQuery.isFetching)) + : isLoadingErc4626Deposit || isLoadingErc4626Mint); return { + initialCollateralAmount, flashCollateralAmount, flashLoanAmount, - totalAddedCollateral: userCollateralAmount + flashCollateralAmount, + totalAddedCollateral, isLoading, error, + swapPriceRoute, }; } diff --git a/src/hooks/useLeverageSupport.ts b/src/hooks/useLeverageSupport.ts deleted file mode 100644 index 05f64c30..00000000 --- a/src/hooks/useLeverageSupport.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useMemo } from 'react'; -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'; - -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, - }; - } - - 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.', - }; - }, [collateralToken, loanToken, data, isLoading, isRefetching]); -} diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 290b5cbe..41c117d7 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -1,7 +1,14 @@ -import { useCallback } from 'react'; -import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256 } 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 morphoAbi from '@/abis/morpho'; +import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; +import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; +import permit2Abi from '@/abis/permit2'; +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'; @@ -11,10 +18,14 @@ import { useTransactionTracking } from '@/hooks/useTransactionTracking'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; -import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { getBundlerV2, getMorphoAddress, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { PERMIT2_ADDRESS } from '@/utils/permit2'; +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'; +import { isVeloraBypassablePrecheckError } from './leverage/velora-precheck'; export type LeverageStepType = | 'approve_permit2' @@ -31,17 +42,18 @@ type UseLeverageTransactionProps = { collateralAmountInCollateralToken: bigint; flashCollateralAmount: bigint; flashLoanAmount: bigint; + totalAddedCollateral: bigint; + swapPriceRoute: VeloraPriceRoute | null; useLoanAssetAsInput: boolean; onSuccess?: () => void; }; +const LEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); + /** - * 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 +62,8 @@ export function useLeverageTransaction({ collateralAmountInCollateralToken, flashCollateralAmount, flashLoanAmount, + totalAddedCollateral, + swapPriceRoute, useLoanAssetAsInput, onSuccess, }: UseLeverageTransactionProps) { @@ -57,20 +71,41 @@ 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; + + 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 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, - }, - ); + const { + isBundlerAuthorized, + isBundlerAuthorizationStatusReady, + isBundlerAuthorizationReady, + isAuthorizingBundler, + ensureBundlerAuthorization, + refetchIsBundlerAuthorized, + } = useBundlerAuthorizationStep({ + chainId: market.morphoBlue.chain.id, + bundlerAddress: authorizationTarget, + }); const { authorizePermit2, @@ -79,17 +114,18 @@ 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 isAuthorizationReadyForRoute = usePermit2ForRoute ? isBundlerAuthorizationReady : isBundlerAuthorizationStatusReady; const { isApproved, approve, isApproving } = useERC20Approval({ token: inputTokenAddress, - spender: bundlerAddress, + spender: approvalSpender, amount: inputTokenAmountForTransfer, tokenSymbol: inputTokenSymbol, chainId: market.morphoBlue.chain.id, @@ -110,7 +146,52 @@ export function useLeverageTransaction({ }); const getStepsForFlow = useCallback( - (isPermit2: boolean) => { + (isPermit2: boolean, isSwap: boolean) => { + if (isSwap && isPermit2) { + return [ + { + id: 'approve_permit2', + title: 'Authorize Permit2', + description: "One-time approval so future leverage transactions don't need token approvals.", + }, + { + id: 'authorize_bundler_sig', + title: 'Authorize Morpho Adapter', + description: 'Sign a message authorizing the Morpho general adapter for this leverage flow.', + }, + { + id: 'sign_permit', + title: 'Sign Token Permit', + description: 'Sign Permit2 transfer authorization for the general adapter.', + }, + { + id: 'execute', + title: 'Confirm Leverage', + description: 'Confirm the Bundler3 leverage transaction in your wallet.', + }, + ]; + } + + 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 [ { @@ -168,15 +249,18 @@ export function useLeverageTransaction({ return; } - if (collateralAmount <= 0n || flashLoanAmount <= 0n || flashCollateralAmount <= 0n) { + const hasCollateralOutput = route.kind === 'swap' && isLoanAssetInput ? totalAddedCollateral > 0n : flashCollateralAmount > 0n; + if (collateralAmount <= 0n || flashLoanAmount <= 0n || !hasCollateralOutput) { toast.info('Invalid leverage inputs', 'Set collateral and multiplier above 1x before submitting.'); return; } try { const txs: `0x${string}`[] = []; + let swapRouteAuthorizationCall: Bundler3Call | null = null; + let swapRoutePermit2Call: Bundler3Call | null = null; - if (usePermit2Setting) { + if (usePermit2ForRoute) { if (!permit2Authorized) { tracking.update('approve_permit2'); await authorizePermit2(); @@ -186,134 +270,366 @@ export function useLeverageTransaction({ if (!isBundlerAuthorized) { tracking.update('authorize_bundler_sig'); } - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + const { authorized, authorizationTxData, authorizationSignatureData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via signature.'); + } + if (isBundlerAuthorized && authorizationTxData) { + throw new Error('Authorization state changed. Please retry leverage.'); + } if (authorizationTxData) { - txs.push(authorizationTxData); + if (route.kind === 'swap') { + if (!authorizationSignatureData) { + throw new Error('Missing Morpho authorization signature payload for swap-backed leverage.'); + } + swapRouteAuthorizationCall = { + to: getMorphoAddress(market.morphoBlue.chain.id) as Address, + data: encodeFunctionData({ + abi: morphoAbi, + functionName: 'setAuthorizationWithSig', + args: [authorizationSignatureData.authorization, authorizationSignatureData.signature], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }; + } else { + txs.push(authorizationTxData); + } await new Promise((resolve) => setTimeout(resolve, 800)); } tracking.update('sign_permit'); const { sigs, permitSingle } = await signForBundlers(); - txs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'approve2', - args: [permitSingle, sigs, false], - }), - ); + if (route.kind === 'swap') { + swapRoutePermit2Call = { + to: PERMIT2_ADDRESS, + data: encodeFunctionData({ + abi: permit2Abi, + functionName: 'permit', + args: [account as Address, permitSingle, sigs], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }; + } else { + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }), + ); + } } else { - tracking.update('authorize_bundler_tx'); - const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); - if (!authorized) { - throw new Error('Failed to authorize Bundler via transaction.'); + if (!isBundlerAuthorized) { + tracking.update('authorize_bundler_tx'); + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } } - tracking.update('approve_token'); if (!isApproved) { + tracking.update('approve_token'); await approve(); await new Promise((resolve) => setTimeout(resolve, 900)); } } - 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 swapExecutionAddress = route.paraswapAdapterAddress; + // WHY: when starting from loan on a swap route, we combine the user's loan input + // with the flash-loaned loan and sell them together before supplying collateral. + const totalLoanSellAmount = isLoanAssetInput ? inputTokenAmountForTransfer + flashLoanAmount : flashLoanAmount; + if (totalLoanSellAmount <= 0n) { + throw new Error('Invalid total sell amount for swap-backed leverage.'); + } + + const activePriceRoute = swapPriceRoute; + const swapTxPayload = await (async () => { + const buildPayload = async (ignoreChecks: boolean) => + buildVeloraTransactionPayload({ + srcToken: market.loanAsset.address, + srcDecimals: market.loanAsset.decimals, + destToken: market.collateralAsset.address, + destDecimals: market.collateralAsset.decimals, + srcAmount: totalLoanSellAmount, + network: market.morphoBlue.chain.id, + userAddress: swapExecutionAddress, + priceRoute: activePriceRoute, + slippageBps: LEVERAGE_SWAP_SLIPPAGE_BPS, + 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.'); + } + if ( + !isVeloraBypassablePrecheckError({ + error: buildError, + sourceTokenAddress: market.loanAsset.address, + sourceTokenSymbol: market.loanAsset.symbol, + }) + ) { + 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 expectedCollateralOut = isLoanAssetInput ? totalAddedCollateral : flashCollateralAmount; + if (expectedCollateralOut <= 0n) { + throw new Error('Velora returned zero collateral output for leverage swap.'); + } + + const sellOffsets = getParaswapSellOffsets(swapTxPayload.data); + const quotedSellAmount = BigInt(activePriceRoute.srcAmount); + const calldataSellAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount); + const calldataMinCollateralOut = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount); + if ( + quotedSellAmount !== totalLoanSellAmount || + calldataSellAmount !== totalLoanSellAmount || + calldataMinCollateralOut !== expectedCollateralOut + ) { + 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, totalLoanSellAmount], + }), + 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.paraswapAdapterAddress, + data: encodeFunctionData({ + abi: paraswapAdapterAbi, + functionName: 'erc20Transfer', + args: [market.loanAsset.address as Address, account as Address, maxUint256], + }), + 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 (swapRouteAuthorizationCall) { + bundleCalls.push(swapRouteAuthorizationCall); + } + if (swapRoutePermit2Call) { + bundleCalls.push(swapRoutePermit2Call); + } + + if (inputTokenAmountForTransfer > 0n) { + bundleCalls.push({ + to: route.generalAdapterAddress, + data: encodeFunctionData({ + abi: morphoGeneralAdapterV1Abi, + functionName: usePermit2ForRoute ? 'permit2TransferFrom' : 'erc20TransferFrom', + args: [inputTokenAddress, route.generalAdapterAddress, inputTokenAmountForTransfer], + }), + value: 0n, + skipRevert: false, + callbackHash: zeroHash, + }); + if (!isLoanAssetInput) { + 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', - ], - }), - ]; + tracking.update('execute'); + await new Promise((resolve) => setTimeout(resolve, 800)); - 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)); - - 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 +642,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 +658,9 @@ export function useLeverageTransaction({ isLoanAssetInput, flashCollateralAmount, flashLoanAmount, - usePermit2Setting, + totalAddedCollateral, + swapPriceRoute, + usePermit2ForRoute, permit2Authorized, isBundlerAuthorized, authorizePermit2, @@ -363,9 +682,19 @@ export function useLeverageTransaction({ } try { - const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + const initialStep: LeverageStepType = usePermit2ForRoute + ? permit2Authorized + ? isBundlerAuthorized + ? 'sign_permit' + : 'authorize_bundler_sig' + : 'approve_permit2' + : isBundlerAuthorized + ? isApproved + ? 'execute' + : 'approve_token' + : 'authorize_bundler_tx'; tracking.start( - getStepsForFlow(usePermit2Setting), + getStepsForFlow(usePermit2ForRoute, isSwapRoute), { title: 'Leverage', description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, @@ -380,19 +709,33 @@ 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, + permit2Authorized, + isBundlerAuthorized, + isApproved, + 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 +749,7 @@ export function useLeverageTransaction({ : 'approve_permit2'; tracking.start( - getStepsForFlow(usePermit2Setting), + getStepsForFlow(true, isSwapRoute), { title: 'Leverage', description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, @@ -421,21 +764,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,18 +784,20 @@ export function useLeverageTransaction({ toast, ]); - const isLoading = leveragePending || isLoadingPermit2 || isApproving || isAuthorizingBundler; + const isLoading = + leveragePending || (usePermit2ForRoute && isLoadingPermit2) || !isAuthorizationReadyForRoute || 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, + isBundlerAuthorizationReady: isAuthorizationReadyForRoute, approveAndLeverage, signAndLeverage, }; diff --git a/src/hooks/useMorphoAuthorization.ts b/src/hooks/useMorphoAuthorization.ts index bb55bdf9..b5f43a1b 100644 --- a/src/hooks/useMorphoAuthorization.ts +++ b/src/hooks/useMorphoAuthorization.ts @@ -1,24 +1,43 @@ import { useCallback, useState } from 'react'; import { type Address, encodeFunctionData, parseSignature } from 'viem'; -import { useConnection, useReadContract, useSignTypedData } from 'wagmi'; +import { useConnection, usePublicClient, useReadContract, useSignTypedData } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import morphoAbi from '@/abis/morpho'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { getMorphoAddress } from '@/utils/morpho'; -import { useStyledToast } from './useStyledToast'; type UseMorphoAuthorizationProps = { chainId: number; authorized: Address; }; +export type MorphoAuthorizationSignatureData = { + authorizationTxData: `0x${string}`; + authorization: { + authorizer: Address; + authorized: Address; + isAuthorized: boolean; + nonce: bigint; + deadline: bigint; + }; + signature: { + v: number; + r: `0x${string}`; + s: `0x${string}`; + }; +}; + export const useMorphoAuthorization = ({ chainId, authorized }: UseMorphoAuthorizationProps) => { const { address: account } = useConnection(); + const publicClient = usePublicClient({ chainId }); const { signTypedDataAsync } = useSignTypedData(); - const toast = useStyledToast(); const [isAuthorizing, setIsAuthorizing] = useState(false); - const { data: isBundlerAuthorized, refetch: refetchIsBundlerAuthorized } = useReadContract({ + const { + data: isBundlerAuthorized, + refetch: refetchIsBundlerAuthorized, + isLoading: isLoadingBundlerAuthorization, + } = useReadContract({ address: getMorphoAddress(chainId), abi: morphoAbi, functionName: 'isAuthorized', @@ -40,6 +59,10 @@ export const useMorphoAuthorization = ({ chainId, authorized }: UseMorphoAuthori }, }); + const isBundlerAuthorizationStatusReady = !!account && !isLoadingBundlerAuthorization && isBundlerAuthorized !== undefined; + const isBundlerAuthorizationReady = + !!account && isBundlerAuthorizationStatusReady && (isBundlerAuthorized === true || nonce !== undefined); + const { sendTransactionAsync: sendBundlerAuthorizationTx, isConfirming: isConfirmingBundlerTx } = useTransactionWithToast({ toastId: 'morpho-authorize', pendingText: 'Authorizing Bundler on Morpho', @@ -52,14 +75,22 @@ export const useMorphoAuthorization = ({ chainId, authorized }: UseMorphoAuthori }, }); - const authorizeBundlerWithSignature = useCallback(async () => { - if (!account || isBundlerAuthorized === true || nonce === undefined) { - console.log('Skipping authorizeBundlerWithSignature:', { - account, - isBundlerAuthorized, - nonce, - }); - return null; // Already authorized or missing data + const authorizeBundlerWithSignature = useCallback(async (): Promise => { + if (!account) { + throw new Error('No account connected.'); + } + + if (!isBundlerAuthorizationReady) { + throw new Error('Morpho authorization is still loading. Please wait a moment and try again.'); + } + + if (isBundlerAuthorized === true) { + return null; // Already authorized + } + + const authorizationNonce = nonce; + if (authorizationNonce === undefined) { + throw new Error('Morpho authorization nonce is unavailable. Please wait a moment and try again.'); } setIsAuthorizing(true); @@ -85,7 +116,7 @@ export const useMorphoAuthorization = ({ chainId, authorized }: UseMorphoAuthori authorizer: account, authorized: authorized, isAuthorized: true, - nonce: nonce, + nonce: authorizationNonce, deadline: BigInt(deadline), }; @@ -98,63 +129,73 @@ export const useMorphoAuthorization = ({ chainId, authorized }: UseMorphoAuthori const signature = parseSignature(signatureRaw); + const authorization = { + authorizer: account as Address, + authorized: authorized, + isAuthorized: true, + nonce: BigInt(authorizationNonce), + deadline: BigInt(deadline), + }; + const parsedSignature = { + v: Number(signature.v), + r: signature.r, + s: signature.s, + }; + const authorizationTxData = encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'morphoSetAuthorizationWithSig', args: [ - { - authorizer: account as Address, - authorized: authorized, - isAuthorized: true, - nonce: BigInt(nonce), - deadline: BigInt(deadline), - }, - { - v: Number(signature.v), - r: signature.r, - s: signature.s, - }, + authorization, + parsedSignature, false, // useEOA = false, since we want the bundler to submit ], }); await refetchIsBundlerAuthorized(); await refetchNonce(); - return authorizationTxData; - } catch (error) { - console.error('Error during signature authorization:', error); - if (error instanceof Error && error.message.includes('User rejected')) { - toast.error('Signature Rejected', 'Authorization signature rejected by user'); - } else { - toast.error('Authorization Failed', 'Could not authorize bundler via signature'); - } - throw error; // Re-throw to be caught by the calling function + return { + authorizationTxData, + authorization, + signature: parsedSignature, + }; } finally { setIsAuthorizing(false); } - }, [account, isBundlerAuthorized, nonce, chainId, authorized, signTypedDataAsync, refetchIsBundlerAuthorized, refetchNonce, toast]); + }, [ + account, + isBundlerAuthorized, + isBundlerAuthorizationReady, + nonce, + chainId, + authorized, + signTypedDataAsync, + refetchIsBundlerAuthorized, + refetchNonce, + ]); const authorizeWithTransaction = useCallback( async (shouldAuthorize?: boolean) => { const authorize = shouldAuthorize ?? true; if (!account) { - console.log('Skipping authorizeWithTransaction: no account'); - return true; // No account + throw new Error('No account connected.'); + } + + if (!isBundlerAuthorizationStatusReady) { + throw new Error('Morpho authorization is still loading. Please wait a moment and try again.'); } // Skip if trying to authorize when already authorized, or revoke when not authorized if (authorize && isBundlerAuthorized === true) { - console.log('Already authorized, skipping'); return true; } if (!authorize && isBundlerAuthorized === false) { - console.log('Already not authorized, skipping'); return true; } setIsAuthorizing(true); try { // Simple Morpho setAuthorization transaction - await sendBundlerAuthorizationTx({ + const authorizationTxHash = await sendBundlerAuthorizationTx({ account: account, to: getMorphoAddress(chainId), data: encodeFunctionData({ @@ -164,24 +205,43 @@ export const useMorphoAuthorization = ({ chainId, authorized }: UseMorphoAuthori }), chainId: chainId, }); - return true; - } catch (error) { - console.error('Error during transaction authorization:', error); - // Toast is handled by useTransactionWithToast - if (error instanceof Error && error.message.includes('User rejected')) { - // Handle specific user rejection if not caught by useTransactionWithToast - toast.error('Transaction Rejected', 'Authorization transaction rejected by user'); + + if (!publicClient) { + throw new Error('Missing public client for authorization confirmation.'); + } + + await publicClient.waitForTransactionReceipt({ + hash: authorizationTxHash, + confirmations: 1, + }); + + const refreshedAuthorization = await refetchIsBundlerAuthorized(); + const isAuthorizedAfterConfirmation = refreshedAuthorization.data === authorize; + if (!isAuthorizedAfterConfirmation) { + throw new Error('Morpho authorization was not confirmed on-chain.'); } - return false; // Indicate failure + + return true; } finally { setIsAuthorizing(false); } }, - [account, isBundlerAuthorized, authorized, sendBundlerAuthorizationTx, chainId, toast], + [ + account, + publicClient, + isBundlerAuthorizationStatusReady, + isBundlerAuthorized, + authorized, + sendBundlerAuthorizationTx, + chainId, + refetchIsBundlerAuthorized, + ], ); return { isBundlerAuthorized, + isBundlerAuthorizationStatusReady, + isBundlerAuthorizationReady, isAuthorizingBundler: isAuthorizing || isConfirmingBundlerTx, authorizeBundlerWithSignature, authorizeWithTransaction, 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/hooks/usePermit2.ts b/src/hooks/usePermit2.ts index b96c6d30..67da70db 100644 --- a/src/hooks/usePermit2.ts +++ b/src/hooks/usePermit2.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react'; import type { Address } from 'abitype'; -import moment from 'moment'; import type { Chain } from 'viem/chains'; import { useReadContract, useSignTypedData } from 'wagmi'; @@ -8,6 +7,8 @@ import permit2Abi from '@/abis/permit2'; import { PERMIT2_ADDRESS } from '@/utils/permit2'; import { useAllowance } from './useAllowance'; +const PERMIT2_TTL_SECONDS = 600; + type Props = { token: Address; chainId?: Chain['id']; @@ -54,7 +55,8 @@ export function usePermit2({ user, chainId = 1, token, spender, refetchInterval async (amountOverride?: bigint) => { if (!user || !spender || !token) throw new Error('User, spender, or token not provided'); - const deadline = moment.now() + 600; + const nowInSeconds = Math.floor(Date.now() / 1000); + const deadline = nowInSeconds + PERMIT2_TTL_SECONDS; const permitAmount = amountOverride ?? amount; const nonce = packedAllowance ? ((packedAllowance as number[])[2] as number) : 0; diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index d63b918f..b1def0b0 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -11,8 +11,9 @@ 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'; -import { useLeverageSupport } from '@/hooks/useLeverageSupport'; +import { useAppSettings } from '@/stores/useAppSettings'; type BorrowModalProps = { market: Market; @@ -40,15 +41,19 @@ export function BorrowModal({ const [mode, setMode] = useState<'borrow' | 'repay'>(() => defaultMode); const { address: account } = useConnection(); const { open: openModal } = useModal(); - const leverageSupport = useLeverageSupport({ market }); + const { enableExperimentalLeverage } = useAppSettings(); + 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 = - !leverageSupport.isLoading && (mode === 'borrow' ? leverageSupport.supportsLeverage : leverageSupport.supportsDeleverage); + const canOpenLeverageModal = enableExperimentalLeverage && hasAnyRoute; 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 bf5b2874..eaa3ed5c 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -9,7 +9,6 @@ import { TokenIcon } from '@/components/shared/token-icon'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { IconSwitch } from '@/components/ui/icon-switch'; import { Tooltip } from '@/components/ui/tooltip'; -import { erc4626Abi } from '@/abis/erc4626'; import { clampMultiplierBps, computeLeverageProjectedPosition, @@ -22,14 +21,16 @@ import { use4626VaultAPR } from '@/hooks/use4626VaultAPR'; import { useLeverageQuote } from '@/hooks/useLeverageQuote'; import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; import { useAppSettings } from '@/stores/useAppSettings'; +import { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; +import { formatSlippagePercent, formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; 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 +42,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(); @@ -59,15 +59,18 @@ export function AddCollateralAndLeverage({ const multiplierBps = useMemo(() => clampMultiplierBps(parseMultiplierToBps(multiplierInput)), [multiplierInput]); const isErc4626Route = route?.kind === 'erc4626'; + const isSwapRoute = route?.kind === 'swap'; + const routeLabel = isSwapRoute ? 'Route: Swap (Bundler3 + Velora)' : isErc4626Route ? 'Route: Vault (ERC4626)' : 'Route: Unsupported'; + const canUseLoanAssetInput = isErc4626Route || isSwapRoute; - const { data: loanTokenBalance } = useReadContract({ + const { data: loanTokenBalance, refetch: refetchLoanTokenBalance } = useReadContract({ address: market.loanAsset.address as `0x${string}`, args: [account as `0x${string}`], functionName: 'balanceOf', abi: erc20Abi, chainId: market.morphoBlue.chain.id, query: { - enabled: !!account && isErc4626Route, + enabled: !!account && useLoanAssetInput, }, }); @@ -78,44 +81,21 @@ export function AddCollateralAndLeverage({ }, [useLoanAssetInput]); useEffect(() => { - if (isErc4626Route) return; + if (canUseLoanAssetInput) return; setUseLoanAssetInput(false); - }, [isErc4626Route]); - - const { - data: previewCollateralSharesFromUnderlying, - isLoading: isLoadingUnderlyingToCollateralConversion, - error: underlyingToCollateralConversionError, - } = 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, - abi: erc4626Abi, - functionName: 'previewDeposit', - args: [collateralAmount], - chainId: market.morphoBlue.chain.id, - query: { - enabled: isErc4626Route && useLoanAssetInput && collateralAmount > 0n, - }, - }); - - const collateralAmountForLeverageQuote = useMemo(() => { - if (useLoanAssetInput) return (previewCollateralSharesFromUnderlying as bigint | undefined) ?? 0n; - return collateralAmount; - }, [useLoanAssetInput, previewCollateralSharesFromUnderlying, collateralAmount]); - - const conversionErrorMessage = useMemo(() => { - if (!useLoanAssetInput || !underlyingToCollateralConversionError) return null; - return underlyingToCollateralConversionError instanceof Error - ? underlyingToCollateralConversionError.message - : 'Failed to quote loan asset to collateral conversion.'; - }, [useLoanAssetInput, underlyingToCollateralConversionError]); + }, [canUseLoanAssetInput]); const quote = useLeverageQuote({ chainId: market.morphoBlue.chain.id, route, - userCollateralAmount: collateralAmountForLeverageQuote, + userInputAmount: collateralAmount, + inputMode: useLoanAssetInput ? 'loan' : 'collateral', 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); @@ -168,20 +148,32 @@ export function AddCollateralAndLeverage({ setCollateralAmount(0n); setCollateralInputError(null); setMultiplierInput(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); + if (useLoanAssetInput) { + void refetchLoanTokenBalance(); + } if (onSuccess) onSuccess(); - }, [onSuccess]); + }, [onSuccess, refetchLoanTokenBalance, useLoanAssetInput]); - const { transaction, isLoadingPermit2, isApproved, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = - useLeverageTransaction({ - market, - route, - collateralAmount, - collateralAmountInCollateralToken: collateralAmountForLeverageQuote, - flashCollateralAmount: quote.flashCollateralAmount, - flashLoanAmount: quote.flashLoanAmount, - useLoanAssetAsInput: useLoanAssetInput, - onSuccess: handleTransactionSuccess, - }); + const { + transaction, + isLoadingPermit2, + permit2Authorized, + leveragePending, + isBundlerAuthorizationReady, + approveAndLeverage, + signAndLeverage, + } = useLeverageTransaction({ + market, + route, + collateralAmount, + collateralAmountInCollateralToken: quote.initialCollateralAmount, + flashCollateralAmount: quote.flashCollateralAmount, + flashLoanAmount: quote.flashLoanAmount, + totalAddedCollateral: quote.totalAddedCollateral, + swapPriceRoute: quote.swapPriceRoute, + useLoanAssetAsInput: useLoanAssetInput, + onSuccess: handleTransactionSuccess, + }); const handleMultiplierInputChange = useCallback((value: string) => { const normalized = value.replace(',', '.'); @@ -194,25 +186,23 @@ export function AddCollateralAndLeverage({ }, [multiplierInput]); const handleLeverage = useCallback(() => { - if (usePermit2Setting && permit2Authorized) { + const usePermit2Flow = usePermit2Setting; + + if (usePermit2Flow && permit2Authorized) { void signAndLeverage(); return; } - if (!usePermit2Setting && isApproved) { - void approveAndLeverage(); - return; - } + void approveAndLeverage(); - }, [usePermit2Setting, permit2Authorized, signAndLeverage, isApproved, approveAndLeverage]); + }, [usePermit2Setting, permit2Authorized, signAndLeverage, approveAndLeverage]); const projectedOverLimit = projectedLTV >= lltv; const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity; - const hasChanges = collateralAmountForLeverageQuote > 0n && quote.flashLoanAmount > 0n; + const hasChanges = quote.totalAddedCollateral > 0n && quote.flashLoanAmount > 0n; const inputAssetSymbol = useLoanAssetInput ? market.loanAsset.symbol : market.collateralAsset.symbol; const inputAssetDecimals = useLoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; const inputAssetBalance = useLoanAssetInput ? (loanTokenBalance as bigint | undefined) : collateralTokenBalance; const inputTokenIconAddress = useLoanAssetInput ? market.loanAsset.address : market.collateralAsset.address; - const isLoadingInputConversion = useLoanAssetInput && isLoadingUnderlyingToCollateralConversion; const flashBorrowPreview = useMemo( () => formatTokenAmountPreview(quote.flashLoanAmount, market.loanAsset.decimals), [quote.flashLoanAmount, market.loanAsset.decimals], @@ -221,6 +211,57 @@ 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 initialCollateralPreview = useMemo( + () => formatTokenAmountPreview(quote.initialCollateralAmount, market.collateralAsset.decimals), + [quote.initialCollateralAmount, market.collateralAsset.decimals], + ); + const collateralPreviewForDisplay = isSwapRoute && !useLoanAssetInput ? swapCollateralOutPreview : totalCollateralAddedPreview; + const collateralPreviewLabel = isSwapRoute + ? useLoanAssetInput + ? 'Total Collateral Added (Min.)' + : 'Collateral From Swap (Min.)' + : 'Total Collateral Added'; + const hasExecutableInputConversion = useMemo(() => { + if (!useLoanAssetInput) return true; + if (isSwapRoute) return quote.totalAddedCollateral > 0n; + if (isErc4626Route) return quote.initialCollateralAmount > 0n; + return false; + }, [useLoanAssetInput, isSwapRoute, isErc4626Route, quote.totalAddedCollateral, quote.initialCollateralAmount]); + const swapRatePreviewText = useMemo(() => { + if (!isSwapRoute || !quote.swapPriceRoute) return null; + + let quotedLoanAmount: bigint; + let quotedCollateralAmount: bigint; + try { + quotedLoanAmount = BigInt(quote.swapPriceRoute.srcAmount); + quotedCollateralAmount = BigInt(quote.swapPriceRoute.destAmount); + } catch { + return null; + } + + return formatSwapRatePreview({ + baseAmount: quotedLoanAmount, + baseTokenDecimals: market.loanAsset.decimals, + baseTokenSymbol: market.loanAsset.symbol, + quoteAmount: quotedCollateralAmount, + quoteTokenDecimals: market.collateralAsset.decimals, + quoteTokenSymbol: market.collateralAsset.symbol, + }); + }, [ + isSwapRoute, + quote.swapPriceRoute, + market.loanAsset.decimals, + market.loanAsset.symbol, + market.collateralAsset.decimals, + market.collateralAsset.symbol, + ]); + const shouldShowSwapPreviewDetails = isSwapRoute && quote.swapPriceRoute != null && swapRatePreviewText != null; + const shouldShowInputConversionPreview = isErc4626Route && useLoanAssetInput && quote.initialCollateralAmount > 0n; + const swapSlippagePreviewText = `${formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)}%`; const renderRateValue = useCallback( (apy: number | null): JSX.Element => { if (apy == null || !Number.isFinite(apy)) return -; @@ -254,6 +295,7 @@ export function AddCollateralAndLeverage({ {!transaction?.isModalVisible && (

Leverage Preview

+

{routeLabel}

{useLoanAssetInput ? `Start with ${market.loanAsset.symbol}` : `Add Collateral ${market.collateralAsset.symbol}`}

- {isErc4626Route && ( + {canUseLoanAssetInput && (
Use {market.loanAsset.symbol}
- {collateralInputError ?? ''} Balance: {formatBalance(inputAssetBalance ?? 0n, inputAssetDecimals)} {inputAssetSymbol} @@ -336,7 +377,7 @@ export function AddCollateralAndLeverage({

Transaction Preview

- Flash Borrow + {isSwapRoute ? 'Flash Borrow Required' : 'Flash Borrow'} {flashBorrowPreview.full}}> {flashBorrowPreview.compact} @@ -351,10 +392,10 @@ export function AddCollateralAndLeverage({
- Total Collateral Added + {collateralPreviewLabel} - {totalCollateralAddedPreview.full}}> - {totalCollateralAddedPreview.compact} + {collateralPreviewForDisplay.full}}> + {collateralPreviewForDisplay.compact}
+ {shouldShowInputConversionPreview && ( +
+ Collateral Shares From Input + + {initialCollateralPreview.full}}> + {initialCollateralPreview.compact} + + + +
+ )} + {shouldShowSwapPreviewDetails && ( + <> +
+ Swap Quote + {swapRatePreviewText} +
+
+ Max Slippage + {swapSlippagePreviewText} +
+ + )}
Borrow {rateLabel} {renderRateValue(previewBorrowApy)} @@ -387,7 +457,6 @@ export function AddCollateralAndLeverage({ )}
- {conversionErrorMessage &&

{conversionErrorMessage}

} {quote.error &&

{quote.error}

} {isErc4626Route && vaultRateInsight.error && (

Failed to fetch 3-day vault/borrow rates: {vaultRateInsight.error}

@@ -406,15 +475,14 @@ export function AddCollateralAndLeverage({ void; isRefreshing?: boolean; }; +const UNSIGNED_DIGITS_REGEX = /^\d+$/; +const parseUnsignedBigInt = (value: unknown): bigint | null => { + if (typeof value === 'bigint') return value >= 0n ? value : null; + if (typeof value === 'string') { + const normalized = value.trim(); + if (!UNSIGNED_DIGITS_REGEX.test(normalized)) return null; + try { + return BigInt(normalized); + } catch { + return null; + } + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return null; + return BigInt(value); + } + return null; +}; + export function RemoveCollateralAndDeleverage({ market, - support, + route, currentPosition, oraclePrice, onSuccess, isRefreshing = false, }: RemoveCollateralAndDeleverageProps): JSX.Element { - const route = support.route; + const { address: account } = useConnection(); + const isSwapRoute = route?.kind === 'swap'; + const isErc4626Route = route?.kind === 'erc4626'; + const routeLabel = isSwapRoute ? 'Route: Swap (Bundler3 + Velora)' : isErc4626Route ? 'Route: Vault (ERC4626)' : 'Route: Unsupported'; const [withdrawCollateralAmount, setWithdrawCollateralAmount] = useState(0n); const [withdrawInputError, setWithdrawInputError] = useState(null); - const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); - const currentBorrowAssets = BigInt(currentPosition?.state.borrowAssets ?? 0); - const currentBorrowShares = BigInt(currentPosition?.state.borrowShares ?? 0); - const lltv = BigInt(market.lltv); + const currentCollateralAssetsRaw = parseUnsignedBigInt(currentPosition?.state.collateral); + const currentBorrowAssetsRaw = parseUnsignedBigInt(currentPosition?.state.borrowAssets); + const currentBorrowSharesRaw = parseUnsignedBigInt(currentPosition?.state.borrowShares); + const lltvRaw = parseUnsignedBigInt(market.lltv); + const hasInvalidPositionData = + currentCollateralAssetsRaw == null || currentBorrowAssetsRaw == null || currentBorrowSharesRaw == null || lltvRaw == null; + const currentCollateralAssets = currentCollateralAssetsRaw ?? 0n; + const currentBorrowAssets = currentBorrowAssetsRaw ?? 0n; + const currentBorrowShares = currentBorrowSharesRaw ?? 0n; + const lltv = lltvRaw ?? 0n; + const quoteWithdrawCollateralAmount = hasInvalidPositionData ? 0n : withdrawCollateralAmount; const quote = useDeleverageQuote({ chainId: market.morphoBlue.chain.id, route, - withdrawCollateralAmount, + withdrawCollateralAmount: quoteWithdrawCollateralAmount, currentBorrowAssets, + currentBorrowShares, + loanTokenAddress: market.loanAsset.address, + loanTokenDecimals: market.loanAsset.decimals, + collateralTokenAddress: market.collateralAsset.address, + collateralTokenDecimals: market.collateralAsset.decimals, + userAddress: account as `0x${string}` | undefined, }); + const closeRoutePendingResolution = route?.kind === 'swap' && quote.closeRouteRequiresResolution && quote.canCurrentSellCloseDebt; + const closeRouteAvailableForPreview = quote.closeRouteAvailable || closeRoutePendingResolution; + const closeBoundForPreview = closeRoutePendingResolution ? withdrawCollateralAmount : quote.maxCollateralForDebtRepay; + const projection = useMemo( () => computeDeleverageProjectedPosition({ @@ -53,18 +94,20 @@ export function RemoveCollateralAndDeleverage({ currentBorrowAssets, currentBorrowShares, withdrawCollateralAmount, - rawRouteRepayAmount: quote.rawRouteRepayAmount, repayAmount: quote.repayAmount, - maxCollateralForDebtRepay: quote.maxCollateralForDebtRepay, + maxCollateralForDebtRepay: closeBoundForPreview, + closeRouteAvailable: closeRouteAvailableForPreview, + closeBoundIsInputCap: route?.kind !== 'swap', }), [ currentCollateralAssets, currentBorrowAssets, currentBorrowShares, withdrawCollateralAmount, - quote.rawRouteRepayAmount, quote.repayAmount, - quote.maxCollateralForDebtRepay, + closeBoundForPreview, + closeRouteAvailableForPreview, + route, ], ); @@ -95,22 +138,46 @@ export function RemoveCollateralAndDeleverage({ if (onSuccess) onSuccess(); }, [onSuccess]); - const { transaction, deleveragePending, authorizeAndDeleverage } = useDeleverageTransaction({ + const { + transaction, + isLoading: deleverageFlowLoading, + authorizeAndDeleverage, + } = useDeleverageTransaction({ market, route, withdrawCollateralAmount, + maxWithdrawCollateralAmount: projection.maxWithdrawCollateral, flashLoanAmount: projection.flashLoanAmountForTx, repayBySharesAmount: projection.repayBySharesAmount, + useCloseRoute: projection.usesCloseRoute, autoWithdrawCollateralAmount: projection.autoWithdrawCollateralAmount, + maxCollateralForDebtRepay: quote.maxCollateralForDebtRepay, + swapSellPriceRoute: quote.swapSellPriceRoute, onSuccess: handleTransactionSuccess, }); const handleDeleverage = useCallback(() => { + if ( + hasInvalidPositionData || + withdrawInputError || + quote.closeRouteRequiresResolution || + withdrawCollateralAmount > projection.maxWithdrawCollateral + ) { + return; + } void authorizeAndDeleverage(); - }, [authorizeAndDeleverage]); + }, [ + hasInvalidPositionData, + withdrawInputError, + quote.closeRouteRequiresResolution, + withdrawCollateralAmount, + projection.maxWithdrawCollateral, + authorizeAndDeleverage, + ]); // Treat user input as an intent change immediately so the preview card updates as soon as the amount changes. const hasChanges = withdrawCollateralAmount > 0n; + const exceedsMaxWithdraw = withdrawCollateralAmount > projection.maxWithdrawCollateral; const projectedOverLimit = projectedLTV >= lltv; const flashBorrowPreview = useMemo( () => formatTokenAmountPreview(projection.flashLoanAmountForTx, market.loanAsset.decimals), @@ -120,12 +187,48 @@ export function RemoveCollateralAndDeleverage({ () => formatTokenAmountPreview(projection.previewDebtRepaid, market.loanAsset.decimals), [projection.previewDebtRepaid, market.loanAsset.decimals], ); + const unwindCollateralPreview = useMemo( + () => formatTokenAmountPreview(withdrawCollateralAmount, market.collateralAsset.decimals), + [withdrawCollateralAmount, market.collateralAsset.decimals], + ); + const collateralFlowLabel = isSwapRoute ? 'Collateral Sold' : 'Collateral Unwound'; + const swapRatePreviewText = useMemo(() => { + if (!isSwapRoute || !quote.swapSellPriceRoute) return null; + + let quotedCollateralIn: bigint; + let quotedLoanOut: bigint; + try { + quotedCollateralIn = BigInt(quote.swapSellPriceRoute.srcAmount); + quotedLoanOut = BigInt(quote.swapSellPriceRoute.destAmount); + } catch { + return null; + } + + return formatSwapRatePreview({ + baseAmount: quotedCollateralIn, + baseTokenDecimals: market.collateralAsset.decimals, + baseTokenSymbol: market.collateralAsset.symbol, + quoteAmount: quotedLoanOut, + quoteTokenDecimals: market.loanAsset.decimals, + quoteTokenSymbol: market.loanAsset.symbol, + }); + }, [ + isSwapRoute, + quote.swapSellPriceRoute, + market.collateralAsset.decimals, + market.collateralAsset.symbol, + market.loanAsset.decimals, + market.loanAsset.symbol, + ]); + const shouldShowSwapPreviewDetails = isSwapRoute && quote.swapSellPriceRoute != null && swapRatePreviewText != null; + const swapSlippagePreviewText = `${formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)}%`; return (
{!transaction?.isModalVisible && (

Deleverage Preview

+

{routeLabel}

} /> -

- Max: {formatBalance(projection.maxWithdrawCollateral, market.collateralAsset.decimals)} {market.collateralAsset.symbol} -

{withdrawInputError &&

{withdrawInputError}

} + {!withdrawInputError && exceedsMaxWithdraw && ( +

Exceeds deleverageable collateral

+ )}

Transaction Preview

+
+ {collateralFlowLabel} + + {unwindCollateralPreview.full}}> + {unwindCollateralPreview.compact} + + + +
Flash Borrow @@ -200,12 +318,32 @@ export function RemoveCollateralAndDeleverage({ />
+ {shouldShowSwapPreviewDetails && ( + <> +
+ Swap Quote + {swapRatePreviewText} +
+
+ Max Slippage + {swapSlippagePreviewText} +
+ + )}
Projected LTV {formatLtvPercent(projectedLTV)}%
{quote.error &&

{quote.error}

} + {hasInvalidPositionData && ( +

Unable to read valid position data. Refresh balances and try again.

+ )} + {quote.closeRouteRequiresResolution && ( +

+ Resolving exact full-close bound... preview values may adjust before execution. +

+ )}
@@ -214,12 +352,14 @@ export function RemoveCollateralAndDeleverage({ void; +}; + +function RouteModeBadge({ value, availableModes, onValueChange }: RouteModeBadgeProps): JSX.Element { + const canSwitch = availableModes.length > 1; + const label = ROUTE_MODE_LABELS[value]; + const badge = ( + + #{label} + {canSwitch && } + + ); + + if (!canSwitch) { + return badge; + } + + return ( + + + + + + {availableModes.map((mode) => ( + onValueChange(mode)} + className={cn(mode === value && 'bg-hovered')} + > + {ROUTE_MODE_LABELS[mode]} + + ))} + + + ); +} + export function LeverageModal({ market, onOpenChange, @@ -32,9 +100,58 @@ export function LeverageModal({ toggleLeverageDeleverage = true, }: LeverageModalProps): JSX.Element { const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); + const [routeMode, setRouteMode] = useState('erc4626'); const { address: account } = useConnection(); - const support = useLeverageSupport({ market }); - const isErc4626Route = support.route?.kind === 'erc4626'; + + 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)) 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') { + 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, + isErc4626ProbeLoading, + isErc4626ProbeRefetching, + market.collateralAsset.address, + market.loanAsset.address, + swapRoute, + ]); + const isErc4626Route = route?.kind === 'erc4626'; + const isSwapRoute = route?.kind === 'swap'; + const displayedRouteMode = useMemo(() => { + if (route?.kind) return route.kind; + if (availableRouteModes.length === 1) return availableRouteModes[0]; + if (availableRouteModes.includes(routeMode)) return routeMode; + return availableRouteModes[0] ?? routeMode; + }, [route, availableRouteModes, routeMode]); const effectiveMode = mode; const modeOptions: { value: string; label: string }[] = toggleLeverageDeleverage @@ -110,14 +227,12 @@ export function LeverageModal({ options={modeOptions} onValueChange={(nextMode) => setMode(nextMode as 'leverage' | 'deleverage')} /> - {isErc4626Route && ( - - #ERC4626 - + {(route || availableRouteModes.length > 0) && ( + )}
} @@ -125,43 +240,43 @@ export function LeverageModal({ 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 + ? `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.
)}
diff --git a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx index 4d9df0f8..40784478 100644 --- a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx +++ b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx @@ -12,7 +12,14 @@ type ExperimentalPanelProps = { export function ExperimentalPanel({ onNavigateToDetail }: ExperimentalPanelProps) { const { showOfficialTrending, setShowOfficialTrending, customTagConfig, setCustomTagEnabled } = useMarketPreferences(); - const { showDeveloperOptions, setShowDeveloperOptions, usePublicAllocator, setUsePublicAllocator } = useAppSettings(); + const { + showDeveloperOptions, + setShowDeveloperOptions, + usePublicAllocator, + setUsePublicAllocator, + enableExperimentalLeverage, + setEnableExperimentalLeverage, + } = useAppSettings(); return (
@@ -63,6 +70,18 @@ export function ExperimentalPanel({ onNavigateToDetail }: ExperimentalPanelProps />
+ {/* Leverage / Deleverage */} +
+

Leverage

+ +
+ {/* Developer */}

Developer

diff --git a/src/stores/useAppSettings.ts b/src/stores/useAppSettings.ts index 90398aad..88cd8467 100644 --- a/src/stores/useAppSettings.ts +++ b/src/stores/useAppSettings.ts @@ -19,6 +19,9 @@ type AppSettingsState = { // Public Allocator (source liquidity) usePublicAllocator: boolean; + + // Experimental feature gates + enableExperimentalLeverage: boolean; }; type AppSettingsActions = { @@ -30,6 +33,7 @@ type AppSettingsActions = { setTrustedVaultsWarningDismissed: (dismissed: boolean) => void; setShowDeveloperOptions: (show: boolean) => void; setUsePublicAllocator: (show: boolean) => void; + setEnableExperimentalLeverage: (show: boolean) => void; // Bulk update for migration setAll: (state: Partial) => void; @@ -58,6 +62,7 @@ export const useAppSettings = create()( trustedVaultsWarningDismissed: false, showDeveloperOptions: false, usePublicAllocator: true, + enableExperimentalLeverage: false, // Actions setUsePermit2: (use) => set({ usePermit2: use }), @@ -68,6 +73,7 @@ export const useAppSettings = create()( setTrustedVaultsWarningDismissed: (dismissed) => set({ trustedVaultsWarningDismissed: dismissed }), setShowDeveloperOptions: (show) => set({ showDeveloperOptions: show }), setUsePublicAllocator: (show) => set({ usePublicAllocator: show }), + setEnableExperimentalLeverage: (show) => set({ enableExperimentalLeverage: show }), setAll: (state) => set(state), }), {