diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index 392e8b21..7563ec99 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -1,6 +1,6 @@ import { isAddress, isHex, type Address } from 'viem'; import { toCanonicalTokenAddress } from '@/types/token'; -import { SWAP_PARTNER, VELORA_API_BASE_URL, VELORA_PRICES_API_VERSION } from '../constants'; +import { SWAP_PARTNER, SWAP_PARTNER_ADDRESS, SWAP_PARTNER_FEE_BPS, VELORA_API_BASE_URL, VELORA_PRICES_API_VERSION } from '../constants'; export type VeloraSwapSide = 'SELL' | 'BUY'; @@ -52,6 +52,9 @@ export type FetchVeloraPriceRouteParams = { network: number; userAddress: Address; partner?: string; + partnerAddress?: Address; + partnerFeeBps?: number; + chargeFee?: boolean; side?: VeloraSwapSide; }; @@ -66,6 +69,9 @@ export type BuildVeloraTransactionPayloadParams = { priceRoute: VeloraPriceRoute; slippageBps: number; partner?: string; + partnerAddress?: Address; + partnerFeeBps?: number; + chargeFee?: boolean; ignoreChecks?: boolean; }; @@ -80,6 +86,9 @@ export type PrepareVeloraSwapPayloadParams = { slippageBps: number; side?: VeloraSwapSide; partner?: string; + partnerAddress?: Address; + partnerFeeBps?: number; + chargeFee?: boolean; ignoreChecks?: boolean; }; @@ -129,6 +138,14 @@ const getVeloraApiErrorMessage = (payload: unknown, fallbackMessage: string): st return message === 'Unknown Velora API error' ? fallbackMessage : message; }; +const normalizePartnerFeeBps = (partnerFeeBps: number): number => { + if (!Number.isInteger(partnerFeeBps) || partnerFeeBps < 0) { + throw new VeloraApiError('Velora partner fee bps must be a non-negative integer', 400, { partnerFeeBps }); + } + + return partnerFeeBps; +}; + const fetchVeloraJson = async (url: string, init?: RequestInit): Promise => { try { const response = await fetch(url, init); @@ -261,6 +278,9 @@ export const fetchVeloraPriceRoute = async ({ network, userAddress, partner = SWAP_PARTNER, + partnerAddress = SWAP_PARTNER_ADDRESS, + partnerFeeBps = SWAP_PARTNER_FEE_BPS, + chargeFee = true, side = 'SELL', }: FetchVeloraPriceRouteParams): Promise => { const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken); @@ -282,10 +302,16 @@ export const fetchVeloraPriceRoute = async ({ side, network: network.toString(), userAddress, - partner, version: VELORA_PRICES_API_VERSION, }); + if (chargeFee) { + const effectivePartnerFeeBps = normalizePartnerFeeBps(partnerFeeBps); + query.set('partner', partner); + query.set('partnerAddress', partnerAddress); + query.set('partnerFeeBps', effectivePartnerFeeBps.toString()); + } + const response = await fetchVeloraJson(`${VELORA_API_BASE_URL}/prices?${query.toString()}`, { method: 'GET', }); @@ -328,6 +354,9 @@ export const buildVeloraTransactionPayload = async ({ priceRoute, slippageBps, partner = SWAP_PARTNER, + partnerAddress = SWAP_PARTNER_ADDRESS, + partnerFeeBps = SWAP_PARTNER_FEE_BPS, + chargeFee = true, ignoreChecks = false, }: BuildVeloraTransactionPayloadParams): Promise => { if (srcAmount <= 0n) { @@ -383,7 +412,7 @@ export const buildVeloraTransactionPayload = async ({ query.size > 0 ? `${VELORA_API_BASE_URL}/transactions/${network}?${query.toString()}` : `${VELORA_API_BASE_URL}/transactions/${network}`; - const requestBody = { + const requestBody: Record = { srcToken, srcDecimals, destToken, @@ -393,9 +422,17 @@ export const buildVeloraTransactionPayload = async ({ slippage: slippageBps, priceRoute, userAddress, - partner, }; + if (chargeFee) { + const effectivePartnerFeeBps = normalizePartnerFeeBps(partnerFeeBps); + requestBody.partner = partner; + requestBody.partnerAddress = partnerAddress; + requestBody.partnerFeeBps = effectivePartnerFeeBps; + requestBody.isDirectFeeTransfer = true; + requestBody.takeSurplus = true; + } + const response = await fetchVeloraJson(transactionUrl, { method: 'POST', headers: { @@ -433,6 +470,9 @@ export const prepareVeloraSwapPayload = async ({ slippageBps, side = 'SELL', partner = SWAP_PARTNER, + partnerAddress = SWAP_PARTNER_ADDRESS, + partnerFeeBps = SWAP_PARTNER_FEE_BPS, + chargeFee = true, ignoreChecks = false, }: PrepareVeloraSwapPayloadParams): Promise<{ priceRoute: VeloraPriceRoute; txPayload: VeloraTransactionPayload }> => { if (side !== 'SELL') { @@ -449,6 +489,9 @@ export const prepareVeloraSwapPayload = async ({ userAddress, side: 'SELL', partner, + partnerAddress, + partnerFeeBps, + chargeFee, }); const txPayload = await buildVeloraTransactionPayload({ @@ -462,6 +505,9 @@ export const prepareVeloraSwapPayload = async ({ priceRoute, slippageBps, partner, + partnerAddress, + partnerFeeBps, + chargeFee, ignoreChecks, }); diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index ad15f8f7..17188982 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -8,10 +8,13 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/ import { Button } from '@/components/ui/button'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { Tooltip } from '@/components/ui/tooltip'; import { useUserBalancesQuery } from '@/hooks/queries/useUserBalancesQuery'; import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAllowance } from '@/hooks/useAllowance'; +import { formatTokenAmountPreview, withSlippageFloor } from '@/hooks/leverage/math'; import { formatBalance } from '@/utils/balance'; import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { formatCompactTokenAmount } from '@/utils/token-amount-format'; @@ -39,6 +42,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp const [amount, setAmount] = useState(BigInt(0)); const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); const [isRateInverted, setIsRateInverted] = useState(false); + const swapSlippageBps = useMemo(() => slippagePercentToBps(slippage), [slippage]); const amountInputClassName = 'h-10 w-full rounded bg-hovered px-3 pr-44 text-lg font-medium tabular-nums focus:border-primary focus:outline-none'; @@ -146,7 +150,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp sourceToken, targetToken, amount, - slippageBps: slippagePercentToBps(slippage), + slippageBps: swapSlippageBps, onSwapConfirmed: handleSwapConfirmed, }); @@ -287,6 +291,20 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }); }, [quote, sourceToken, targetToken, error, chainsMatch, isRateInverted]); + const receivePreview = useMemo(() => { + if (!quote || !targetToken || error || !chainsMatch) return null; + return formatTokenAmountPreview(quote.buyAmount, targetToken.decimals); + }, [quote, targetToken, error, chainsMatch]); + + const minReceivePreview = useMemo(() => { + if (!quote || !targetToken || error || !chainsMatch) return null; + return formatTokenAmountPreview(withSlippageFloor(quote.buyAmount, swapSlippageBps), targetToken.decimals); + }, [quote, targetToken, error, chainsMatch, swapSlippageBps]); + const zeroReceivePreview = useMemo(() => { + if (!targetToken) return null; + return formatTokenAmountPreview(0n, targetToken.decimals); + }, [targetToken]); + return ( } - footer={ -
- {ratePreviewText && ( - + footer={null} + /> + + {/* Transaction Preview */} +
+

TRANSACTION PREVIEW

+
+
+ Est. Receive + {!targetToken || !zeroReceivePreview ? ( + - + ) : isQuoting ? ( + Quoting... + ) : ( + + {(receivePreview ?? zeroReceivePreview).full}}> + + {(receivePreview ?? zeroReceivePreview).compact} + + + + )} - {ratePreviewText}
- } - /> - {/* Slippage */} -
-
+
+ Min Receive + {!targetToken || !zeroReceivePreview ? ( + - + ) : isQuoting ? ( + Quoting... + ) : ( + + {(minReceivePreview ?? zeroReceivePreview).full}}> + + {(minReceivePreview ?? zeroReceivePreview).compact} + + + + + )} +
+ +
+ Swap Quote + + {ratePreviewText && ( + + )} + {ratePreviewText ?? '-'} + +
+
- Max slippage + Max Slippage { return toUserFacingTransactionErrorMessage(err, 'An unknown error occurred'); @@ -103,6 +104,7 @@ export function useVeloraSwap({ amount, network: sourceToken.chainId, userAddress: account, + chargeFee: CHARGE_SWAP_FEE, }); const buyAmount = BigInt(nextPriceRoute.destAmount); @@ -146,6 +148,7 @@ export function useVeloraSwap({ userAddress: account, priceRoute: activePriceRoute, slippageBps, + chargeFee: CHARGE_SWAP_FEE, }); } catch (buildError: unknown) { if (!isVeloraRateChangedError(buildError)) { @@ -160,6 +163,7 @@ export function useVeloraSwap({ amount, network: sourceToken.chainId, userAddress: account, + chargeFee: CHARGE_SWAP_FEE, }); activePriceRoute = refreshedRoute; setPriceRoute(refreshedRoute); @@ -184,6 +188,7 @@ export function useVeloraSwap({ userAddress: account, priceRoute: activePriceRoute, slippageBps, + chargeFee: CHARGE_SWAP_FEE, }); } diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index 36c3c407..56b2b8fa 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -6,6 +6,8 @@ import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/ap import { withSlippageCeil, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; +const CHARGE_SWAP_FEE = true; + type UseDeleverageQuoteParams = { chainId: number; route: LeverageRoute | null; @@ -109,6 +111,7 @@ export function useDeleverageQuote({ amount: withdrawCollateralAmount, network: chainId, userAddress: swapExecutionAddress as `0x${string}`, + chargeFee: CHARGE_SWAP_FEE, side: 'SELL', }); @@ -148,6 +151,7 @@ export function useDeleverageQuote({ amount: bufferedBorrowAssets, network: chainId, userAddress: swapExecutionAddress as `0x${string}`, + chargeFee: CHARGE_SWAP_FEE, side: 'BUY', }); const quotedDebtCloseAmount = BigInt(buyRoute.destAmount); diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index 9d05c355..f33d5a2e 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -21,6 +21,8 @@ import { withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; import { computeMaxSharePriceE27, isVeloraBypassablePrecheckError } from './leverage/velora-precheck'; +const CHARGE_SWAP_FEE = true; + export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx' | 'execute'; type UseDeleverageTransactionProps = { @@ -245,6 +247,7 @@ export function useDeleverageTransaction({ userAddress: swapExecutionAddress, priceRoute: activePriceRoute, slippageBps, + chargeFee: CHARGE_SWAP_FEE, ignoreChecks, }); diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts index d6fc6075..18caafcd 100644 --- a/src/hooks/useLeverageQuote.ts +++ b/src/hooks/useLeverageQuote.ts @@ -6,6 +6,8 @@ import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/ap import { computeFlashCollateralAmount, computeLeveragedExtraAmount, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; +const CHARGE_SWAP_FEE = true; + type UseLeverageQuoteParams = { chainId: number; route: LeverageRoute | null; @@ -121,6 +123,7 @@ export function useLeverageQuote({ amount: targetFlashCollateralAmount, network: chainId, userAddress: swapExecutionAddress as `0x${string}`, + chargeFee: CHARGE_SWAP_FEE, side: 'BUY', }); @@ -141,6 +144,7 @@ export function useLeverageQuote({ amount: borrowAssets, network: chainId, userAddress: swapExecutionAddress as `0x${string}`, + chargeFee: CHARGE_SWAP_FEE, side: 'SELL', }); if (BigInt(sellRoute.srcAmount) !== borrowAssets) { @@ -192,6 +196,7 @@ export function useLeverageQuote({ amount: totalLoanSellAmount, network: chainId, userAddress: swapExecutionAddress as `0x${string}`, + chargeFee: CHARGE_SWAP_FEE, side: 'SELL', }); if (BigInt(sellRoute.srcAmount) !== totalLoanSellAmount) { diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 9073e96d..76bc46c4 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -28,6 +28,8 @@ import { computeBorrowSharesWithBuffer, withSlippageFloor } from './leverage/mat import type { LeverageRoute } from './leverage/types'; import { isVeloraBypassablePrecheckError } from './leverage/velora-precheck'; +const CHARGE_SWAP_FEE = true; + export type LeverageStepType = | 'approve_permit2' | 'authorize_bundler_sig' @@ -398,6 +400,7 @@ export function useLeverageTransaction({ userAddress: swapExecutionAddress, priceRoute: activePriceRoute, slippageBps, + chargeFee: CHARGE_SWAP_FEE, ignoreChecks, });