From 9344fd17bff76c7fba8e6c4b0212dc757639b15b Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Thu, 5 Mar 2026 17:42:16 +0800 Subject: [PATCH 1/4] feat: add Velora partner recipient and fee defaults --- src/features/swap/api/velora.ts | 34 ++++++++++++++++++++++++++++++++- src/features/swap/constants.ts | 15 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index 392e8b21..b56bc6fb 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,8 @@ export type FetchVeloraPriceRouteParams = { network: number; userAddress: Address; partner?: string; + partnerAddress?: Address; + partnerFeeBps?: number; side?: VeloraSwapSide; }; @@ -66,6 +68,8 @@ export type BuildVeloraTransactionPayloadParams = { priceRoute: VeloraPriceRoute; slippageBps: number; partner?: string; + partnerAddress?: Address; + partnerFeeBps?: number; ignoreChecks?: boolean; }; @@ -80,6 +84,8 @@ export type PrepareVeloraSwapPayloadParams = { slippageBps: number; side?: VeloraSwapSide; partner?: string; + partnerAddress?: Address; + partnerFeeBps?: number; ignoreChecks?: boolean; }; @@ -129,6 +135,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 +275,8 @@ export const fetchVeloraPriceRoute = async ({ network, userAddress, partner = SWAP_PARTNER, + partnerAddress = SWAP_PARTNER_ADDRESS, + partnerFeeBps = SWAP_PARTNER_FEE_BPS, side = 'SELL', }: FetchVeloraPriceRouteParams): Promise => { const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken); @@ -273,6 +289,8 @@ export const fetchVeloraPriceRoute = async ({ }); } + const effectivePartnerFeeBps = normalizePartnerFeeBps(partnerFeeBps); + const query = new URLSearchParams({ srcToken, destToken, @@ -283,6 +301,8 @@ export const fetchVeloraPriceRoute = async ({ network: network.toString(), userAddress, partner, + partnerAddress, + partnerFeeBps: effectivePartnerFeeBps.toString(), version: VELORA_PRICES_API_VERSION, }); @@ -328,6 +348,8 @@ export const buildVeloraTransactionPayload = async ({ priceRoute, slippageBps, partner = SWAP_PARTNER, + partnerAddress = SWAP_PARTNER_ADDRESS, + partnerFeeBps = SWAP_PARTNER_FEE_BPS, ignoreChecks = false, }: BuildVeloraTransactionPayloadParams): Promise => { if (srcAmount <= 0n) { @@ -374,6 +396,8 @@ export const buildVeloraTransactionPayload = async ({ }); } + const effectivePartnerFeeBps = normalizePartnerFeeBps(partnerFeeBps); + const query = new URLSearchParams(); if (ignoreChecks) { query.set('ignoreChecks', 'true'); @@ -394,6 +418,8 @@ export const buildVeloraTransactionPayload = async ({ priceRoute, userAddress, partner, + partnerAddress, + partnerFeeBps: effectivePartnerFeeBps, }; const response = await fetchVeloraJson(transactionUrl, { @@ -433,6 +459,8 @@ export const prepareVeloraSwapPayload = async ({ slippageBps, side = 'SELL', partner = SWAP_PARTNER, + partnerAddress = SWAP_PARTNER_ADDRESS, + partnerFeeBps = SWAP_PARTNER_FEE_BPS, ignoreChecks = false, }: PrepareVeloraSwapPayloadParams): Promise<{ priceRoute: VeloraPriceRoute; txPayload: VeloraTransactionPayload }> => { if (side !== 'SELL') { @@ -449,6 +477,8 @@ export const prepareVeloraSwapPayload = async ({ userAddress, side: 'SELL', partner, + partnerAddress, + partnerFeeBps, }); const txPayload = await buildVeloraTransactionPayload({ @@ -462,6 +492,8 @@ export const prepareVeloraSwapPayload = async ({ priceRoute, slippageBps, partner, + partnerAddress, + partnerFeeBps, ignoreChecks, }); diff --git a/src/features/swap/constants.ts b/src/features/swap/constants.ts index 31742f4e..60badc30 100644 --- a/src/features/swap/constants.ts +++ b/src/features/swap/constants.ts @@ -1,8 +1,23 @@ +import { MONARCH_FEE_RECIPIENT } from '@/config/smart-rebalance'; + /** * Application identifier for Velora integration */ export const SWAP_PARTNER = 'monarchlend'; +/** + * Recipient for Velora partner fees. + * Reuses Monarch's existing fee recipient configuration. + */ +export const SWAP_PARTNER_ADDRESS = MONARCH_FEE_RECIPIENT; + +/** + * Desired Velora partner fee is 0.3 bps (0.003%), but the API only accepts integer bps. + * We use the nearest safe supported integer value (floor), which is 0 bps. + */ +export const SWAP_PARTNER_TARGET_FEE_BPS = 0.3; +export const SWAP_PARTNER_FEE_BPS = Math.floor(SWAP_PARTNER_TARGET_FEE_BPS); + /** * Velora API base URL */ From d57396e159b03b4234cfe6bb94e148ea998ba02a Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Thu, 5 Mar 2026 18:00:14 +0800 Subject: [PATCH 2/4] feat: enable direct transfer mode for Velora partner fee --- src/features/swap/api/velora.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index b56bc6fb..131182ef 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -420,6 +420,7 @@ export const buildVeloraTransactionPayload = async ({ partner, partnerAddress, partnerFeeBps: effectivePartnerFeeBps, + isDirectFeeTransfer: true, }; const response = await fetchVeloraJson(transactionUrl, { From 6dcd15177a91228ebe31e61fdfac5ff9761a65b2 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 5 Mar 2026 18:50:10 +0800 Subject: [PATCH 3/4] chore: fee --- src/features/swap/api/velora.ts | 37 ++++--- src/features/swap/components/SwapModal.tsx | 108 +++++++++++++++++---- src/features/swap/constants.ts | 6 +- src/features/swap/hooks/useVeloraSwap.ts | 5 + src/hooks/useDeleverageQuote.ts | 4 + src/hooks/useDeleverageTransaction.ts | 3 + src/hooks/useLeverageQuote.ts | 5 + src/hooks/useLeverageTransaction.ts | 3 + 8 files changed, 136 insertions(+), 35 deletions(-) diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index 131182ef..7563ec99 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -54,6 +54,7 @@ export type FetchVeloraPriceRouteParams = { partner?: string; partnerAddress?: Address; partnerFeeBps?: number; + chargeFee?: boolean; side?: VeloraSwapSide; }; @@ -70,6 +71,7 @@ export type BuildVeloraTransactionPayloadParams = { partner?: string; partnerAddress?: Address; partnerFeeBps?: number; + chargeFee?: boolean; ignoreChecks?: boolean; }; @@ -86,6 +88,7 @@ export type PrepareVeloraSwapPayloadParams = { partner?: string; partnerAddress?: Address; partnerFeeBps?: number; + chargeFee?: boolean; ignoreChecks?: boolean; }; @@ -277,6 +280,7 @@ export const fetchVeloraPriceRoute = async ({ partner = SWAP_PARTNER, partnerAddress = SWAP_PARTNER_ADDRESS, partnerFeeBps = SWAP_PARTNER_FEE_BPS, + chargeFee = true, side = 'SELL', }: FetchVeloraPriceRouteParams): Promise => { const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken); @@ -289,8 +293,6 @@ export const fetchVeloraPriceRoute = async ({ }); } - const effectivePartnerFeeBps = normalizePartnerFeeBps(partnerFeeBps); - const query = new URLSearchParams({ srcToken, destToken, @@ -300,12 +302,16 @@ export const fetchVeloraPriceRoute = async ({ side, network: network.toString(), userAddress, - partner, - partnerAddress, - partnerFeeBps: effectivePartnerFeeBps.toString(), 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', }); @@ -350,6 +356,7 @@ export const buildVeloraTransactionPayload = async ({ partner = SWAP_PARTNER, partnerAddress = SWAP_PARTNER_ADDRESS, partnerFeeBps = SWAP_PARTNER_FEE_BPS, + chargeFee = true, ignoreChecks = false, }: BuildVeloraTransactionPayloadParams): Promise => { if (srcAmount <= 0n) { @@ -396,8 +403,6 @@ export const buildVeloraTransactionPayload = async ({ }); } - const effectivePartnerFeeBps = normalizePartnerFeeBps(partnerFeeBps); - const query = new URLSearchParams(); if (ignoreChecks) { query.set('ignoreChecks', 'true'); @@ -407,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, @@ -417,12 +422,17 @@ export const buildVeloraTransactionPayload = async ({ slippage: slippageBps, priceRoute, userAddress, - partner, - partnerAddress, - partnerFeeBps: effectivePartnerFeeBps, - isDirectFeeTransfer: true, }; + 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: { @@ -462,6 +472,7 @@ export const prepareVeloraSwapPayload = async ({ 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') { @@ -480,6 +491,7 @@ export const prepareVeloraSwapPayload = async ({ partner, partnerAddress, partnerFeeBps, + chargeFee, }); const txPayload = await buildVeloraTransactionPayload({ @@ -495,6 +507,7 @@ export const prepareVeloraSwapPayload = async ({ partner, partnerAddress, partnerFeeBps, + chargeFee, ignoreChecks, }); diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index ad15f8f7..bccaa033 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) return null; + return formatTokenAmountPreview(quote.buyAmount, targetToken.decimals); + }, [quote, targetToken]); + + const minReceivePreview = useMemo(() => { + if (!quote || !targetToken) return null; + return formatTokenAmountPreview(withSlippageFloor(quote.buyAmount, swapSlippageBps), targetToken.decimals); + }, [quote, targetToken, 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, }); From 55c76d2a40a8e76ebaca97453a56920807d669e8 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 5 Mar 2026 19:28:06 +0800 Subject: [PATCH 4/4] chore: review fixes --- src/features/swap/components/SwapModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index bccaa033..17188982 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -292,14 +292,14 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }, [quote, sourceToken, targetToken, error, chainsMatch, isRateInverted]); const receivePreview = useMemo(() => { - if (!quote || !targetToken) return null; + if (!quote || !targetToken || error || !chainsMatch) return null; return formatTokenAmountPreview(quote.buyAmount, targetToken.decimals); - }, [quote, targetToken]); + }, [quote, targetToken, error, chainsMatch]); const minReceivePreview = useMemo(() => { - if (!quote || !targetToken) return null; + if (!quote || !targetToken || error || !chainsMatch) return null; return formatTokenAmountPreview(withSlippageFloor(quote.buyAmount, swapSlippageBps), targetToken.decimals); - }, [quote, targetToken, swapSlippageBps]); + }, [quote, targetToken, error, chainsMatch, swapSlippageBps]); const zeroReceivePreview = useMemo(() => { if (!targetToken) return null; return formatTokenAmountPreview(0n, targetToken.decimals);