diff --git a/AGENTS.md b/AGENTS.md
index 6ab44e82..eb3f13b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -156,6 +156,11 @@ When touching transaction and position flows, validation MUST include all releva
21. **Modal UX integrity**: transaction-modal help/fee tooltips must render above modal layers via shared tooltip z-index chokepoints, and per-flow input mode toggles (for example target LTV vs amount) must persist through shared settings across modal reopen.
22. **Chain-scoped identity integrity**: all market/token/route identity checks must be chain-scoped and use canonical identifiers (`chainId + market.uniqueKey` or `chainId + address`), including matching, dedupe keys, routing, and trust/allowlist gates.
23. **Bundler residual-asset integrity**: any flash-loan transaction path that routes assets through Bundler/adapter balances (Bundler V2, GeneralAdapter, ParaswapAdapter) must end with explicit trailing sweeps of both loan and collateral tokens to the intended recipient across leverage/deleverage and swap/ERC4626 paths, and must keep execute-time slippage bounds consistent with quote-time slippage settings.
+24. **Swap execution-field integrity**: for Velora/Paraswap routes, hard preview and execution guards must validate execution-authoritative fields only (trusted target, exact sell amount, min-out / close floor, token identities). Do not block flows on echoed route metadata such as quoted source or quoted destination amounts when calldata checks already enforce the executable bounds.
+25. **Deterministic flash-loan asset floors**: when a no-swap ERC4626 redeem/withdraw leg is the source of flash-loan repayment assets, its execute-time minimum asset bound must be at least the flash-loan settlement amount itself; do not apply swap-style slippage floors that allow the callback to under-return assets and fail only at final flash-loan settlement.
+26. **Deterministic ERC4626 quote/execution matching**: when a no-swap ERC4626 leverage leg uses vault previews, the execute-time operation must match the preview semantics exactly: `previewDeposit` should map to exact-asset deposit with the quoted share floor, and `previewMint` should map to exact-share mint with the quoted asset cap. Do not reuse swap-style slippage floors on either path.
+27. **Transaction-tracking preflight integrity**: do not call `tracking.start(...)` until all synchronous preflight validation for the flow has passed (account, route, quote, input, fee viability). Once tracking has started, execution helpers must either complete successfully or throw so the caller can finish the lifecycle with exactly one `tracking.complete()` or `tracking.fail()`.
+28. **Close-route collateral handoff integrity**: when a deleverage projection derives an exact close-bound collateral amount for full-repay-by-shares, route-specific executors must receive and use that quote-derived close bound explicitly for withdraw/redeem steps instead of relying on the raw user input amount. Any remaining collateral must be returned through the dedicated post-close withdraw/sweep path.
### REQUIRED: Regression Rule Capture
diff --git a/app/tools/page.tsx b/app/tools/page.tsx
index 998b78c2..c4eff89c 100644
--- a/app/tools/page.tsx
+++ b/app/tools/page.tsx
@@ -12,7 +12,7 @@ import { useMorphoAuthorization } from '@/hooks/useMorphoAuthorization';
import { useStyledToast } from '@/hooks/useStyledToast';
import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
-import { getNetworkName, SupportedNetworks } from '@/utils/networks';
+import { getNetworkName, type SupportedNetworks } from '@/utils/networks';
import NetworkFilter from '@/features/markets/components/filters/network-filter';
export default function ToolsPage() {
@@ -159,14 +159,16 @@ export default function ToolsPage() {
}) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
value: 0n,
chainId: selectedChainId,
- }).catch((error: unknown) => {
- const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to submit Bundler V2 asset sweep.');
- if (userFacingMessage !== 'User rejected transaction.') {
- toast.error('Asset sweep failed', userFacingMessage);
- }
- }).finally(() => {
- setIsSweepConfirmModalOpen(false);
- });
+ })
+ .catch((error: unknown) => {
+ const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to submit Bundler V2 asset sweep.');
+ if (userFacingMessage !== 'User rejected transaction.') {
+ toast.error('Asset sweep failed', userFacingMessage);
+ }
+ })
+ .finally(() => {
+ setIsSweepConfirmModalOpen(false);
+ });
};
const getInputClassName = () => {
@@ -277,9 +279,7 @@ export default function ToolsPage() {
Executes via Bundler V2 multicall on the selected network:
erc20Transfer(asset, yourWallet, maxUint256).
-
- ⚠️ This transfers only what Bundler V2 already holds for the asset address.
-
+ ⚠️ This transfers only what Bundler V2 already holds for the asset address.
@@ -292,7 +292,10 @@ export default function ToolsPage() {
/>
-
+
Asset address
Network: {getNetworkName(selectedChainId)} ({selectedChainId})
-
- Bundler V2: {bundlerV2Address === zeroAddress ? 'Not configured' : bundlerV2Address}
-
+ Bundler V2: {bundlerV2Address === zeroAddress ? 'Not configured' : bundlerV2Address}
Recipient: {account ?? 'Connect wallet'}
@@ -360,7 +361,10 @@ export default function ToolsPage() {
-
+
Cancel
void;
+ useCloseRoute: boolean;
+ useSignatureAuthorization: boolean;
+};
+
+export const deleverageWithErc4626Redeem = async ({
+ account,
+ autoWithdrawCollateralAmount,
+ bundlerAddress,
+ collateralToRedeem,
+ ensureBundlerAuthorization,
+ flashLoanAmount,
+ isBundlerAuthorized,
+ market,
+ marketParams,
+ repayBySharesAmount,
+ route,
+ sendTransactionAsync,
+ updateStep,
+ useCloseRoute,
+ useSignatureAuthorization,
+}: DeleverageWithErc4626RedeemParams): Promise => {
+ const txs: `0x${string}`[] = [];
+
+ if (useSignatureAuthorization) {
+ if (!isBundlerAuthorized) {
+ updateStep('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 sleep(700);
+ }
+ } else if (!isBundlerAuthorized) {
+ updateStep('authorize_bundler_tx');
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
+ if (!authorized) {
+ throw new Error('Failed to authorize Bundler via transaction.');
+ }
+ }
+
+ const { bundlerV2RepaySlippageAmount } = getDeleverageRepayBounds({
+ flashLoanRepayAssets: flashLoanAmount,
+ repayBySharesAmount,
+ useRepayByShares: useCloseRoute,
+ });
+
+ // No swap slippage exists on the ERC4626 redeem path.
+ // This redeem leg must return at least the flash-loan settlement amount or the whole bundle
+ // would revert later during flash-loan repayment. Extra loan assets from the buffered close
+ // amount are swept back to the user, while any remaining collateral is withdrawn separately below.
+ const minLoanAssetsOut = flashLoanAmount;
+ const callbackTxs: `0x${string}`[] = [
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoRepay',
+ args: [
+ marketParams,
+ useCloseRoute ? 0n : flashLoanAmount,
+ useCloseRoute ? repayBySharesAmount : 0n,
+ bundlerV2RepaySlippageAmount,
+ account,
+ '0x',
+ ],
+ }),
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoWithdrawCollateral',
+ // Withdraw ERC4626 shares onto the bundler because the same bundler multicall redeems them immediately.
+ args: [marketParams, collateralToRedeem, bundlerAddress],
+ }),
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc4626Redeem',
+ args: [route.collateralVault, collateralToRedeem, minLoanAssetsOut, bundlerAddress, bundlerAddress],
+ }),
+ ];
+
+ if (autoWithdrawCollateralAmount > 0n) {
+ callbackTxs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoWithdrawCollateral',
+ args: [marketParams, autoWithdrawCollateralAmount, account],
+ }),
+ );
+ }
+
+ const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]);
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoFlashLoan',
+ args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData],
+ }),
+ );
+ // Safety net: sweep any residual loan/collateral balances from the bundler back to the user.
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20Transfer',
+ args: [market.loanAsset.address as Address, account, maxUint256],
+ }),
+ );
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20Transfer',
+ args: [market.collateralAsset.address as Address, account, maxUint256],
+ }),
+ );
+
+ updateStep('execute');
+ await sleep(700);
+
+ await sendTransactionAsync({
+ account,
+ to: bundlerAddress,
+ data: (encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'multicall',
+ args: [txs],
+ }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
+ value: 0n,
+ });
+};
diff --git a/src/hooks/deleverage/deleverageWithSwap.ts b/src/hooks/deleverage/deleverageWithSwap.ts
new file mode 100644
index 00000000..356011fc
--- /dev/null
+++ b/src/hooks/deleverage/deleverageWithSwap.ts
@@ -0,0 +1,242 @@
+import { type Address, encodeFunctionData, keccak256, zeroHash } from 'viem';
+import { bundlerV3Abi } from '@/abis/bundlerV3';
+import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1';
+import { paraswapAdapterAbi } from '@/abis/paraswapAdapter';
+import type { VeloraPriceRoute } from '@/features/swap/api/velora';
+import {
+ buildBundler3Erc20SweepCalls,
+ type Bundler3Call,
+ encodeBundler3Calls,
+ getParaswapSellOffsets,
+ readCalldataUint256,
+} from '@/hooks/leverage/bundler3';
+import { withSlippageFloor } from '@/hooks/leverage/math';
+import {
+ type EnsureBundlerAuthorization,
+ type MorphoMarketParams,
+ type SendBundlerTransaction,
+ sleep,
+} from '@/hooks/leverage/transaction-shared';
+import type { SwapLeverageRoute } from '@/hooks/leverage/types';
+import { assertTrustedVeloraExecutionTarget, buildVeloraBundlerTransactionPayload } from '@/hooks/leverage/velora-transaction';
+import { MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
+import type { Market } from '@/utils/types';
+import { type DeleverageStepType, getDeleverageRepayBounds } from './transaction-shared';
+
+type DeleverageWithSwapParams = {
+ account: Address;
+ autoWithdrawCollateralAmount: bigint;
+ bundlerAddress: Address;
+ ensureBundlerAuthorization: EnsureBundlerAuthorization;
+ flashLoanAmount: bigint;
+ isBundlerAuthorized: boolean | undefined;
+ market: Market;
+ marketParams: MorphoMarketParams;
+ maxCollateralForDebtRepay: bigint;
+ repayBySharesAmount: bigint;
+ route: SwapLeverageRoute;
+ sendTransactionAsync: SendBundlerTransaction;
+ slippageBps: number;
+ swapSellPriceRoute: VeloraPriceRoute;
+ updateStep: (step: DeleverageStepType) => void;
+ useCloseRoute: boolean;
+ withdrawCollateralAmount: bigint;
+};
+
+export const deleverageWithSwap = async ({
+ account,
+ autoWithdrawCollateralAmount,
+ bundlerAddress,
+ ensureBundlerAuthorization,
+ flashLoanAmount,
+ isBundlerAuthorized,
+ market,
+ marketParams,
+ maxCollateralForDebtRepay,
+ repayBySharesAmount,
+ route,
+ sendTransactionAsync,
+ slippageBps,
+ swapSellPriceRoute,
+ updateStep,
+ useCloseRoute,
+ withdrawCollateralAmount,
+}: DeleverageWithSwapParams): Promise => {
+ if (!Number.isFinite(slippageBps) || slippageBps <= 0) {
+ throw new Error('Invalid slippage tolerance. Please set a positive slippage value.');
+ }
+
+ if (!isBundlerAuthorized) {
+ updateStep('authorize_bundler_tx');
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
+ if (!authorized) {
+ throw new Error('Failed to authorize Bundler via transaction.');
+ }
+ }
+
+ 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 { generalAdapterRepayMaxSharePriceE27 } = getDeleverageRepayBounds({
+ flashLoanRepayAssets: flashLoanAmount,
+ repayBySharesAmount,
+ useRepayByShares: useCloseRoute,
+ });
+
+ const swapTxPayload = await buildVeloraBundlerTransactionPayload({
+ destinationTokenAddress: market.loanAsset.address,
+ destinationTokenDecimals: market.loanAsset.decimals,
+ executionAddress: route.paraswapAdapterAddress,
+ network: market.morphoBlue.chain.id,
+ priceRoute: swapSellPriceRoute,
+ quoteChangedMessage: 'Deleverage quote changed. Please review the updated preview and try again.',
+ slippageBps,
+ sourceTokenAddress: market.collateralAsset.address,
+ sourceTokenAmount: withdrawCollateralAmount,
+ sourceTokenDecimals: market.collateralAsset.decimals,
+ sourceTokenSymbol: market.collateralAsset.symbol,
+ });
+
+ assertTrustedVeloraExecutionTarget({
+ priceRoute: swapSellPriceRoute,
+ quoteChangedMessage: 'Deleverage quote changed. Please review the updated preview and try again.',
+ transactionTarget: swapTxPayload.to,
+ });
+
+ const sellOffsets = getParaswapSellOffsets(swapTxPayload.data);
+ const quotedLoanAssetsOut = BigInt(swapSellPriceRoute.destAmount);
+ const calldataSellCollateralAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount);
+ // Only validate execution-authoritative swap fields here:
+ // - exactAmount: the collateral amount the adapter will actually sell
+ // - limitAmount: the minimum loan assets the adapter must receive
+ //
+ // Paraswap's quotedAmount is informational route metadata, not the actual execution floor.
+ // Rejecting on that field creates false positives without improving flash-loan safety.
+ if (calldataSellCollateralAmount !== withdrawCollateralAmount) {
+ throw new Error('Deleverage quote changed. Please review the updated preview and try again.');
+ }
+
+ const loanAssetsOutSlippageFloor = withSlippageFloor(quotedLoanAssetsOut, slippageBps);
+ if (useCloseRoute) {
+ if (loanAssetsOutSlippageFloor < flashLoanAmount) {
+ throw new Error('Deleverage quote changed. Please review the updated preview and try again.');
+ }
+ } else if (loanAssetsOutSlippageFloor <= 0n) {
+ throw new Error('Velora returned zero loan output for deleverage swap.');
+ }
+
+ const calldataLoanAssetsOutSlippageFloor = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount);
+ if (calldataLoanAssetsOutSlippageFloor !== loanAssetsOutSlippageFloor) {
+ 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',
+ // Repay first so Morpho can release the exact collateral leg needed for the unwind.
+ args: [
+ marketParams,
+ useCloseRoute ? 0n : flashLoanAmount,
+ useCloseRoute ? repayBySharesAmount : 0n,
+ generalAdapterRepayMaxSharePriceE27,
+ account,
+ '0x',
+ ],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ },
+ {
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'morphoWithdrawCollateral',
+ // Withdraw collateral straight into the Paraswap adapter because that contract executes the sell leg.
+ args: [marketParams, withdrawCollateralAmount, route.paraswapAdapterAddress],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ },
+ {
+ to: route.paraswapAdapterAddress,
+ data: encodeFunctionData({
+ abi: paraswapAdapterAbi,
+ functionName: 'sell',
+ args: [
+ swapTxPayload.to,
+ swapTxPayload.data,
+ market.collateralAsset.address as Address,
+ market.loanAsset.address as Address,
+ false,
+ sellOffsets,
+ route.generalAdapterAddress,
+ ],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ },
+ ];
+
+ if (autoWithdrawCollateralAmount > 0n) {
+ callbackBundle.push({
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'morphoWithdrawCollateral',
+ args: [marketParams, autoWithdrawCollateralAmount, account],
+ }),
+ 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),
+ },
+ ...buildBundler3Erc20SweepCalls({
+ recipient: account,
+ sweepTargets: [
+ { adapterAbi: morphoGeneralAdapterV1Abi, adapterAddress: route.generalAdapterAddress },
+ { adapterAbi: paraswapAdapterAbi, adapterAddress: route.paraswapAdapterAddress },
+ ],
+ tokenAddresses: [market.loanAsset.address as Address, market.collateralAsset.address as Address],
+ }),
+ ];
+
+ updateStep('execute');
+ await sleep(700);
+
+ await sendTransactionAsync({
+ account,
+ to: bundlerAddress,
+ data: (encodeFunctionData({
+ abi: bundlerV3Abi,
+ functionName: 'multicall',
+ args: [bundleCalls],
+ }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
+ value: 0n,
+ });
+};
diff --git a/src/hooks/deleverage/transaction-shared.ts b/src/hooks/deleverage/transaction-shared.ts
new file mode 100644
index 00000000..b6aa33fc
--- /dev/null
+++ b/src/hooks/deleverage/transaction-shared.ts
@@ -0,0 +1,39 @@
+import { computeMaxSharePriceE27 } from '@/hooks/leverage/velora-precheck';
+
+export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx' | 'execute';
+
+export const MIN_REPAY_SHARES_SLIPPAGE_AMOUNT = 1n;
+
+/**
+ * Morpho repay slippage is mode-dependent:
+ * - repay by assets: the slippage value is a minimum shares floor
+ * - repay by shares: the slippage value is a maximum assets ceiling
+ *
+ * The Bundler V2 path passes this value directly to `morphoRepay`.
+ * The GeneralAdapter path expresses the same protection as `maxSharePriceE27`.
+ */
+export const getDeleverageRepayBounds = ({
+ flashLoanRepayAssets,
+ repayBySharesAmount,
+ useRepayByShares,
+}: {
+ flashLoanRepayAssets: bigint;
+ repayBySharesAmount: bigint;
+ useRepayByShares: boolean;
+}) => {
+ const repaySharesSlippageAmount = MIN_REPAY_SHARES_SLIPPAGE_AMOUNT;
+ const bundlerV2RepaySlippageAmount = useRepayByShares ? flashLoanRepayAssets : repaySharesSlippageAmount;
+ const generalAdapterRepayMaxSharePriceE27 = computeMaxSharePriceE27(
+ flashLoanRepayAssets,
+ useRepayByShares ? repayBySharesAmount : repaySharesSlippageAmount,
+ );
+
+ if (generalAdapterRepayMaxSharePriceE27 <= 0n) {
+ throw new Error('Invalid deleverage bounds for repay-by-shares. Refresh the quote and try again.');
+ }
+
+ return {
+ bundlerV2RepaySlippageAmount,
+ generalAdapterRepayMaxSharePriceE27,
+ };
+};
diff --git a/src/hooks/leverage/bundler3.ts b/src/hooks/leverage/bundler3.ts
index 1ed50b98..9a0a9a53 100644
--- a/src/hooks/leverage/bundler3.ts
+++ b/src/hooks/leverage/bundler3.ts
@@ -1,4 +1,4 @@
-import { type Address, encodeAbiParameters } from 'viem';
+import { type Abi, type Address, encodeAbiParameters, encodeFunctionData, maxUint256, zeroHash } from 'viem';
const PARASWAP_SELL_EXACT_AMOUNT_OFFSET = 100n;
const PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET = 132n;
@@ -53,3 +53,42 @@ export const readCalldataUint256 = (callData: `0x${string}`, offset: bigint): bi
return BigInt(`0x${callData.slice(start, end)}`);
};
+
+type Bundler3SweepTarget = {
+ adapterAbi: Abi;
+ adapterAddress: Address;
+};
+
+/**
+ * Explicitly sweep every touched adapter/token pair after execution.
+ * This keeps residual dust from remaining in Bundler3 adapters after flash-loan settlement.
+ */
+export const buildBundler3Erc20SweepCalls = ({
+ recipient,
+ sweepTargets,
+ tokenAddresses,
+}: {
+ recipient: Address;
+ sweepTargets: Bundler3SweepTarget[];
+ tokenAddresses: Address[];
+}): Bundler3Call[] => {
+ const sweepCalls: Bundler3Call[] = [];
+
+ for (const { adapterAbi, adapterAddress } of sweepTargets) {
+ for (const tokenAddress of tokenAddresses) {
+ sweepCalls.push({
+ to: adapterAddress,
+ data: encodeFunctionData({
+ abi: adapterAbi,
+ functionName: 'erc20Transfer',
+ args: [tokenAddress, recipient, maxUint256],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ });
+ }
+ }
+
+ return sweepCalls;
+};
diff --git a/src/hooks/leverage/leverageWithErc4626Deposit.ts b/src/hooks/leverage/leverageWithErc4626Deposit.ts
new file mode 100644
index 00000000..113782dc
--- /dev/null
+++ b/src/hooks/leverage/leverageWithErc4626Deposit.ts
@@ -0,0 +1,225 @@
+import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256 } from 'viem';
+import morphoBundlerAbi from '@/abis/bundlerV2';
+import { LEVERAGE_FEE_RECIPIENT } from '@/config/leverage';
+import { MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
+import type { Market } from '@/utils/types';
+import { getBorrowSharesSlippageAmount } from './math';
+import {
+ type EnsureBundlerAuthorization,
+ type MorphoMarketParams,
+ type LeverageStepType,
+ type SendBundlerTransaction,
+ type SignForBundlers,
+ sleep,
+} from './transaction-shared';
+import type { Erc4626LeverageRoute } from './types';
+
+type LeverageWithErc4626DepositParams = {
+ account: Address;
+ bundlerAddress: Address;
+ market: Market;
+ marketParams: MorphoMarketParams;
+ route: Erc4626LeverageRoute;
+ /** Exact user-entered starting capital, denominated by `isLoanAssetInput`. */
+ initialCapitalInputAmount: bigint;
+ /** Market collateral-token amount sourced from the initial capital before the flash leg. */
+ initialCapitalCollateralTokenAmount: bigint;
+ initialCapitalInputTokenAddress: Address;
+ initialCapitalTransferAmount: bigint;
+ isLoanAssetInput: boolean;
+ flashLegCollateralTokenAmount: bigint;
+ flashLoanAssetAmount: bigint; // amount to flashloan in loan asset
+ leverageFeeAmount: bigint;
+ usePermit2: boolean;
+ permit2Authorized: boolean;
+ isBundlerAuthorized: boolean | undefined;
+ authorizePermit2: () => Promise;
+ ensureBundlerAuthorization: EnsureBundlerAuthorization;
+ signForBundlers: SignForBundlers;
+ isApproved: boolean;
+ approve: () => Promise;
+ updateStep: (step: LeverageStepType) => void;
+ sendTransactionAsync: SendBundlerTransaction;
+};
+
+export const leverageWithErc4626Deposit = async ({
+ account,
+ bundlerAddress,
+ market,
+ marketParams,
+ route,
+ initialCapitalInputAmount,
+ initialCapitalCollateralTokenAmount,
+ initialCapitalInputTokenAddress,
+ initialCapitalTransferAmount,
+ isLoanAssetInput,
+ flashLegCollateralTokenAmount,
+ flashLoanAssetAmount,
+ leverageFeeAmount,
+ usePermit2,
+ permit2Authorized,
+ isBundlerAuthorized,
+ authorizePermit2,
+ ensureBundlerAuthorization,
+ signForBundlers,
+ isApproved,
+ approve,
+ updateStep,
+ sendTransactionAsync,
+}: LeverageWithErc4626DepositParams): Promise => {
+ const txs: `0x${string}`[] = [];
+
+ if (usePermit2) {
+ if (!permit2Authorized) {
+ updateStep('approve_permit2');
+ await authorizePermit2();
+ await sleep(800);
+ }
+
+ if (!isBundlerAuthorized) {
+ updateStep('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 leverage.');
+ }
+ if (authorizationTxData) {
+ txs.push(authorizationTxData);
+ await sleep(800);
+ }
+
+ updateStep('sign_permit');
+ const { sigs, permitSingle } = await signForBundlers();
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'approve2',
+ args: [permitSingle, sigs, false],
+ }),
+ );
+ } else {
+ if (!isBundlerAuthorized) {
+ updateStep('authorize_bundler_tx');
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
+ if (!authorized) {
+ throw new Error('Failed to authorize Bundler via transaction.');
+ }
+ }
+
+ if (!isApproved) {
+ updateStep('approve_token');
+ await approve();
+ await sleep(900);
+ }
+ }
+
+ // Asset-based borrow uses an exact asset amount plus a max-share slippage bound.
+ const borrowSharesSlippageAmount = getBorrowSharesSlippageAmount({
+ borrowAssets: flashLoanAssetAmount,
+ totalBorrowAssets: BigInt(market.state.borrowAssets),
+ totalBorrowShares: BigInt(market.state.borrowShares),
+ });
+
+ if (initialCapitalTransferAmount > 0n) {
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: usePermit2 ? 'transferFrom2' : 'erc20TransferFrom',
+ args: [initialCapitalInputTokenAddress, initialCapitalTransferAmount],
+ }),
+ );
+ }
+
+ if (isLoanAssetInput) {
+ // WHY: the user provided an exact amount of loan-token assets, so this leg should deposit that
+ // exact asset amount into the vault. The share floor is the exact quote returned by previewDeposit,
+ // not a swap-style slippage floor.
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc4626Deposit',
+ args: [route.collateralVault, initialCapitalInputAmount, initialCapitalCollateralTokenAmount, bundlerAddress],
+ }),
+ );
+ }
+
+ const callbackTxs: `0x${string}`[] = [
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc4626Mint',
+ // Mint the exact flash-leg collateral shares quoted off-chain. If the vault now requires
+ // more than flashLoanAssetAmount assets, revert early instead of minting fewer shares and drifting
+ // above the previewed leverage target. If it requires fewer, residual loan assets are swept later.
+ args: [route.collateralVault, flashLegCollateralTokenAmount, flashLoanAssetAmount, bundlerAddress],
+ }),
+ ];
+
+ // leverage fee is always in collateral unit.
+ if (leverageFeeAmount > 0n) {
+ callbackTxs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20Transfer',
+ args: [market.collateralAsset.address as Address, LEVERAGE_FEE_RECIPIENT, leverageFeeAmount],
+ }),
+ );
+ }
+
+ callbackTxs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoSupplyCollateral',
+ args: [marketParams, maxUint256, account, '0x'],
+ }),
+ );
+
+ callbackTxs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoBorrow',
+ args: [marketParams, flashLoanAssetAmount, 0n, borrowSharesSlippageAmount, bundlerAddress],
+ }),
+ );
+
+ const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]);
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoFlashLoan',
+ args: [market.loanAsset.address as Address, flashLoanAssetAmount, flashLoanCallbackData],
+ }),
+ );
+ // Safety net: sweep any residual loan/collateral balances from bundler to the user.
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20Transfer',
+ args: [market.loanAsset.address as Address, account, maxUint256],
+ }),
+ );
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20Transfer',
+ args: [market.collateralAsset.address as Address, account, maxUint256],
+ }),
+ );
+
+ updateStep('execute');
+ await sleep(500);
+
+ await sendTransactionAsync({
+ account,
+ to: bundlerAddress,
+ data: (encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'multicall',
+ args: [txs],
+ }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
+ value: 0n,
+ });
+};
diff --git a/src/hooks/leverage/leverageWithSwap.ts b/src/hooks/leverage/leverageWithSwap.ts
new file mode 100644
index 00000000..c2a75425
--- /dev/null
+++ b/src/hooks/leverage/leverageWithSwap.ts
@@ -0,0 +1,383 @@
+import { type Address, encodeFunctionData, keccak256, maxUint256, zeroHash } from 'viem';
+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 { LEVERAGE_FEE_RECIPIENT } from '@/config/leverage';
+import type { VeloraPriceRoute } from '@/features/swap/api/velora';
+import { getMorphoAddress, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
+import { PERMIT2_ADDRESS } from '@/utils/permit2';
+import type { Market } from '@/utils/types';
+import {
+ buildBundler3Erc20SweepCalls,
+ type Bundler3Call,
+ encodeBundler3Calls,
+ getParaswapSellOffsets,
+ readCalldataUint256,
+} from './bundler3';
+import {
+ type EnsureBundlerAuthorization,
+ type MorphoMarketParams,
+ type LeverageStepType,
+ type SendBundlerTransaction,
+ type SignForBundlers,
+ sleep,
+} from './transaction-shared';
+import type { SwapLeverageRoute } from './types';
+import { assertTrustedVeloraExecutionTarget, buildVeloraBundlerTransactionPayload } from './velora-transaction';
+
+type LeverageWithSwapParams = {
+ account: Address;
+ bundlerAddress: Address;
+ market: Market;
+ marketParams: MorphoMarketParams;
+ route: SwapLeverageRoute;
+ initialCapitalInputTokenAddress: Address;
+ initialCapitalTransferAmount: bigint;
+ isLoanAssetInput: boolean;
+ flashLoanAssetAmount: bigint;
+ flashLegCollateralTokenAmount: bigint;
+ totalCollateralTokenAmountAdded: bigint;
+ leverageFeeAmount: bigint;
+ swapPriceRoute: VeloraPriceRoute;
+ slippageBps: number;
+ usePermit2: boolean;
+ permit2Authorized: boolean;
+ isBundlerAuthorized: boolean | undefined;
+ authorizePermit2: () => Promise;
+ ensureBundlerAuthorization: EnsureBundlerAuthorization;
+ signForBundlers: SignForBundlers;
+ isApproved: boolean;
+ approve: () => Promise;
+ updateStep: (step: LeverageStepType) => void;
+ sendTransactionAsync: SendBundlerTransaction;
+};
+
+export const leverageWithSwap = async ({
+ account,
+ bundlerAddress,
+ market,
+ marketParams,
+ route,
+ initialCapitalInputTokenAddress,
+ initialCapitalTransferAmount,
+ isLoanAssetInput,
+ flashLoanAssetAmount,
+ flashLegCollateralTokenAmount,
+ totalCollateralTokenAmountAdded,
+ leverageFeeAmount,
+ swapPriceRoute,
+ slippageBps,
+ usePermit2,
+ permit2Authorized,
+ isBundlerAuthorized,
+ authorizePermit2,
+ ensureBundlerAuthorization,
+ signForBundlers,
+ isApproved,
+ approve,
+ updateStep,
+ sendTransactionAsync,
+}: LeverageWithSwapParams): Promise => {
+ if (!Number.isFinite(slippageBps) || slippageBps <= 0) {
+ throw new Error('Invalid slippage tolerance. Please set a positive slippage value.');
+ }
+
+ const preFlashCollateralFee =
+ !isLoanAssetInput && initialCapitalTransferAmount > 0n
+ ? leverageFeeAmount < initialCapitalTransferAmount
+ ? leverageFeeAmount
+ : initialCapitalTransferAmount
+ : 0n;
+ const callbackCollateralFee = leverageFeeAmount - preFlashCollateralFee;
+ const preFlashCollateralSupplyAmount = initialCapitalTransferAmount - preFlashCollateralFee;
+ const totalLoanSellAmount = isLoanAssetInput ? initialCapitalTransferAmount + flashLoanAssetAmount : flashLoanAssetAmount;
+ if (totalLoanSellAmount <= 0n) {
+ throw new Error('Invalid total sell amount for swap-backed leverage.');
+ }
+
+ let authorizationCall: Bundler3Call | null = null;
+ let permit2Call: Bundler3Call | null = null;
+
+ if (usePermit2) {
+ if (!permit2Authorized) {
+ updateStep('approve_permit2');
+ await authorizePermit2();
+ await sleep(800);
+ }
+
+ if (!isBundlerAuthorized) {
+ updateStep('authorize_bundler_sig');
+ }
+
+ 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) {
+ if (!authorizationSignatureData) {
+ throw new Error('Missing Morpho authorization signature payload for swap-backed leverage.');
+ }
+
+ authorizationCall = {
+ 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,
+ };
+ await sleep(800);
+ }
+
+ updateStep('sign_permit');
+ const { sigs, permitSingle } = await signForBundlers();
+ permit2Call = {
+ to: PERMIT2_ADDRESS,
+ data: encodeFunctionData({
+ abi: permit2Abi,
+ functionName: 'permit',
+ args: [account, permitSingle, sigs],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ };
+ } else {
+ if (!isBundlerAuthorized) {
+ updateStep('authorize_bundler_tx');
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
+ if (!authorized) {
+ throw new Error('Failed to authorize Bundler via transaction.');
+ }
+ }
+
+ if (!isApproved) {
+ updateStep('approve_token');
+ await approve();
+ await sleep(900);
+ }
+ }
+
+ const swapTxPayload = await buildSwapTransactionPayload({
+ market,
+ route,
+ totalLoanSellAmount,
+ swapPriceRoute,
+ slippageBps,
+ });
+ assertTrustedVeloraExecutionTarget({
+ priceRoute: swapPriceRoute,
+ quoteChangedMessage: 'Leverage quote changed. Please review the updated preview and try again.',
+ transactionTarget: swapTxPayload.to,
+ });
+
+ const collateralOutSlippageFloor = isLoanAssetInput ? totalCollateralTokenAmountAdded : flashLegCollateralTokenAmount;
+ if (collateralOutSlippageFloor <= 0n) {
+ throw new Error('Velora returned zero collateral output for leverage swap.');
+ }
+
+ const sellOffsets = getParaswapSellOffsets(swapTxPayload.data);
+ const calldataSellAmount = readCalldataUint256(swapTxPayload.data, sellOffsets.exactAmount);
+ const calldataMinCollateralOut = readCalldataUint256(swapTxPayload.data, sellOffsets.limitAmount);
+ if (calldataSellAmount !== totalLoanSellAmount || calldataMinCollateralOut !== collateralOutSlippageFloor) {
+ throw new Error('Leverage quote changed. Please review the updated preview and try again.');
+ }
+
+ const callbackBundle: Bundler3Call[] = [
+ // Move the sold loan asset into the Paraswap adapter, which is the contract that actually executes the swap.
+ {
+ 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,
+ },
+ ];
+
+ if (callbackCollateralFee > 0n) {
+ callbackBundle.push({
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'erc20Transfer',
+ args: [market.collateralAsset.address as Address, LEVERAGE_FEE_RECIPIENT, callbackCollateralFee],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ });
+ }
+
+ callbackBundle.push(
+ {
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'morphoSupplyCollateral',
+ args: [marketParams, maxUint256, account, '0x'],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ },
+ {
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'morphoBorrow',
+ // receive the flashloan in general adapter address
+ args: [marketParams, flashLoanAssetAmount, 0n, 0n, route.generalAdapterAddress],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ },
+ );
+ const callbackBundleData = encodeBundler3Calls(callbackBundle);
+
+ const bundleCalls: Bundler3Call[] = [];
+ if (authorizationCall) {
+ bundleCalls.push(authorizationCall);
+ }
+ if (permit2Call) {
+ bundleCalls.push(permit2Call);
+ }
+
+ if (initialCapitalTransferAmount > 0n) {
+ bundleCalls.push({
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: usePermit2 ? 'permit2TransferFrom' : 'erc20TransferFrom',
+ args: [initialCapitalInputTokenAddress, route.generalAdapterAddress, initialCapitalTransferAmount],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ });
+
+ if (!isLoanAssetInput) {
+ if (preFlashCollateralFee > 0n) {
+ bundleCalls.push({
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'erc20Transfer',
+ args: [market.collateralAsset.address as Address, LEVERAGE_FEE_RECIPIENT, preFlashCollateralFee],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ });
+ }
+ if (preFlashCollateralSupplyAmount > 0n) {
+ bundleCalls.push({
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'morphoSupplyCollateral',
+ args: [marketParams, preFlashCollateralSupplyAmount, account, '0x'],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: zeroHash,
+ });
+ }
+ }
+ }
+
+ bundleCalls.push({
+ to: route.generalAdapterAddress,
+ data: encodeFunctionData({
+ abi: morphoGeneralAdapterV1Abi,
+ functionName: 'morphoFlashLoan',
+ args: [market.loanAsset.address as Address, flashLoanAssetAmount, callbackBundleData],
+ }),
+ value: 0n,
+ skipRevert: false,
+ callbackHash: keccak256(callbackBundleData),
+ });
+ // Safety net: sweep both assets from every adapter touched by the swap route.
+ bundleCalls.push(
+ ...buildBundler3Erc20SweepCalls({
+ recipient: account,
+ sweepTargets: [
+ { adapterAbi: morphoGeneralAdapterV1Abi, adapterAddress: route.generalAdapterAddress },
+ { adapterAbi: paraswapAdapterAbi, adapterAddress: route.paraswapAdapterAddress },
+ ],
+ tokenAddresses: [market.loanAsset.address as Address, market.collateralAsset.address as Address],
+ }),
+ );
+
+ updateStep('execute');
+ await sleep(800);
+
+ await sendTransactionAsync({
+ account,
+ to: bundlerAddress,
+ data: (encodeFunctionData({
+ abi: bundlerV3Abi,
+ functionName: 'multicall',
+ args: [bundleCalls],
+ }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
+ value: 0n,
+ });
+};
+
+const buildSwapTransactionPayload = ({
+ market,
+ route,
+ totalLoanSellAmount,
+ swapPriceRoute,
+ slippageBps,
+}: {
+ market: Market;
+ route: SwapLeverageRoute;
+ totalLoanSellAmount: bigint;
+ swapPriceRoute: VeloraPriceRoute;
+ slippageBps: number;
+}) =>
+ buildVeloraBundlerTransactionPayload({
+ destinationTokenAddress: market.collateralAsset.address,
+ destinationTokenDecimals: market.collateralAsset.decimals,
+ executionAddress: route.paraswapAdapterAddress,
+ network: market.morphoBlue.chain.id,
+ priceRoute: swapPriceRoute,
+ quoteChangedMessage: 'Leverage quote changed. Please review the updated preview and try again.',
+ slippageBps,
+ sourceTokenAddress: market.loanAsset.address,
+ sourceTokenAmount: totalLoanSellAmount,
+ sourceTokenDecimals: market.loanAsset.decimals,
+ sourceTokenSymbol: market.loanAsset.symbol,
+ });
diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts
index 248a342d..99c53a85 100644
--- a/src/hooks/leverage/math.ts
+++ b/src/hooks/leverage/math.ts
@@ -4,6 +4,9 @@ import { LEVERAGE_MIN_MULTIPLIER_BPS, LEVERAGE_MULTIPLIER_SCALE_BPS } from './ty
export const LEVERAGE_SLIPPAGE_BUFFER_BPS = 9_950n; // 0.50% tolerance
export const BPS_SCALE = 10_000n;
export const WAD_TO_BPS_SCALE = 100_000_000_000_000n;
+const ASSET_INPUT_SHARE_SLIPPAGE_BUFFER_BPS = 50n; // 0.50%
+const MORPHO_VIRTUAL_SHARES = 1_000_000n;
+const MORPHO_VIRTUAL_ASSETS = 1n;
const DEFAULT_SLIPPAGE_TOLERANCE_BPS = BPS_SCALE - LEVERAGE_SLIPPAGE_BUFFER_BPS;
const MAX_TARGET_LTV_BPS = BPS_SCALE - 1n;
const COMPACT_AMOUNT_LOCALE = 'en-US';
@@ -364,7 +367,13 @@ export const withSlippageCeil = (value: bigint, slippageBps?: number): bigint =>
return (value * ceilBps + LEVERAGE_MULTIPLIER_SCALE_BPS - 1n) / LEVERAGE_MULTIPLIER_SCALE_BPS;
};
-export const computeBorrowSharesWithBuffer = ({
+/**
+ * Returns the slippage bound for asset-based `morphoBorrow` calls.
+ *
+ * This intentionally overestimates the borrow shares that may be minted for a fixed
+ * asset input. It is only for the bundler slippage parameter, not for previews.
+ */
+export const getBorrowSharesSlippageAmount = ({
borrowAssets,
totalBorrowAssets,
totalBorrowShares,
@@ -375,16 +384,11 @@ export const computeBorrowSharesWithBuffer = ({
}): bigint => {
if (borrowAssets <= 0n) return 0n;
- // Morpho virtual shares/assets from SharesMathLib to avoid edge-case division by zero.
- const VIRTUAL_SHARES = 1_000_000n;
- const VIRTUAL_ASSETS = 1n;
-
- const denominator = totalBorrowAssets + VIRTUAL_ASSETS;
- const numerator = borrowAssets * (totalBorrowShares + VIRTUAL_SHARES);
+ const denominator = totalBorrowAssets + MORPHO_VIRTUAL_ASSETS;
+ const numerator = borrowAssets * (totalBorrowShares + MORPHO_VIRTUAL_SHARES);
const expectedShares = (numerator + denominator - 1n) / denominator; // round up
- // Add 0.5% headroom to keep borrow slippage checks stable across minor state drift.
- return expectedShares + expectedShares / 200n + 1n;
+ return expectedShares + (expectedShares * ASSET_INPUT_SHARE_SLIPPAGE_BUFFER_BPS) / BPS_SCALE + 1n;
};
export const computeRepaySharesWithBuffer = ({
@@ -400,5 +404,11 @@ export const computeRepaySharesWithBuffer = ({
const numerator = repayAssets * totalBorrowShares;
const expectedShares = (numerator + totalBorrowAssets - 1n) / totalBorrowAssets; // round up
- return expectedShares + expectedShares / 200n + 1n;
+ return expectedShares + (expectedShares * ASSET_INPUT_SHARE_SLIPPAGE_BUFFER_BPS) / BPS_SCALE + 1n;
};
+
+/**
+ * Asset-based supply/deposit flows use a minimum-share slippage floor instead of a
+ * share estimate. `1n` means "accept any positive share output".
+ */
+export const MIN_SHARES_SLIPPAGE_AMOUNT = 1n;
diff --git a/src/hooks/leverage/transaction-shared.ts b/src/hooks/leverage/transaction-shared.ts
new file mode 100644
index 00000000..f9969ba2
--- /dev/null
+++ b/src/hooks/leverage/transaction-shared.ts
@@ -0,0 +1,55 @@
+import type { Address } from 'viem';
+import type { MorphoAuthorizationSignatureData } from '@/hooks/useMorphoAuthorization';
+import type { Market } from '@/utils/types';
+
+export type LeverageStepType =
+ | 'approve_permit2'
+ | 'authorize_bundler_sig'
+ | 'sign_permit'
+ | 'authorize_bundler_tx'
+ | 'approve_token'
+ | 'execute';
+
+export type MorphoMarketParams = {
+ loanToken: Address;
+ collateralToken: Address;
+ oracle: Address;
+ irm: Address;
+ lltv: bigint;
+};
+
+type AuthorizationMode = 'signature' | 'transaction';
+
+export type EnsureBundlerAuthorization = (params: { mode: AuthorizationMode }) => Promise<{
+ authorized: boolean;
+ authorizationTxData: `0x${string}` | null;
+ authorizationSignatureData: MorphoAuthorizationSignatureData | null;
+}>;
+
+export type Permit2BundlerSignature = {
+ sigs: `0x${string}`;
+ permitSingle: {
+ details: {
+ token: Address;
+ amount: bigint;
+ expiration: number;
+ nonce: number;
+ };
+ spender: Address;
+ sigDeadline: bigint;
+ };
+};
+
+export type SignForBundlers = () => Promise;
+
+export type SendBundlerTransaction = (params: { account: Address; to: Address; data: `0x${string}`; value: bigint }) => Promise;
+
+export const buildMorphoMarketParams = (market: Market): MorphoMarketParams => ({
+ 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),
+});
+
+export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/src/hooks/leverage/velora-transaction.ts b/src/hooks/leverage/velora-transaction.ts
new file mode 100644
index 00000000..92a25e1a
--- /dev/null
+++ b/src/hooks/leverage/velora-transaction.ts
@@ -0,0 +1,91 @@
+import { type Address, isAddress, isAddressEqual } from 'viem';
+import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora';
+import { isVeloraBypassablePrecheckError } from './velora-precheck';
+
+type BuildVeloraBundlerTransactionPayloadParams = {
+ destinationTokenAddress: string;
+ destinationTokenDecimals: number;
+ executionAddress: Address;
+ network: number;
+ priceRoute: VeloraPriceRoute;
+ quoteChangedMessage: string;
+ slippageBps: number;
+ sourceTokenAddress: string;
+ sourceTokenAmount: bigint;
+ sourceTokenDecimals: number;
+ sourceTokenSymbol: string;
+};
+
+export const buildVeloraBundlerTransactionPayload = async ({
+ destinationTokenAddress,
+ destinationTokenDecimals,
+ executionAddress,
+ network,
+ priceRoute,
+ quoteChangedMessage,
+ slippageBps,
+ sourceTokenAddress,
+ sourceTokenAmount,
+ sourceTokenDecimals,
+ sourceTokenSymbol,
+}: BuildVeloraBundlerTransactionPayloadParams) => {
+ const buildPayload = async (ignoreChecks: boolean) =>
+ buildVeloraTransactionPayload({
+ srcToken: sourceTokenAddress,
+ srcDecimals: sourceTokenDecimals,
+ destToken: destinationTokenAddress,
+ destDecimals: destinationTokenDecimals,
+ srcAmount: sourceTokenAmount,
+ network,
+ userAddress: executionAddress,
+ priceRoute,
+ slippageBps,
+ ignoreChecks,
+ });
+
+ try {
+ return await buildPayload(false);
+ } catch (buildError: unknown) {
+ if (isVeloraRateChangedError(buildError)) {
+ throw new Error(quoteChangedMessage);
+ }
+
+ if (
+ !isVeloraBypassablePrecheckError({
+ error: buildError,
+ sourceTokenAddress,
+ sourceTokenSymbol,
+ })
+ ) {
+ throw buildError;
+ }
+
+ try {
+ return await buildPayload(true);
+ } catch (fallbackBuildError: unknown) {
+ if (isVeloraRateChangedError(fallbackBuildError)) {
+ throw new Error(quoteChangedMessage);
+ }
+
+ throw fallbackBuildError;
+ }
+ }
+};
+
+export const assertTrustedVeloraExecutionTarget = ({
+ priceRoute,
+ quoteChangedMessage,
+ transactionTarget,
+}: {
+ priceRoute: VeloraPriceRoute;
+ quoteChangedMessage: string;
+ transactionTarget: Address;
+}) => {
+ const trustedTargets = [priceRoute.contractAddress, priceRoute.tokenTransferProxy].filter(
+ (candidate): candidate is Address => typeof candidate === 'string' && isAddress(candidate),
+ );
+
+ if (trustedTargets.length === 0 || !trustedTargets.some((target) => isAddressEqual(transactionTarget, target))) {
+ throw new Error(quoteChangedMessage);
+ }
+};
diff --git a/src/hooks/useBorrowTransaction.ts b/src/hooks/useBorrowTransaction.ts
index 74759338..279e6b71 100644
--- a/src/hooks/useBorrowTransaction.ts
+++ b/src/hooks/useBorrowTransaction.ts
@@ -6,6 +6,7 @@ import { formatBalance } from '@/utils/balance';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import { isUserRejectedTransactionError, toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors';
import type { Market } from '@/utils/types';
+import { getBorrowSharesSlippageAmount } from './leverage/math';
import { useERC20Approval } from './useERC20Approval';
import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep';
import { usePermit2 } from './usePermit2';
@@ -132,20 +133,12 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
// Core transaction execution logic
const executeBorrowTransaction = useCallback(async () => {
- // Morpho virtual shares/assets (from SharesMathLib.sol) - prevents division by zero on fresh markets
- const VIRTUAL_SHARES = 1000000n; // 1e6
- const VIRTUAL_ASSETS = 1n;
-
- // Calculate max borrow shares using Morpho's formula with 0.5% slippage buffer
- // Formula: shares = assets * (totalShares + VIRTUAL_SHARES) / (totalAssets + VIRTUAL_ASSETS)
- const totalBorrowShares = BigInt(market.state.borrowShares);
- const totalBorrowAssets = BigInt(market.state.borrowAssets);
- const denominator = totalBorrowAssets + VIRTUAL_ASSETS;
- const numerator = borrowAmount * (totalBorrowShares + VIRTUAL_SHARES);
- // Round up: (a + b - 1) / b
- const expectedShares = borrowAmount === 0n ? 0n : (numerator + denominator - 1n) / denominator;
- // Add 0.5% buffer + 1 for safety margin
- const maxBorrowShares = borrowAmount === 0n ? 0n : expectedShares + expectedShares / 200n + 1n;
+ // Asset-based borrow uses an exact asset amount plus a max-share slippage bound.
+ const borrowSharesSlippageAmount = getBorrowSharesSlippageAmount({
+ borrowAssets: borrowAmount,
+ totalBorrowAssets: BigInt(market.state.borrowAssets),
+ totalBorrowShares: BigInt(market.state.borrowShares),
+ });
try {
const transactions: `0x${string}`[] = [];
@@ -284,7 +277,7 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
},
borrowAmount, // asset to borrow
0n, // shares to mint (0), we always use `assets` as param
- maxBorrowShares, // slippageAmount: max borrow shares to mint
+ borrowSharesSlippageAmount,
account as Address,
],
});
diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts
index 36c3c407..323fbee6 100644
--- a/src/hooks/useDeleverageQuote.ts
+++ b/src/hooks/useDeleverageQuote.ts
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
-import { useReadContract } from 'wagmi';
+import { zeroAddress } from 'viem';
+import { useReadContracts } from 'wagmi';
import { erc4626Abi } from '@/abis/erc4626';
import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora';
import { withSlippageCeil, withSlippageFloor } from './leverage/math';
@@ -54,36 +55,41 @@ export function useDeleverageQuote({
}: UseDeleverageQuoteParams): DeleverageQuote {
const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets, slippageBps);
const swapExecutionAddress = route?.kind === 'swap' ? route.paraswapAdapterAddress : null;
+ const erc4626VaultAddress = route?.kind === 'erc4626' ? route.collateralVault : zeroAddress;
const {
- data: erc4626PreviewRedeem,
- isLoading: isLoadingRedeem,
- error: redeemError,
- } = useReadContract({
- address: route?.kind === 'erc4626' ? route.collateralVault : undefined,
- abi: erc4626Abi,
- functionName: 'previewRedeem',
- args: [withdrawCollateralAmount],
- chainId,
+ data: erc4626PreviewData,
+ isLoading: isLoadingErc4626Previews,
+ error: erc4626PreviewError,
+ } = useReadContracts({
+ contracts: [
+ {
+ address: erc4626VaultAddress,
+ abi: erc4626Abi,
+ functionName: 'previewRedeem',
+ // `previewRedeem(collateral shares)` -> loan-token assets returned by redeeming that exact share amount.
+ args: [withdrawCollateralAmount],
+ chainId,
+ },
+ {
+ address: erc4626VaultAddress,
+ abi: erc4626Abi,
+ functionName: 'previewWithdraw',
+ // `previewWithdraw(buffered loan assets)` -> collateral shares needed to withdraw that exact asset amount.
+ args: [bufferedBorrowAssets],
+ chainId,
+ },
+ ],
+ allowFailure: false,
query: {
- enabled: route?.kind === 'erc4626' && withdrawCollateralAmount > 0n,
+ enabled: route?.kind === 'erc4626' && (withdrawCollateralAmount > 0n || bufferedBorrowAssets > 0n),
},
});
- const {
- data: erc4626PreviewWithdrawForDebt,
- isLoading: isLoadingWithdraw,
- error: withdrawError,
- } = useReadContract({
- address: route?.kind === 'erc4626' ? route.collateralVault : undefined,
- abi: erc4626Abi,
- functionName: 'previewWithdraw',
- args: [bufferedBorrowAssets],
- chainId,
- query: {
- enabled: route?.kind === 'erc4626' && bufferedBorrowAssets > 0n,
- },
- });
+ const [previewRedeemLoanAssetsFromCollateralShares, previewWithdrawCollateralSharesForBufferedDebtAssets] = useMemo(
+ () => (erc4626PreviewData as readonly [bigint, bigint] | undefined) ?? [0n, 0n],
+ [erc4626PreviewData],
+ );
const swapRepayQuoteQuery = useQuery({
queryKey: [
@@ -112,12 +118,9 @@ export function useDeleverageQuote({
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 {
+ // Preview uses the requested unwind amount as the authoritative sell size.
+ // The submit path still re-validates the executable calldata before sending.
rawRouteRepayAmount: withSlippageFloor(BigInt(sellRoute.destAmount), slippageBps),
priceRoute: sellRoute,
};
@@ -181,8 +184,8 @@ export function useDeleverageQuote({
const rawRouteRepayAmount = useMemo(() => {
if (!route || withdrawCollateralAmount <= 0n) return 0n;
if (route.kind === 'swap') return swapRepayQuote.rawRouteRepayAmount;
- return (erc4626PreviewRedeem as bigint | undefined) ?? 0n;
- }, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, erc4626PreviewRedeem]);
+ return previewRedeemLoanAssetsFromCollateralShares;
+ }, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, previewRedeemLoanAssetsFromCollateralShares]);
const repayAmount = useMemo(() => {
if (rawRouteRepayAmount <= 0n) return 0n;
@@ -200,14 +203,14 @@ export function useDeleverageQuote({
if (!userAddress || swapMaxCollateralForDebtQuery.error) return 0n;
return swapMaxCollateralForDebtQuery.data?.maxCollateralForDebtRepay ?? 0n;
}
- return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n;
+ return previewWithdrawCollateralSharesForBufferedDebtAssets;
}, [
route,
currentBorrowAssets,
swapMaxCollateralForDebtQuery.data,
swapMaxCollateralForDebtQuery.error,
userAddress,
- erc4626PreviewWithdrawForDebt,
+ previewWithdrawCollateralSharesForBufferedDebtAssets,
]);
const closeRouteRequiresResolution = useMemo(() => {
@@ -280,7 +283,7 @@ export function useDeleverageQuote({
if (!routeError) return null;
return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for deleverage.';
}
- const routeError = redeemError ?? withdrawError;
+ const routeError = erc4626PreviewError;
if (!routeError) return null;
return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route';
}, [
@@ -292,15 +295,14 @@ export function useDeleverageQuote({
currentBorrowShares,
swapMaxCollateralForDebtQuery.error,
swapRepayQuoteQuery.error,
- redeemError,
- withdrawError,
+ erc4626PreviewError,
]);
const isLoading =
!!route &&
(route.kind === 'swap'
? swapRepayQuoteQuery.isLoading || swapRepayQuoteQuery.isFetching || closeRouteRequiresResolution
- : isLoadingRedeem || isLoadingWithdraw);
+ : isLoadingErc4626Previews);
return {
repayAmount,
diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts
index 5ff50510..2894bb86 100644
--- a/src/hooks/useDeleverageTransaction.ts
+++ b/src/hooks/useDeleverageTransaction.ts
@@ -1,12 +1,13 @@
import { useCallback, useMemo, useState } from 'react';
-import { type Address, encodeAbiParameters, encodeFunctionData, isAddress, isAddressEqual, keccak256, maxUint256, zeroHash } from 'viem';
+import { type Address, isAddress } 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 { resolveErc4626RouteBundler } from '@/config/leverage';
-import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora';
+import type { VeloraPriceRoute } from '@/features/swap/api/velora';
+import { deleverageWithErc4626Redeem } from '@/hooks/deleverage/deleverageWithErc4626Redeem';
+import { deleverageWithSwap } from '@/hooks/deleverage/deleverageWithSwap';
+import type { DeleverageStepType } from '@/hooks/deleverage/transaction-shared';
+import { buildMorphoMarketParams } from '@/hooks/leverage/transaction-shared';
+import type { LeverageRoute } from '@/hooks/leverage/types';
import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep';
import { useStyledToast } from '@/hooks/useStyledToast';
import { useTransactionWithToast } from '@/hooks/useTransactionWithToast';
@@ -14,29 +15,24 @@ import { useTransactionTracking } from '@/hooks/useTransactionTracking';
import { useUserMarketsCache } from '@/stores/useUserMarketsCache';
import { useAppSettings } from '@/stores/useAppSettings';
import { formatBalance } from '@/utils/balance';
-import { 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';
+export type { DeleverageStepType } from '@/hooks/deleverage/transaction-shared';
type UseDeleverageTransactionProps = {
+ autoWithdrawCollateralAmount: bigint;
+ flashLoanAmount: bigint;
market: Market;
- route: LeverageRoute | null;
- withdrawCollateralAmount: bigint;
+ maxCollateralForDebtRepay: bigint;
maxWithdrawCollateralAmount: bigint;
- flashLoanAmount: bigint;
+ onSuccess?: () => void;
repayBySharesAmount: bigint;
- useCloseRoute: boolean;
- autoWithdrawCollateralAmount: bigint;
- maxCollateralForDebtRepay: bigint;
- swapSellPriceRoute: VeloraPriceRoute | null;
+ route: LeverageRoute | null;
slippageBps: number;
- onSuccess?: () => void;
+ swapSellPriceRoute: VeloraPriceRoute | null;
+ useCloseRoute: boolean;
+ withdrawCollateralAmount: bigint;
};
/**
@@ -45,18 +41,18 @@ type UseDeleverageTransactionProps = {
* - generalized swap-backed loops on Bundler3 + adapters
*/
export function useDeleverageTransaction({
+ autoWithdrawCollateralAmount,
+ flashLoanAmount,
market,
- route,
- withdrawCollateralAmount,
+ maxCollateralForDebtRepay,
maxWithdrawCollateralAmount,
- flashLoanAmount,
+ onSuccess,
repayBySharesAmount,
- useCloseRoute,
- autoWithdrawCollateralAmount,
- maxCollateralForDebtRepay,
- swapSellPriceRoute,
+ route,
slippageBps,
- onSuccess,
+ swapSellPriceRoute,
+ useCloseRoute,
+ withdrawCollateralAmount,
}: UseDeleverageTransactionProps) {
const { usePermit2: usePermit2Setting } = useAppSettings();
const tracking = useTransactionTracking('deleverage');
@@ -68,6 +64,7 @@ export function useDeleverageTransaction({
const bundlerAddress = useMemo(() => {
if (!route) return undefined;
if (route.kind === 'swap') return route.bundler3Address;
+
try {
const resolvedBundler = resolveErc4626RouteBundler(market.morphoBlue.chain.id, market.uniqueKey);
return isAddress(resolvedBundler) ? resolvedBundler : undefined;
@@ -105,7 +102,7 @@ export function useDeleverageTransaction({
},
});
- const getStepsForFlow = useCallback((isPermit2: boolean, isSwap: boolean) => {
+ const getStepsForFlow = useCallback((isSignatureAuthorization: boolean, isSwap: boolean) => {
if (isSwap) {
return [
{
@@ -121,7 +118,7 @@ export function useDeleverageTransaction({
];
}
- if (isPermit2) {
+ if (isSignatureAuthorization) {
return [
{
id: 'authorize_bundler_sig',
@@ -154,427 +151,94 @@ export function useDeleverageTransaction({
if (!account) {
throw new Error('No account connected. Please connect your wallet.');
}
-
if (!route) {
throw new Error('This market is not supported for deleverage.');
}
if (!bundlerAddress) {
throw new Error('Deleverage route data is unavailable. Please refresh and try again.');
}
-
if (withdrawCollateralAmount <= 0n || flashLoanAmount <= 0n) {
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.');
}
+ if (useCloseRoute && repayBySharesAmount <= 0n) {
+ throw new Error('Debt shares are unavailable for a full close. Refresh your position data and try again.');
+ }
+ const collateralToRedeem = useCloseRoute ? maxCollateralForDebtRepay : withdrawCollateralAmount;
+ if (collateralToRedeem <= 0n) {
+ throw new Error('Collateral redeem amount is unavailable. Refresh the deleverage quote and try again.');
+ }
+ if (useCloseRoute && withdrawCollateralAmount < collateralToRedeem) {
+ throw new Error('Deleverage quote changed. Please review the updated preview and try again.');
+ }
- try {
- const txs: `0x${string}`[] = [];
-
- 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 {
- 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 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.');
- }
-
- if (route.kind === 'swap') {
- if (!Number.isFinite(slippageBps) || slippageBps <= 0) {
- throw new Error('Invalid slippage tolerance. Please set a positive slippage value.');
- }
- 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 isCloseSwap = isRepayByShares;
- const activePriceRoute = swapSellPriceRoute;
- if (!activePriceRoute) {
- throw new Error('Missing Velora swap quote for deleverage.');
- }
-
- 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,
- 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, slippageBps);
- 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,
- },
- ];
-
- 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),
- },
- // Safety net: sweep any residual loan/collateral balances from adapters to the user.
- {
- 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,
- },
- {
- to: route.generalAdapterAddress,
- data: encodeFunctionData({
- abi: morphoGeneralAdapterV1Abi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, account as Address, maxUint256],
- }),
- 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.paraswapAdapterAddress,
- data: encodeFunctionData({
- abi: paraswapAdapterAbi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.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: 'morphoRepay',
- args: [
- 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, slippageBps);
- callbackTxs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc4626Redeem',
- args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address],
- }),
- );
-
- 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],
- }),
- );
- }
-
- const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]);
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'morphoFlashLoan',
- args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData],
- }),
- );
- // Safety net: sweep any residual loan/collateral balances from bundler to the user.
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20Transfer',
- args: [market.loanAsset.address as Address, account as Address, maxUint256],
- }),
- );
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, account as Address, maxUint256],
- }),
- );
-
- tracking.update('execute');
- await new Promise((resolve) => setTimeout(resolve, 700));
+ const marketParams = buildMorphoMarketParams(market);
- await sendTransactionAsync({
- account,
- to: bundlerAddress,
- data: (encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'multicall',
- args: [txs],
- }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
- value: 0n,
- });
+ if (route.kind === 'swap') {
+ if (!swapSellPriceRoute) {
+ throw new Error('Missing Velora swap quote for deleverage.');
}
- batchAddUserMarkets([
- {
- marketUniqueKey: market.uniqueKey,
- chainId: market.morphoBlue.chain.id,
- },
- ]);
-
- tracking.complete();
- } catch (error: unknown) {
- tracking.fail();
- console.error('Error during deleverage execution:', error);
- const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'An unexpected error occurred during deleverage.');
- setExecutionError(userFacingMessage === 'User rejected transaction.' ? null : userFacingMessage);
- if (userFacingMessage !== 'User rejected transaction.') {
- toast.error('Deleverage Failed', userFacingMessage);
- }
+ await deleverageWithSwap({
+ account,
+ autoWithdrawCollateralAmount,
+ bundlerAddress,
+ ensureBundlerAuthorization,
+ flashLoanAmount,
+ isBundlerAuthorized,
+ market,
+ marketParams,
+ maxCollateralForDebtRepay,
+ repayBySharesAmount,
+ route,
+ sendTransactionAsync,
+ slippageBps,
+ swapSellPriceRoute,
+ updateStep: tracking.update,
+ useCloseRoute,
+ withdrawCollateralAmount,
+ });
+ return;
}
+
+ await deleverageWithErc4626Redeem({
+ account,
+ autoWithdrawCollateralAmount,
+ bundlerAddress,
+ collateralToRedeem,
+ ensureBundlerAuthorization,
+ flashLoanAmount,
+ isBundlerAuthorized,
+ market,
+ marketParams,
+ repayBySharesAmount,
+ route,
+ sendTransactionAsync,
+ updateStep: tracking.update,
+ useCloseRoute,
+ useSignatureAuthorization,
+ });
}, [
account,
+ autoWithdrawCollateralAmount,
+ bundlerAddress,
+ ensureBundlerAuthorization,
+ flashLoanAmount,
+ isBundlerAuthorized,
market,
- route,
- withdrawCollateralAmount,
+ maxCollateralForDebtRepay,
maxWithdrawCollateralAmount,
- flashLoanAmount,
repayBySharesAmount,
- useCloseRoute,
- autoWithdrawCollateralAmount,
- maxCollateralForDebtRepay,
- swapSellPriceRoute,
+ route,
+ sendTransactionAsync,
slippageBps,
+ swapSellPriceRoute,
+ tracking.update,
+ useCloseRoute,
useSignatureAuthorization,
- isBundlerAuthorized,
- ensureBundlerAuthorization,
- bundlerAddress,
- sendTransactionAsync,
- batchAddUserMarkets,
- tracking,
- toast,
- setExecutionError,
+ withdrawCollateralAmount,
]);
const clearExecutionError = useCallback(() => {
@@ -615,6 +279,15 @@ export function useDeleverageTransaction({
);
await executeDeleverage();
+
+ batchAddUserMarkets([
+ {
+ marketUniqueKey: market.uniqueKey,
+ chainId: market.morphoBlue.chain.id,
+ },
+ ]);
+
+ tracking.complete();
} catch (error: unknown) {
console.error('Error in authorizeAndDeleverage:', error);
tracking.fail();
@@ -626,20 +299,20 @@ export function useDeleverageTransaction({
}
}, [
account,
- route,
+ batchAddUserMarkets,
bundlerAddress,
- isBundlerAuthorized,
+ executeDeleverage,
+ getStepsForFlow,
isBundlerAuthorizationReady,
isBundlerAuthorizationStatusReady,
- useSignatureAuthorization,
- tracking,
- getStepsForFlow,
+ isBundlerAuthorized,
isSwapRoute,
market,
- withdrawCollateralAmount,
- executeDeleverage,
+ route,
toast,
- setExecutionError,
+ tracking,
+ useSignatureAuthorization,
+ withdrawCollateralAmount,
]);
const isAuthorizationStatusLoading =
diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts
index d6fc6075..58ecf223 100644
--- a/src/hooks/useLeverageQuote.ts
+++ b/src/hooks/useLeverageQuote.ts
@@ -9,7 +9,12 @@ import type { LeverageRoute } from './leverage/types';
type UseLeverageQuoteParams = {
chainId: number;
route: LeverageRoute | null;
- userInputAmount: bigint;
+ /**
+ * Exact user-entered starting capital, denominated by `inputMode`.
+ * - `loan`: market loan asset amount
+ * - `collateral`: market collateral token amount
+ */
+ initialCapitalInputAmount: bigint;
inputMode: 'collateral' | 'loan';
slippageBps: number;
multiplierBps: bigint;
@@ -21,10 +26,25 @@ type UseLeverageQuoteParams = {
};
export type LeverageQuote = {
- initialCollateralAmount: bigint;
- flashCollateralAmount: bigint;
- flashLoanAmount: bigint;
- totalAddedCollateral: bigint;
+ /**
+ * Market collateral-token amount sourced directly from the user's starting capital before the flash leg.
+ *
+ * - collateral-input mode: equals `initialCapitalInputAmount`
+ * - ERC4626 loan-input mode: `previewDeposit(initialCapitalInputAmount)`
+ * - swap loan-input mode: `0n` because the user loan input is sold together with the flash leg
+ */
+ initialCapitalCollateralTokenAmount: bigint;
+ /**
+ * Additional market collateral-token amount sourced by the flash leg.
+ *
+ * - ERC4626 route: exact vault share amount minted in the callback
+ * - swap route: minimum collateral output expected from selling the flash-borrowed loan asset
+ */
+ flashLegCollateralTokenAmount: bigint;
+ /** Flash-loaned market loan-asset amount. */
+ flashLoanAssetAmount: bigint;
+ /** Total market collateral-token amount added before leverage fee. */
+ totalCollateralTokenAmountAdded: bigint;
isLoading: boolean;
error: string | null;
swapPriceRoute: VeloraPriceRoute | null;
@@ -33,13 +53,13 @@ export type LeverageQuote = {
/**
* Converts user leverage intent into deterministic route amounts.
*
- * - `flashCollateralAmount`: extra collateral target sourced via the flash leg
- * - `flashLoanAmount`: debt token flash amount needed to mint that extra collateral
+ * - `flashLegCollateralTokenAmount`: extra market collateral-token amount sourced via the flash leg
+ * - `flashLoanAssetAmount`: market loan-asset amount needed to source that extra collateral
*/
export function useLeverageQuote({
chainId,
route,
- userInputAmount,
+ initialCapitalInputAmount,
inputMode,
slippageBps,
multiplierBps,
@@ -54,45 +74,49 @@ export function useLeverageQuote({
const swapExecutionAddress = route?.kind === 'swap' ? route.paraswapAdapterAddress : null;
const {
- data: erc4626PreviewDeposit,
+ data: previewDepositCollateralSharesFromUserLoanAssets,
isLoading: isLoadingErc4626Deposit,
error: erc4626DepositError,
} = useReadContract({
address: route?.kind === 'erc4626' ? route.collateralVault : undefined,
abi: erc4626Abi,
functionName: 'previewDeposit',
- args: [userInputAmount],
+ // `previewDeposit(user loan assets)` -> ERC4626 collateral shares minted from that exact asset input.
+ args: [initialCapitalInputAmount],
chainId,
query: {
- enabled: route?.kind === 'erc4626' && isLoanAssetInput && userInputAmount > 0n,
+ enabled: route?.kind === 'erc4626' && isLoanAssetInput && initialCapitalInputAmount > 0n,
},
});
- const initialCollateralAmount = useMemo(() => {
+ const initialCapitalCollateralTokenAmount = useMemo(() => {
if (!route) return 0n;
if (isSwapLoanAssetInput) return 0n;
- if (!isLoanAssetInput) return userInputAmount;
- if (route.kind === 'erc4626') return (erc4626PreviewDeposit as bigint | undefined) ?? 0n;
+ if (!isLoanAssetInput) return initialCapitalInputAmount;
+ // `previewDeposit(initialCapitalInputAmount)` returns the ERC4626 collateral-share amount minted by
+ // depositing the user's exact loan-token asset input into the vault.
+ if (route.kind === 'erc4626') return (previewDepositCollateralSharesFromUserLoanAssets as bigint | undefined) ?? 0n;
return 0n;
- }, [route, isSwapLoanAssetInput, isLoanAssetInput, userInputAmount, erc4626PreviewDeposit]);
+ }, [route, isSwapLoanAssetInput, isLoanAssetInput, initialCapitalInputAmount, previewDepositCollateralSharesFromUserLoanAssets]);
- const targetFlashCollateralAmount = useMemo(
- () => (isSwapLoanAssetInput ? 0n : computeFlashCollateralAmount(initialCollateralAmount, multiplierBps)),
- [isSwapLoanAssetInput, initialCollateralAmount, multiplierBps],
+ const targetFlashCollateralTokenAmount = useMemo(
+ () => (isSwapLoanAssetInput ? 0n : computeFlashCollateralAmount(initialCapitalCollateralTokenAmount, multiplierBps)),
+ [isSwapLoanAssetInput, initialCapitalCollateralTokenAmount, multiplierBps],
);
const {
- data: erc4626PreviewMint,
+ data: previewMintRequiredLoanAssetsForFlashCollateralShares,
isLoading: isLoadingErc4626Mint,
error: erc4626MintError,
} = useReadContract({
address: route?.kind === 'erc4626' ? route.collateralVault : undefined,
abi: erc4626Abi,
functionName: 'previewMint',
- args: [targetFlashCollateralAmount],
+ // `previewMint(target flash collateral shares)` -> loan-token assets required to mint those exact shares.
+ args: [targetFlashCollateralTokenAmount],
chainId,
query: {
- enabled: route?.kind === 'erc4626' && targetFlashCollateralAmount > 0n,
+ enabled: route?.kind === 'erc4626' && targetFlashCollateralTokenAmount > 0n,
},
});
@@ -107,18 +131,18 @@ export function useLeverageQuote({
collateralTokenAddress,
collateralTokenDecimals,
swapExecutionAddress,
- targetFlashCollateralAmount.toString(),
+ targetFlashCollateralTokenAmount.toString(),
slippageBps,
userAddress ?? null,
],
- enabled: route?.kind === 'swap' && !isLoanAssetInput && targetFlashCollateralAmount > 0n && !!userAddress,
+ enabled: route?.kind === 'swap' && !isLoanAssetInput && targetFlashCollateralTokenAmount > 0n && !!userAddress,
queryFn: async () => {
const buyRoute = await fetchVeloraPriceRoute({
srcToken: loanTokenAddress,
srcDecimals: loanTokenDecimals,
destToken: collateralTokenAddress,
destDecimals: collateralTokenDecimals,
- amount: targetFlashCollateralAmount,
+ amount: targetFlashCollateralTokenAmount,
network: chainId,
userAddress: swapExecutionAddress as `0x${string}`,
side: 'BUY',
@@ -127,8 +151,8 @@ export function useLeverageQuote({
const borrowAssets = BigInt(buyRoute.srcAmount);
if (borrowAssets <= 0n) {
return {
- flashLoanAmount: 0n,
- flashCollateralAmount: 0n,
+ flashLoanAssetAmount: 0n,
+ flashLegCollateralTokenAmount: 0n,
priceRoute: null,
};
}
@@ -143,13 +167,12 @@ export function useLeverageQuote({
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), slippageBps),
+ flashLoanAssetAmount: borrowAssets,
+ // Quote preview uses the requested sell size as authoritative. The built calldata
+ // still has to prove that exact sell amount before leverage execution can proceed.
+ flashLegCollateralTokenAmount: withSlippageFloor(BigInt(sellRoute.destAmount), slippageBps),
priceRoute: sellRoute,
};
},
@@ -166,24 +189,24 @@ export function useLeverageQuote({
collateralTokenAddress,
collateralTokenDecimals,
swapExecutionAddress,
- userInputAmount.toString(),
+ initialCapitalInputAmount.toString(),
multiplierBps.toString(),
slippageBps,
userAddress ?? null,
],
- enabled: route?.kind === 'swap' && isLoanAssetInput && userInputAmount > 0n && !!userAddress,
+ enabled: route?.kind === 'swap' && isLoanAssetInput && initialCapitalInputAmount > 0n && !!userAddress,
queryFn: async () => {
- const flashLoanAmount = computeLeveragedExtraAmount(userInputAmount, multiplierBps);
- if (flashLoanAmount <= 0n) {
+ const flashLoanAssetAmount = computeLeveragedExtraAmount(initialCapitalInputAmount, multiplierBps);
+ if (flashLoanAssetAmount <= 0n) {
return {
- flashLoanAmount: 0n,
- flashCollateralAmount: 0n,
- totalAddedCollateral: 0n,
+ flashLoanAssetAmount: 0n,
+ flashLegCollateralTokenAmount: 0n,
+ totalCollateralTokenAmountAdded: 0n,
priceRoute: null,
};
}
- const totalLoanSellAmount = userInputAmount + flashLoanAmount;
+ const totalLoanSellAmount = initialCapitalInputAmount + flashLoanAssetAmount;
const sellRoute = await fetchVeloraPriceRoute({
srcToken: loanTokenAddress,
srcDecimals: loanTokenDecimals,
@@ -194,59 +217,64 @@ export function useLeverageQuote({
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), slippageBps);
+ const totalCollateralTokenAmountAdded = withSlippageFloor(BigInt(sellRoute.destAmount), slippageBps);
return {
- flashLoanAmount,
- flashCollateralAmount: 0n,
- totalAddedCollateral,
+ flashLoanAssetAmount,
+ flashLegCollateralTokenAmount: 0n,
+ totalCollateralTokenAmountAdded,
priceRoute: sellRoute,
};
},
});
- const flashCollateralAmount = useMemo(() => {
+ const flashLegCollateralTokenAmount = useMemo(() => {
if (!route) return 0n;
if (route.kind === 'swap') {
- if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashCollateralAmount ?? 0n;
- return swapCollateralInputQuoteQuery.data?.flashCollateralAmount ?? 0n;
+ if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashLegCollateralTokenAmount ?? 0n;
+ return swapCollateralInputQuoteQuery.data?.flashLegCollateralTokenAmount ?? 0n;
}
- return targetFlashCollateralAmount;
+ return targetFlashCollateralTokenAmount;
}, [
route,
isLoanAssetInput,
- targetFlashCollateralAmount,
- swapLoanInputCombinedQuoteQuery.data?.flashCollateralAmount,
- swapCollateralInputQuoteQuery.data?.flashCollateralAmount,
+ targetFlashCollateralTokenAmount,
+ swapLoanInputCombinedQuoteQuery.data?.flashLegCollateralTokenAmount,
+ swapCollateralInputQuoteQuery.data?.flashLegCollateralTokenAmount,
]);
- const flashLoanAmount = useMemo(() => {
+ const flashLoanAssetAmount = useMemo(() => {
if (!route) return 0n;
if (route.kind === 'swap') {
- if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashLoanAmount ?? 0n;
- return swapCollateralInputQuoteQuery.data?.flashLoanAmount ?? 0n;
+ if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashLoanAssetAmount ?? 0n;
+ return swapCollateralInputQuoteQuery.data?.flashLoanAssetAmount ?? 0n;
}
- return (erc4626PreviewMint as bigint | undefined) ?? 0n;
+ // `previewMint(targetFlashCollateralTokenAmount)` returns how many loan-token assets the flash leg
+ // must source to mint that exact collateral-share amount.
+ return (previewMintRequiredLoanAssetsForFlashCollateralShares as bigint | undefined) ?? 0n;
}, [
route,
isLoanAssetInput,
- swapLoanInputCombinedQuoteQuery.data?.flashLoanAmount,
- swapCollateralInputQuoteQuery.data?.flashLoanAmount,
- erc4626PreviewMint,
+ swapLoanInputCombinedQuoteQuery.data?.flashLoanAssetAmount,
+ swapCollateralInputQuoteQuery.data?.flashLoanAssetAmount,
+ previewMintRequiredLoanAssetsForFlashCollateralShares,
]);
- const totalAddedCollateral = useMemo(() => {
+ const totalCollateralTokenAmountAdded = useMemo(() => {
if (!route) return 0n;
if (route.kind === 'swap') {
- if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.totalAddedCollateral ?? 0n;
- return initialCollateralAmount + flashCollateralAmount;
+ if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.totalCollateralTokenAmountAdded ?? 0n;
+ return initialCapitalCollateralTokenAmount + flashLegCollateralTokenAmount;
}
- return initialCollateralAmount + flashCollateralAmount;
- }, [route, isLoanAssetInput, initialCollateralAmount, flashCollateralAmount, swapLoanInputCombinedQuoteQuery.data?.totalAddedCollateral]);
+ return initialCapitalCollateralTokenAmount + flashLegCollateralTokenAmount;
+ }, [
+ route,
+ isLoanAssetInput,
+ initialCapitalCollateralTokenAmount,
+ flashLegCollateralTokenAmount,
+ swapLoanInputCombinedQuoteQuery.data?.totalCollateralTokenAmountAdded,
+ ]);
const swapPriceRoute = useMemo(() => {
if (route?.kind !== 'swap') return null;
@@ -257,7 +285,7 @@ export function useLeverageQuote({
const error = useMemo(() => {
if (!route) return null;
if (route.kind === 'swap') {
- if (!userAddress && userInputAmount > 0n) return 'Connect wallet to fetch swap-backed leverage route.';
+ if (!userAddress && initialCapitalInputAmount > 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.';
@@ -269,7 +297,7 @@ export function useLeverageQuote({
route,
swapExecutionAddress,
userAddress,
- userInputAmount,
+ initialCapitalInputAmount,
isLoanAssetInput,
swapLoanInputCombinedQuoteQuery.error,
swapCollateralInputQuoteQuery.error,
@@ -285,10 +313,10 @@ export function useLeverageQuote({
: isLoadingErc4626Deposit || isLoadingErc4626Mint);
return {
- initialCollateralAmount,
- flashCollateralAmount,
- flashLoanAmount,
- totalAddedCollateral,
+ initialCapitalCollateralTokenAmount,
+ flashLegCollateralTokenAmount,
+ flashLoanAssetAmount,
+ totalCollateralTokenAmountAdded,
isLoading,
error,
swapPriceRoute,
diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts
index 063d2f6b..9cbb788f 100644
--- a/src/hooks/useLeverageTransaction.ts
+++ b/src/hooks/useLeverageTransaction.ts
@@ -1,56 +1,61 @@
import { useCallback, useMemo } from 'react';
-import { type Address, encodeAbiParameters, encodeFunctionData, isAddress, isAddressEqual, keccak256, maxUint256, zeroHash } from 'viem';
+import type { Address } from 'viem';
+import type { VeloraPriceRoute } from '@/features/swap/api/velora';
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 { getLeverageFee } from '@/config/fees';
-import { LEVERAGE_FEE_RECIPIENT, resolveErc4626RouteBundler } from '@/config/leverage';
-import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora';
+import { resolveErc4626RouteBundler } from '@/config/leverage';
import { useERC20Approval } from '@/hooks/useERC20Approval';
import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep';
import { usePermit2 } from '@/hooks/usePermit2';
import { useStyledToast } from '@/hooks/useStyledToast';
import { useTransactionWithToast } from '@/hooks/useTransactionWithToast';
import { useTransactionTracking } from '@/hooks/useTransactionTracking';
+import { leverageWithErc4626Deposit } from '@/hooks/leverage/leverageWithErc4626Deposit';
+import { leverageWithSwap } from '@/hooks/leverage/leverageWithSwap';
+import { buildMorphoMarketParams, type LeverageStepType } from '@/hooks/leverage/transaction-shared';
+import type { LeverageRoute } from '@/hooks/leverage/types';
import { useUserMarketsCache } from '@/stores/useUserMarketsCache';
import { useAppSettings } from '@/stores/useAppSettings';
import { formatBalance } from '@/utils/balance';
-import { 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'
- | 'authorize_bundler_sig'
- | 'sign_permit'
- | 'authorize_bundler_tx'
- | 'approve_token'
- | 'execute';
+
+export type { LeverageStepType } from '@/hooks/leverage/transaction-shared';
type UseLeverageTransactionProps = {
market: Market;
route: LeverageRoute | null;
- collateralAmount: bigint;
- collateralAmountInCollateralToken: bigint;
- flashCollateralAmount: bigint;
- flashLoanAmount: bigint;
- totalAddedCollateral: bigint;
+ /** Exact user-entered starting capital, denominated by `useLoanAssetInput`. */
+ initialCapitalInputAmount: bigint;
+ /** Market collateral-token amount sourced from the initial capital before the flash leg. */
+ initialCapitalCollateralTokenAmount: bigint;
+ /** Market collateral-token amount added by the flash leg. */
+ flashLegCollateralTokenAmount: bigint;
+ /** Flash-loaned market loan-asset amount. */
+ flashLoanAssetAmount: bigint;
+ /** Total market collateral-token amount added before leverage fee. */
+ totalCollateralTokenAmountAdded: bigint;
collateralAssetPriceUsd: number | null;
swapPriceRoute: VeloraPriceRoute | null;
slippageBps: number;
- useLoanAssetAsInput: boolean;
+ useLoanAssetInput: boolean;
onSuccess?: () => void;
};
+type LeverageExecutionPreflight =
+ | {
+ account: Address;
+ leverageFeeAmount: bigint;
+ route: Extract;
+ swapPriceRoute: VeloraPriceRoute;
+ }
+ | {
+ account: Address;
+ leverageFeeAmount: bigint;
+ route: Extract;
+ swapPriceRoute: null;
+ };
+
/**
* Executes leverage transactions for:
* - ERC4626 deterministic loops on Bundler V2
@@ -59,15 +64,15 @@ type UseLeverageTransactionProps = {
export function useLeverageTransaction({
market,
route,
- collateralAmount,
- collateralAmountInCollateralToken,
- flashCollateralAmount,
- flashLoanAmount,
- totalAddedCollateral,
+ initialCapitalInputAmount,
+ initialCapitalCollateralTokenAmount,
+ flashLegCollateralTokenAmount,
+ flashLoanAssetAmount,
+ totalCollateralTokenAmountAdded,
collateralAssetPriceUsd,
swapPriceRoute,
slippageBps,
- useLoanAssetAsInput,
+ useLoanAssetInput,
onSuccess,
}: UseLeverageTransactionProps) {
const { usePermit2: usePermit2Setting } = useAppSettings();
@@ -81,21 +86,25 @@ export function useLeverageTransaction({
if (route?.kind === 'swap') {
return route.bundler3Address;
}
+
return resolveErc4626RouteBundler(market.morphoBlue.chain.id, market.uniqueKey);
}, [route, market.uniqueKey, 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 initialCapitalUsesLoanAsset = useLoanAssetInput;
+ const initialCapitalInputTokenAddress = initialCapitalUsesLoanAsset
+ ? (market.loanAsset.address as Address)
+ : (market.collateralAsset.address as Address);
+ const initialCapitalInputTokenSymbol = initialCapitalUsesLoanAsset ? market.loanAsset.symbol : market.collateralAsset.symbol;
+ const initialCapitalInputTokenDecimals = initialCapitalUsesLoanAsset ? market.loanAsset.decimals : market.collateralAsset.decimals;
+ const initialCapitalTransferAmount = initialCapitalUsesLoanAsset ? initialCapitalInputAmount : initialCapitalCollateralTokenAmount;
const approvalSpender = route?.kind === 'swap' ? route.generalAdapterAddress : bundlerAddress;
const {
@@ -118,25 +127,25 @@ export function useLeverageTransaction({
} = usePermit2({
user: account as `0x${string}`,
spender: approvalSpender,
- token: inputTokenAddress as `0x${string}`,
+ token: initialCapitalInputTokenAddress as `0x${string}`,
refetchInterval: 10_000,
chainId: market.morphoBlue.chain.id,
- tokenSymbol: inputTokenSymbol,
- amount: usePermit2ForRoute ? inputTokenAmountForTransfer : 0n,
+ tokenSymbol: initialCapitalInputTokenSymbol,
+ amount: usePermit2ForRoute ? initialCapitalTransferAmount : 0n,
});
const isAuthorizationReadyForRoute = usePermit2ForRoute ? isBundlerAuthorizationReady : isBundlerAuthorizationStatusReady;
const { isApproved, approve, isApproving } = useERC20Approval({
- token: inputTokenAddress,
+ token: initialCapitalInputTokenAddress,
spender: approvalSpender,
- amount: inputTokenAmountForTransfer,
- tokenSymbol: inputTokenSymbol,
+ amount: initialCapitalTransferAmount,
+ tokenSymbol: initialCapitalInputTokenSymbol,
chainId: market.morphoBlue.chain.id,
});
const { isConfirming: leveragePending, sendTransactionAsync } = useTransactionWithToast({
toastId: 'leverage',
- pendingText: `Leveraging ${formatBalance(collateralAmount, inputTokenDecimals)} ${inputTokenSymbol}`,
+ pendingText: `Leveraging ${formatBalance(initialCapitalInputAmount, initialCapitalInputTokenDecimals)} ${initialCapitalInputTokenSymbol}`,
successText: 'Leverage Executed',
errorText: 'Failed to execute leverage',
chainId,
@@ -147,6 +156,16 @@ export function useLeverageTransaction({
if (onSuccess) void onSuccess();
},
});
+ const trackingMetadata = useMemo(
+ () => ({
+ title: 'Leverage',
+ description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`,
+ tokenSymbol: initialCapitalInputTokenSymbol,
+ amount: initialCapitalInputAmount,
+ marketId: market.uniqueKey,
+ }),
+ [market.collateralAsset.symbol, market.loanAsset.symbol, initialCapitalInputTokenSymbol, initialCapitalInputAmount, market.uniqueKey],
+ );
const getStepsForFlow = useCallback(
(isPermit2: boolean, isSwap: boolean) => {
@@ -184,8 +203,8 @@ export function useLeverageTransaction({
},
{
id: 'approve_token',
- title: `Approve ${inputTokenSymbol}`,
- description: `Approve ${inputTokenSymbol} transfer for the leverage flow.`,
+ title: `Approve ${initialCapitalInputTokenSymbol}`,
+ description: `Approve ${initialCapitalInputTokenSymbol} transfer for the leverage flow.`,
},
{
id: 'execute',
@@ -210,7 +229,7 @@ export function useLeverageTransaction({
{
id: 'sign_permit',
title: 'Sign Token Permit',
- description: 'Sign Permit2 transfer authorization for collateral transfer.',
+ description: `Sign Permit2 transfer authorization for ${initialCapitalInputTokenSymbol}.`,
},
{
id: 'execute',
@@ -228,8 +247,8 @@ export function useLeverageTransaction({
},
{
id: 'approve_token',
- title: `Approve ${inputTokenSymbol}`,
- description: `Approve ${inputTokenSymbol} transfer for the leverage flow.`,
+ title: `Approve ${initialCapitalInputTokenSymbol}`,
+ description: `Approve ${initialCapitalInputTokenSymbol} transfer for the leverage flow.`,
},
{
id: 'execute',
@@ -238,622 +257,253 @@ export function useLeverageTransaction({
},
];
},
- [inputTokenSymbol],
+ [initialCapitalInputTokenSymbol],
);
- const executeLeverage = useCallback(async () => {
+ const getLeverageExecutionPreflight = useCallback((): LeverageExecutionPreflight | null => {
if (!account) {
toast.info('No account connected', 'Please connect your wallet.');
- return;
+ return null;
}
if (!route) {
toast.info('Unsupported route', 'This market is not supported for leverage.');
- return;
+ return null;
}
- 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;
+ const hasCollateralOutput =
+ route.kind === 'swap' && initialCapitalUsesLoanAsset ? totalCollateralTokenAmountAdded > 0n : flashLegCollateralTokenAmount > 0n;
+ if (initialCapitalInputAmount <= 0n || flashLoanAssetAmount <= 0n || !hasCollateralOutput) {
+ toast.info('Invalid leverage inputs', 'Set initial capital and multiplier above 1x before submitting.');
+ return null;
}
if (collateralAssetPriceUsd == null || !Number.isFinite(collateralAssetPriceUsd) || collateralAssetPriceUsd <= 0) {
toast.info('Leverage unavailable', 'Collateral price unavailable for fee calculation.');
- return;
+ return null;
}
+
const leverageFeeAmount = getLeverageFee({
- amount: totalAddedCollateral,
+ amount: totalCollateralTokenAmountAdded,
assetPriceUsd: collateralAssetPriceUsd,
assetDecimals: market.collateralAsset.decimals,
});
- if (totalAddedCollateral - leverageFeeAmount <= 0n) {
+ if (totalCollateralTokenAmountAdded - leverageFeeAmount <= 0n) {
toast.info('Leverage unavailable', 'Net collateral after fee must be positive.');
- return;
+ return null;
}
- try {
- const txs: `0x${string}`[] = [];
- let swapRouteAuthorizationCall: Bundler3Call | null = null;
- let swapRoutePermit2Call: Bundler3Call | null = null;
-
- if (usePermit2ForRoute) {
- if (!permit2Authorized) {
- tracking.update('approve_permit2');
- await authorizePermit2();
- await new Promise((resolve) => setTimeout(resolve, 800));
- }
-
- if (!isBundlerAuthorized) {
- tracking.update('authorize_bundler_sig');
- }
- 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) {
- 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();
- 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 {
- if (!isBundlerAuthorized) {
- tracking.update('authorize_bundler_tx');
- const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
- if (!authorized) {
- throw new Error('Failed to authorize Bundler via transaction.');
- }
- }
-
- if (!isApproved) {
- tracking.update('approve_token');
- await approve();
- await new Promise((resolve) => setTimeout(resolve, 900));
- }
+ if (route.kind === 'swap') {
+ if (!swapPriceRoute) {
+ toast.info('Quote unavailable', 'Missing swap quote for leverage. Refresh the preview and try again.');
+ return null;
}
- 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),
+ return {
+ account: account as Address,
+ leverageFeeAmount,
+ route,
+ swapPriceRoute,
};
+ }
- if (route.kind === 'swap') {
- if (!Number.isFinite(slippageBps) || slippageBps <= 0) {
- throw new Error('Invalid slippage tolerance. Please set a positive slippage value.');
- }
- if (!swapPriceRoute) {
- throw new Error('Missing Velora swap quote for leverage.');
- }
- const preFlashCollateralFee =
- !isLoanAssetInput && inputTokenAmountForTransfer > 0n
- ? leverageFeeAmount < inputTokenAmountForTransfer
- ? leverageFeeAmount
- : inputTokenAmountForTransfer
- : 0n;
- const callbackCollateralFee = leverageFeeAmount - preFlashCollateralFee;
- const preFlashCollateralSupplyAmount = inputTokenAmountForTransfer - preFlashCollateralFee;
- 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,
- 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,
- },
- ];
-
- if (callbackCollateralFee > 0n) {
- callbackBundle.push({
- to: route.generalAdapterAddress,
- data: encodeFunctionData({
- abi: morphoGeneralAdapterV1Abi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, LEVERAGE_FEE_RECIPIENT, callbackCollateralFee],
- }),
- value: 0n,
- skipRevert: false,
- callbackHash: zeroHash,
- });
- }
-
- callbackBundle.push(
- {
- 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);
- }
+ return {
+ account: account as Address,
+ leverageFeeAmount,
+ route,
+ swapPriceRoute: null,
+ };
+ }, [
+ account,
+ route,
+ initialCapitalUsesLoanAsset,
+ totalCollateralTokenAmountAdded,
+ flashLegCollateralTokenAmount,
+ initialCapitalInputAmount,
+ flashLoanAssetAmount,
+ collateralAssetPriceUsd,
+ market.collateralAsset.decimals,
+ swapPriceRoute,
+ toast,
+ ]);
- 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) {
- if (preFlashCollateralFee > 0n) {
- bundleCalls.push({
- to: route.generalAdapterAddress,
- data: encodeFunctionData({
- abi: morphoGeneralAdapterV1Abi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, LEVERAGE_FEE_RECIPIENT, preFlashCollateralFee],
- }),
- value: 0n,
- skipRevert: false,
- callbackHash: zeroHash,
- });
- }
- if (preFlashCollateralSupplyAmount > 0n) {
- bundleCalls.push({
- to: route.generalAdapterAddress,
- data: encodeFunctionData({
- abi: morphoGeneralAdapterV1Abi,
- functionName: 'morphoSupplyCollateral',
- args: [marketParams, preFlashCollateralSupplyAmount, account as Address, '0x'],
- }),
- value: 0n,
- skipRevert: false,
- callbackHash: zeroHash,
- });
- }
- }
+ const executeLeverage = useCallback(
+ async (
+ execution: LeverageExecutionPreflight & {
+ updateStep: (step: LeverageStepType) => void;
+ },
+ ) => {
+ const marketParams = buildMorphoMarketParams(market);
+
+ if (execution.route.kind === 'swap') {
+ const swapExecutionPriceRoute = execution.swapPriceRoute;
+ if (!swapExecutionPriceRoute) {
+ throw new Error('Missing Velora swap quote for leverage.');
}
- 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),
- });
- // Safety net: sweep any residual loan/collateral balances from adapters to the user.
- bundleCalls.push(
- {
- 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,
- },
- {
- to: route.generalAdapterAddress,
- data: encodeFunctionData({
- abi: morphoGeneralAdapterV1Abi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, account as Address, maxUint256],
- }),
- 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.paraswapAdapterAddress,
- data: encodeFunctionData({
- abi: paraswapAdapterAbi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, account as Address, maxUint256],
- }),
- value: 0n,
- skipRevert: false,
- callbackHash: zeroHash,
- },
- );
-
- 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,
+ await leverageWithSwap({
+ account: execution.account,
+ bundlerAddress,
+ market,
+ marketParams,
+ route: execution.route,
+ initialCapitalInputTokenAddress,
+ initialCapitalTransferAmount,
+ isLoanAssetInput: initialCapitalUsesLoanAsset,
+ flashLoanAssetAmount,
+ flashLegCollateralTokenAmount,
+ totalCollateralTokenAmountAdded,
+ leverageFeeAmount: execution.leverageFeeAmount,
+ swapPriceRoute: swapExecutionPriceRoute,
+ slippageBps,
+ usePermit2: usePermit2ForRoute,
+ permit2Authorized,
+ isBundlerAuthorized,
+ authorizePermit2,
+ ensureBundlerAuthorization,
+ signForBundlers,
+ isApproved,
+ approve,
+ updateStep: execution.updateStep,
+ sendTransactionAsync,
});
} else {
- const maxBorrowShares = computeBorrowSharesWithBuffer({
- borrowAssets: flashLoanAmount,
- totalBorrowAssets: BigInt(market.state.borrowAssets),
- totalBorrowShares: BigInt(market.state.borrowShares),
+ await leverageWithErc4626Deposit({
+ account: execution.account,
+ bundlerAddress,
+ market,
+ marketParams,
+ route: execution.route,
+ initialCapitalInputAmount,
+ initialCapitalCollateralTokenAmount,
+ initialCapitalInputTokenAddress,
+ initialCapitalTransferAmount,
+ isLoanAssetInput: initialCapitalUsesLoanAsset,
+ flashLegCollateralTokenAmount,
+ flashLoanAssetAmount,
+ leverageFeeAmount: execution.leverageFeeAmount,
+ usePermit2: usePermit2ForRoute,
+ permit2Authorized,
+ isBundlerAuthorized,
+ authorizePermit2,
+ ensureBundlerAuthorization,
+ signForBundlers,
+ isApproved,
+ approve,
+ updateStep: execution.updateStep,
+ sendTransactionAsync,
});
+ }
+ },
+ [
+ market,
+ bundlerAddress,
+ initialCapitalInputAmount,
+ initialCapitalCollateralTokenAmount,
+ initialCapitalInputTokenAddress,
+ initialCapitalTransferAmount,
+ initialCapitalUsesLoanAsset,
+ flashLoanAssetAmount,
+ flashLegCollateralTokenAmount,
+ totalCollateralTokenAmountAdded,
+ slippageBps,
+ usePermit2ForRoute,
+ permit2Authorized,
+ isBundlerAuthorized,
+ authorizePermit2,
+ ensureBundlerAuthorization,
+ signForBundlers,
+ isApproved,
+ approve,
+ sendTransactionAsync,
+ ],
+ );
- 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: 'erc4626Deposit',
- args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address],
- }),
- ];
+ const runLeverageFlow = useCallback(
+ async ({
+ initialStep,
+ usePermit2Flow,
+ errorTitle,
+ logLabel,
+ }: {
+ initialStep: LeverageStepType;
+ usePermit2Flow: boolean;
+ errorTitle: string;
+ logLabel: string;
+ }) => {
+ const preflight = getLeverageExecutionPreflight();
+ if (!preflight) {
+ return;
+ }
- if (leverageFeeAmount > 0n) {
- callbackTxs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, LEVERAGE_FEE_RECIPIENT, leverageFeeAmount],
- }),
- );
+ const steps = getStepsForFlow(usePermit2Flow, isSwapRoute);
+ const stepIndexes = new Map(steps.map((step, index) => [step.id, index]));
+ let highestStepIndex = stepIndexes.get(initialStep) ?? -1;
+ const updateTrackedStep = (step: LeverageStepType) => {
+ const nextIndex = stepIndexes.get(step) ?? -1;
+ if (nextIndex > highestStepIndex) {
+ highestStepIndex = nextIndex;
+ tracking.update(step);
}
+ };
- callbackTxs.push(
- 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],
- }),
- );
-
- const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]);
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'morphoFlashLoan',
- args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData],
- }),
- );
- // Safety net: sweep any residual loan/collateral balances from bundler to the user.
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20Transfer',
- args: [market.loanAsset.address as Address, account as Address, maxUint256],
- }),
- );
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20Transfer',
- args: [market.collateralAsset.address as Address, account as Address, maxUint256],
- }),
- );
-
- 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,
+ try {
+ tracking.start(steps, trackingMetadata, initialStep);
+ await executeLeverage({
+ ...preflight,
+ updateStep: updateTrackedStep,
});
- }
- batchAddUserMarkets([
- {
- marketUniqueKey: market.uniqueKey,
- chainId: market.morphoBlue.chain.id,
- },
- ]);
-
- tracking.complete();
- } catch (error: unknown) {
- tracking.fail();
- console.error('Error during leverage execution:', error);
- const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'An unexpected error occurred during leverage.');
- if (userFacingMessage !== 'User rejected transaction.') {
- toast.error('Leverage Failed', userFacingMessage);
+ batchAddUserMarkets([
+ {
+ marketUniqueKey: market.uniqueKey,
+ chainId: market.morphoBlue.chain.id,
+ },
+ ]);
+
+ tracking.complete();
+ } catch (error: unknown) {
+ console.error(`Error in ${logLabel}:`, error);
+ tracking.fail();
+ const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process leverage transaction');
+ if (userFacingMessage !== 'User rejected transaction.') {
+ toast.error(errorTitle, userFacingMessage);
+ }
}
- }
- }, [
- account,
- market,
- route,
- collateralAmount,
- collateralAmountInCollateralToken,
- inputTokenAmountForTransfer,
- inputTokenAddress,
- isLoanAssetInput,
- flashCollateralAmount,
- flashLoanAmount,
- totalAddedCollateral,
- collateralAssetPriceUsd,
- swapPriceRoute,
- slippageBps,
- usePermit2ForRoute,
- permit2Authorized,
- isBundlerAuthorized,
- authorizePermit2,
- ensureBundlerAuthorization,
- signForBundlers,
- isApproved,
- approve,
- bundlerAddress,
- sendTransactionAsync,
- batchAddUserMarkets,
- tracking,
- toast,
- ]);
+ },
+ [
+ getLeverageExecutionPreflight,
+ getStepsForFlow,
+ isSwapRoute,
+ trackingMetadata,
+ executeLeverage,
+ batchAddUserMarkets,
+ market.uniqueKey,
+ market.morphoBlue.chain.id,
+ tracking,
+ toast,
+ ],
+ );
const approveAndLeverage = useCallback(async () => {
- if (!account) {
- toast.info('No account connected', 'Please connect your wallet.');
- return;
- }
-
- try {
- const initialStep: LeverageStepType = usePermit2ForRoute
- ? permit2Authorized
- ? isBundlerAuthorized
- ? 'sign_permit'
- : 'authorize_bundler_sig'
- : 'approve_permit2'
- : isBundlerAuthorized
- ? isApproved
- ? 'execute'
- : 'approve_token'
- : 'authorize_bundler_tx';
- tracking.start(
- getStepsForFlow(usePermit2ForRoute, isSwapRoute),
- {
- title: 'Leverage',
- description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`,
- tokenSymbol: inputTokenSymbol,
- amount: collateralAmount,
- marketId: market.uniqueKey,
- },
- initialStep,
- );
-
- await executeLeverage();
- } catch (error: unknown) {
- console.error('Error in approveAndLeverage:', error);
- tracking.fail();
- const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process leverage transaction');
- if (userFacingMessage !== 'User rejected transaction.') {
- toast.error('Error', userFacingMessage);
- }
- }
- }, [
- account,
- usePermit2ForRoute,
- permit2Authorized,
- isBundlerAuthorized,
- isApproved,
- tracking,
- getStepsForFlow,
- isSwapRoute,
- market,
- inputTokenSymbol,
- collateralAmount,
- executeLeverage,
- toast,
- ]);
+ const initialStep: LeverageStepType = usePermit2ForRoute
+ ? permit2Authorized
+ ? isBundlerAuthorized
+ ? 'sign_permit'
+ : 'authorize_bundler_sig'
+ : 'approve_permit2'
+ : isBundlerAuthorized
+ ? isApproved
+ ? 'execute'
+ : 'approve_token'
+ : 'authorize_bundler_tx';
+
+ await runLeverageFlow({
+ initialStep,
+ usePermit2Flow: usePermit2ForRoute,
+ errorTitle: 'Error',
+ logLabel: 'approveAndLeverage',
+ });
+ }, [usePermit2ForRoute, permit2Authorized, isBundlerAuthorized, isApproved, runLeverageFlow]);
const signAndLeverage = useCallback(async () => {
if (!usePermit2ForRoute) {
@@ -861,53 +511,19 @@ export function useLeverageTransaction({
return;
}
- if (!account) {
- toast.info('No account connected', 'Please connect your wallet.');
- return;
- }
-
- try {
- const initialStep: LeverageStepType = permit2Authorized
- ? isBundlerAuthorized
- ? 'sign_permit'
- : 'authorize_bundler_sig'
- : 'approve_permit2';
-
- tracking.start(
- getStepsForFlow(true, isSwapRoute),
- {
- title: 'Leverage',
- description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`,
- tokenSymbol: inputTokenSymbol,
- amount: collateralAmount,
- marketId: market.uniqueKey,
- },
- initialStep,
- );
-
- await executeLeverage();
- } catch (error: unknown) {
- console.error('Error in signAndLeverage:', error);
- tracking.fail();
- const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process leverage transaction');
- if (userFacingMessage !== 'User rejected transaction.') {
- toast.error('Transaction Error', userFacingMessage);
- }
- }
- }, [
- usePermit2ForRoute,
- approveAndLeverage,
- account,
- tracking,
- getStepsForFlow,
- permit2Authorized,
- isBundlerAuthorized,
- market,
- inputTokenSymbol,
- collateralAmount,
- executeLeverage,
- toast,
- ]);
+ const initialStep: LeverageStepType = permit2Authorized
+ ? isBundlerAuthorized
+ ? 'sign_permit'
+ : 'authorize_bundler_sig'
+ : 'approve_permit2';
+
+ await runLeverageFlow({
+ initialStep,
+ usePermit2Flow: true,
+ errorTitle: 'Transaction Error',
+ logLabel: 'signAndLeverage',
+ });
+ }, [usePermit2ForRoute, approveAndLeverage, permit2Authorized, isBundlerAuthorized, runLeverageFlow]);
const isLoading =
leveragePending || (usePermit2ForRoute && isLoadingPermit2) || !isAuthorizationReadyForRoute || isApproving || isAuthorizingBundler;
diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts
index ee11335f..d4b39886 100644
--- a/src/hooks/useMultiMarketSupply.ts
+++ b/src/hooks/useMultiMarketSupply.ts
@@ -7,6 +7,7 @@ import { useTransactionWithToast } from '@/hooks/useTransactionWithToast';
import { useTransactionTracking } from '@/hooks/useTransactionTracking';
import type { NetworkToken } from '@/types/token';
import { formatBalance } from '@/utils/balance';
+import { MIN_SHARES_SLIPPAGE_AMOUNT } from '@/hooks/leverage/math';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import type { Market } from '@/utils/types';
import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants';
@@ -135,6 +136,7 @@ export function useMultiMarketSupply(
// Add supply transactions for each market
for (const supply of supplies) {
+ // Asset-based supply keeps the asset amount exact and only needs a minimum-share floor.
const morphoSupplyTx = encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'morphoSupply',
@@ -148,7 +150,7 @@ export function useMultiMarketSupply(
},
supply.amount,
BigInt(0),
- BigInt(1), // minShares
+ MIN_SHARES_SLIPPAGE_AMOUNT,
account as `0x${string}`,
'0x', // callback
],
diff --git a/src/hooks/useSupplyMarket.ts b/src/hooks/useSupplyMarket.ts
index 11c78ef5..b948f00f 100644
--- a/src/hooks/useSupplyMarket.ts
+++ b/src/hooks/useSupplyMarket.ts
@@ -10,6 +10,7 @@ import { useTransactionWithToast } from '@/hooks/useTransactionWithToast';
import { useUserMarketsCache } from '@/stores/useUserMarketsCache';
import { useTransactionTracking } from '@/hooks/useTransactionTracking';
import { formatBalance } from '@/utils/balance';
+import { MIN_SHARES_SLIPPAGE_AMOUNT } from '@/hooks/leverage/math';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import type { Market } from '@/utils/types';
import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants';
@@ -219,7 +220,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
update('supplying');
- const minShares = BigInt(1);
+ // Asset-based supply keeps the asset amount exact and only needs a minimum-share floor.
+ const minSharesSlippageAmount = MIN_SHARES_SLIPPAGE_AMOUNT;
const morphoSupplyTx = encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'morphoSupply',
@@ -233,7 +235,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
},
supplyAmount,
BigInt(0),
- minShares,
+ minSharesSlippageAmount,
account as `0x${string}`,
'0x', // callback
],
diff --git a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx
index 16c142cb..ca1518a8 100644
--- a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx
+++ b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState, useEffect, useCallback } from 'react';
+import { useMemo, useState, useCallback } from 'react';
import { LTVWarning } from '@/components/shared/ltv-warning';
import { IconSwitch } from '@/components/ui/icon-switch';
import Input from '@/components/Input/Input';
diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx
index 93a600c2..b379cb20 100644
--- a/src/modals/leverage/components/add-collateral-and-leverage.tsx
+++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx
@@ -81,8 +81,8 @@ export function AddCollateralAndLeverage({
[defaultMultiplierBps, maxTargetLtvBps],
);
- const [collateralAmount, setCollateralAmount] = useState(0n);
- const [collateralInputError, setCollateralInputError] = useState(null);
+ const [initialCapitalInputAmount, setInitialCapitalInputAmount] = useState(0n);
+ const [initialCapitalInputError, setInitialCapitalInputError] = useState(null);
const [useLoanAssetInput, setUseLoanAssetInput] = useState(false);
const [targetMultiplierBps, setTargetMultiplierBps] = useState(defaultMultiplierBps);
const [targetLtvIntentBps, setTargetLtvIntentBps] = useState(defaultTargetLtvIntentBps);
@@ -110,9 +110,9 @@ export function AddCollateralAndLeverage({
});
useEffect(() => {
- // Underlying and collateral shares use different units. Reset amount when switching input source.
- setCollateralAmount(0n);
- setCollateralInputError(null);
+ // The initial-capital field flips between loan-asset units and collateral-token units.
+ setInitialCapitalInputAmount(0n);
+ setInitialCapitalInputError(null);
}, [useLoanAssetInput]);
useEffect(() => {
@@ -136,7 +136,7 @@ export function AddCollateralAndLeverage({
const quote = useLeverageQuote({
chainId: market.morphoBlue.chain.id,
route,
- userInputAmount: collateralAmount,
+ initialCapitalInputAmount,
inputMode: useLoanAssetInput ? 'loan' : 'collateral',
multiplierBps,
loanTokenAddress: market.loanAsset.address,
@@ -149,7 +149,7 @@ export function AddCollateralAndLeverage({
const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0);
const currentBorrowAssets = BigInt(currentPosition?.state.borrowAssets ?? 0);
- const hasQuoteChanges = quote.totalAddedCollateral > 0n && quote.flashLoanAmount > 0n;
+ const hasQuoteChanges = quote.totalCollateralTokenAmountAdded > 0n && quote.flashLoanAssetAmount > 0n;
const collateralAssetPriceUsd = useMemo(() => {
const totalCollateralAssets = BigInt(market.state.collateralAssets);
const totalCollateralAssetsUsd = market.state.collateralAssetsUsd;
@@ -171,15 +171,15 @@ export function AddCollateralAndLeverage({
const leverageTransferFee = useMemo(() => {
if (collateralAssetPriceUsd == null) return null;
return getLeverageFee({
- amount: quote.totalAddedCollateral,
+ amount: quote.totalCollateralTokenAmountAdded,
assetPriceUsd: collateralAssetPriceUsd,
assetDecimals: market.collateralAsset.decimals,
});
- }, [quote.totalAddedCollateral, collateralAssetPriceUsd, market.collateralAsset.decimals]);
+ }, [quote.totalCollateralTokenAmountAdded, collateralAssetPriceUsd, market.collateralAsset.decimals]);
const netAddedCollateral = useMemo(() => {
if (leverageTransferFee == null) return null;
- return quote.totalAddedCollateral - leverageTransferFee;
- }, [quote.totalAddedCollateral, leverageTransferFee]);
+ return quote.totalCollateralTokenAmountAdded - leverageTransferFee;
+ }, [quote.totalCollateralTokenAmountAdded, leverageTransferFee]);
const isLeverageFeeReady = useMemo(
() => hasQuoteChanges && leverageTransferFee != null && netAddedCollateral != null && netAddedCollateral > 0n,
[hasQuoteChanges, leverageTransferFee, netAddedCollateral],
@@ -195,7 +195,10 @@ export function AddCollateralAndLeverage({
() => (isLeverageFeeReady && netAddedCollateral != null ? netAddedCollateral : 0n),
[isLeverageFeeReady, netAddedCollateral],
);
- const addedBorrowAssets = useMemo(() => (isLeverageFeeReady ? quote.flashLoanAmount : 0n), [isLeverageFeeReady, quote.flashLoanAmount]);
+ const addedBorrowAssets = useMemo(
+ () => (isLeverageFeeReady ? quote.flashLoanAssetAmount : 0n),
+ [isLeverageFeeReady, quote.flashLoanAssetAmount],
+ );
const { projectedCollateralAssets, projectedBorrowAssets } = useMemo(
() =>
computeLeverageProjectedPosition({
@@ -255,8 +258,8 @@ export function AddCollateralAndLeverage({
const handleTransactionSuccess = useCallback(() => {
// WHY: after a confirmed leverage tx, reset drafts so the panel reflects refreshed onchain position state.
- setCollateralAmount(0n);
- setCollateralInputError(null);
+ setInitialCapitalInputAmount(0n);
+ setInitialCapitalInputError(null);
syncInputFieldsFromMultiplier(defaultMultiplierBps);
if (useLoanAssetInput) {
void refetchLoanTokenBalance();
@@ -275,14 +278,14 @@ export function AddCollateralAndLeverage({
} = useLeverageTransaction({
market,
route,
- collateralAmount,
- collateralAmountInCollateralToken: quote.initialCollateralAmount,
- flashCollateralAmount: quote.flashCollateralAmount,
- flashLoanAmount: quote.flashLoanAmount,
- totalAddedCollateral: quote.totalAddedCollateral,
+ initialCapitalInputAmount,
+ initialCapitalCollateralTokenAmount: quote.initialCapitalCollateralTokenAmount,
+ flashLegCollateralTokenAmount: quote.flashLegCollateralTokenAmount,
+ flashLoanAssetAmount: quote.flashLoanAssetAmount,
+ totalCollateralTokenAmountAdded: quote.totalCollateralTokenAmountAdded,
collateralAssetPriceUsd,
swapPriceRoute: quote.swapPriceRoute,
- useLoanAssetAsInput: useLoanAssetInput,
+ useLoanAssetInput,
slippageBps: swapSlippageBps,
onSuccess: handleTransactionSuccess,
});
@@ -311,18 +314,18 @@ export function AddCollateralAndLeverage({
}, [isLeverageFeeReady, usePermit2Setting, permit2Authorized, signAndLeverage, approveAndLeverage]);
const projectedOverLimit = projectedLTV >= lltv;
- const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity;
+ const insufficientLiquidity = quote.flashLoanAssetAmount > marketLiquidity;
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 flashBorrowPreview = useMemo(
- () => formatTokenAmountPreview(quote.flashLoanAmount, market.loanAsset.decimals),
- [quote.flashLoanAmount, market.loanAsset.decimals],
+ () => formatTokenAmountPreview(quote.flashLoanAssetAmount, market.loanAsset.decimals),
+ [quote.flashLoanAssetAmount, market.loanAsset.decimals],
);
const totalCollateralAddedPreview = useMemo(
- () => formatTokenAmountPreview(quote.totalAddedCollateral, market.collateralAsset.decimals),
- [quote.totalAddedCollateral, market.collateralAsset.decimals],
+ () => formatTokenAmountPreview(quote.totalCollateralTokenAmountAdded, market.collateralAsset.decimals),
+ [quote.totalCollateralTokenAmountAdded, market.collateralAsset.decimals],
);
const leverageFeePreview = useMemo(() => {
if (!isLeverageFeeReady || leverageTransferFee == null) return null;
@@ -345,8 +348,8 @@ export function AddCollateralAndLeverage({
}).format(feeUsdValue);
}, [isLeverageFeeReady, leverageTransferFee, collateralAssetPriceUsd, market.collateralAsset.decimals]);
const swapCollateralOutPreview = useMemo(
- () => formatTokenAmountPreview(quote.flashCollateralAmount, market.collateralAsset.decimals),
- [quote.flashCollateralAmount, market.collateralAsset.decimals],
+ () => formatTokenAmountPreview(quote.flashLegCollateralTokenAmount, market.collateralAsset.decimals),
+ [quote.flashLegCollateralTokenAmount, market.collateralAsset.decimals],
);
const collateralPreviewForDisplay = isSwapRoute && !useLoanAssetInput ? swapCollateralOutPreview : totalCollateralAddedPreview;
const collateralPreviewLabel = isSwapRoute
@@ -354,12 +357,12 @@ export function AddCollateralAndLeverage({
? 'Total Collateral Added (Min.)'
: 'Collateral From Swap (Min.)'
: 'Total Collateral Added';
- const hasExecutableInputConversion = useMemo(() => {
+ const hasExecutableInitialCapitalConversion = useMemo(() => {
if (!useLoanAssetInput) return true;
- if (isSwapRoute) return quote.totalAddedCollateral > 0n;
- if (isErc4626Route) return quote.initialCollateralAmount > 0n;
+ if (isSwapRoute) return quote.totalCollateralTokenAmountAdded > 0n;
+ if (isErc4626Route) return quote.initialCapitalCollateralTokenAmount > 0n;
return false;
- }, [useLoanAssetInput, isSwapRoute, isErc4626Route, quote.totalAddedCollateral, quote.initialCollateralAmount]);
+ }, [useLoanAssetInput, isSwapRoute, isErc4626Route, quote.totalCollateralTokenAmountAdded, quote.initialCapitalCollateralTokenAmount]);
const swapRatePreviewText = useMemo(() => {
if (!isSwapRoute || !quote.swapPriceRoute) return null;
@@ -416,9 +419,9 @@ export function AddCollateralAndLeverage({
}, [isErc4626Route, vaultRateInsight.borrowApy3d, market.state.borrowApy]);
const projectedBorrowApy = useMemo(() => {
if (!hasChanges) return null;
- const preview = previewMarketState(market, undefined, quote.flashLoanAmount);
+ const preview = previewMarketState(market, undefined, quote.flashLoanAssetAmount);
return preview?.borrowApy ?? null;
- }, [hasChanges, market, quote.flashLoanAmount]);
+ }, [hasChanges, market, quote.flashLoanAssetAmount]);
const previewBorrowApy = projectedBorrowApy ?? fallbackBorrowApy;
const borrowRatePreviewLabel = projectedBorrowApy != null ? `Borrow ${rateLabel} (Est.)` : `Borrow ${rateLabel}`;
const vaultTokenApy = isErc4626Route ? vaultRateInsight.vaultApy3d : 0;
@@ -495,24 +498,25 @@ export function AddCollateralAndLeverage({
if (addedCollateralAssets <= 0n) return null;
if (isSwapRoute && useLoanAssetInput) {
- const totalLoanInput = collateralAmount + addedBorrowAssets;
+ const totalLoanInput = initialCapitalInputAmount + addedBorrowAssets;
if (totalLoanInput <= 0n) return null;
- const contributedFromLoanInput = (addedCollateralAssets * collateralAmount) / totalLoanInput;
+ const contributedFromLoanInput = (addedCollateralAssets * initialCapitalInputAmount) / totalLoanInput;
return contributedFromLoanInput > 0n ? contributedFromLoanInput : null;
}
- if (quote.totalAddedCollateral <= 0n || quote.initialCollateralAmount <= 0n) return null;
+ if (quote.totalCollateralTokenAmountAdded <= 0n || quote.initialCapitalCollateralTokenAmount <= 0n) return null;
- const contributedFromInitialCollateral = (addedCollateralAssets * quote.initialCollateralAmount) / quote.totalAddedCollateral;
+ const contributedFromInitialCollateral =
+ (addedCollateralAssets * quote.initialCapitalCollateralTokenAmount) / quote.totalCollateralTokenAmountAdded;
return contributedFromInitialCollateral > 0n ? contributedFromInitialCollateral : null;
}, [
addedBorrowAssets,
addedCollateralAssets,
- collateralAmount,
+ initialCapitalInputAmount,
isSwapRoute,
- quote.initialCollateralAmount,
- quote.totalAddedCollateral,
+ quote.initialCapitalCollateralTokenAmount,
+ quote.totalCollateralTokenAmountAdded,
useLoanAssetInput,
]);
const holdRewardsApy = useMemo(() => {
@@ -616,9 +620,7 @@ export function AddCollateralAndLeverage({
-
- {useLoanAssetInput ? `Start with ${market.loanAsset.symbol}` : `Add Collateral ${market.collateralAsset.symbol}`}
-
+
Initial Capital
{canUseLoanAssetInput && (
@@ -881,13 +879,13 @@ export function AddCollateralAndLeverage({
isLoading={isLoadingPermit2 || leveragePending || quote.isLoading}
disabled={
route == null ||
- collateralInputError !== null ||
+ initialCapitalInputError !== null ||
quote.error !== null ||
- collateralAmount <= 0n ||
+ initialCapitalInputAmount <= 0n ||
!isBundlerAuthorizationReady ||
- !hasExecutableInputConversion ||
+ !hasExecutableInitialCapitalConversion ||
!isLeverageFeeReady ||
- quote.flashLoanAmount <= 0n ||
+ quote.flashLoanAssetAmount <= 0n ||
projectedOverLimit ||
insufficientLiquidity
}
diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx
index 6658cd39..346d4323 100644
--- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx
+++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx
@@ -13,11 +13,7 @@ import { useDeleverageQuote } from '@/hooks/useDeleverageQuote';
import { useDeleverageTransaction } from '@/hooks/useDeleverageTransaction';
import type { Market, MarketPosition } from '@/utils/types';
import type { LeverageRoute } from '@/hooks/leverage/types';
-import {
- computeLtv,
- formatLtvPercent,
- getLTVColor,
-} from '@/modals/borrow/components/helpers';
+import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers';
import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card';
type RemoveCollateralAndDeleverageProps = {