Skip to content
Closed
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
54 changes: 50 additions & 4 deletions src/features/swap/api/velora.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -52,6 +52,9 @@ export type FetchVeloraPriceRouteParams = {
network: number;
userAddress: Address;
partner?: string;
partnerAddress?: Address;
partnerFeeBps?: number;
chargeFee?: boolean;
side?: VeloraSwapSide;
};

Expand All @@ -66,6 +69,9 @@ export type BuildVeloraTransactionPayloadParams = {
priceRoute: VeloraPriceRoute;
slippageBps: number;
partner?: string;
partnerAddress?: Address;
partnerFeeBps?: number;
chargeFee?: boolean;
ignoreChecks?: boolean;
};

Expand All @@ -80,6 +86,9 @@ export type PrepareVeloraSwapPayloadParams = {
slippageBps: number;
side?: VeloraSwapSide;
partner?: string;
partnerAddress?: Address;
partnerFeeBps?: number;
chargeFee?: boolean;
ignoreChecks?: boolean;
};

Expand Down Expand Up @@ -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;
};
Comment on lines +141 to +147
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/swap/api/velora.ts | sed -n '130,155p'

Repository: antoncoding/monarch

Length of output: 1084


🏁 Script executed:

# Check for any existing safe-integer checks or bps constraints in the codebase
rg -n "isSafeInteger|MAX.*BPS|PARTNER.*FEE" src/features/swap/

Repository: antoncoding/monarch

Length of output: 747


🏁 Script executed:

# Look at the full normalizePartnerFeeBps context and its call sites
rg -B5 -A10 "const normalizePartnerFeeBps" src/features/swap/api/velora.ts

Repository: antoncoding/monarch

Length of output: 699


🏁 Script executed:

# Check what value range is typical for partner fee bps in configs/constants
rg -n "PARTNER.*FEE|partnerFeeBps" src/features/swap/ -A2 | head -40

Repository: antoncoding/monarch

Length of output: 2219


Harden partner fee validation: use isSafeInteger and upper bound.

Number.isInteger alone misses precision constraints. Use Number.isSafeInteger and add an upper bound check (bps cannot exceed 10,000).

