From 495a927f5fc18f21765d0b1896a1f8ca894a61f2 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 14:36:16 +0800 Subject: [PATCH 1/6] feat: change swap to velora --- AGENTS.md | 3 + docs/BUNDLER_STRATEGY.md | 59 +++ docs/TECHNICAL_OVERVIEW.md | 24 +- .../ui/ExecuteTransactionButton.tsx | 10 +- src/components/ui/button.tsx | 20 +- src/features/swap/api/velora.ts | 304 +++++++++++++++ src/features/swap/components/SwapModal.tsx | 351 +++++++++++------- .../swap/components/SwapTokenAmountField.tsx | 21 ++ .../swap/components/TokenNetworkDropdown.tsx | 20 +- src/features/swap/constants.ts | 14 +- src/features/swap/cowSwapSdk.ts | 16 - src/features/swap/hooks/useCowSwap.ts | 211 ----------- src/features/swap/hooks/useVeloraSwap.ts | 262 +++++++++++++ src/features/swap/index.ts | 21 +- src/features/swap/types.ts | 22 +- src/hooks/queries/useUserBalancesQuery.ts | 3 +- src/hooks/useMultiMarketSupply.ts | 34 +- src/hooks/useSupplyMarket.ts | 34 +- src/hooks/useTransactionWithToast.tsx | 3 +- src/utils/transaction-errors.ts | 108 ++++++ 20 files changed, 1126 insertions(+), 414 deletions(-) create mode 100644 docs/BUNDLER_STRATEGY.md create mode 100644 src/features/swap/api/velora.ts create mode 100644 src/features/swap/components/SwapTokenAmountField.tsx delete mode 100644 src/features/swap/cowSwapSdk.ts delete mode 100644 src/features/swap/hooks/useCowSwap.ts create mode 100644 src/features/swap/hooks/useVeloraSwap.ts create mode 100644 src/utils/transaction-errors.ts diff --git a/AGENTS.md b/AGENTS.md index 2a68149e..b9c778fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,9 @@ When touching transaction and position flows, validation MUST include all releva 7. **UI clarity and duplication checks**: remove duplicate/redundant/low-signal data and keep only decision-critical information. 8. **Null/data-corruption resilience**: guard null/undefined/stale API/contract fields so malformed data fails gracefully. 9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. +10. **Bundler authorization chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook. +11. **Aggregator API schema separation**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders and surface typed API errors. +12. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. ### REQUIRED: Regression Rule Capture diff --git a/docs/BUNDLER_STRATEGY.md b/docs/BUNDLER_STRATEGY.md new file mode 100644 index 00000000..e3caf96d --- /dev/null +++ b/docs/BUNDLER_STRATEGY.md @@ -0,0 +1,59 @@ +# Bundler Strategy (V2 + V3) + +Last updated: February 27, 2026 + +## Goal + +Keep stable V2 transaction paths for existing product behavior while introducing a separate V3 path only where swaps are required. + +## Current Production Split + +### Bundler V2 (active) + +Use Bundler V2 for: + +- Multi-supply and direct supply +- Borrow +- Repay +- Rebalance +- Existing deterministic leverage/deleverage flow (ERC4626-only route) + +Implementation rule: + +- Any V2 Morpho transaction path must use `src/hooks/useBundlerAuthorizationStep.ts`. + +## Planned Bundler V3 Scope + +Use Bundler V3 only for swap-dependent features: + +- `rebalanceWithSwap` +- Generalized `useLeverage` route (any pair, not only ERC4626 deterministic route) + +Do not migrate all legacy V2 hooks at once. Keep both tracks parallel so current users are not forced into new contract-approval risk. + +## Bundler V3 Architecture Notes + +Bundler V3 is adapter-driven and supports composing actions (including swaps) through dedicated adapter contracts and callbacks. This is structurally different from the narrower direct-action shape used in current V2 hooks. + +For Monarch, this implies: + +- Keep swap quote/execution logic isolated from V2 hooks. +- Introduce V3-specific builders/hooks instead of overloading existing V2 hooks. +- Add route guards for unsupported adapter paths and degrade gracefully. +- Reuse shared Velora API chokepoints from `src/features/swap/api/velora.ts` (quote + tx payload preparation) in future V3 bundler flows. + +## Historical Approval Incident (April 2025) + +Morpho published a security notice on April 10, 2025 regarding approvals to Bundler3 contracts. The guidance was to revoke approvals to affected Bundler3 addresses on specific networks. + +Engineering implications: + +- Never assume perpetual approvals are harmless for adapter-capable contracts. +- Default to explicit, minimal-privilege approvals and clear spender visibility in UI. +- Keep V3 rollout isolated and auditable before broadening to all transaction paths. + +## Migration Sequence + +1. Swap-first (now): use Velora quote + transaction payload execution in standalone swap flow. +2. Add V3 for swap-dependent product features only (`rebalanceWithSwap`, generalized leverage). +3. Keep V2 as default for non-swap flows until V3 parity and risk posture are validated. diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index e3d035bb..e120664c 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -22,7 +22,7 @@ Monarch is a client-side DeFi dashboard for the Morpho Blue lending protocol. It | Viem | 2.40.2 | Ethereum utilities | | @reown/appkit | 1.8.14 | Wallet connection (WalletConnect v3) | | @morpho-org/blue-sdk | 5.3.0 | Morpho Blue protocol SDK | -| @cowprotocol/cow-sdk | 7.2.9 | Intent-based swaps | +| Velora (ParaSwap) API | HTTP | Same-chain quote + transaction payloads for swaps | **Wagmi v3 integration notes:** - Prefer `useConnection()` when you need wallet state (`address`, `chainId`, `isConnected`) in one place. @@ -126,6 +126,26 @@ MorphoChainlinkOracleData { --- +## Transaction Architecture + +### Bundler Responsibility Matrix + +| Path | Bundler | Notes | +|------|---------|-------| +| Multi-supply / direct supply / borrow / repay / rebalance | Bundler V2 | Current production path | +| Leverage/deleverage (current deterministic ERC4626 route) | Bundler V2 | Existing route remains unchanged | +| Generic swap-only modal | No bundler (Velora direct tx) | Quote + tx payload from Velora API | +| Planned: `rebalanceWithSwap`, generalized `useLeverage` (any pair) | Bundler V3 | Planned migration path for swap-dependent actions | + +### Authorization Model + +- Any flow that sends Morpho actions through Bundler V2 must pass through `useBundlerAuthorizationStep`. +- Signature mode is used for Permit2 and native-token flows. +- Transaction mode is used for standard ERC20 approval flow when signature mode is not selected. +- See [`BUNDLER_STRATEGY.md`](/Users/antonasso/programming/morpho/monarch/docs/BUNDLER_STRATEGY.md) for migration rules and security guardrails. + +--- + ## Data Sources ### Dual-Source Strategy @@ -304,6 +324,7 @@ Fallback Strategy: | Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions | | The Graph | Per-chain subgraph URLs | Fallback data, suppliers, borrowers | | Merkl API | `https://api.merkl.xyz` | Reward campaigns | +| Velora API | `https://api.paraswap.io` | Swap quotes and executable tx payloads | | Alchemy | Per-chain RPC | Default RPC provider | ### Smart Contracts @@ -336,3 +357,4 @@ Fallback Strategy: | All Stores | `/src/stores/` | | All Query Hooks | `/src/hooks/queries/` | | Vault Storage | `/src/utils/vault-storage.ts` | +| Bundler Migration Notes | `/docs/BUNDLER_STRATEGY.md` | diff --git a/src/components/ui/ExecuteTransactionButton.tsx b/src/components/ui/ExecuteTransactionButton.tsx index 29823228..d21dd9b5 100644 --- a/src/components/ui/ExecuteTransactionButton.tsx +++ b/src/components/ui/ExecuteTransactionButton.tsx @@ -2,7 +2,6 @@ import { useCallback, useState } from 'react'; import { useAppKit } from '@reown/appkit/react'; import { useConnection } from 'wagmi'; import { Button, type ButtonProps } from '@/components/ui/button'; -import { Spinner } from '@/components/ui/spinner'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { getNetworkName } from '@/utils/networks'; @@ -130,14 +129,7 @@ export function ExecuteTransactionButton({ isLoading={isSwitching} {...buttonProps} > - {isSwitching ? ( -
- - Switching... -
- ) : ( - (switchChainText ?? defaultSwitchText) - )} + {switchChainText ?? defaultSwitchText} ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 9ad1ff6f..b154a029 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,9 +3,10 @@ import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/utils/index'; +import { Spinner } from './spinner'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-200 ease-in-out border-0 outline-0 ring-0 focus:border-0 focus:outline-0 focus:ring-0 active:border-0 active:outline-0 active:ring-0 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'relative inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-200 ease-in-out border-0 outline-0 ring-0 focus:border-0 focus:outline-0 focus:ring-0 active:border-0 active:outline-0 active:ring-0 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { @@ -55,7 +56,7 @@ const buttonVariants = cva( compoundVariants: [ { isLoading: true, - className: 'gap-2 [&>span]:opacity-0 [&>svg]:opacity-0 [&>*:not(.loading-spinner)]:opacity-0', + className: 'gap-2 [&>span:not(.loading-spinner)]:opacity-0 [&>svg]:opacity-0 [&>*:not(.loading-spinner)]:opacity-0', }, // Ghost button hover effects - subtle background changes with brightness adjustments { @@ -90,7 +91,7 @@ export type ButtonProps = { VariantProps; const Button = forwardRef( - ({ className, variant, size, radius, fullWidth, isLoading, asChild = false, ...props }, ref) => { + ({ className, variant, size, radius, fullWidth, isLoading, asChild = false, children, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( ( ref={ref} disabled={isLoading ? true : props.disabled} {...props} - /> + > + {isLoading ? ( + + + + ) : null} + {children} + ); }, ); diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts new file mode 100644 index 00000000..d13e80fd --- /dev/null +++ b/src/features/swap/api/velora.ts @@ -0,0 +1,304 @@ +import type { Address } from 'viem'; +import { SWAP_PARTNER, VELORA_API_BASE_URL, VELORA_PRICES_API_VERSION } from '../constants'; + +export type VeloraSwapSide = 'SELL' | 'BUY'; + +export type VeloraPriceRoute = { + srcToken: string; + destToken: string; + srcAmount: string; + destAmount: string; + tokenTransferProxy?: string; + contractAddress?: string; +}; + +export type VeloraTransactionPayload = { + to: Address; + data: `0x${string}`; + value?: string; + gas?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +}; + +type VeloraPriceResponse = { + priceRoute?: VeloraPriceRoute; + error?: string; + message?: string; + description?: string; +}; + +type VeloraBuildTransactionResponse = { + to?: string; + data?: string; + value?: string; + gas?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + error?: string; + message?: string; + description?: string; +}; + +export type FetchVeloraPriceRouteParams = { + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; + amount: bigint; + network: number; + userAddress: Address; + partner?: string; + side?: VeloraSwapSide; +}; + +export type BuildVeloraTransactionPayloadParams = { + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; + srcAmount: bigint; + network: number; + userAddress: Address; + priceRoute: VeloraPriceRoute; + slippageBps: number; + side?: VeloraSwapSide; + partner?: string; + ignoreChecks?: boolean; +}; + +export type PrepareVeloraSwapPayloadParams = { + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; + amount: bigint; + network: number; + userAddress: Address; + slippageBps: number; + side?: VeloraSwapSide; + partner?: string; + ignoreChecks?: boolean; +}; + +export class VeloraApiError extends Error { + readonly status: number; + readonly details: unknown; + + constructor(message: string, status: number, details: unknown) { + super(message); + this.name = 'VeloraApiError'; + this.status = status; + this.details = details; + } +} + +const extractVeloraErrorMessage = (payload: unknown): string => { + if (!payload) return 'Unknown Velora API error'; + + if (typeof payload === 'string') { + return payload; + } + + if (typeof payload === 'object') { + const objectPayload = payload as Record; + + if (typeof objectPayload.description === 'string') { + return objectPayload.description; + } + if (typeof objectPayload.error === 'string') { + return objectPayload.error; + } + if (typeof objectPayload.message === 'string') { + return objectPayload.message; + } + + const nested = objectPayload.error ?? objectPayload.message; + if (nested && nested !== payload) { + return extractVeloraErrorMessage(nested); + } + } + + return 'Unknown Velora API error'; +}; + +const fetchVeloraJson = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init); + const raw = await response.text(); + + let payload: unknown = null; + if (raw) { + try { + payload = JSON.parse(raw) as unknown; + } catch { + payload = raw; + } + } + + if (!response.ok) { + const message = extractVeloraErrorMessage(payload); + throw new VeloraApiError(message, response.status, payload); + } + + return payload as T; +}; + +export const getVeloraApprovalTarget = (priceRoute: VeloraPriceRoute | null): Address | null => { + const spender = priceRoute?.tokenTransferProxy ?? priceRoute?.contractAddress; + if (!spender || !spender.startsWith('0x')) return null; + return spender as Address; +}; + +export const isVeloraRateChangedError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return message.includes('rate has changed') || message.includes('re-query the latest price'); +}; + +export const fetchVeloraPriceRoute = async ({ + srcToken, + srcDecimals, + destToken, + destDecimals, + amount, + network, + userAddress, + partner = SWAP_PARTNER, + side = 'SELL', +}: FetchVeloraPriceRouteParams): Promise => { + const query = new URLSearchParams({ + srcToken, + destToken, + srcDecimals: srcDecimals.toString(), + destDecimals: destDecimals.toString(), + amount: amount.toString(), + side, + network: network.toString(), + userAddress, + partner, + version: VELORA_PRICES_API_VERSION, + }); + + const response = await fetchVeloraJson(`${VELORA_API_BASE_URL}/prices?${query.toString()}`, { + method: 'GET', + }); + + if (!response.priceRoute) { + throw new VeloraApiError( + response.description ?? response.error ?? response.message ?? 'No price route returned by Velora', + 400, + response, + ); + } + + return response.priceRoute; +}; + +export const buildVeloraTransactionPayload = async ({ + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount, + network, + userAddress, + priceRoute, + slippageBps, + side = 'SELL', + partner = SWAP_PARTNER, + ignoreChecks = false, +}: BuildVeloraTransactionPayloadParams): Promise => { + const query = new URLSearchParams(); + if (ignoreChecks) { + query.set('ignoreChecks', 'true'); + } + + const transactionUrl = + query.size > 0 + ? `${VELORA_API_BASE_URL}/transactions/${network}?${query.toString()}` + : `${VELORA_API_BASE_URL}/transactions/${network}`; + + const response = await fetchVeloraJson(transactionUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount: srcAmount.toString(), + side, + slippage: slippageBps, + priceRoute, + userAddress, + partner, + }), + }); + + if (!response.to || !response.data) { + throw new VeloraApiError( + response.description ?? response.error ?? response.message ?? 'Invalid transaction payload from Velora', + 400, + response, + ); + } + + return { + to: response.to as Address, + data: response.data as `0x${string}`, + value: response.value, + gas: response.gas, + gasPrice: response.gasPrice, + maxFeePerGas: response.maxFeePerGas, + maxPriorityFeePerGas: response.maxPriorityFeePerGas, + }; +}; + +export const prepareVeloraSwapPayload = async ({ + srcToken, + srcDecimals, + destToken, + destDecimals, + amount, + network, + userAddress, + slippageBps, + side = 'SELL', + partner = SWAP_PARTNER, + ignoreChecks = false, +}: PrepareVeloraSwapPayloadParams): Promise<{ priceRoute: VeloraPriceRoute; txPayload: VeloraTransactionPayload }> => { + const priceRoute = await fetchVeloraPriceRoute({ + srcToken, + srcDecimals, + destToken, + destDecimals, + amount, + network, + userAddress, + side, + partner, + }); + + const txPayload = await buildVeloraTransactionPayload({ + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount: amount, + network, + userAddress, + priceRoute, + slippageBps, + side, + partner, + ignoreChecks, + }); + + return { + priceRoute, + txPayload, + }; +}; diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 69ae5b44..051e437e 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -1,42 +1,59 @@ import { useCallback, useMemo, useState } from 'react'; -import Image from 'next/image'; -import { ArrowDownIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; -import { formatUnits, parseUnits } from 'viem'; +import { ArrowDownIcon, ChevronDownIcon } from '@radix-ui/react-icons'; +import { IoIosSwap } from 'react-icons/io'; +import { formatUnits, parseUnits, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; -import { motion } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { Button } from '@/components/ui/button'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +import { Spinner } from '@/components/ui/spinner'; import { useUserBalancesQuery } from '@/hooks/queries/useUserBalancesQuery'; import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAllowance } from '@/hooks/useAllowance'; import { formatBalance } from '@/utils/balance'; -import { getNetworkName } from '@/utils/networks'; -import { useCowSwap } from '../hooks/useCowSwap'; +import { useVeloraSwap } from '../hooks/useVeloraSwap'; import { TokenNetworkDropdown } from './TokenNetworkDropdown'; -import { COW_SWAP_CHAINS, COW_VAULT_RELAYER, type SwapToken } from '../types'; +import { SwapTokenAmountField } from './SwapTokenAmountField'; +import { VELORA_SWAP_CHAINS, type SwapToken } from '../types'; import { DEFAULT_SLIPPAGE_PERCENT } from '../constants'; -const img = '/imgs/protocols/cow.svg'; - type SwapModalProps = { isOpen: boolean; onClose: () => void; defaultTargetToken?: SwapToken; }; +const MIN_SLIPPAGE_PERCENT = 0.1; +const MAX_SLIPPAGE_PERCENT = 5; + +const formatRateValue = (value: number): string => { + if (!Number.isFinite(value) || value <= 0) return '0'; + if (value >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + if (value >= 1) return value.toLocaleString(undefined, { maximumFractionDigits: 6 }); + return value.toLocaleString(undefined, { maximumFractionDigits: 8 }); +}; + export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProps) { const { address: account } = useConnection(); const [sourceToken, setSourceToken] = useState(null); const [targetToken, setTargetToken] = useState(defaultTargetToken ?? null); const [inputAmount, setInputAmount] = useState('0'); const [amount, setAmount] = useState(BigInt(0)); - const [slippage, _setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); - - // Fetch user balances from CoW-supported chains - const { data: balances = [], isLoading: balancesLoading } = useUserBalancesQuery({ - networkIds: COW_SWAP_CHAINS as unknown as number[], + const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isRateInverted, setIsRateInverted] = useState(false); + 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'; + + // Fetch user balances from Velora-supported chains + const { + data: balances = [], + isLoading: balancesLoading, + refetch: refetchBalances, + } = useUserBalancesQuery({ + networkIds: VELORA_SWAP_CHAINS as unknown as number[], }); // Fetch all tokens for target selection @@ -45,15 +62,6 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp // Fetch markets to filter target tokens const { data: markets } = useMarketsQuery(); - // Handle approval for source token - const { allowance, approveInfinite, approvePending } = useAllowance({ - token: (sourceToken?.address ?? '0x0000000000000000000000000000000000000000') as `0x${string}`, - chainId: sourceToken?.chainId, - user: account, - spender: COW_VAULT_RELAYER, - tokenSymbol: sourceToken?.symbol, - }); - // Convert balances to SwapTokens (for source selection) const sourceTokens = useMemo(() => { return balances @@ -72,18 +80,18 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }); }, [balances]); - // Target tokens: all tokens with Morpho markets on CoW-supported chains + // Target tokens: all tokens with Morpho markets on Velora-supported chains const targetTokens = useMemo(() => { if (!markets) return []; // Get unique loan asset keys (address-chainId) that have markets const loanAssetKeys = new Set(markets.map((m) => `${m.loanAsset.address.toLowerCase()}-${m.morphoBlue.chain.id}`)); - // Filter allTokens to only those with markets on CoW-supported chains + // Filter allTokens to only those with markets on Velora-supported chains return allTokens .flatMap((token) => token.networks - .filter((net) => COW_SWAP_CHAINS.includes(net.chain.id as (typeof COW_SWAP_CHAINS)[number])) + .filter((net) => VELORA_SWAP_CHAINS.includes(net.chain.id as (typeof VELORA_SWAP_CHAINS)[number])) .filter((net) => loanAssetKeys.has(`${net.address.toLowerCase()}-${net.chain.id}`)) .map((net) => ({ address: net.address, @@ -107,32 +115,50 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp // Filter target tokens: exclude selected source (same token on same chain) const availableTargetTokens = useMemo(() => { if (!sourceToken) return targetTokens; - return targetTokens.filter( - (t) => !(t.chainId === sourceToken.chainId && t.address.toLowerCase() === sourceToken.address.toLowerCase()), - ); + return targetTokens.filter((t) => t.chainId === sourceToken.chainId && t.address.toLowerCase() !== sourceToken.address.toLowerCase()); }, [targetTokens, sourceToken]); - // CoW Swap hook - const { quote, isQuoting, isExecuting, error, orderUid, chainsMatch, executeSwap, reset } = useCowSwap({ + // Velora swap hook + const handleSwapConfirmed = useCallback(() => { + setInputAmount('0'); + setAmount(BigInt(0)); + void refetchBalances(); + }, [refetchBalances]); + + const { quote, isQuoting, isExecuting, error, chainsMatch, approvalTarget, executeSwap, reset } = useVeloraSwap({ sourceToken, targetToken, amount, slippageBps: Math.round(slippage * 100), + onSwapConfirmed: handleSwapConfirmed, }); // Check if approval is needed - const needsApproval = allowance < amount && amount > BigInt(0); + const spenderForAllowance = approvalTarget ?? (zeroAddress as `0x${string}`); - // Check if chains match (for showing warning) - const showChainMismatch = sourceToken && targetToken && !chainsMatch; + // Handle approval for source token + const { allowance, approveInfinite, approvePending } = useAllowance({ + token: (sourceToken?.address ?? zeroAddress) as `0x${string}`, + chainId: sourceToken?.chainId, + user: account, + spender: spenderForAllowance, + tokenSymbol: sourceToken?.symbol, + }); + const needsApproval = allowance < amount && amount > BigInt(0); const handleSourceTokenSelect = (token: SwapToken) => { + if (targetToken && targetToken.chainId !== token.chainId) { + setTargetToken(null); + } setSourceToken(token); setAmount(BigInt(0)); setInputAmount('0'); }; const handleTargetTokenSelect = (token: SwapToken) => { + if (sourceToken && sourceToken.chainId !== token.chainId) { + return; + } setTargetToken(token); }; @@ -150,6 +176,26 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp } }; + const handleSlippageChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '') { + return; + } + + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + + const clamped = Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, parsed)); + setSlippage(clamped); + }; + + const handleSlippageBlur = () => { + const normalized = Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, slippage)); + setSlippage(Number(normalized.toFixed(2))); + }; + const handleMaxClick = () => { if (sourceToken?.balance) { setAmount(sourceToken.balance); @@ -168,11 +214,13 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp // Unified execution handler - handles approve + swap automatically const handleSwap = useCallback(async () => { + if (!sourceToken || !targetToken || !approvalTarget) return; + if (needsApproval) { await approveInfinite(); } await executeSwap(); - }, [needsApproval, approveInfinite, executeSwap]); + }, [sourceToken, targetToken, approvalTarget, needsApproval, approveInfinite, executeSwap]); const isLoading = isQuoting || approvePending || isExecuting; @@ -188,63 +236,69 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp const getOutputDisplay = () => { if (!targetToken) return 'Select token below'; if (!sourceToken) return 'Select token above'; - if (showChainMismatch) { - return Select source on {getNetworkName(targetToken.chainId)}; - } if (amount === BigInt(0)) return '0'; - if (isQuoting) return 'Loading...'; - if (error) return {formatErrorMessage(error)}; + if (isQuoting) { + return ( + + + Quoting + + ); + } if (quote) return {Number(formatUnits(quote.buyAmount, targetToken.decimals)).toFixed(6)}; return '0'; }; + const ratePreviewText = useMemo(() => { + if (!quote || !sourceToken || !targetToken || error || !chainsMatch) return null; + + const sell = Number(formatUnits(quote.sellAmount, sourceToken.decimals)); + const buy = Number(formatUnits(quote.buyAmount, targetToken.decimals)); + if (!Number.isFinite(sell) || !Number.isFinite(buy) || sell <= 0 || buy <= 0) return null; + + if (isRateInverted) { + const inverseRate = sell / buy; + return `1 ${targetToken.symbol} ≈ ${formatRateValue(inverseRate)} ${sourceToken.symbol}`; + } + + const forwardRate = buy / sell; + return `1 ${sourceToken.symbol} ≈ ${formatRateValue(forwardRate)} ${targetToken.symbol}`; + }, [quote, sourceToken, targetToken, error, chainsMatch, isRateInverted]); + return ( !open && handleClose()} - size="lg" + size="xl" > - CoW Protocol - +
V
} />
{/* From Section */} -
-
- From - {sourceToken && ( - - )} -
-
+ + } + dropdown={ -
-
+ } + 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 && ( - -
- Order Created - - View in CoW Explorer - - -
-
- )} - {/* Empty State */} {!balancesLoading && sourceTokens.length === 0 && (

No tokens found on supported chains

-

Supported: Ethereum, Base, Arbitrum

+

Supported: Ethereum, Polygon, Unichain, Base, Arbitrum

)}
@@ -351,20 +434,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}

