+ }
+ footer={
+ sourceToken ? (
+
+ ) : null
+ }
+ />
{/* Arrow */}
-
+
{/* To Section */}
-
-
To
-
-
{getOutputDisplay()}
+
{getOutputDisplay()}}
+ dropdown={
-
-
- {quote && sourceToken && targetToken && !error && chainsMatch && (
-
- 1 {sourceToken.symbol} ≈{' '}
- {(
- Number(formatUnits(quote.buyAmount, targetToken.decimals)) / Number(formatUnits(quote.sellAmount, sourceToken.decimals))
- ).toFixed(6)}{' '}
- {targetToken.symbol}
+ }
+ footer={
+
+ {ratePreviewText && (
+
+ )}
+ {ratePreviewText}
+
+ }
+ />
+
+ {/* Slippage */}
+
+
+
+
+ {isSettingsOpen && (
+
+
+
+
Max slippage
+
+
+ %
+
+
+
+
+ )}
+
- {/* Chain Mismatch Warning */}
- {showChainMismatch && (
-
-
- Select a source token on {getNetworkName(targetToken.chainId)} to swap
-
-
- )}
-
{/* Error Display */}
- {error && !showChainMismatch && (
+ {error && (
)}
- {/* Success Message */}
- {orderUid && (
-
-
-
- )}
-
{/* Empty State */}
{!balancesLoading && sourceTokens.length === 0 && (
No tokens found on supported chains
-
Supported: Ethereum, Base, Arbitrum
+
Supported: Ethereum, Polygon, Unichain, Base, Arbitrum
)}
@@ -351,20 +517,18 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp
variant="default"
onClick={handleClose}
>
- {orderUid ? 'Close' : 'Cancel'}
+ Cancel
- {!orderUid && (
-
void handleSwap()}
- isLoading={isLoading}
- disabled={!sourceToken || !targetToken || !quote || amount === BigInt(0) || !!error || !chainsMatch}
- variant="primary"
- >
- {needsApproval ? 'Approve & Swap' : 'Swap'}
-
- )}
+
void handleSwap()}
+ isLoading={isLoading}
+ disabled={!sourceToken || !targetToken || !quote || amount === BigInt(0) || !!error || !chainsMatch || !approvalTarget}
+ variant="primary"
+ >
+ {needsApproval ? 'Approve & Swap' : 'Swap'}
+
);
diff --git a/src/features/swap/components/SwapTokenAmountField.tsx b/src/features/swap/components/SwapTokenAmountField.tsx
new file mode 100644
index 00000000..9dee5eb4
--- /dev/null
+++ b/src/features/swap/components/SwapTokenAmountField.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react';
+
+type SwapTokenAmountFieldProps = {
+ label: string;
+ field: ReactNode;
+ dropdown: ReactNode;
+ footer?: ReactNode;
+};
+
+export function SwapTokenAmountField({ label, field, dropdown, footer }: SwapTokenAmountFieldProps) {
+ return (
+
+
{label}
+
+ {footer ?
{footer}
: null}
+
+ );
+}
diff --git a/src/features/swap/components/TokenNetworkDropdown.tsx b/src/features/swap/components/TokenNetworkDropdown.tsx
index 156ef292..dbb66bf5 100644
--- a/src/features/swap/components/TokenNetworkDropdown.tsx
+++ b/src/features/swap/components/TokenNetworkDropdown.tsx
@@ -4,6 +4,7 @@ import { formatUnits } from 'viem';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { TokenIcon } from '@/components/shared/token-icon';
import { NetworkIcon } from '@/components/shared/network-icon';
+import { cn } from '@/utils/components';
import { getNetworkName } from '@/utils/networks';
import type { SwapToken } from '../types';
@@ -15,6 +16,8 @@ type TokenNetworkDropdownProps = {
disabled?: boolean;
/** Optional chain ID to highlight tokens on (e.g., to show matching network) */
highlightChainId?: number;
+ triggerVariant?: 'default' | 'inline';
+ triggerClassName?: string;
};
/**
@@ -28,6 +31,8 @@ export function TokenNetworkDropdown({
placeholder = 'Select',
disabled,
highlightChainId,
+ triggerVariant = 'default',
+ triggerClassName,
}: TokenNetworkDropdownProps) {
const [query, setQuery] = useState('');
@@ -54,7 +59,12 @@ export function TokenNetworkDropdown({
diff --git a/src/features/swap/constants.ts b/src/features/swap/constants.ts
index eb2849bd..1952bb8f 100644
--- a/src/features/swap/constants.ts
+++ b/src/features/swap/constants.ts
@@ -1,7 +1,17 @@
/**
- * Application identifier for CoW Protocol integration
+ * Application identifier for Velora integration
*/
-export const SWAP_APP_CODE = 'monarchlend';
+export const SWAP_PARTNER = 'monarchlend';
+
+/**
+ * Velora API base URL
+ */
+export const VELORA_API_BASE_URL = 'https://api.paraswap.io';
+
+/**
+ * Velora API version for price quote endpoint
+ */
+export const VELORA_PRICES_API_VERSION = '6.2';
/**
* Default slippage tolerance as a percentage (0.5 = 0.5%)
diff --git a/src/features/swap/cowSwapSdk.ts b/src/features/swap/cowSwapSdk.ts
deleted file mode 100644
index d4fed126..00000000
--- a/src/features/swap/cowSwapSdk.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { TradingSdk } from '@cowprotocol/cow-sdk';
-import { SWAP_APP_CODE } from './constants';
-
-/**
- * CoW Protocol Trading SDK for same-chain swaps
- * Handles quotes, order signing, and posting
- */
-export const tradingSdk = new TradingSdk(
- {
- chainId: 1, // Default, will be updated per swap
- appCode: SWAP_APP_CODE,
- },
- {
- enableLogging: false,
- },
-);
diff --git a/src/features/swap/hooks/useCowSwap.ts b/src/features/swap/hooks/useCowSwap.ts
deleted file mode 100644
index 18183cc9..00000000
--- a/src/features/swap/hooks/useCowSwap.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import { useConnection, usePublicClient, useWalletClient } from 'wagmi';
-import { OrderKind, setGlobalAdapter, type QuoteAndPost } from '@cowprotocol/cow-sdk';
-import { ViemAdapter } from '@cowprotocol/sdk-viem-adapter';
-import { tradingSdk } from '../cowSwapSdk';
-import type { SwapQuoteDisplay, SwapToken } from '../types';
-
-type UseCowSwapParams = {
- sourceToken: SwapToken | null;
- targetToken: SwapToken | null;
- amount: bigint;
- slippageBps: number;
-};
-
-type UseCowSwapReturn = {
- quote: SwapQuoteDisplay | null;
- isQuoting: boolean;
- isExecuting: boolean;
- error: string | null;
- orderUid: string | null;
- chainsMatch: boolean;
-
- executeSwap: () => Promise
;
- reset: () => void;
-};
-
-/**
- * Hook for managing CoW Protocol same-chain swaps
- */
-export function useCowSwap({ sourceToken, targetToken, amount, slippageBps }: UseCowSwapParams): UseCowSwapReturn {
- const { address: account } = useConnection();
- const { data: walletClient } = useWalletClient();
- const publicClient = usePublicClient();
-
- const [quote, setQuote] = useState(null);
- const [quoteAndPost, setQuoteAndPost] = useState(null);
- const [isQuoting, setIsQuoting] = useState(false);
- const [isExecuting, setIsExecuting] = useState(false);
- const [error, setError] = useState(null);
- const [orderUid, setOrderUid] = useState(null);
-
- // Check if source and target chains match
- const chainsMatch = sourceToken && targetToken ? sourceToken.chainId === targetToken.chainId : false;
-
- // Bind SDK to wagmi
- useEffect(() => {
- if (!walletClient || !publicClient) return;
-
- try {
- const adapter = new ViemAdapter({
- provider: publicClient,
- walletClient,
- });
-
- setGlobalAdapter(adapter);
-
- if (sourceToken?.chainId) {
- tradingSdk.setTraderParams({ chainId: sourceToken.chainId });
- }
- } catch (err) {
- console.error('Failed to bind SDK to wagmi:', err);
- }
- }, [publicClient, walletClient, sourceToken?.chainId]);
-
- /**
- * Parse error to extract description from CoW Protocol API errors
- */
- const parseErrorDescription = (err: unknown): string => {
- if (err && typeof err === 'object' && 'description' in err && typeof err.description === 'string') {
- return err.description;
- }
- if (err instanceof Error) {
- return err.message;
- }
- return 'An unknown error occurred';
- };
-
- /**
- * Get quote for swap
- */
- const getQuote = useCallback(async () => {
- if (!sourceToken || !targetToken || !account || amount === BigInt(0)) {
- setQuote(null);
- setQuoteAndPost(null);
- return;
- }
-
- // Only fetch quote if chains match
- if (sourceToken.chainId !== targetToken.chainId) {
- setQuote(null);
- setQuoteAndPost(null);
- return;
- }
-
- setIsQuoting(true);
- setError(null);
-
- try {
- // Update SDK chain if needed
- tradingSdk.setTraderParams({ chainId: sourceToken.chainId });
-
- const result = await tradingSdk.getQuote({
- chainId: sourceToken.chainId,
- kind: OrderKind.SELL,
- owner: account,
- amount: amount.toString(),
- sellToken: sourceToken.address,
- sellTokenDecimals: sourceToken.decimals,
- buyToken: targetToken.address,
- buyTokenDecimals: targetToken.decimals,
- slippageBps,
- });
-
- // Store the QuoteAndPost for later execution
- setQuoteAndPost(result);
-
- // Extract display info
- setQuote({
- buyAmount: result.quoteResults.amountsAndCosts.afterNetworkCosts.buyAmount,
- sellAmount: amount,
- });
- } catch (err) {
- console.error('Error fetching quote:', err);
- setError(parseErrorDescription(err));
- setQuote(null);
- setQuoteAndPost(null);
- } finally {
- setIsQuoting(false);
- }
- }, [sourceToken, targetToken, account, amount, slippageBps]);
-
- /**
- * Execute the swap
- */
- const executeSwap = useCallback(async () => {
- if (!quoteAndPost) return;
-
- setIsExecuting(true);
- setError(null);
-
- try {
- const result = await quoteAndPost.postSwapOrderFromQuote({
- appData: {
- metadata: {
- quote: {
- slippageBips: slippageBps,
- },
- },
- },
- });
-
- if (!result) {
- throw new Error('No response from order posting');
- }
-
- setOrderUid(result.orderId);
- } catch (err) {
- setError(parseErrorDescription(err));
- } finally {
- setIsExecuting(false);
- }
- }, [quoteAndPost, slippageBps]);
-
- /**
- * Reset state
- */
- const reset = useCallback(() => {
- setQuote(null);
- setQuoteAndPost(null);
- setError(null);
- setOrderUid(null);
- }, []);
-
- // Auto-fetch quote when parameters change (only if chains match)
- useEffect(() => {
- if (!sourceToken || !targetToken || amount === BigInt(0)) {
- setQuote(null);
- setQuoteAndPost(null);
- return;
- }
-
- // Don't fetch if chains don't match
- if (sourceToken.chainId !== targetToken.chainId) {
- setQuote(null);
- setQuoteAndPost(null);
- return;
- }
-
- // Reset state
- setOrderUid(null);
- setError(null);
-
- // Debounce quote fetching
- const timeoutId = setTimeout(() => {
- void getQuote();
- }, 800);
-
- return () => clearTimeout(timeoutId);
- }, [sourceToken, targetToken, amount, slippageBps, getQuote]);
-
- return {
- quote,
- isQuoting,
- isExecuting,
- error,
- orderUid,
- chainsMatch,
- executeSwap,
- reset,
- };
-}
diff --git a/src/features/swap/hooks/useVeloraSwap.ts b/src/features/swap/hooks/useVeloraSwap.ts
new file mode 100644
index 00000000..e54effba
--- /dev/null
+++ b/src/features/swap/hooks/useVeloraSwap.ts
@@ -0,0 +1,262 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { Address, Hex } from 'viem';
+import { useConnection, usePublicClient } from 'wagmi';
+import { buildVeloraTransactionPayload, fetchVeloraPriceRoute, getVeloraApprovalTarget, isVeloraRateChangedError } from '../api/velora';
+import type { SwapQuoteDisplay, SwapToken } from '../types';
+import { useTransactionWithToast } from '@/hooks/useTransactionWithToast';
+import { formatBalance } from '@/utils/balance';
+import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors';
+
+type UseVeloraSwapParams = {
+ sourceToken: SwapToken | null;
+ targetToken: SwapToken | null;
+ amount: bigint;
+ slippageBps: number;
+ onSwapConfirmed?: () => void;
+};
+
+type UseVeloraSwapReturn = {
+ quote: SwapQuoteDisplay | null;
+ isQuoting: boolean;
+ isExecuting: boolean;
+ error: string | null;
+ chainsMatch: boolean;
+ approvalTarget: Address | null;
+ executeSwap: () => Promise;
+ reset: () => void;
+};
+
+const QUOTE_DEBOUNCE_MS = 800;
+
+const parseErrorMessage = (err: unknown): string => {
+ return toUserFacingTransactionErrorMessage(err, 'An unknown error occurred');
+};
+
+/**
+ * Hook for managing same-chain swaps through Velora (ParaSwap).
+ * Quote path: `/prices`
+ * Execution path: `/transactions/:network` + wallet sendTransaction
+ */
+export function useVeloraSwap({
+ sourceToken,
+ targetToken,
+ amount,
+ slippageBps,
+ onSwapConfirmed,
+}: UseVeloraSwapParams): UseVeloraSwapReturn {
+ const { address: account } = useConnection();
+ const publicClient = usePublicClient({
+ chainId: sourceToken?.chainId,
+ });
+
+ const [quote, setQuote] = useState(null);
+ const [priceRoute, setPriceRoute] = useState> | null>(null);
+ const [isQuoting, setIsQuoting] = useState(false);
+ const [isExecuting, setIsExecuting] = useState(false); // preparing payload + submitting tx
+ const [error, setError] = useState(null);
+
+ const chainsMatch = sourceToken && targetToken ? sourceToken.chainId === targetToken.chainId : false;
+
+ const approvalTarget = useMemo(() => getVeloraApprovalTarget(priceRoute), [priceRoute]);
+
+ const pendingText = useMemo(() => {
+ if (!sourceToken || amount <= 0n) return 'Swapping tokens';
+ return `Swapping ${formatBalance(amount, sourceToken.decimals)} ${sourceToken.symbol}`;
+ }, [sourceToken, amount]);
+
+ const successText = useMemo(() => {
+ if (!targetToken) return 'Swap completed';
+ return `${targetToken.symbol} swapped`;
+ }, [targetToken]);
+
+ const { sendTransactionAsync, isConfirming: swapPending } = useTransactionWithToast({
+ toastId: 'velora-swap',
+ pendingText,
+ successText,
+ errorText: 'Failed to swap',
+ chainId: sourceToken?.chainId,
+ pendingDescription:
+ sourceToken && targetToken ? `${sourceToken.symbol} → ${targetToken.symbol} via Velora` : 'Submitting Velora swap transaction',
+ successDescription:
+ sourceToken && targetToken ? `${sourceToken.symbol} → ${targetToken.symbol} swap confirmed` : 'Swap transaction confirmed',
+ onSuccess: () => {
+ if (onSwapConfirmed) onSwapConfirmed();
+ },
+ });
+
+ const getQuote = useCallback(async () => {
+ if (!sourceToken || !targetToken || !account || amount <= 0n || sourceToken.chainId !== targetToken.chainId) {
+ setQuote(null);
+ setPriceRoute(null);
+ return;
+ }
+
+ setIsQuoting(true);
+ setError(null);
+
+ try {
+ const nextPriceRoute = await fetchVeloraPriceRoute({
+ srcToken: sourceToken.address,
+ srcDecimals: sourceToken.decimals,
+ destToken: targetToken.address,
+ destDecimals: targetToken.decimals,
+ amount,
+ network: sourceToken.chainId,
+ userAddress: account,
+ });
+
+ const buyAmount = BigInt(nextPriceRoute.destAmount);
+ const sellAmount = BigInt(nextPriceRoute.srcAmount);
+
+ setPriceRoute(nextPriceRoute);
+ setQuote({
+ buyAmount,
+ sellAmount,
+ });
+ } catch (err: unknown) {
+ console.error('Error fetching Velora quote:', err);
+ setError(parseErrorMessage(err));
+ setQuote(null);
+ setPriceRoute(null);
+ } finally {
+ setIsQuoting(false);
+ }
+ }, [sourceToken, targetToken, account, amount]);
+
+ const executeSwap = useCallback(async () => {
+ if (!sourceToken || !targetToken || !account || !priceRoute) {
+ return;
+ }
+
+ setIsExecuting(true);
+ setError(null);
+
+ try {
+ let activePriceRoute = priceRoute;
+
+ let txPayload;
+ try {
+ txPayload = await buildVeloraTransactionPayload({
+ srcToken: sourceToken.address,
+ srcDecimals: sourceToken.decimals,
+ destToken: targetToken.address,
+ destDecimals: targetToken.decimals,
+ srcAmount: amount,
+ network: sourceToken.chainId,
+ userAddress: account,
+ priceRoute: activePriceRoute,
+ slippageBps,
+ });
+ } catch (buildError: unknown) {
+ if (!isVeloraRateChangedError(buildError)) {
+ throw buildError;
+ }
+
+ const refreshedRoute = await fetchVeloraPriceRoute({
+ srcToken: sourceToken.address,
+ srcDecimals: sourceToken.decimals,
+ destToken: targetToken.address,
+ destDecimals: targetToken.decimals,
+ amount,
+ network: sourceToken.chainId,
+ userAddress: account,
+ });
+ activePriceRoute = refreshedRoute;
+ setPriceRoute(refreshedRoute);
+ setQuote({
+ buyAmount: BigInt(refreshedRoute.destAmount),
+ sellAmount: BigInt(refreshedRoute.srcAmount),
+ });
+
+ const previousSpender = getVeloraApprovalTarget(priceRoute);
+ const refreshedSpender = getVeloraApprovalTarget(refreshedRoute);
+ if (previousSpender && refreshedSpender && previousSpender.toLowerCase() !== refreshedSpender.toLowerCase()) {
+ throw new Error('Swap route changed and requires approval for a new spender. Please approve and retry.');
+ }
+
+ txPayload = await buildVeloraTransactionPayload({
+ srcToken: sourceToken.address,
+ srcDecimals: sourceToken.decimals,
+ destToken: targetToken.address,
+ destDecimals: targetToken.decimals,
+ srcAmount: amount,
+ network: sourceToken.chainId,
+ userAddress: account,
+ priceRoute: activePriceRoute,
+ slippageBps,
+ });
+ }
+
+ const value = txPayload.value ? BigInt(txPayload.value) : 0n;
+ const gas = txPayload.gas
+ ? BigInt(txPayload.gas)
+ : await publicClient?.estimateGas({
+ account,
+ to: txPayload.to,
+ data: txPayload.data as Hex,
+ value,
+ });
+
+ const baseTx = {
+ account,
+ to: txPayload.to,
+ data: txPayload.data as Hex,
+ value,
+ gas,
+ };
+
+ if (txPayload.maxFeePerGas || txPayload.maxPriorityFeePerGas) {
+ await sendTransactionAsync({
+ ...baseTx,
+ maxFeePerGas: txPayload.maxFeePerGas ? BigInt(txPayload.maxFeePerGas) : undefined,
+ maxPriorityFeePerGas: txPayload.maxPriorityFeePerGas ? BigInt(txPayload.maxPriorityFeePerGas) : undefined,
+ });
+ } else if (txPayload.gasPrice) {
+ await sendTransactionAsync({
+ ...baseTx,
+ gasPrice: BigInt(txPayload.gasPrice),
+ });
+ } else {
+ await sendTransactionAsync(baseTx);
+ }
+ } catch (err: unknown) {
+ console.error('Error executing Velora swap:', err);
+ setError(parseErrorMessage(err));
+ } finally {
+ setIsExecuting(false);
+ }
+ }, [sourceToken, targetToken, account, amount, slippageBps, priceRoute, publicClient, sendTransactionAsync]);
+
+ const reset = useCallback(() => {
+ setQuote(null);
+ setPriceRoute(null);
+ setError(null);
+ }, []);
+
+ useEffect(() => {
+ if (!sourceToken || !targetToken || amount <= 0n || sourceToken.chainId !== targetToken.chainId) {
+ setQuote(null);
+ setPriceRoute(null);
+ return;
+ }
+
+ setError(null);
+
+ const timeoutId = setTimeout(() => {
+ void getQuote();
+ }, QUOTE_DEBOUNCE_MS);
+
+ return () => clearTimeout(timeoutId);
+ }, [sourceToken, targetToken, amount, slippageBps, getQuote]);
+
+ return {
+ quote,
+ isQuoting,
+ isExecuting: isExecuting || swapPending,
+ error,
+ chainsMatch,
+ approvalTarget,
+ executeSwap,
+ reset,
+ };
+}
diff --git a/src/features/swap/index.ts b/src/features/swap/index.ts
index 0dff7146..04840876 100644
--- a/src/features/swap/index.ts
+++ b/src/features/swap/index.ts
@@ -1,13 +1,20 @@
/**
- * CoW Protocol Swap Feature
+ * Velora Swap Feature
*
- * Provides same-chain token swaps via CoW Protocol
+ * Provides same-chain token swaps via Velora
*/
export { SwapModal } from './components/SwapModal';
export { TokenNetworkDropdown } from './components/TokenNetworkDropdown';
-export { useCowSwap } from './hooks/useCowSwap';
-export { tradingSdk } from './cowSwapSdk';
-export type { SwapToken, SwapQuoteDisplay, CowSwapChainId } from './types';
-export { COW_SWAP_CHAINS, COW_VAULT_RELAYER, isCowSwapChain } from './types';
-export { SWAP_APP_CODE, DEFAULT_SLIPPAGE_PERCENT } from './constants';
+export {
+ buildVeloraTransactionPayload,
+ fetchVeloraPriceRoute,
+ getVeloraApprovalTarget,
+ isVeloraRateChangedError,
+ prepareVeloraSwapPayload,
+ VeloraApiError,
+} from './api/velora';
+export { useVeloraSwap } from './hooks/useVeloraSwap';
+export type { SwapToken, SwapQuoteDisplay, VeloraSwapChainId } from './types';
+export { VELORA_SWAP_CHAINS, VELORA_NATIVE_TOKEN_ADDRESS, isVeloraSwapChain } from './types';
+export { SWAP_PARTNER, DEFAULT_SLIPPAGE_PERCENT } from './constants';
diff --git a/src/features/swap/types.ts b/src/features/swap/types.ts
index a528edc5..7a14580b 100644
--- a/src/features/swap/types.ts
+++ b/src/features/swap/types.ts
@@ -19,26 +19,24 @@ export type SwapQuoteDisplay = {
};
/**
- * CoW Protocol supported chains for swaps
- * Mainnet (1), Base (8453), Arbitrum (42161)
- * Note: These are chains supported by both CoW Protocol and our balance API
+ * Velora supported chains that overlap with Monarch-supported networks.
+ * Mainnet (1), Polygon (137), Unichain (130), Base (8453), Arbitrum (42161)
*/
-export const COW_SWAP_CHAINS = [1, 8453, 42_161] as const;
+export const VELORA_SWAP_CHAINS = [1, 137, 130, 8453, 42_161] as const;
/**
- * CoW Protocol VaultRelayer address (same across all chains)
- * This is the address that needs to be approved to spend tokens
+ * Canonical native-token pseudo address used by Velora API.
*/
-export const COW_VAULT_RELAYER = '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110' as const;
+export const VELORA_NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as const;
/**
- * Type for CoW swap supported chain IDs
+ * Type for Velora swap supported chain IDs
*/
-export type CowSwapChainId = (typeof COW_SWAP_CHAINS)[number];
+export type VeloraSwapChainId = (typeof VELORA_SWAP_CHAINS)[number];
/**
- * Check if a chain ID is supported by CoW Swap
+ * Check if a chain ID is supported by Velora swap
*/
-export function isCowSwapChain(chainId: number): chainId is CowSwapChainId {
- return COW_SWAP_CHAINS.includes(chainId as CowSwapChainId);
+export function isVeloraSwapChain(chainId: number): chainId is VeloraSwapChainId {
+ return VELORA_SWAP_CHAINS.includes(chainId as VeloraSwapChainId);
}
diff --git a/src/hooks/queries/useUserBalancesQuery.ts b/src/hooks/queries/useUserBalancesQuery.ts
index dab37bf3..be7d9af0 100644
--- a/src/hooks/queries/useUserBalancesQuery.ts
+++ b/src/hooks/queries/useUserBalancesQuery.ts
@@ -79,6 +79,7 @@ export const useUserBalancesQuery = (options: UseUserBalancesOptions = {}) => {
isLoading,
isError,
error,
+ refetch,
} = useReadContracts({
contracts,
query: {
@@ -117,7 +118,7 @@ export const useUserBalancesQuery = (options: UseUserBalancesOptions = {}) => {
return balances;
}, [rawResults, tokenEntries, findToken]);
- return { data, isLoading, isError, error };
+ return { data, isLoading, isError, error, refetch };
};
/**
diff --git a/src/hooks/useAllowance.ts b/src/hooks/useAllowance.ts
index 86f9b6cb..c5c92dea 100644
--- a/src/hooks/useAllowance.ts
+++ b/src/hooks/useAllowance.ts
@@ -24,6 +24,7 @@ type Props = {
export function useAllowance({ user, spender, chainId = 1, token, refetchInterval = 10_000, tokenSymbol }: Props) {
const { chain } = useConnection();
const chainIdFromArgumentOrConnectedWallet = chainId ?? chain?.id;
+ const hasValidAllowanceRoute = spender !== zeroAddress && token !== zeroAddress;
const { data } = useReadContract({
abi: erc20Abi,
@@ -31,7 +32,7 @@ export function useAllowance({ user, spender, chainId = 1, token, refetchInterva
address: token,
args: [user ?? zeroAddress, spender],
query: {
- enabled: !!user && !!spender && !!token,
+ enabled: !!user && hasValidAllowanceRoute,
refetchInterval,
},
chainId,
@@ -48,7 +49,7 @@ export function useAllowance({ user, spender, chainId = 1, token, refetchInterva
});
const approveInfinite = useCallback(async () => {
- if (!user || !spender || !token) throw new Error('User, spender, or token not provided');
+ if (!user || !hasValidAllowanceRoute) throw new Error('User, spender, or token not provided');
// some weird bug with writeContract, update to use useSendTransaction
await sendTransactionAsync({
account: user,
@@ -60,7 +61,7 @@ export function useAllowance({ user, spender, chainId = 1, token, refetchInterva
}),
chainId: chainIdFromArgumentOrConnectedWallet,
});
- }, [user, spender, token, sendTransactionAsync, chainIdFromArgumentOrConnectedWallet]);
+ }, [user, hasValidAllowanceRoute, sendTransactionAsync, chainIdFromArgumentOrConnectedWallet, token, spender]);
const allowance = data ? data : BigInt(0);
diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts
index 4e4fcb0e..781e171f 100644
--- a/src/hooks/useMultiMarketSupply.ts
+++ b/src/hooks/useMultiMarketSupply.ts
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
-import { type Address, encodeFunctionData } from 'viem';
+import { type Address, encodeFunctionData, zeroAddress } from 'viem';
import { useConnection } from 'wagmi';
import morphoBundlerAbi from '@/abis/bundlerV2';
import { usePermit2 } from '@/hooks/usePermit2';
@@ -8,7 +8,6 @@ import { useTransactionTracking } from '@/hooks/useTransactionTracking';
import type { NetworkToken } from '@/types/token';
import { formatBalance } from '@/utils/balance';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
-import { SupportedNetworks } from '@/utils/networks';
import type { Market } from '@/utils/types';
import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants';
import { useERC20Approval } from './useERC20Approval';
@@ -34,6 +33,11 @@ export function useMultiMarketSupply(
const chainId = loanAsset?.network;
const tokenSymbol = loanAsset?.symbol;
const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n);
+ const bundlerAddress = chainId ? getBundlerV2(chainId) : zeroAddress;
+ const isBundlerAddressValid = chainId !== undefined && bundlerAddress !== zeroAddress;
+ const bundlerAddressErrorMessage = chainId
+ ? `No bundler configured for chain ${chainId}.`
+ : 'No chain selected for multi-market supply.';
const { batchAddUserMarkets } = useUserMarketsCache(account);
@@ -44,7 +48,7 @@ export function useMultiMarketSupply(
signForBundlers,
} = usePermit2({
user: account as `0x${string}`,
- spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet),
+ spender: isBundlerAddressValid ? bundlerAddress : undefined,
token: loanAsset?.address as `0x${string}`,
refetchInterval: 10_000,
chainId,
@@ -54,7 +58,7 @@ export function useMultiMarketSupply(
const { isApproved, approve } = useERC20Approval({
token: loanAsset?.address as Address,
- spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet),
+ spender: bundlerAddress,
amount: totalAmount,
tokenSymbol: loanAsset?.symbol ?? '',
});
@@ -67,18 +71,23 @@ export function useMultiMarketSupply(
chainId,
pendingDescription: `Supplying to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`,
successDescription: `Successfully supplied to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`,
- onSuccess,
+ onSuccess: () => {
+ if (onSuccess) onSuccess();
+ },
});
const executeSupplyTransaction = useCallback(async () => {
if (!account) throw new Error('No account connected');
if (!loanAsset || !chainId) throw new Error('Invalid loan asset or chain');
+ if (!isBundlerAddressValid) throw new Error(bundlerAddressErrorMessage);
const txs: `0x${string}`[] = [];
let gas: bigint | undefined = undefined;
try {
+ // Supply flows do not require Morpho/Bundler authorization.
+ // We only need funding/transfer steps (ETH wrap, Permit2, or ERC20 transferFrom) plus morphoSupply calls.
// Handle ETH wrapping if needed
if (useEth) {
txs.push(
@@ -155,7 +164,7 @@ export function useMultiMarketSupply(
await sendTransactionAsync({
account,
- to: getBundlerV2(chainId),
+ to: bundlerAddress,
data: (encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'multicall',
@@ -195,6 +204,9 @@ export function useMultiMarketSupply(
signForBundlers,
usePermit2Setting,
chainId,
+ bundlerAddress,
+ bundlerAddressErrorMessage,
+ isBundlerAddressValid,
loanAsset,
toast,
tracking,
@@ -205,6 +217,10 @@ export function useMultiMarketSupply(
toast.error('No account connected', 'Please connect your wallet to continue.');
return false;
}
+ if (!isBundlerAddressValid) {
+ toast.error('Unsupported network', bundlerAddressErrorMessage);
+ return false;
+ }
try {
// Start tracking with appropriate steps based on flow
@@ -292,6 +308,8 @@ export function useMultiMarketSupply(
tracking,
tokenSymbol,
supplies,
+ bundlerAddressErrorMessage,
+ isBundlerAddressValid,
]);
return {
diff --git a/src/hooks/useSupplyMarket.ts b/src/hooks/useSupplyMarket.ts
index e7556d40..11c78ef5 100644
--- a/src/hooks/useSupplyMarket.ts
+++ b/src/hooks/useSupplyMarket.ts
@@ -1,5 +1,5 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
-import { type Address, encodeFunctionData, erc20Abi } from 'viem';
+import { type Address, encodeFunctionData, erc20Abi, zeroAddress } from 'viem';
import { useConnection, useBalance, useReadContract } from 'wagmi';
import morphoBundlerAbi from '@/abis/bundlerV2';
import { useERC20Approval } from '@/hooks/useERC20Approval';
@@ -65,6 +65,9 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
const { address: account, chainId } = useConnection();
const { batchAddUserMarkets } = useUserMarketsCache(account);
const toast = useStyledToast();
+ const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id);
+ const isBundlerAddressValid = bundlerAddress !== zeroAddress;
+ const bundlerAddressErrorMessage = `No bundler configured for chain ${market.morphoBlue.chain.id}.`;
// Get token balance
const {
@@ -100,7 +103,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
signForBundlers,
} = usePermit2({
user: account as `0x${string}`,
- spender: getBundlerV2(market.morphoBlue.chain.id),
+ spender: isBundlerAddressValid ? bundlerAddress : undefined,
token: market.loanAsset.address as `0x${string}`,
refetchInterval: 10_000,
chainId: market.morphoBlue.chain.id,
@@ -111,7 +114,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
// Handle ERC20 approval
const { isApproved, approve } = useERC20Approval({
token: market.loanAsset.address as Address,
- spender: getBundlerV2(market.morphoBlue.chain.id),
+ spender: bundlerAddress,
amount: supplyAmount,
tokenSymbol: market.loanAsset.symbol,
});
@@ -130,7 +133,9 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
chainId,
pendingDescription: `Supplying to market ${market.uniqueKey.slice(2, 8)}...`,
successDescription: `Successfully supplied to market ${market.uniqueKey.slice(2, 8)}`,
- onSuccess,
+ onSuccess: () => {
+ if (onSuccess) onSuccess();
+ },
});
// Helper to generate steps based on flow type
@@ -161,10 +166,16 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
// Execute supply transaction
const executeSupplyTransaction = useCallback(async () => {
try {
+ if (!isBundlerAddressValid) {
+ throw new Error(bundlerAddressErrorMessage);
+ }
+
const txs: `0x${string}`[] = [];
let gas: bigint | undefined = undefined;
+ // Supply flow does not need Morpho/Bundler authorization.
+ // We only compose transfer funding steps and then call morphoSupply in the same multicall.
if (useEth) {
txs.push(
encodeFunctionData({
@@ -235,7 +246,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
await sendTransactionAsync({
account,
- to: getBundlerV2(market.morphoBlue.chain.id),
+ to: bundlerAddress,
data: (encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'multicall',
@@ -256,9 +267,13 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
complete();
return true;
- } catch (_error: unknown) {
+ } catch (error: unknown) {
fail();
- toast.error('Supply Failed', 'Supply to market failed or cancelled');
+ if (error instanceof Error) {
+ toast.error('Supply Failed', error.message);
+ } else {
+ toast.error('Supply Failed', 'Supply to market failed or cancelled');
+ }
return false;
}
}, [
@@ -274,6 +289,9 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
update,
complete,
fail,
+ bundlerAddress,
+ bundlerAddressErrorMessage,
+ isBundlerAddressValid,
]);
// Approve and supply handler
@@ -282,6 +300,10 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
toast.info('No account connected', 'Please connect your wallet to continue.');
return;
}
+ if (!isBundlerAddressValid) {
+ toast.error('Unsupported network', bundlerAddressErrorMessage);
+ return;
+ }
try {
const initialStep = useEth ? 'supplying' : 'approve';
@@ -373,6 +395,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
getStepsForFlow,
market,
supplyAmount,
+ bundlerAddressErrorMessage,
+ isBundlerAddressValid,
]);
// Sign and supply handler
@@ -381,6 +405,10 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
toast.info('No account connected', 'Please connect your wallet to continue.');
return;
}
+ if (!isBundlerAddressValid) {
+ toast.error('Unsupported network', bundlerAddressErrorMessage);
+ return;
+ }
try {
start(
@@ -409,7 +437,20 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp
toast.error('Transaction Error', 'An unexpected error occurred');
}
}
- }, [account, executeSupplyTransaction, toast, start, fail, getStepsForFlow, useEth, usePermit2Setting, market, supplyAmount]);
+ }, [
+ account,
+ executeSupplyTransaction,
+ toast,
+ start,
+ fail,
+ getStepsForFlow,
+ useEth,
+ usePermit2Setting,
+ market,
+ supplyAmount,
+ bundlerAddressErrorMessage,
+ isBundlerAddressValid,
+ ]);
return {
// State
diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx
index 5ef2b3c7..66e10865 100644
--- a/src/hooks/useTransactionWithToast.tsx
+++ b/src/hooks/useTransactionWithToast.tsx
@@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi';
import { StyledToast, TransactionToast } from '@/components/ui/styled-toast';
import { reportHandledError } from '@/utils/sentry';
+import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors';
import { getExplorerTxURL } from '../utils/external';
import type { SupportedNetworks } from '../utils/networks';
@@ -127,7 +128,7 @@ export function useTransactionWithToast({
reportedErrorKeyRef.current = reportKey;
}
- const errorMessage = (txError ?? receiptError)?.message ?? 'Transaction Failed';
+ const errorMessage = toUserFacingTransactionErrorMessage(txError ?? receiptError, 'Transaction failed');
toast.update(toastId, {
render: (
diff --git a/src/types/token.ts b/src/types/token.ts
index 373d0015..bead2f8c 100644
--- a/src/types/token.ts
+++ b/src/types/token.ts
@@ -1,4 +1,4 @@
-import type { Address } from 'viem';
+import { isAddress, type Address } from 'viem';
import { SupportedNetworks } from '@/utils/networks';
/**
@@ -29,3 +29,12 @@ export const WETH_BY_CHAIN: Partial> = {
export const getCanonicalWethAddress = (chainId: number): Address | undefined => {
return WETH_BY_CHAIN[chainId as SupportedNetworks];
};
+
+/**
+ * Normalizes an address for canonical token identity checks.
+ * Returns null when the input is not a valid EVM address.
+ */
+export const toCanonicalTokenAddress = (address: string | null | undefined): Address | null => {
+ if (!address || !isAddress(address)) return null;
+ return address.toLowerCase() as Address;
+};
diff --git a/src/utils/decimal-input.ts b/src/utils/decimal-input.ts
new file mode 100644
index 00000000..4739bf8d
--- /dev/null
+++ b/src/utils/decimal-input.ts
@@ -0,0 +1,26 @@
+const DECIMAL_INPUT_REGEX = /^\d*\.?\d*$/;
+
+export const sanitizeDecimalInput = (value: string): string => {
+ // Normalize decimal separators only; do not strip unsupported characters.
+ // This keeps invalid formats invalid instead of mutating them into another number.
+ const normalized = value.trim().replace(/,/g, '.');
+ const [wholePart, ...fractionParts] = normalized.split('.');
+ if (fractionParts.length === 0) {
+ return wholePart;
+ }
+ return `${wholePart}.${fractionParts.join('')}`;
+};
+
+export const isValidDecimalInput = (value: string): boolean => {
+ return DECIMAL_INPUT_REGEX.test(value);
+};
+
+export const toParseableDecimalInput = (value: string): string | null => {
+ if (value === '' || value === '.') {
+ return null;
+ }
+
+ const withLeadingZero = value.startsWith('.') ? `0${value}` : value;
+ const trimmedTrailingDot = withLeadingZero.endsWith('.') ? withLeadingZero.slice(0, -1) : withLeadingZero;
+ return trimmedTrailingDot.length > 0 ? trimmedTrailingDot : null;
+};
diff --git a/src/utils/token-amount-format.ts b/src/utils/token-amount-format.ts
index d345a0da..6b56d79e 100644
--- a/src/utils/token-amount-format.ts
+++ b/src/utils/token-amount-format.ts
@@ -1 +1 @@
-export { formatCompactTokenAmount, formatFullTokenAmount } from '@/hooks/leverage/math';
+export { formatCompactTokenAmount, formatFullTokenAmount, formatTokenAmountPreview } from '@/hooks/leverage/math';
diff --git a/src/utils/transaction-errors.ts b/src/utils/transaction-errors.ts
new file mode 100644
index 00000000..b780892d
--- /dev/null
+++ b/src/utils/transaction-errors.ts
@@ -0,0 +1,108 @@
+const USER_REJECTED_TRANSACTION_MESSAGE = 'User rejected transaction.';
+const USER_REJECTED_CODE = 4001;
+const ACTION_REJECTED_CODE = 'ACTION_REJECTED';
+const ERROR_CAUSE_MAX_DEPTH = 6;
+
+const USER_REJECTED_PATTERNS = [
+ 'user rejected',
+ 'rejected the request',
+ 'denied transaction signature',
+ 'user denied',
+ 'user cancelled',
+ 'user canceled',
+];
+
+type ErrorLike = {
+ code?: unknown;
+ message?: unknown;
+ shortMessage?: unknown;
+ details?: unknown;
+ cause?: unknown;
+};
+
+const isErrorLike = (value: unknown): value is ErrorLike => {
+ return typeof value === 'object' && value !== null;
+};
+
+const asNonEmptyString = (value: unknown): string | null => {
+ if (typeof value !== 'string') {
+ return null;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+};
+
+const sanitizeViemErrorMessage = (message: string): string => {
+ let nextMessage = message;
+ for (const marker of ['Request Arguments:', 'Details:', 'Version:']) {
+ const index = nextMessage.indexOf(marker);
+ if (index !== -1) {
+ nextMessage = nextMessage.slice(0, index);
+ }
+ }
+
+ const trimmed = nextMessage.trim();
+ return trimmed.length > 0 ? trimmed : message;
+};
+
+const collectErrorChain = (error: unknown): ErrorLike[] => {
+ const chain: ErrorLike[] = [];
+ const visited = new Set();
+ let current: unknown = error;
+ let depth = 0;
+
+ while (isErrorLike(current) && !visited.has(current) && depth < ERROR_CAUSE_MAX_DEPTH) {
+ chain.push(current);
+ visited.add(current);
+ current = current.cause;
+ depth += 1;
+ }
+
+ return chain;
+};
+
+export const isUserRejectedTransactionError = (error: unknown): boolean => {
+ if (!error) return false;
+
+ for (const chainItem of collectErrorChain(error)) {
+ const code = chainItem.code;
+ if (code === USER_REJECTED_CODE || code === ACTION_REJECTED_CODE) {
+ return true;
+ }
+
+ const messages = [chainItem.shortMessage, chainItem.message, chainItem.details];
+ for (const candidate of messages) {
+ const normalized = asNonEmptyString(candidate)?.toLowerCase();
+ if (!normalized) continue;
+ if (USER_REJECTED_PATTERNS.some((pattern) => normalized.includes(pattern))) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+};
+
+export const toUserFacingTransactionErrorMessage = (error: unknown, fallbackMessage: string): string => {
+ if (isUserRejectedTransactionError(error)) {
+ return USER_REJECTED_TRANSACTION_MESSAGE;
+ }
+
+ for (const chainItem of collectErrorChain(error)) {
+ const shortMessage = asNonEmptyString(chainItem.shortMessage);
+ if (shortMessage) {
+ return sanitizeViemErrorMessage(shortMessage);
+ }
+
+ const message = asNonEmptyString(chainItem.message);
+ if (message) {
+ return sanitizeViemErrorMessage(message);
+ }
+ }
+
+ if (error instanceof Error && error.message.trim().length > 0) {
+ return sanitizeViemErrorMessage(error.message);
+ }
+
+ return fallbackMessage;
+};