Suggested fix
 const normalizePartnerFeeBps = (partnerFeeBps: number): number => {
-  if (!Number.isInteger(partnerFeeBps) || partnerFeeBps < 0) {
+  const MAX_PARTNER_FEE_BPS = 10_000;
+  if (!Number.isSafeInteger(partnerFeeBps) || partnerFeeBps < 0 || partnerFeeBps > MAX_PARTNER_FEE_BPS) {
     throw new VeloraApiError('Velora partner fee bps must be a non-negative integer', 400, { partnerFeeBps });
   }
 
   return partnerFeeBps;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 normalizePartnerFeeBps = (partnerFeeBps: number): number => {
const MAX_PARTNER_FEE_BPS = 10_000;
if (!Number.isSafeInteger(partnerFeeBps) || partnerFeeBps < 0 || partnerFeeBps > MAX_PARTNER_FEE_BPS) {
throw new VeloraApiError('Velora partner fee bps must be a non-negative integer', 400, { partnerFeeBps });
}
return partnerFeeBps;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/swap/api/velora.ts` around lines 138 - 144, The partner fee
validation in normalizePartnerFeeBps is too weak—replace Number.isInteger check
with Number.isSafeInteger and add an upper bound so partnerFeeBps is between 0
and 10,000 inclusive; on validation failure continue to throw VeloraApiError
with the same message/status but include the invalid value in the metadata
(partnerFeeBps) and use the new validation logic in the normalizePartnerFeeBps
function.


const fetchVeloraJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
try {
const response = await fetch(url, init);
Expand Down Expand Up @@ -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<VeloraPriceRoute> => {
const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken);
Expand All @@ -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<VeloraPriceResponse | null>(`${VELORA_API_BASE_URL}/prices?${query.toString()}`, {
method: 'GET',
});
Expand Down Expand Up @@ -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<VeloraTransactionPayload> => {
if (srcAmount <= 0n) {
Expand Down Expand Up @@ -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<string, unknown> = {
srcToken,
srcDecimals,
destToken,
Expand All @@ -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<VeloraBuildTransactionResponse | null>(transactionUrl, {
method: 'POST',
headers: {
Expand Down Expand Up @@ -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') {
Expand All @@ -449,6 +489,9 @@ export const prepareVeloraSwapPayload = async ({
userAddress,
side: 'SELL',
partner,
partnerAddress,
partnerFeeBps,
chargeFee,
});

const txPayload = await buildVeloraTransactionPayload({
Expand All @@ -462,6 +505,9 @@ export const prepareVeloraSwapPayload = async ({
priceRoute,
slippageBps,
partner,
partnerAddress,
partnerFeeBps,
chargeFee,
ignoreChecks,
});

Expand Down
108 changes: 89 additions & 19 deletions src/features/swap/components/SwapModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +42,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp
const [amount, setAmount] = useState<bigint>(BigInt(0));
const [slippage, setSlippage] = useState<number>(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';

Expand Down Expand Up @@ -146,7 +150,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp
sourceToken,
targetToken,
amount,
slippageBps: slippagePercentToBps(slippage),
slippageBps: swapSlippageBps,
onSwapConfirmed: handleSwapConfirmed,
});

Expand Down Expand Up @@ -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 (
<Modal
isOpen={isOpen}
Expand Down Expand Up @@ -361,28 +379,80 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp
triggerVariant="inline"
/>
}
footer={
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-secondary">
{ratePreviewText && (
<button
type="button"
onClick={() => setIsRateInverted((prev) => !prev)}
className="inline-flex shrink-0 rounded p-0.5 transition hover:bg-surface hover:text-primary"
aria-label="Swap price direction"
>
<IoIosSwap className="h-3.5 w-3.5" />
</button>
footer={null}
/>

{/* Transaction Preview */}
<div className="rounded border border-white/10 bg-hovered px-3 py-2.5">
<p className="mb-2 text-xs uppercase tracking-[0.14em] text-secondary">TRANSACTION PREVIEW</p>
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="text-secondary">Est. Receive</span>
{!targetToken || !zeroReceivePreview ? (
<span className="text-right">-</span>
) : isQuoting ? (
<span className="text-right">Quoting...</span>
) : (
<span className="tabular-nums inline-flex items-center gap-1.5">
<Tooltip content={<span className="text-xs">{(receivePreview ?? zeroReceivePreview).full}</span>}>
<span className="cursor-help border-b border-dotted border-white/40">
{(receivePreview ?? zeroReceivePreview).compact}
</span>
</Tooltip>
<TokenIcon
address={targetToken.address}
chainId={targetToken.chainId}
symbol={targetToken.symbol}
width={14}
height={14}
/>
</span>
)}
<span className="truncate">{ratePreviewText}</span>
</div>
}
/>

{/* Slippage */}
<div className="pt-2">
<div className="rounded bg-hovered px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-secondary">Min Receive</span>
{!targetToken || !zeroReceivePreview ? (
<span className="text-right">-</span>
) : isQuoting ? (
<span className="text-right">Quoting...</span>
) : (
<span className="tabular-nums inline-flex items-center gap-1.5">
<Tooltip content={<span className="text-xs">{(minReceivePreview ?? zeroReceivePreview).full}</span>}>
<span className="cursor-help border-b border-dotted border-white/40">
{(minReceivePreview ?? zeroReceivePreview).compact}
</span>
</Tooltip>
<TokenIcon
address={targetToken.address}
chainId={targetToken.chainId}
symbol={targetToken.symbol}
width={14}
height={14}
/>
</span>
)}
</div>

<div className="flex items-center justify-between gap-3">
<span className="text-secondary">Swap Quote</span>
<span className="inline-flex items-center gap-1.5 text-right">
{ratePreviewText && (
<button
type="button"
onClick={() => setIsRateInverted((prev) => !prev)}
className="inline-flex shrink-0 rounded p-0.5 transition hover:bg-surface hover:text-primary"
aria-label="Swap price direction"
>
<IoIosSwap className="h-3.5 w-3.5" />
</button>
)}
<span>{ratePreviewText ?? '-'}</span>
</span>
</div>

<div className="flex items-center justify-between text-xs">
<span className="text-secondary">Max slippage</span>
<span className="text-secondary">Max Slippage</span>
<SlippageInlineEditor
value={slippage}
onChange={setSlippage}
Expand Down
13 changes: 13 additions & 0 deletions src/features/swap/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
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;

/**
* Velora partner fee in basis points. Velora enforces integer bps values.
*/
export const SWAP_PARTNER_FEE_BPS = 1;

/**
* Velora API base URL
*/
Expand Down
5 changes: 5 additions & 0 deletions src/features/swap/hooks/useVeloraSwap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type UseVeloraSwapReturn = {
};

const QUOTE_DEBOUNCE_MS = 800;
const CHARGE_SWAP_FEE = true;

const parseErrorMessage = (err: unknown): string => {
return toUserFacingTransactionErrorMessage(err, 'An unknown error occurred');
Expand Down Expand Up @@ -103,6 +104,7 @@ export function useVeloraSwap({
amount,
network: sourceToken.chainId,
userAddress: account,
chargeFee: CHARGE_SWAP_FEE,
});

const buyAmount = BigInt(nextPriceRoute.destAmount);
Expand Down Expand Up @@ -146,6 +148,7 @@ export function useVeloraSwap({
userAddress: account,
priceRoute: activePriceRoute,
slippageBps,
chargeFee: CHARGE_SWAP_FEE,
});
} catch (buildError: unknown) {
if (!isVeloraRateChangedError(buildError)) {
Expand All @@ -160,6 +163,7 @@ export function useVeloraSwap({
amount,
network: sourceToken.chainId,
userAddress: account,
chargeFee: CHARGE_SWAP_FEE,
});
activePriceRoute = refreshedRoute;
setPriceRoute(refreshedRoute);
Expand All @@ -184,6 +188,7 @@ export function useVeloraSwap({
userAddress: account,
priceRoute: activePriceRoute,
slippageBps,
chargeFee: CHARGE_SWAP_FEE,
});
}

Expand Down
4 changes: 4 additions & 0 deletions src/hooks/useDeleverageQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +111,7 @@ export function useDeleverageQuote({
amount: withdrawCollateralAmount,
network: chainId,
userAddress: swapExecutionAddress as `0x${string}`,
chargeFee: CHARGE_SWAP_FEE,
side: 'SELL',
});

Expand Down Expand Up @@ -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);
Expand Down
Loading