Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/hooks/leverage/velora-quote-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { VeloraApiError } from '@/features/swap/api/velora';

const normalizeMessage = (error: unknown): string => {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return '';
};

const extractImpactValue = (error: unknown): string | null => {
if (!(error instanceof VeloraApiError) || !error.details || typeof error.details !== 'object') {
return null;
}

const details = error.details as Record<string, unknown>;
return typeof details.value === 'string' ? details.value : null;
};

const isPairValidationError = (message: string): boolean =>
message.includes('validation failed') && (message.includes('srctoken') || message.includes('desttoken'));

export const toUserFacingVeloraQuoteError = ({ error, action }: { error: unknown; action: 'leverage' | 'deleverage' }): string => {
const rawMessage = normalizeMessage(error);
const message = rawMessage.toLowerCase();

if (message.includes('no routes found with enough liquidity')) {
return 'No swap route is available for this size right now. Try a smaller amount or a different market.';
}

if (message.includes('estimated_loss_greater_than_max_impact') || message.includes('max impact')) {
const impactValue = extractImpactValue(error);
return impactValue
? `Could not find a quote with reasonable impact right now (~${impactValue} impact). Try a smaller amount or a different market.`
: 'Could not find a quote with reasonable impact right now. Try a smaller amount or a different market.';
}

if (message.includes('failed to size velora sell route for target leverage')) {
return 'Could not find a swap route that reaches this size. Try a smaller amount or lower leverage.';
}

if (isPairValidationError(message)) {
return 'This pair is not available through the swap router right now.';
}

if (message.includes('failed to fetch velora api response')) {
return 'Could not reach the swap router. Please try again.';
}

if (!rawMessage) {
return action === 'leverage' ? 'Failed to quote swap-backed leverage route.' : 'Failed to quote swap-backed deleverage route.';
}

return rawMessage;
};
4 changes: 3 additions & 1 deletion src/hooks/useDeleverageQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useReadContract } from 'wagmi';
import { erc4626Abi } from '@/abis/erc4626';
import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora';
import { withSlippageCeil, withSlippageFloor } from './leverage/math';
import { toUserFacingVeloraQuoteError } from './leverage/velora-quote-errors';
import type { LeverageRoute } from './leverage/types';