+
+ {field} +
{dropdown}
+
+ {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/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index 4e4fcb0e..7022067e 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -12,6 +12,7 @@ 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'; +import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; import { useStyledToast } from './useStyledToast'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; @@ -34,6 +35,7 @@ export function useMultiMarketSupply( const chainId = loanAsset?.network; const tokenSymbol = loanAsset?.symbol; const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); + const bundlerAddress = getBundlerV2(chainId ?? SupportedNetworks.Mainnet); const { batchAddUserMarkets } = useUserMarketsCache(account); @@ -44,7 +46,7 @@ export function useMultiMarketSupply( signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet), + spender: bundlerAddress, token: loanAsset?.address as `0x${string}`, refetchInterval: 10_000, chainId, @@ -54,11 +56,16 @@ export function useMultiMarketSupply( const { isApproved, approve } = useERC20Approval({ token: loanAsset?.address as Address, - spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet), + spender: bundlerAddress, amount: totalAmount, tokenSymbol: loanAsset?.symbol ?? '', }); + const { isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep({ + chainId: chainId ?? SupportedNetworks.Mainnet, + bundlerAddress: bundlerAddress as Address, + }); + const { isConfirming: supplyPending, sendTransactionAsync } = useTransactionWithToast({ toastId: 'multi-supply', pendingText: `Supplying ${formatBalance(totalAmount, loanAsset?.decimals ?? 18)} ${tokenSymbol}`, @@ -67,7 +74,10 @@ 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: () => { + void refetchIsBundlerAuthorized(); + if (onSuccess) onSuccess(); + }, }); const executeSupplyTransaction = useCallback(async () => { @@ -79,6 +89,18 @@ export function useMultiMarketSupply( let gas: bigint | undefined = undefined; try { + if (useEth || usePermit2Setting) { + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + txs.push(authorizationTxData); + } + } else { + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + } + // Handle ETH wrapping if needed if (useEth) { txs.push( @@ -155,7 +177,7 @@ export function useMultiMarketSupply( await sendTransactionAsync({ account, - to: getBundlerV2(chainId), + to: bundlerAddress, data: (encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'multicall', @@ -193,8 +215,10 @@ export function useMultiMarketSupply( sendTransactionAsync, useEth, signForBundlers, + ensureBundlerAuthorization, usePermit2Setting, chainId, + bundlerAddress, loanAsset, toast, tracking, @@ -300,6 +324,6 @@ export function useMultiMarketSupply( dismiss: tracking.dismiss, currentStep: tracking.currentStep, supplyPending, - isLoadingPermit2, + isLoadingPermit2: isLoadingPermit2 || isAuthorizingBundler, }; } diff --git a/src/hooks/useSupplyMarket.ts b/src/hooks/useSupplyMarket.ts index e7556d40..47755a28 100644 --- a/src/hooks/useSupplyMarket.ts +++ b/src/hooks/useSupplyMarket.ts @@ -3,6 +3,7 @@ import { type Address, encodeFunctionData, erc20Abi } from 'viem'; import { useConnection, useBalance, useReadContract } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { useERC20Approval } from '@/hooks/useERC20Approval'; +import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { usePermit2 } from '@/hooks/usePermit2'; import { useAppSettings } from '@/stores/useAppSettings'; import { useStyledToast } from '@/hooks/useStyledToast'; @@ -65,6 +66,7 @@ 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); // Get token balance const { @@ -100,7 +102,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: getBundlerV2(market.morphoBlue.chain.id), + spender: bundlerAddress, token: market.loanAsset.address as `0x${string}`, refetchInterval: 10_000, chainId: market.morphoBlue.chain.id, @@ -108,10 +110,15 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp amount: supplyAmount, }); + const { isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep({ + chainId: market.morphoBlue.chain.id, + bundlerAddress: bundlerAddress as Address, + }); + // 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 +137,10 @@ 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: () => { + void refetchIsBundlerAuthorized(); + if (onSuccess) onSuccess(); + }, }); // Helper to generate steps based on flow type @@ -165,6 +175,18 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp let gas: bigint | undefined = undefined; + if (useEth || usePermit2Setting) { + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + txs.push(authorizationTxData); + } + } else { + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + } + if (useEth) { txs.push( encodeFunctionData({ @@ -235,7 +257,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', @@ -268,12 +290,14 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp sendTransactionAsync, useEth, signForBundlers, + ensureBundlerAuthorization, usePermit2Setting, toast, batchAddUserMarkets, update, complete, fail, + bundlerAddress, ]); // Approve and supply handler @@ -438,7 +462,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp // Transaction state isApproved, permit2Authorized, - isLoadingPermit2, + isLoadingPermit2: isLoadingPermit2 || isAuthorizingBundler, supplyPending, // Actions 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/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; +}; From f95f464fceb244261d51bb93d52a980f61dc91a5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 15:07:13 +0800 Subject: [PATCH 2/6] feat: fix decimal inputs on slippage --- AGENTS.md | 5 +- package.json | 3 - pnpm-lock.yaml | 173 --------------------- public/imgs/protocols/cow.svg | 7 - src/components/Input/Input.tsx | 33 +++- src/components/ui/button.tsx | 18 +-- src/features/swap/components/SwapModal.tsx | 70 +++++++-- src/utils/decimal-input.ts | 24 +++ 8 files changed, 112 insertions(+), 221 deletions(-) delete mode 100644 public/imgs/protocols/cow.svg create mode 100644 src/utils/decimal-input.ts diff --git a/AGENTS.md b/AGENTS.md index b9c778fd..6498035b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,8 +143,9 @@ When touching transaction and position flows, validation MUST include all releva 8. **Null/data-corruption resilience**: guard null/undefined/stale API/contract fields so malformed data fails gracefully. 9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. 10. **Bundler authorization chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook. -11. **Aggregator API schema separation**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders and surface typed API errors. -12. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. +11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. +12. **Aggregator API schema separation**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders and surface typed API errors. +13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. ### REQUIRED: Regression Rule Capture diff --git a/package.json b/package.json index 31e81abd..384d885a 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@cowprotocol/cow-sdk": "^7.2.9", - "@cowprotocol/sdk-bridging": "^1.2.0", - "@cowprotocol/sdk-viem-adapter": "^0.3.0", "@heroicons/react": "^2.2.0", "@internationalized/date": "^3.8.2", "@merkl/api": "^1.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2144c5ed..5d7a73e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,6 @@ importers: .: dependencies: - '@cowprotocol/cow-sdk': - specifier: ^7.2.9 - version: 7.2.9(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-bridging': - specifier: ^1.2.0 - version: 1.2.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-viem-adapter': - specifier: ^0.3.0 - version: 0.3.0(viem@2.40.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) @@ -910,61 +901,6 @@ packages: '@coinbase/wallet-sdk@4.3.7': resolution: {integrity: sha512-z6e5XDw6EF06RqkeyEa+qD0dZ2ZbLci99vx3zwDY//XO8X7166tqKJrR2XlQnzVmtcUuJtCd5fCvr9Cu6zzX7w==} - '@cowprotocol/cow-sdk@7.2.9': - resolution: {integrity: sha512-rAy7cG2xz+1z/jUU6avKNmViJWNE//JYxUojPz44kzwkMlrfistFHqO3J6CWyzrfoKdLcN1ZI5PiBX+sgxb1og==} - peerDependencies: - '@openzeppelin/merkle-tree': ^1.x - cross-fetch: ^3.x - ipfs-only-hash: ^4.x - multiformats: ^9.x - peerDependenciesMeta: - '@openzeppelin/merkle-tree': - optional: true - ipfs-only-hash: - optional: true - multiformats: - optional: true - - '@cowprotocol/sdk-app-data@4.5.0': - resolution: {integrity: sha512-+MDjZei/Seb724fyU6UgIDkXfAD9DDVMH1PmgQc8W/Z4Cq4k3PPL8abYBMl2IWP8RvdipMxUnDdAx8rlQp0FXw==} - peerDependencies: - ajv: ^8.x - cross-fetch: ^3.x - ipfs-only-hash: ^4.x - multiformats: ^9.x - - '@cowprotocol/sdk-bridging@1.2.0': - resolution: {integrity: sha512-tt2MOEIO2+Wg/u0vEgAkKpp130yI/vpAkESsp1mWBEndtWsXjrK4/2UloKtM8wW6+47zYZHTRfIrhGIF5YtfRg==} - - '@cowprotocol/sdk-common@0.4.0': - resolution: {integrity: sha512-ciXiHzTzj7LKZqMKssgyNooZp1nS/mvRE9oO/6DlMQcxVA7/4ajPmk/XzseX/rjdRbSbYyXIEF1oY5w0tcbVjQ==} - - '@cowprotocol/sdk-config@0.6.2': - resolution: {integrity: sha512-L6cPT3pQCrHChRUjWffuYielUlw7TXPbI0o3IvimF/DSpPiGuW+A1g7XSLEGUhNcoKNfQf5YUZPaoenAKl9MNg==} - - '@cowprotocol/sdk-contracts-ts@1.0.0': - resolution: {integrity: sha512-i5clnrOZlixHiWUGrSJXlckx5gHXnQu7hcwiL4ut9PpwmqBpFXitS5mYbFr8Py5UAJZ86QHmiXUodL/392QAAA==} - - '@cowprotocol/sdk-cow-shed@0.2.9': - resolution: {integrity: sha512-nv+/ALXU0ur3NSKmislq0e+2zl2XCyXqylJil7XfXlbcrzn7AaF1SkTJHgmwZ3P3R4nCYGS/nWfYkgRyBdoaug==} - - '@cowprotocol/sdk-order-book@0.5.0': - resolution: {integrity: sha512-teFVH9FILYGPOpj5v539ogeqAk97kxMxeIG93Dj5G7Qfp+V97/gOKcE8/8Rw0quKt2KQYtyUspspQFhYF4g6SQ==} - - '@cowprotocol/sdk-order-signing@0.1.23': - resolution: {integrity: sha512-TnmGElnPlaUvp5GV+aaJrwXY/csyJZWETeIg56AxBj5xKXC555uamOVCTFO4rM9sHfCVLwS71JZR3WrQcOgfkw==} - - '@cowprotocol/sdk-trading@0.8.0': - resolution: {integrity: sha512-aNxnUBkbxXEkcQyCiL+WZQAFptORtOmQFH7cZq+dDNgOzFIy7xErtQ7PVnDZP497We3+EcJVq50Q7D6b6Gc+BA==} - - '@cowprotocol/sdk-viem-adapter@0.3.0': - resolution: {integrity: sha512-IDCrdSBKaLJsgJR8kPFb5wjIt8Hk3dw4hqZg7nIkHAz36N+feHA2DYucgGOC0r1iuAy0EC+F/XXiemqGMb+nXg==} - peerDependencies: - viem: ^2.28.4 - - '@cowprotocol/sdk-weiroll@0.1.11': - resolution: {integrity: sha512-l1lNaN46VnQrPsCqLPiD4yxOSBhRUDT1Am8lWsUN8U6xD8NPdTJWE/UbpkrHxBd0nvpW246zWlLm31FJcz5XXA==} - '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} @@ -7669,115 +7605,6 @@ snapshots: - zod optional: true - '@cowprotocol/cow-sdk@7.2.9(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-app-data': 4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-order-book': 0.5.0 - '@cowprotocol/sdk-order-signing': 0.1.23 - '@cowprotocol/sdk-trading': 0.8.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - cross-fetch: 4.1.0 - optionalDependencies: - ipfs-only-hash: 4.0.0 - multiformats: 9.9.0 - transitivePeerDependencies: - - ajv - - encoding - - '@cowprotocol/sdk-app-data@4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - ajv: 8.17.1 - cross-fetch: 4.1.0 - ipfs-only-hash: 4.0.0 - json-stringify-deterministic: 1.0.12 - multiformats: 9.9.0 - - '@cowprotocol/sdk-bridging@1.2.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-app-data': 4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-cow-shed': 0.2.9 - '@cowprotocol/sdk-order-book': 0.5.0 - '@cowprotocol/sdk-trading': 0.8.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-weiroll': 0.1.11 - '@defuse-protocol/one-click-sdk-typescript': 0.1.1-0.2 - json-stable-stringify: 1.3.0 - transitivePeerDependencies: - - ajv - - cross-fetch - - debug - - encoding - - ipfs-only-hash - - multiformats - - '@cowprotocol/sdk-common@0.4.0': {} - - '@cowprotocol/sdk-config@0.6.2': - dependencies: - exponential-backoff: 3.1.3 - limiter: 2.1.0 - - '@cowprotocol/sdk-contracts-ts@1.0.0': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - - '@cowprotocol/sdk-cow-shed@0.2.9': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - - '@cowprotocol/sdk-order-book@0.5.0': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - cross-fetch: 3.2.0 - exponential-backoff: 3.1.3 - limiter: 3.0.0 - transitivePeerDependencies: - - encoding - - '@cowprotocol/sdk-order-signing@0.1.23': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-order-book': 0.5.0 - transitivePeerDependencies: - - encoding - - '@cowprotocol/sdk-trading@0.8.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-app-data': 4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-order-book': 0.5.0 - '@cowprotocol/sdk-order-signing': 0.1.23 - deepmerge: 4.3.1 - transitivePeerDependencies: - - ajv - - cross-fetch - - encoding - - ipfs-only-hash - - multiformats - - '@cowprotocol/sdk-viem-adapter@0.3.0(viem@2.40.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - viem: 2.40.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - - '@cowprotocol/sdk-weiroll@0.1.11': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 diff --git a/public/imgs/protocols/cow.svg b/public/imgs/protocols/cow.svg deleted file mode 100644 index 7e9ab1a9..00000000 --- a/public/imgs/protocols/cow.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index e2e3c095..081cbfe2 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -2,6 +2,7 @@ import { useCallback, useState, useEffect } from 'react'; import { parseUnits } from 'viem'; import { formatBalance } from '@/utils/balance'; +import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { Button } from '@/components/ui/button'; type InputProps = { @@ -46,11 +47,21 @@ export default function Input({ const onInputChange = useCallback( (e: React.ChangeEvent) => { // update the shown input text regardless - const inputText = e.target.value; - setInputAmount(inputText); + const normalizedInput = sanitizeDecimalInput(e.target.value); + if (!isValidDecimalInput(normalizedInput)) { + return; + } + setInputAmount(normalizedInput); + + const parseableInput = toParseableDecimalInput(normalizedInput); + if (!parseableInput) { + setValue(BigInt(0)); + if (setError) setError(null); + return; + } try { - const inputBigInt = parseUnits(inputText, decimals); + const inputBigInt = parseUnits(parseableInput, decimals); if (max !== undefined && inputBigInt > max && !bypassMax) { if (setError) setError(exceedMaxErrMessage ?? 'Input exceeds max'); @@ -62,9 +73,8 @@ export default function Input({ setValue(inputBigInt); if (setError) setError(null); - } catch (err) { + } catch { if (setError) setError('Invalid input'); - console.log('e', err); } }, [decimals, setError, setInputAmount, setValue, max, exceedMaxErrMessage, allowExceedMax, bypassMax], @@ -74,8 +84,15 @@ export default function Input({ const handleDismissError = useCallback(() => { setBypassMax(true); if (setError) setError(null); + + const parseableInput = toParseableDecimalInput(inputAmount); + if (!parseableInput) { + setValue(BigInt(0)); + return; + } + try { - const inputBigInt = parseUnits(inputAmount, decimals); + const inputBigInt = parseUnits(parseableInput, decimals); setValue(inputBigInt); } catch { // Invalid input, ignore @@ -96,7 +113,9 @@ export default function Input({
span:not(.loading-spinner)]:opacity-0 [&>svg]:opacity-0 [&>*:not(.loading-spinner)]:opacity-0', - }, // Ghost button hover effects - subtle background changes with brightness adjustments { variant: 'ghost', @@ -100,16 +96,14 @@ const Button = forwardRef( disabled={isLoading ? true : props.disabled} {...props} > + {children} {isLoading ? ( - - - + ) : null} - {children} ); }, diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 051e437e..d5109bf1 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -13,6 +13,7 @@ import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAllowance } from '@/hooks/useAllowance'; import { formatBalance } from '@/utils/balance'; +import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { useVeloraSwap } from '../hooks/useVeloraSwap'; import { TokenNetworkDropdown } from './TokenNetworkDropdown'; import { SwapTokenAmountField } from './SwapTokenAmountField'; @@ -28,6 +29,14 @@ type SwapModalProps = { const MIN_SLIPPAGE_PERCENT = 0.1; const MAX_SLIPPAGE_PERCENT = 5; +const formatSlippagePercent = (value: number): string => { + return value.toFixed(2).replace(/\.?0+$/, ''); +}; + +const clampSlippagePercent = (value: number): number => { + return Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, value)); +}; + const formatRateValue = (value: number): string => { if (!Number.isFinite(value) || value <= 0) return '0'; if (value >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); @@ -42,6 +51,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp const [inputAmount, setInputAmount] = useState('0'); const [amount, setAmount] = useState(BigInt(0)); const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); + const [slippageInput, setSlippageInput] = useState(formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isRateInverted, setIsRateInverted] = useState(false); const amountInputClassName = @@ -163,37 +173,64 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }; const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setInputAmount(value); + const normalizedInput = sanitizeDecimalInput(e.target.value); + if (!isValidDecimalInput(normalizedInput)) { + return; + } + setInputAmount(normalizedInput); if (!sourceToken) return; + const parseableInput = toParseableDecimalInput(normalizedInput); + if (!parseableInput) { + setAmount(BigInt(0)); + return; + } + try { - const parsed = parseUnits(value, sourceToken.decimals); + const parsed = parseUnits(parseableInput, sourceToken.decimals); setAmount(parsed); } catch { - // Invalid input, keep previous amount + // Invalid input, keep previous parsed amount } }; const handleSlippageChange = (e: React.ChangeEvent) => { - const value = e.target.value; - if (value === '') { + const normalizedInput = sanitizeDecimalInput(e.target.value); + if (!isValidDecimalInput(normalizedInput)) { return; } + setSlippageInput(normalizedInput); - const parsed = Number(value); + const parseableInput = toParseableDecimalInput(normalizedInput); + if (!parseableInput) { + return; + } + + const parsed = Number(parseableInput); if (Number.isNaN(parsed)) { return; } - const clamped = Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, parsed)); - setSlippage(clamped); + setSlippage(clampSlippagePercent(parsed)); }; const handleSlippageBlur = () => { - const normalized = Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, slippage)); - setSlippage(Number(normalized.toFixed(2))); + const parseableInput = toParseableDecimalInput(slippageInput); + if (!parseableInput) { + setSlippageInput(formatSlippagePercent(slippage)); + return; + } + + const parsed = Number(parseableInput); + if (Number.isNaN(parsed)) { + setSlippageInput(formatSlippagePercent(slippage)); + return; + } + + const normalized = clampSlippagePercent(parsed); + setSlippage(normalized); + setSlippageInput(formatSlippagePercent(normalized)); }; const handleMaxClick = () => { @@ -370,7 +407,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp > Settings - Slippage {slippage}% + Slippage {formatSlippagePercent(slippage)}% @@ -388,11 +425,10 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp Max slippage
{ + const normalized = value.replace(/,/g, '.').replace(/[^\d.]/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; +}; From 2a6e23600f94574f2db0217f4ca0a0540d789093 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 15:37:50 +0800 Subject: [PATCH 3/6] chore: revie fixes --- src/components/ui/button.tsx | 4 ++-- src/features/swap/api/velora.ts | 25 ++++++++++++++++++---- src/features/swap/components/SwapModal.tsx | 3 ++- src/hooks/useMultiMarketSupply.ts | 24 +++------------------ src/hooks/useSupplyMarket.ts | 24 +++------------------ 5 files changed, 31 insertions(+), 49 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e642b920..4bee92c3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -87,14 +87,14 @@ export type ButtonProps = { VariantProps; const Button = forwardRef( - ({ className, variant, size, radius, fullWidth, isLoading, asChild = false, children, ...props }, ref) => { + ({ className, variant, size, radius, fullWidth, isLoading, asChild = false, children, disabled, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( {children} {isLoading ? ( diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index d13e80fd..3d879447 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -1,4 +1,4 @@ -import type { Address } from 'viem'; +import { isAddress, isHex, type Address } from 'viem'; import { SWAP_PARTNER, VELORA_API_BASE_URL, VELORA_PRICES_API_VERSION } from '../constants'; export type VeloraSwapSide = 'SELL' | 'BUY'; @@ -145,9 +145,23 @@ const fetchVeloraJson = async (url: string, init?: RequestInit): Promise = return payload as T; }; +const parseVeloraAddressField = (value: unknown, fieldName: string): Address => { + if (typeof value !== 'string' || !isAddress(value)) { + throw new VeloraApiError(`Invalid ${fieldName} address returned by Velora`, 400, { [fieldName]: value }); + } + return value as Address; +}; + +const parseVeloraHexDataField = (value: unknown, fieldName: string): `0x${string}` => { + if (typeof value !== 'string' || !isHex(value) || value.length <= 2) { + throw new VeloraApiError(`Invalid ${fieldName} payload returned by Velora`, 400, { [fieldName]: value }); + } + return value as `0x${string}`; +}; + export const getVeloraApprovalTarget = (priceRoute: VeloraPriceRoute | null): Address | null => { const spender = priceRoute?.tokenTransferProxy ?? priceRoute?.contractAddress; - if (!spender || !spender.startsWith('0x')) return null; + if (!spender || !isAddress(spender)) return null; return spender as Address; }; @@ -246,9 +260,12 @@ export const buildVeloraTransactionPayload = async ({ ); } + const to = parseVeloraAddressField(response.to, 'to'); + const data = parseVeloraHexDataField(response.data, 'data'); + return { - to: response.to as Address, - data: response.data as `0x${string}`, + to, + data, value: response.value, gas: response.gas, gasPrice: response.gasPrice, diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index d5109bf1..4e667dbb 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -191,7 +191,8 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp const parsed = parseUnits(parseableInput, sourceToken.decimals); setAmount(parsed); } catch { - // Invalid input, keep previous parsed amount + // Clear parsed amount to avoid submitting a stale previous value. + setAmount(BigInt(0)); } }; diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index 7022067e..465f783d 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -12,7 +12,6 @@ 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'; -import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; import { useStyledToast } from './useStyledToast'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; @@ -61,11 +60,6 @@ export function useMultiMarketSupply( tokenSymbol: loanAsset?.symbol ?? '', }); - const { isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep({ - chainId: chainId ?? SupportedNetworks.Mainnet, - bundlerAddress: bundlerAddress as Address, - }); - const { isConfirming: supplyPending, sendTransactionAsync } = useTransactionWithToast({ toastId: 'multi-supply', pendingText: `Supplying ${formatBalance(totalAmount, loanAsset?.decimals ?? 18)} ${tokenSymbol}`, @@ -75,7 +69,6 @@ export function useMultiMarketSupply( pendingDescription: `Supplying to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`, successDescription: `Successfully supplied to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`, onSuccess: () => { - void refetchIsBundlerAuthorized(); if (onSuccess) onSuccess(); }, }); @@ -89,18 +82,8 @@ export function useMultiMarketSupply( let gas: bigint | undefined = undefined; try { - if (useEth || usePermit2Setting) { - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); - if (authorizationTxData) { - txs.push(authorizationTxData); - } - } else { - const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); - if (!authorized) { - throw new Error('Failed to authorize Bundler via transaction.'); - } - } - + // 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( @@ -215,7 +198,6 @@ export function useMultiMarketSupply( sendTransactionAsync, useEth, signForBundlers, - ensureBundlerAuthorization, usePermit2Setting, chainId, bundlerAddress, @@ -324,6 +306,6 @@ export function useMultiMarketSupply( dismiss: tracking.dismiss, currentStep: tracking.currentStep, supplyPending, - isLoadingPermit2: isLoadingPermit2 || isAuthorizingBundler, + isLoadingPermit2, }; } diff --git a/src/hooks/useSupplyMarket.ts b/src/hooks/useSupplyMarket.ts index 47755a28..a4478d1a 100644 --- a/src/hooks/useSupplyMarket.ts +++ b/src/hooks/useSupplyMarket.ts @@ -3,7 +3,6 @@ import { type Address, encodeFunctionData, erc20Abi } from 'viem'; import { useConnection, useBalance, useReadContract } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { useERC20Approval } from '@/hooks/useERC20Approval'; -import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { usePermit2 } from '@/hooks/usePermit2'; import { useAppSettings } from '@/stores/useAppSettings'; import { useStyledToast } from '@/hooks/useStyledToast'; @@ -110,11 +109,6 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp amount: supplyAmount, }); - const { isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep({ - chainId: market.morphoBlue.chain.id, - bundlerAddress: bundlerAddress as Address, - }); - // Handle ERC20 approval const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, @@ -138,7 +132,6 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp pendingDescription: `Supplying to market ${market.uniqueKey.slice(2, 8)}...`, successDescription: `Successfully supplied to market ${market.uniqueKey.slice(2, 8)}`, onSuccess: () => { - void refetchIsBundlerAuthorized(); if (onSuccess) onSuccess(); }, }); @@ -175,18 +168,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp let gas: bigint | undefined = undefined; - if (useEth || usePermit2Setting) { - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); - if (authorizationTxData) { - txs.push(authorizationTxData); - } - } else { - const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); - if (!authorized) { - throw new Error('Failed to authorize Bundler via transaction.'); - } - } - + // 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({ @@ -290,7 +273,6 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp sendTransactionAsync, useEth, signForBundlers, - ensureBundlerAuthorization, usePermit2Setting, toast, batchAddUserMarkets, @@ -462,7 +444,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp // Transaction state isApproved, permit2Authorized, - isLoadingPermit2: isLoadingPermit2 || isAuthorizingBundler, + isLoadingPermit2, supplyPending, // Actions From fe3fde43983d5a146f70267ace4f8c0386ce92ce Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 16:11:33 +0800 Subject: [PATCH 4/6] chore: review fixes --- AGENTS.md | 1 + src/features/swap/api/velora.ts | 25 ++++++------ src/features/swap/components/SwapModal.tsx | 24 +++++++++++- src/hooks/useMultiMarketSupply.ts | 17 +++++++- src/hooks/useSupplyMarket.ts | 45 +++++++++++++++++++--- src/utils/decimal-input.ts | 4 +- 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6498035b..6e7ef86b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,7 @@ When touching transaction and position flows, validation MUST include all releva 11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. 12. **Aggregator API schema separation**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders and surface typed API errors. 13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. +14. **Input/state integrity in tx-critical UIs**: never strip unsupported numeric syntax into a different value (e.g. `1e-6` must be rejected, not rewritten), and after any balance refetch re-derive selected token objects from refreshed data before allowing `Max`/submit. ### REQUIRED: Regression Rule Capture diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index 3d879447..b9192606 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -124,6 +124,11 @@ const extractVeloraErrorMessage = (payload: unknown): string => { return 'Unknown Velora API error'; }; +const getVeloraApiErrorMessage = (payload: unknown, fallbackMessage: string): string => { + const message = extractVeloraErrorMessage(payload); + return message === 'Unknown Velora API error' ? fallbackMessage : message; +}; + const fetchVeloraJson = async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init); const raw = await response.text(); @@ -194,16 +199,12 @@ export const fetchVeloraPriceRoute = async ({ version: VELORA_PRICES_API_VERSION, }); - const response = await fetchVeloraJson(`${VELORA_API_BASE_URL}/prices?${query.toString()}`, { + const response = await fetchVeloraJson(`${VELORA_API_BASE_URL}/prices?${query.toString()}`, { method: 'GET', }); - if (!response.priceRoute) { - throw new VeloraApiError( - response.description ?? response.error ?? response.message ?? 'No price route returned by Velora', - 400, - response, - ); + if (!response || typeof response !== 'object' || !response.priceRoute) { + throw new VeloraApiError(getVeloraApiErrorMessage(response, 'No price route returned by Velora'), 400, response); } return response.priceRoute; @@ -233,7 +234,7 @@ export const buildVeloraTransactionPayload = async ({ ? `${VELORA_API_BASE_URL}/transactions/${network}?${query.toString()}` : `${VELORA_API_BASE_URL}/transactions/${network}`; - const response = await fetchVeloraJson(transactionUrl, { + const response = await fetchVeloraJson(transactionUrl, { method: 'POST', headers: { 'content-type': 'application/json', @@ -252,12 +253,8 @@ export const buildVeloraTransactionPayload = async ({ }), }); - if (!response.to || !response.data) { - throw new VeloraApiError( - response.description ?? response.error ?? response.message ?? 'Invalid transaction payload from Velora', - 400, - response, - ); + if (!response || typeof response !== 'object' || !response.to || !response.data) { + throw new VeloraApiError(getVeloraApiErrorMessage(response, 'Invalid transaction payload from Velora'), 400, response); } const to = parseVeloraAddressField(response.to, 'to'); diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 4e667dbb..9cc5d122 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ArrowDownIcon, ChevronDownIcon } from '@radix-ui/react-icons'; import { IoIosSwap } from 'react-icons/io'; import { formatUnits, parseUnits, zeroAddress } from 'viem'; @@ -90,6 +90,28 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }); }, [balances]); + useEffect(() => { + if (balancesLoading) return; + if (!sourceToken) return; + + const refreshedSourceToken = sourceTokens.find( + (token) => token.chainId === sourceToken.chainId && token.address.toLowerCase() === sourceToken.address.toLowerCase(), + ); + + if (!refreshedSourceToken) { + setSourceToken(null); + return; + } + + if ( + refreshedSourceToken.balance !== sourceToken.balance || + refreshedSourceToken.decimals !== sourceToken.decimals || + refreshedSourceToken.symbol !== sourceToken.symbol + ) { + setSourceToken(refreshedSourceToken); + } + }, [balancesLoading, sourceToken, sourceTokens]); + // Target tokens: all tokens with Morpho markets on Velora-supported chains const targetTokens = useMemo(() => { if (!markets) return []; diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index 465f783d..1ad084da 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'; @@ -35,6 +35,10 @@ export function useMultiMarketSupply( const tokenSymbol = loanAsset?.symbol; const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); const bundlerAddress = getBundlerV2(chainId ?? SupportedNetworks.Mainnet); + const isBundlerAddressValid = Boolean(bundlerAddress) && bundlerAddress !== zeroAddress; + const bundlerAddressErrorMessage = chainId + ? `No bundler configured for chain ${chainId}.` + : 'No bundler configured for the selected network.'; const { batchAddUserMarkets } = useUserMarketsCache(account); @@ -45,7 +49,7 @@ export function useMultiMarketSupply( signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: bundlerAddress, + spender: isBundlerAddressValid ? bundlerAddress : undefined, token: loanAsset?.address as `0x${string}`, refetchInterval: 10_000, chainId, @@ -76,6 +80,7 @@ export function useMultiMarketSupply( 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}`[] = []; @@ -201,6 +206,8 @@ export function useMultiMarketSupply( usePermit2Setting, chainId, bundlerAddress, + bundlerAddressErrorMessage, + isBundlerAddressValid, loanAsset, toast, tracking, @@ -211,6 +218,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 @@ -298,6 +309,8 @@ export function useMultiMarketSupply( tracking, tokenSymbol, supplies, + bundlerAddressErrorMessage, + isBundlerAddressValid, ]); return { diff --git a/src/hooks/useSupplyMarket.ts b/src/hooks/useSupplyMarket.ts index a4478d1a..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'; @@ -66,6 +66,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp 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 { @@ -101,7 +103,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: bundlerAddress, + spender: isBundlerAddressValid ? bundlerAddress : undefined, token: market.loanAsset.address as `0x${string}`, refetchInterval: 10_000, chainId: market.morphoBlue.chain.id, @@ -164,6 +166,10 @@ 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; @@ -261,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; } }, [ @@ -280,6 +290,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp complete, fail, bundlerAddress, + bundlerAddressErrorMessage, + isBundlerAddressValid, ]); // Approve and supply handler @@ -288,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'; @@ -379,6 +395,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp getStepsForFlow, market, supplyAmount, + bundlerAddressErrorMessage, + isBundlerAddressValid, ]); // Sign and supply handler @@ -387,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( @@ -415,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/utils/decimal-input.ts b/src/utils/decimal-input.ts index d7f47cb7..4739bf8d 100644 --- a/src/utils/decimal-input.ts +++ b/src/utils/decimal-input.ts @@ -1,7 +1,9 @@ const DECIMAL_INPUT_REGEX = /^\d*\.?\d*$/; export const sanitizeDecimalInput = (value: string): string => { - const normalized = value.replace(/,/g, '.').replace(/[^\d.]/g, ''); + // 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; From b7d320b2ce3f918af668fd08648da0a5128f4fe1 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 16:26:18 +0800 Subject: [PATCH 5/6] misc: review fixes --- AGENTS.md | 2 +- src/features/swap/api/velora.ts | 94 ++++++++++++++++++---- src/features/swap/components/SwapModal.tsx | 60 +++++++++----- src/hooks/useAllowance.ts | 7 +- src/hooks/useMultiMarketSupply.ts | 7 +- src/types/token.ts | 11 ++- src/utils/token-amount-format.ts | 2 +- 7 files changed, 140 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e7ef86b..4e762b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ When touching transaction and position flows, validation MUST include all releva 9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. 10. **Bundler authorization chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook. 11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. -12. **Aggregator API schema separation**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders and surface typed API errors. +12. **Aggregator API contract integrity**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders, normalize fetch/network failures into typed API errors, and verify returned route token addresses match requested canonical token addresses before using previews/tx payloads. 13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. 14. **Input/state integrity in tx-critical UIs**: never strip unsupported numeric syntax into a different value (e.g. `1e-6` must be rejected, not rewritten), and after any balance refetch re-derive selected token objects from refreshed data before allowing `Max`/submit. diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index b9192606..327c6981 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -1,4 +1,5 @@ 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'; export type VeloraSwapSide = 'SELL' | 'BUY'; @@ -130,24 +131,33 @@ const getVeloraApiErrorMessage = (payload: unknown, fallbackMessage: string): st }; const fetchVeloraJson = async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init); - const raw = await response.text(); - - let payload: unknown = null; - if (raw) { - try { - payload = JSON.parse(raw) as unknown; - } catch { - payload = raw; + try { + const response = await fetch(url, init); + const raw = await response.text(); + + let payload: unknown = null; + if (raw) { + try { + payload = JSON.parse(raw) as unknown; + } catch { + payload = raw; + } } - } - if (!response.ok) { - const message = extractVeloraErrorMessage(payload); - throw new VeloraApiError(message, response.status, payload); - } + if (!response.ok) { + const message = extractVeloraErrorMessage(payload); + throw new VeloraApiError(message, response.status, payload); + } - return payload as T; + return payload as T; + } catch (error: unknown) { + if (error instanceof VeloraApiError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Unknown network error'; + throw new VeloraApiError(`Failed to fetch Velora API response: ${message}`, 0, error); + } }; const parseVeloraAddressField = (value: unknown, fieldName: string): Address => { @@ -164,6 +174,29 @@ const parseVeloraHexDataField = (value: unknown, fieldName: string): `0x${string return value as `0x${string}`; }; +const PRICE_ROUTE_SOURCE_TOKEN_FIELDS = ['fromTokenAddress', 'inputToken', 'srcToken', 'srcTokenAddress'] as const; +const PRICE_ROUTE_DESTINATION_TOKEN_FIELDS = ['toTokenAddress', 'outputToken', 'destToken', 'destTokenAddress'] as const; + +const resolveCanonicalRouteTokenAddress = (priceRoute: VeloraPriceRoute, fields: readonly string[]): Address | null => { + const routePayload = priceRoute as Record; + + for (const field of fields) { + const rawFieldValue = routePayload[field]; + if (typeof rawFieldValue !== 'string' || rawFieldValue.length === 0) { + continue; + } + + const canonicalAddress = toCanonicalTokenAddress(rawFieldValue); + if (!canonicalAddress) { + throw new VeloraApiError(`Invalid ${field} token address returned by Velora`, 400, { [field]: rawFieldValue, priceRoute }); + } + + return canonicalAddress; + } + + return null; +}; + export const getVeloraApprovalTarget = (priceRoute: VeloraPriceRoute | null): Address | null => { const spender = priceRoute?.tokenTransferProxy ?? priceRoute?.contractAddress; if (!spender || !isAddress(spender)) return null; @@ -186,6 +219,16 @@ export const fetchVeloraPriceRoute = async ({ partner = SWAP_PARTNER, side = 'SELL', }: FetchVeloraPriceRouteParams): Promise => { + const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken); + const requestedDestinationTokenAddress = toCanonicalTokenAddress(destToken); + if (!requestedSourceTokenAddress || !requestedDestinationTokenAddress) { + throw new VeloraApiError('Invalid source or destination token address provided for Velora quote request', 400, { + srcToken, + destToken, + network, + }); + } + const query = new URLSearchParams({ srcToken, destToken, @@ -207,6 +250,27 @@ export const fetchVeloraPriceRoute = async ({ throw new VeloraApiError(getVeloraApiErrorMessage(response, 'No price route returned by Velora'), 400, response); } + const routeSourceTokenAddress = resolveCanonicalRouteTokenAddress(response.priceRoute, PRICE_ROUTE_SOURCE_TOKEN_FIELDS); + if (routeSourceTokenAddress && routeSourceTokenAddress !== requestedSourceTokenAddress) { + throw new VeloraApiError('Velora route source token does not match the requested source token', 400, { + requestedSourceTokenAddress, + routeSourceTokenAddress, + response, + }); + } + + const routeDestinationTokenAddress = resolveCanonicalRouteTokenAddress( + response.priceRoute, + PRICE_ROUTE_DESTINATION_TOKEN_FIELDS, + ); + if (routeDestinationTokenAddress && routeDestinationTokenAddress !== requestedDestinationTokenAddress) { + throw new VeloraApiError('Velora route destination token does not match the requested destination token', 400, { + requestedDestinationTokenAddress, + routeDestinationTokenAddress, + response, + }); + } + return response.priceRoute; }; diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 9cc5d122..111e0a82 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { ArrowDownIcon, ChevronDownIcon } from '@radix-ui/react-icons'; import { IoIosSwap } from 'react-icons/io'; -import { formatUnits, parseUnits, zeroAddress } from 'viem'; +import { formatUnits, isAddress, parseUnits, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; import { AnimatePresence, motion } from 'framer-motion'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; @@ -14,6 +14,7 @@ import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAllowance } from '@/hooks/useAllowance'; import { formatBalance } from '@/utils/balance'; import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; +import { formatCompactTokenAmount, formatTokenAmountPreview } from '@/utils/token-amount-format'; import { useVeloraSwap } from '../hooks/useVeloraSwap'; import { TokenNetworkDropdown } from './TokenNetworkDropdown'; import { SwapTokenAmountField } from './SwapTokenAmountField'; @@ -28,6 +29,8 @@ type SwapModalProps = { const MIN_SLIPPAGE_PERCENT = 0.1; const MAX_SLIPPAGE_PERCENT = 5; +const DEFAULT_CHAIN_ID = 1; +const RATE_PREVIEW_DECIMALS = 8; const formatSlippagePercent = (value: number): string => { return value.toFixed(2).replace(/\.?0+$/, ''); @@ -37,11 +40,19 @@ const clampSlippagePercent = (value: number): number => { return Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, value)); }; -const formatRateValue = (value: number): string => { - if (!Number.isFinite(value) || value <= 0) return '0'; - if (value >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); - if (value >= 1) return value.toLocaleString(undefined, { maximumFractionDigits: 6 }); - return value.toLocaleString(undefined, { maximumFractionDigits: 8 }); +const computeUnitRatePreviewAmount = ( + baseAmount: bigint, + baseTokenDecimals: number, + quoteAmount: bigint, + quoteTokenDecimals: number, +): bigint | null => { + if (baseAmount <= 0n || quoteAmount <= 0n) return null; + + const scaledNumerator = quoteAmount * 10n ** BigInt(baseTokenDecimals + RATE_PREVIEW_DECIMALS); + const scaledDenominator = baseAmount * 10n ** BigInt(quoteTokenDecimals); + if (scaledDenominator <= 0n) return null; + + return scaledNumerator / scaledDenominator; }; export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProps) { @@ -166,11 +177,14 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }); // Check if approval is needed - const spenderForAllowance = approvalTarget ?? (zeroAddress as `0x${string}`); + const sourceTokenAddress = + sourceToken?.address && isAddress(sourceToken.address) ? (sourceToken.address as `0x${string}`) : (zeroAddress as `0x${string}`); + const spenderForAllowance = + approvalTarget && isAddress(approvalTarget) ? (approvalTarget as `0x${string}`) : (zeroAddress as `0x${string}`); // Handle approval for source token const { allowance, approveInfinite, approvePending } = useAllowance({ - token: (sourceToken?.address ?? zeroAddress) as `0x${string}`, + token: sourceTokenAddress, chainId: sourceToken?.chainId, user: account, spender: spenderForAllowance, @@ -309,24 +323,34 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp ); } - if (quote) return {Number(formatUnits(quote.buyAmount, targetToken.decimals)).toFixed(6)}; + if (quote) return {formatCompactTokenAmount(quote.buyAmount, targetToken.decimals)}; return '0'; }; const ratePreviewText = useMemo(() => { if (!quote || !sourceToken || !targetToken || error || !chainsMatch) return null; - const sell = Number(formatUnits(quote.sellAmount, sourceToken.decimals)); - const buy = Number(formatUnits(quote.buyAmount, targetToken.decimals)); - if (!Number.isFinite(sell) || !Number.isFinite(buy) || sell <= 0 || buy <= 0) return null; - if (isRateInverted) { - const inverseRate = sell / buy; - return `1 ${targetToken.symbol} ≈ ${formatRateValue(inverseRate)} ${sourceToken.symbol}`; + const inverseRate = computeUnitRatePreviewAmount( + quote.buyAmount, + targetToken.decimals, + quote.sellAmount, + sourceToken.decimals, + ); + if (!inverseRate) return null; + const inverseRatePreview = formatTokenAmountPreview(inverseRate, RATE_PREVIEW_DECIMALS).compact; + return `1 ${targetToken.symbol} ≈ ${inverseRatePreview} ${sourceToken.symbol}`; } - const forwardRate = buy / sell; - return `1 ${sourceToken.symbol} ≈ ${formatRateValue(forwardRate)} ${targetToken.symbol}`; + const forwardRate = computeUnitRatePreviewAmount( + quote.sellAmount, + sourceToken.decimals, + quote.buyAmount, + targetToken.decimals, + ); + if (!forwardRate) return null; + const forwardRatePreview = formatTokenAmountPreview(forwardRate, RATE_PREVIEW_DECIMALS).compact; + return `1 ${sourceToken.symbol} ≈ ${forwardRatePreview} ${targetToken.symbol}`; }, [quote, sourceToken, targetToken, error, chainsMatch, isRateInverted]); return ( @@ -496,7 +520,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp Cancel void handleSwap()} isLoading={isLoading} 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 1ad084da..781e171f 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -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,11 +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 = getBundlerV2(chainId ?? SupportedNetworks.Mainnet); - const isBundlerAddressValid = Boolean(bundlerAddress) && bundlerAddress !== zeroAddress; + const bundlerAddress = chainId ? getBundlerV2(chainId) : zeroAddress; + const isBundlerAddressValid = chainId !== undefined && bundlerAddress !== zeroAddress; const bundlerAddressErrorMessage = chainId ? `No bundler configured for chain ${chainId}.` - : 'No bundler configured for the selected network.'; + : 'No chain selected for multi-market supply.'; const { batchAddUserMarkets } = useUserMarketsCache(account); 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/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'; From 6cf408b885d4d01f4fa8fd23ca1d49aabe8ba201 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Feb 2026 16:44:02 +0800 Subject: [PATCH 6/6] chore: fix review --- src/features/swap/api/velora.ts | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts index 327c6981..1cededd1 100644 --- a/src/features/swap/api/velora.ts +++ b/src/features/swap/api/velora.ts @@ -174,9 +174,30 @@ const parseVeloraHexDataField = (value: unknown, fieldName: string): `0x${string return value as `0x${string}`; }; +const REQUIRED_PRICE_ROUTE_STRING_FIELDS = ['srcToken', 'destToken', 'srcAmount', 'destAmount'] as const; const PRICE_ROUTE_SOURCE_TOKEN_FIELDS = ['fromTokenAddress', 'inputToken', 'srcToken', 'srcTokenAddress'] as const; const PRICE_ROUTE_DESTINATION_TOKEN_FIELDS = ['toTokenAddress', 'outputToken', 'destToken', 'destTokenAddress'] as const; +const validateVeloraPriceRouteShape = (priceRoute: unknown, responsePayload: unknown): VeloraPriceRoute => { + if (!priceRoute || typeof priceRoute !== 'object') { + throw new VeloraApiError(getVeloraApiErrorMessage(responsePayload, 'Invalid price route returned by Velora'), 400, responsePayload); + } + + const routePayload = priceRoute as Record; + for (const field of REQUIRED_PRICE_ROUTE_STRING_FIELDS) { + const value = routePayload[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new VeloraApiError( + getVeloraApiErrorMessage(responsePayload, `Invalid price route returned by Velora: ${field} is missing or invalid`), + 400, + responsePayload, + ); + } + } + + return routePayload as VeloraPriceRoute; +}; + const resolveCanonicalRouteTokenAddress = (priceRoute: VeloraPriceRoute, fields: readonly string[]): Address | null => { const routePayload = priceRoute as Record; @@ -250,7 +271,9 @@ export const fetchVeloraPriceRoute = async ({ throw new VeloraApiError(getVeloraApiErrorMessage(response, 'No price route returned by Velora'), 400, response); } - const routeSourceTokenAddress = resolveCanonicalRouteTokenAddress(response.priceRoute, PRICE_ROUTE_SOURCE_TOKEN_FIELDS); + const validatedPriceRoute = validateVeloraPriceRouteShape(response.priceRoute, response); + + const routeSourceTokenAddress = resolveCanonicalRouteTokenAddress(validatedPriceRoute, PRICE_ROUTE_SOURCE_TOKEN_FIELDS); if (routeSourceTokenAddress && routeSourceTokenAddress !== requestedSourceTokenAddress) { throw new VeloraApiError('Velora route source token does not match the requested source token', 400, { requestedSourceTokenAddress, @@ -259,10 +282,7 @@ export const fetchVeloraPriceRoute = async ({ }); } - const routeDestinationTokenAddress = resolveCanonicalRouteTokenAddress( - response.priceRoute, - PRICE_ROUTE_DESTINATION_TOKEN_FIELDS, - ); + const routeDestinationTokenAddress = resolveCanonicalRouteTokenAddress(validatedPriceRoute, PRICE_ROUTE_DESTINATION_TOKEN_FIELDS); if (routeDestinationTokenAddress && routeDestinationTokenAddress !== requestedDestinationTokenAddress) { throw new VeloraApiError('Velora route destination token does not match the requested destination token', 400, { requestedDestinationTokenAddress, @@ -271,7 +291,7 @@ export const fetchVeloraPriceRoute = async ({ }); } - return response.priceRoute; + return validatedPriceRoute; }; export const buildVeloraTransactionPayload = async ({