type UseDeleverageQuoteParams = {
Expand Down Expand Up @@ -135,6 +136,7 @@ export function useDeleverageQuote({
userAddress: swapExecutionAddress as `0x${string}`,
side: 'BUY',
});

const quotedDebtCloseAmount = BigInt(buyRoute.destAmount);
if (quotedDebtCloseAmount !== bufferedBorrowAssets) {
throw new Error('Failed to resolve the exact full-close collateral bound. Refresh the quote and try again.');
Expand Down Expand Up @@ -265,7 +267,7 @@ export function useDeleverageQuote({
}
const routeError = withdrawCollateralAmount > 0n ? swapRepayQuoteQuery.error : null;
if (!routeError) return null;
return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for deleverage.';
return toUserFacingVeloraQuoteError({ error: routeError, action: 'deleverage' });
}
const routeError = erc4626PreviewRedeemError;
if (!routeError) return null;
Expand Down
243 changes: 201 additions & 42 deletions src/hooks/useLeverageQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { useQuery } from '@tanstack/react-query';
import { useReadContract } from 'wagmi';
import { erc4626Abi } from '@/abis/erc4626';
import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora';
import { computeFlashCollateralAmount, computeLeveragedExtraAmount, withSlippageFloor } from './leverage/math';
import { BPS_SCALE, computeFlashCollateralAmount, computeLeveragedExtraAmount, withSlippageFloor } from './leverage/math';
import { toUserFacingVeloraQuoteError } from './leverage/velora-quote-errors';
import type { LeverageRoute } from './leverage/types';

const SELL_QUOTE_TARGET_BUFFER_BPS = 10_020n;
const SELL_QUOTE_MAX_ATTEMPTS = 4;
const shouldLogLeverageQuoteDebug = () => process.env.NODE_ENV !== 'production';

type UseLeverageQuoteParams = {
chainId: number;
route: LeverageRoute | null;
Expand Down Expand Up @@ -50,6 +55,189 @@ export type LeverageQuote = {
swapPriceRoute: VeloraPriceRoute | null;
};

/**
* Collateral-input leverage targets a collateral output, but the executable Velora calldata is exact-in SELL.
* Iterate exact-in SELL quotes to size the loan-token input; earlier quotes are discarded and only the final
* route is submitted for transaction calldata.
*/
const scaleRawAmountCeil = (amount: bigint, fromDecimals: number, toDecimals: number): bigint => {
if (amount <= 0n) return 0n;
if (fromDecimals === toDecimals) return amount;

if (toDecimals > fromDecimals) {
return amount * 10n ** BigInt(toDecimals - fromDecimals);
}

const divisor = 10n ** BigInt(fromDecimals - toDecimals);
return (amount + divisor - 1n) / divisor;
};

const withTargetBuffer = (amount: bigint): bigint => {
if (amount <= 0n) return 1n;
return (amount * SELL_QUOTE_TARGET_BUFFER_BPS + BPS_SCALE - 1n) / BPS_SCALE;
};

const getNextSellAmountForTargetCollateral = ({
currentSellAmount,
quotedCollateralAmount,
targetCollateralAmount,
}: {
currentSellAmount: bigint;
quotedCollateralAmount: bigint;
targetCollateralAmount: bigint;
}): bigint => {
if (quotedCollateralAmount <= 0n) return withTargetBuffer(currentSellAmount * 2n);

const proportionalSellAmount = (currentSellAmount * targetCollateralAmount + quotedCollateralAmount - 1n) / quotedCollateralAmount;
const bufferedSellAmount = withTargetBuffer(proportionalSellAmount);
if (bufferedSellAmount > currentSellAmount) return bufferedSellAmount;

return currentSellAmount + (currentSellAmount / 5n || 1n);
};

const resolveInitialSellAmountForTargetCollateral = async ({
chainId,
loanTokenAddress,
loanTokenDecimals,
collateralTokenAddress,
collateralTokenDecimals,
targetCollateralTokenAmount,
swapExecutionAddress,
}: {
chainId: number;
loanTokenAddress: string;
loanTokenDecimals: number;
collateralTokenAddress: string;
collateralTokenDecimals: number;
targetCollateralTokenAmount: bigint;
swapExecutionAddress: `0x${string}`;
}): Promise<bigint> => {
try {
// Prefer a price-aware bootstrap. When exact-out BUY is available, its required source amount is a
// much better first SELL guess than a raw decimal conversion, especially for routes like USDC -> cbBTC.
const buyRoute = await fetchVeloraPriceRoute({
srcToken: loanTokenAddress,
srcDecimals: loanTokenDecimals,
destToken: collateralTokenAddress,
destDecimals: collateralTokenDecimals,
amount: targetCollateralTokenAmount,
network: chainId,
userAddress: swapExecutionAddress,
side: 'BUY',
});

const initialSellAmount = BigInt(buyRoute.srcAmount);
if (initialSellAmount > 0n && BigInt(buyRoute.destAmount) >= targetCollateralTokenAmount) {
return initialSellAmount;
}
} catch (error) {
if (shouldLogLeverageQuoteDebug()) {
console.info('[leverage quote] Exact-out BUY bootstrap unavailable, falling back to estimated SELL seed', {
chainId,
targetCollateralAmount: targetCollateralTokenAmount.toString(),
error: error instanceof Error ? error.message : String(error),
});
}
}

const estimatedSellAmount = scaleRawAmountCeil(targetCollateralTokenAmount, collateralTokenDecimals, loanTokenDecimals);
return estimatedSellAmount > 0n ? estimatedSellAmount : 1n;
};

const quoteVeloraCollateralInputRoute = async ({
chainId,
loanTokenAddress,
loanTokenDecimals,
collateralTokenAddress,
collateralTokenDecimals,
targetCollateralTokenAmount,
slippageBps,
swapExecutionAddress,
}: {
chainId: number;
loanTokenAddress: string;
loanTokenDecimals: number;
collateralTokenAddress: string;
collateralTokenDecimals: number;
targetCollateralTokenAmount: bigint;
slippageBps: number;
swapExecutionAddress: `0x${string}`;
}): Promise<{
flashLoanAssetAmount: bigint;
flashLegCollateralTokenAmount: bigint;
priceRoute: VeloraPriceRoute;
}> => {
let sellAmount = await resolveInitialSellAmountForTargetCollateral({
chainId,
loanTokenAddress,
loanTokenDecimals,
collateralTokenAddress,
collateralTokenDecimals,
targetCollateralTokenAmount,
swapExecutionAddress,
});

let latestQuote: {
flashLoanAssetAmount: bigint;
flashLegCollateralTokenAmount: bigint;
quotedCollateralTokenAmount: bigint;
priceRoute: VeloraPriceRoute;
} | null = null;

for (let attempt = 0; attempt < SELL_QUOTE_MAX_ATTEMPTS; attempt += 1) {
const sellRoute = await fetchVeloraPriceRoute({
srcToken: loanTokenAddress,
srcDecimals: loanTokenDecimals,
destToken: collateralTokenAddress,
destDecimals: collateralTokenDecimals,
amount: sellAmount,
network: chainId,
userAddress: swapExecutionAddress,
side: 'SELL',
});
const quotedCollateralTokenAmount = BigInt(sellRoute.destAmount);

latestQuote = {
flashLoanAssetAmount: sellAmount,
flashLegCollateralTokenAmount: withSlippageFloor(quotedCollateralTokenAmount, slippageBps),
quotedCollateralTokenAmount,
priceRoute: sellRoute,
};
Comment thread
antoncoding marked this conversation as resolved.

if (quotedCollateralTokenAmount >= targetCollateralTokenAmount) {
break;
}

sellAmount = getNextSellAmountForTargetCollateral({
currentSellAmount: sellAmount,
quotedCollateralAmount: quotedCollateralTokenAmount,
targetCollateralAmount: targetCollateralTokenAmount,
});
}

if (!latestQuote) {
throw new Error('Failed to quote Velora sell route for leverage.');
}
if (latestQuote.quotedCollateralTokenAmount < targetCollateralTokenAmount) {
throw new Error('Failed to size Velora sell route for target leverage. Try a lower multiplier or refresh the quote.');
}
if (shouldLogLeverageQuoteDebug()) {
console.info('[leverage quote] Velora collateral-input route sized', {
chainId,
sellAmount: latestQuote.flashLoanAssetAmount.toString(),
quotedCollateralAmount: latestQuote.quotedCollateralTokenAmount.toString(),
minCollateralAmount: latestQuote.flashLegCollateralTokenAmount.toString(),
targetCollateralAmount: targetCollateralTokenAmount.toString(),
slippageBps,
});
}

return {
flashLoanAssetAmount: latestQuote.flashLoanAssetAmount,
flashLegCollateralTokenAmount: latestQuote.flashLegCollateralTokenAmount,
priceRoute: latestQuote.priceRoute,
};
};
/**
* Converts user leverage intent into deterministic route amounts.
*
Expand Down Expand Up @@ -141,46 +329,17 @@ export function useLeverageQuote({
userAddress ?? null,
],
enabled: route?.kind === 'swap' && !isLoanAssetInput && targetFlashCollateralTokenAmount > 0n && !!userAddress,
queryFn: async () => {
const buyRoute = await fetchVeloraPriceRoute({
srcToken: loanTokenAddress,
srcDecimals: loanTokenDecimals,
destToken: collateralTokenAddress,
destDecimals: collateralTokenDecimals,
amount: targetFlashCollateralTokenAmount,
network: chainId,
userAddress: swapExecutionAddress as `0x${string}`,
side: 'BUY',
});

const borrowAssets = BigInt(buyRoute.srcAmount);
if (borrowAssets <= 0n) {
return {
flashLoanAssetAmount: 0n,
flashLegCollateralTokenAmount: 0n,
priceRoute: null,
};
}

const sellRoute = await fetchVeloraPriceRoute({
srcToken: loanTokenAddress,
srcDecimals: loanTokenDecimals,
destToken: collateralTokenAddress,
destDecimals: collateralTokenDecimals,
amount: borrowAssets,
network: chainId,
userAddress: swapExecutionAddress as `0x${string}`,
side: 'SELL',
});

return {
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,
};
},
queryFn: async () =>
quoteVeloraCollateralInputRoute({
chainId,
loanTokenAddress,
loanTokenDecimals,
collateralTokenAddress,
collateralTokenDecimals,
targetCollateralTokenAmount: targetFlashCollateralTokenAmount,
slippageBps,
swapExecutionAddress: swapExecutionAddress as `0x${string}`,
}),
});

const swapLoanInputCombinedQuoteQuery = useQuery({
Expand Down Expand Up @@ -294,7 +453,7 @@ export function useLeverageQuote({
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.';
return toUserFacingVeloraQuoteError({ error: routeError, action: 'leverage' });
}
const erc4626RouteError = erc4626DepositError ?? erc4626MintError;
if (!erc4626RouteError) return null;
Expand Down