diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx index 09cecb924b..80bb8a639f 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx @@ -1,10 +1,13 @@ -import { Dispatch, SetStateAction, useEffect } from 'react' +import { Dispatch, SetStateAction, useEffect, useCallback } from 'react' -import { HookDappBase, HookDappType, HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib' +import { fetchWithTimeout } from '@cowprotocol/common-utils' +import { HookDappType, HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib' import { useWalletInfo } from '@cowprotocol/wallet' import { HookDappIframe } from '../../../types/hooks' +import { validateHookDappUrl } from '../../../utils/urlValidation' import { validateHookDappManifest } from '../../../validateHookDappManifest' +import { ERROR_MESSAGES } from '../constants' interface ExternalDappLoaderProps { input: string @@ -15,6 +18,21 @@ interface ExternalDappLoaderProps { setManifestError: Dispatch> } +const TIMEOUT = 5000 + +// Utility functions for error checking +const isJsonParseError = (error: unknown): boolean => { + return error instanceof Error && error.message?.includes('JSON') +} + +const isTimeoutError = (error: unknown): boolean => { + return error instanceof Error && error.name === 'AbortError' +} + +const isConnectionError = (error: unknown): boolean => { + return error instanceof TypeError && error.message === 'Failed to fetch' +} + export function ExternalDappLoader({ input, setLoading, @@ -25,27 +43,66 @@ export function ExternalDappLoader({ }: ExternalDappLoaderProps) { const { chainId } = useWalletInfo() - useEffect(() => { - let isRequestRelevant = true + const setError = useCallback( + (message: string | React.ReactNode) => { + setManifestError(message) + setDappInfo(null) + setLoading(false) + }, + [setManifestError, setDappInfo, setLoading], + ) + + const fetchManifest = useCallback( + async (url: string) => { + if (!url) return + + setLoading(true) + + try { + const validation = validateHookDappUrl(url) + if (!validation.isValid) { + setError(validation.error) + return + } - setLoading(true) + const trimmedUrl = url.trim() + const manifestUrl = `${trimmedUrl}${trimmedUrl.endsWith('/') ? '' : '/'}manifest.json` - fetch(`${input}/manifest.json`) - .then((res) => res.json()) - .then((data) => { - if (!isRequestRelevant) return + const response = await fetchWithTimeout(manifestUrl, { + timeout: TIMEOUT, + timeoutMessage: ERROR_MESSAGES.TIMEOUT, + }) + if (!response.ok) { + setError(`Failed to fetch manifest from ${manifestUrl}. Please verify the URL and try again.`) + return + } + + const contentType = response.headers.get('content-type') + if (!contentType || !contentType.includes('application/json')) { + setError( + `Invalid content type: Expected JSON but received ${contentType || 'unknown'}. Make sure the URL points to a valid manifest file.`, + ) + return + } + + const data = await response.json() + + if (!data.cow_hook_dapp) { + setError(`Invalid manifest format at ${manifestUrl}: missing cow_hook_dapp property`) + return + } - const dapp = data.cow_hook_dapp as HookDappBase + const dapp = data.cow_hook_dapp const validationError = validateHookDappManifest( - data.cow_hook_dapp as HookDappBase, + dapp, chainId, isPreHook, walletType === HookDappWalletCompatibility.SMART_CONTRACT, ) if (validationError) { - setManifestError(validationError) + setError(validationError) } else { setManifestError(null) setDappInfo({ @@ -54,23 +111,34 @@ export function ExternalDappLoader({ url: input, }) } - }) - .catch((error) => { - if (!isRequestRelevant) return - - console.error(error) - setManifestError('Can not fetch the manifest.json') - }) - .finally(() => { - if (!isRequestRelevant) return + } catch (error) { + console.error('Hook dapp loading error:', error) + if (isJsonParseError(error)) { + setError(ERROR_MESSAGES.INVALID_MANIFEST_HTML) + } else if (isTimeoutError(error)) { + setError(ERROR_MESSAGES.TIMEOUT) + } else if (isConnectionError(error)) { + setError(ERROR_MESSAGES.CONNECTION_ERROR) + } else { + setError(error instanceof Error ? error.message : ERROR_MESSAGES.GENERIC_MANIFEST_ERROR) + } + } finally { setLoading(false) - }) + } + }, + [input, walletType, chainId, isPreHook, setDappInfo, setLoading, setManifestError, setError], + ) + + useEffect(() => { + if (input) { + fetchManifest(input) + } return () => { - isRequestRelevant = false + setLoading(false) } - }, [input, walletType, chainId, isPreHook, setDappInfo, setLoading, setManifestError]) + }, [input, fetchManifest, setLoading]) return null } diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx new file mode 100644 index 0000000000..6afcbf7132 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx @@ -0,0 +1,71 @@ +export const ERROR_MESSAGES = { + INVALID_URL_SPACES: 'Invalid URL: URLs cannot contain spaces', + INVALID_URL_SLASHES: 'Invalid URL: Path contains consecutive forward slashes', + HTTPS_REQUIRED: ( + <> + HTTPS is required. Please use https:// + + ), + MANIFEST_PATH: 'Please enter the base URL of your dapp, not the direct manifest.json path', + TIMEOUT: 'Request timed out. Please try again.', + INVALID_MANIFEST: 'Invalid manifest format: Missing "cow_hook_dapp" property in manifest.json', + SMART_CONTRACT_INCOMPATIBLE: 'This hook is not compatible with smart contract wallets. It only supports EOA wallets.', + INVALID_HOOK_ID: 'Invalid hook dapp ID format. The ID must be a 64-character hexadecimal string.', + INVALID_MANIFEST_HTML: ( + <> + The URL provided does not return a valid manifest file +
+ + The server returned an HTML page instead of the expected JSON manifest.json file. Please check if the URL is + correct and points to a valid hook dapp. + + + ), + CONNECTION_ERROR: + 'Could not connect to the provided URL. Please check if the URL is correct and the server is accessible.', + GENERIC_MANIFEST_ERROR: 'Failed to load manifest. Please verify the URL and try again.', + NETWORK_COMPATIBILITY_ERROR: ( + chainId: number, + chainLabel: string, + supportedNetworks: { id: number; label: string }[], + ) => ( +

+ Network compatibility error +
+
+ This app/hook doesn't support the current network:{' '} + + {chainLabel} (Chain ID: {chainId}) + + . +
+
+ Supported networks: +
+ {supportedNetworks.map(({ id, label }) => ( + <> + • {label} (Chain ID: {id}) +
+ + ))} +

+ ), + HOOK_POSITION_MISMATCH: (hookType: 'pre' | 'post') => ( +

+ Hook position mismatch: +
+ This app/hook can only be used as a {hookType}-hook +
+ and cannot be added as a {hookType === 'pre' ? 'post' : 'pre'}-hook. +

+ ), + MISSING_REQUIRED_FIELDS: (fields: string[]) => `Missing required fields in manifest: ${fields.join(', ')}`, + MANIFEST_NOT_FOUND: 'Invalid URL: No manifest.json file found. Please check the URL and try again.', + INVALID_URL_FORMAT: (error: Error) => ( + <> + Invalid URL format +
+ Technical details: {error.message} + + ), +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/index.tsx index 2f9600e2f5..3dcab4f3d9 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/index.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/index.tsx @@ -1,8 +1,8 @@ -import { ReactElement, useCallback, useEffect, useState } from 'react' +import { ReactElement, useCallback, useState } from 'react' -import { uriToHttp } from '@cowprotocol/common-utils' +import { isDevelopmentEnv, uriToHttp } from '@cowprotocol/common-utils' import { HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib' -import { ButtonOutlined, ButtonPrimary, InlineBanner, Loader, SearchInput } from '@cowprotocol/ui' +import { BannerOrientation, ButtonOutlined, ButtonPrimary, InlineBanner, Loader, SearchInput } from '@cowprotocol/ui' import { ExternalSourceAlert } from 'common/pure/ExternalSourceAlert' @@ -20,52 +20,134 @@ interface AddCustomHookFormProps { } export function AddCustomHookForm({ addHookDapp, children, isPreHook, walletType }: AddCustomHookFormProps) { - const [input, setInput] = useState(undefined) + const [input, setInput] = useState('') const [isSearchOpen, setSearchOpen] = useState(false) const [isWarningAccepted, setWarningAccepted] = useState(false) - const [isUrlValid, setUrlValid] = useState(true) const [loading, setLoading] = useState(false) const [isFinalStep, setFinalStep] = useState(false) const [manifestError, setManifestError] = useState(null) const [dappInfo, setDappInfo] = useState(null) - // Function to reset all states const dismiss = useCallback(() => { setDappInfo(null) - setManifestError(null) - setUrlValid(true) setLoading(false) setFinalStep(false) setWarningAccepted(false) }, []) - // Function to handle going back from search mode const goBack = useCallback(() => { dismiss() - setInput(undefined) + setManifestError(null) + setInput('') setSearchOpen(false) }, [dismiss]) - // Function to handle adding the hook dapp const addHookDappCallback = useCallback(() => { if (!dappInfo) return addHookDapp(dappInfo) goBack() }, [addHookDapp, dappInfo, goBack]) - // Effect to validate the URL whenever the input changes - useEffect(() => { - // Reset state whenever input changes - dismiss() - setUrlValid(input ? uriToHttp(input).length > 0 : true) - }, [input, dismiss]) + // Normalizes URLs only on explicit actions (paste/submit) to prevent interrupting user typing + const normalizeUrl = useCallback((url: string, shouldNormalize = false) => { + if (!url) return '' + + if (shouldNormalize) { + try { + const normalizedUrl = url.trim().replace(/\/+$/, '') + + // Parse URL to check if it's localhost + try { + const urlObject = new URL(normalizedUrl) + const isLocalhost = urlObject.hostname === 'localhost' || urlObject.hostname === '127.0.0.1' + + // In development mode or for localhost, preserve the original protocol + if (isDevelopmentEnv() || isLocalhost) { + return normalizedUrl + } + } catch { + // URL parsing failed, continue with normal normalization + } + + if (normalizedUrl.startsWith('https://')) { + return normalizedUrl + } + + if (normalizedUrl.startsWith('http://')) { + return 'https://' + normalizedUrl.slice(7) + } + + // Handle special protocols (ipfs/ipns/ar) via uriToHttp + const urls = uriToHttp(normalizedUrl) + if (urls.length > 0) { + return urls[0] + } + + return normalizedUrl + } catch (error) { + console.warn('Invalid URL during normalization:', error) + return url + } + } + + return url + }, []) + + const resetStates = useCallback( + (value: string) => { + // Only clear the error if the input value actually changed + if (value !== input) { + setManifestError(null) + } + setInput(value) + setDappInfo(null) + setLoading(false) + setFinalStep(false) + setWarningAccepted(false) + }, + [input], + ) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + resetStates(e.target.value) + }, + [resetStates], + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const pastedValue = e.clipboardData.getData('text') + const normalizedValue = normalizeUrl(pastedValue, true) + e.preventDefault() // Prevent default to avoid double paste + resetStates(normalizedValue) + }, + [normalizeUrl, resetStates], + ) + + const handleBlur = useCallback(() => { + if (!input.startsWith('https://')) { + const normalizedValue = normalizeUrl(input, true) + // Don't reset states on blur if the value hasn't changed + if (normalizedValue !== input) { + resetStates(normalizedValue) + } + } + }, [input, normalizeUrl, resetStates]) + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + const normalizedValue = normalizeUrl(input, true) + resetStates(normalizedValue) + }, + [input, normalizeUrl, resetStates], + ) return ( <> - {/* Render children when not in search mode */} {!isSearchOpen && children} - {/* Render the "Add custom hook" button when not in search mode */} {!isSearchOpen && ( setSearchOpen(true)}> @@ -74,70 +156,62 @@ export function AddCustomHookForm({ addHookDapp, children, isPreHook, walletType )} - {/* Render the search form when in search mode */} {isSearchOpen && ( - {/* Search Input Field */} - setInput(e.target.value?.trim().replace(/\/+$/, ''))} - /> - - {/* Validation and Error Messages */} - {input && !isUrlValid && ( - - Hook Dapp URL must match "https://website" format - - )} - {manifestError && ( - - {manifestError} - - )} - - {/* Load external dapp information */} - {input && isUrlValid && ( - + - )} - - {/* Display dapp details */} - {dappInfo && !isFinalStep && ( - setFinalStep(true)} /> - )} - - {/* Final Step: Warning and Confirmation */} - {isFinalStep && ( - <> - setWarningAccepted((state) => !state)} - > -

- Adding this app/hook grants it access to your wallet actions and trading information. Ensure you - understand the implications.
-
- Always review wallet requests carefully before approving. -

-
- - Add custom hook - - - )} - - {/* Display the "Back" button */} - - Back - + + {manifestError && ( + +
{manifestError}
+
+ )} + + {input && ( + + )} + + {dappInfo && !isFinalStep && ( + setFinalStep(true)} /> + )} + + {isFinalStep && ( + <> + setWarningAccepted((state) => !state)} + > +

+ Adding this app/hook grants it access to your wallet actions and trading information. Ensure you + understand the implications.
+
+ Always review wallet requests carefully before approving. +

+
+ + Add custom hook + + + )} + + + Back + +
)} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/styled.ts b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/styled.ts index d36f2224a8..735165652f 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/styled.ts +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/styled.ts @@ -5,6 +5,13 @@ export const Wrapper = styled.div` flex-direction: column; gap: 10px; padding: 10px; + + > form { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + } ` export const Input = styled.div` diff --git a/apps/cowswap-frontend/src/modules/hooksStore/state/customHookDappsAtom.ts b/apps/cowswap-frontend/src/modules/hooksStore/state/customHookDappsAtom.ts index 95c2842ebd..19611b22ce 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/state/customHookDappsAtom.ts +++ b/apps/cowswap-frontend/src/modules/hooksStore/state/customHookDappsAtom.ts @@ -2,13 +2,13 @@ import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import { getJotaiIsolatedStorage } from '@cowprotocol/core' -import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { walletInfoAtom } from '@cowprotocol/wallet' import { setHooksAtom } from './hookDetailsAtom' import { HookDappIframe } from '../types/hooks' -import { PersistentStateByChain } from '@cowprotocol/types' type CustomHookDapps = Record diff --git a/apps/cowswap-frontend/src/modules/hooksStore/state/hookDetailsAtom.ts b/apps/cowswap-frontend/src/modules/hooksStore/state/hookDetailsAtom.ts index 704b5f55a5..2ab54e6320 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/state/hookDetailsAtom.ts +++ b/apps/cowswap-frontend/src/modules/hooksStore/state/hookDetailsAtom.ts @@ -2,10 +2,10 @@ import { atom, SetStateAction } from 'jotai' import { atomWithStorage } from 'jotai/utils' import { getJotaiIsolatedStorage } from '@cowprotocol/core' -import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { mapSupportedNetworks } from '@cowprotocol/cow-sdk' import { CowHookDetails } from '@cowprotocol/hook-dapp-lib' -import { walletInfoAtom } from '@cowprotocol/wallet' import { PersistentStateByChain } from '@cowprotocol/types' +import { walletInfoAtom } from '@cowprotocol/wallet' export type HooksStoreState = { preHooks: CowHookDetails[] diff --git a/apps/cowswap-frontend/src/modules/hooksStore/utils/urlValidation.tsx b/apps/cowswap-frontend/src/modules/hooksStore/utils/urlValidation.tsx new file mode 100644 index 0000000000..800af73388 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/utils/urlValidation.tsx @@ -0,0 +1,57 @@ +import { ReactNode } from 'react' + +import { isDevelopmentEnv } from '@cowprotocol/common-utils' + +import { ERROR_MESSAGES } from '../pure/AddCustomHookForm/constants' + +interface ValidationResult { + isValid: boolean + error: string | ReactNode | null +} + +export function validateHookDappUrl(url: string): ValidationResult { + if (!url) { + return { isValid: false, error: null } + } + + // Check for spaces in the URL (except leading/trailing which we'll trim) + if (url.trim() !== url.trim().replace(/\s+/g, '')) { + return { isValid: false, error: ERROR_MESSAGES.INVALID_URL_SPACES } + } + + // Trim the URL to handle trailing spaces + const trimmedUrl = url.trim() + + try { + const urlObject = new URL(trimmedUrl) + + // Normalize and validate the pathname + const normalizedPath = urlObject.pathname.replace(/\/+/g, '/') + if (normalizedPath !== urlObject.pathname) { + return { isValid: false, error: ERROR_MESSAGES.INVALID_URL_SLASHES } + } + + const isLocalhost = urlObject.hostname === 'localhost' || urlObject.hostname === '127.0.0.1' + const isHttps = urlObject.protocol.startsWith('https') + + // In production, always require HTTPS except for localhost in development + if (!isDevelopmentEnv() && !isLocalhost && !isHttps) { + return { isValid: false, error: ERROR_MESSAGES.HTTPS_REQUIRED } + } + + // Handle common URL mistakes + if (urlObject.pathname === '/manifest.json') { + return { isValid: false, error: ERROR_MESSAGES.MANIFEST_PATH } + } + + return { isValid: true, error: null } + } catch (error) { + if (error instanceof TypeError && error.message === 'Failed to fetch') { + return { isValid: false, error: ERROR_MESSAGES.MANIFEST_NOT_FOUND } + } + return { + isValid: false, + error: ERROR_MESSAGES.INVALID_URL_FORMAT(error as Error), + } + } +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx b/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx index ac693f500b..ca8fa88009 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx @@ -1,8 +1,11 @@ import { ReactElement } from 'react' +import { getChainInfo } from '@cowprotocol/common-const' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { HOOK_DAPP_ID_LENGTH, HookDappBase, HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib' +import { ERROR_MESSAGES } from './pure/AddCustomHookForm/constants' + type HookDappBaseInfo = Omit const MANDATORY_DAPP_FIELDS: (keyof HookDappBaseInfo)[] = ['id', 'name', 'image', 'version', 'website'] @@ -17,38 +20,41 @@ export function validateHookDappManifest( ): ReactElement | string | null { const { conditions = {}, ...dapp } = data - if (dapp) { - const emptyFields = MANDATORY_DAPP_FIELDS.filter((field) => typeof dapp[field] === 'undefined') - - if (emptyFields.length > 0) { - return `${emptyFields.join(',')} fields are no set.` - } else { - if ( - isSmartContractWallet === true && - typeof conditions.walletCompatibility !== 'undefined' && - !conditions.walletCompatibility.includes(HookDappWalletCompatibility.SMART_CONTRACT) - ) { - return 'The app does not support smart-contract wallets.' - } else if (!isHex(dapp.id) || dapp.id.length !== HOOK_DAPP_ID_LENGTH) { - return

Hook dapp id must be a hex with length 64.

- } else if (chainId && conditions.supportedNetworks && !conditions.supportedNetworks.includes(chainId)) { - return

This app/hook doesn't support current network (chainId={chainId}).

- } else if (conditions.position === 'post' && isPreHook === true) { - return ( -

- This app/hook can only be used as a post-hook and cannot be added as a pre-hook. -

- ) - } else if (conditions.position === 'pre' && isPreHook === false) { - return ( -

- This app/hook can only be used as a pre-hook and cannot be added as a post-hook. -

- ) - } - } - } else { - return 'Manifest does not contain "cow_hook_dapp" property.' + if (!dapp) { + return ERROR_MESSAGES.INVALID_MANIFEST + } + + const emptyFields = MANDATORY_DAPP_FIELDS.filter((field) => typeof dapp[field] === 'undefined') + if (emptyFields.length > 0) { + return ERROR_MESSAGES.MISSING_REQUIRED_FIELDS(emptyFields) + } + + if ( + isSmartContractWallet === true && + typeof conditions.walletCompatibility !== 'undefined' && + !conditions.walletCompatibility.includes(HookDappWalletCompatibility.SMART_CONTRACT) + ) { + return ERROR_MESSAGES.SMART_CONTRACT_INCOMPATIBLE + } + + if (!isHex(dapp.id) || dapp.id.length !== HOOK_DAPP_ID_LENGTH) { + return ERROR_MESSAGES.INVALID_HOOK_ID + } + + if (chainId && conditions.supportedNetworks && !conditions.supportedNetworks.includes(chainId)) { + return ERROR_MESSAGES.NETWORK_COMPATIBILITY_ERROR( + chainId, + getChainInfo(chainId).label, + conditions.supportedNetworks.map((id) => ({ id, label: getChainInfo(id).label })), + ) + } + + if (conditions.position === 'post' && isPreHook === true) { + return ERROR_MESSAGES.HOOK_POSITION_MISMATCH('post') + } + + if (conditions.position === 'pre' && isPreHook === false) { + return ERROR_MESSAGES.HOOK_POSITION_MISMATCH('pre') } return null diff --git a/apps/cowswap-frontend/src/modules/permit/state/permittableTokensAtom.ts b/apps/cowswap-frontend/src/modules/permit/state/permittableTokensAtom.ts index 7b122f6f24..790eb79a69 100644 --- a/apps/cowswap-frontend/src/modules/permit/state/permittableTokensAtom.ts +++ b/apps/cowswap-frontend/src/modules/permit/state/permittableTokensAtom.ts @@ -2,11 +2,11 @@ import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import { getJotaiMergerStorage } from '@cowprotocol/core' -import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { mapSupportedNetworks } from '@cowprotocol/cow-sdk' import { PermitInfo } from '@cowprotocol/permit-utils' +import { PersistentStateByChain } from '@cowprotocol/types' import { AddPermitTokenParams } from '../types' -import { PersistentStateByChain } from '@cowprotocol/types' type PermittableTokens = Record diff --git a/apps/cowswap-frontend/src/modules/tradeSlippage/state/slippageValueAndTypeAtom.ts b/apps/cowswap-frontend/src/modules/tradeSlippage/state/slippageValueAndTypeAtom.ts index 63824e2961..c0dd34c997 100644 --- a/apps/cowswap-frontend/src/modules/tradeSlippage/state/slippageValueAndTypeAtom.ts +++ b/apps/cowswap-frontend/src/modules/tradeSlippage/state/slippageValueAndTypeAtom.ts @@ -3,11 +3,11 @@ import { atomWithStorage } from 'jotai/utils' import { DEFAULT_SLIPPAGE_BPS, MINIMUM_ETH_FLOW_SLIPPAGE_BPS } from '@cowprotocol/common-const' import { bpsToPercent } from '@cowprotocol/common-utils' -import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { walletInfoAtom } from '@cowprotocol/wallet' import { isEoaEthFlowAtom } from 'modules/trade' -import { PersistentStateByChain } from '@cowprotocol/types' type SlippageBpsPerNetwork = PersistentStateByChain diff --git a/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts index 92ca218dd4..2e69e80bde 100644 --- a/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts +++ b/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts @@ -1,11 +1,11 @@ import { SupportedChainId, mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { Fraction, Token } from '@uniswap/sdk-core' import { RateLimitError, UnknownCurrencyError } from '../apis/errors' import { COINGECKO_PLATFORMS, COINGECKO_RATE_LIMIT_TIMEOUT, getCoingeckoUsdPrice } from '../apis/getCoingeckoUsdPrice' import { getCowProtocolUsdPrice } from '../apis/getCowProtocolUsdPrice' import { DEFILLAMA_PLATFORMS, DEFILLAMA_RATE_LIMIT_TIMEOUT, getDefillamaUsdPrice } from '../apis/getDefillamaUsdPrice' -import { PersistentStateByChain } from '@cowprotocol/types' type UnknownCurrencies = { [address: string]: true } type UnknownCurrenciesMap = PersistentStateByChain diff --git a/apps/explorer/src/state/erc20/atoms.ts b/apps/explorer/src/state/erc20/atoms.ts index 759fd60053..959777235b 100644 --- a/apps/explorer/src/state/erc20/atoms.ts +++ b/apps/explorer/src/state/erc20/atoms.ts @@ -2,9 +2,9 @@ import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import { SupportedChainId, mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { TokenErc20 } from '@gnosis.pm/dex-js' -import { PersistentStateByChain } from '@cowprotocol/types' export type TokensLoadedFromChain = PersistentStateByChain> diff --git a/libs/balances-and-allowances/src/state/balancesAtom.ts b/libs/balances-and-allowances/src/state/balancesAtom.ts index a92d1b32ec..5d56c6f4a9 100644 --- a/libs/balances-and-allowances/src/state/balancesAtom.ts +++ b/libs/balances-and-allowances/src/state/balancesAtom.ts @@ -1,10 +1,10 @@ import { atomWithReset, atomWithStorage } from 'jotai/utils' import { getJotaiMergerStorage } from '@cowprotocol/core' -import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { Erc20MulticallState } from '../types' -import { PersistentStateByChain } from '@cowprotocol/types' type BalancesCache = PersistentStateByChain> diff --git a/libs/common-utils/src/fetch/index.ts b/libs/common-utils/src/fetch/index.ts new file mode 100644 index 0000000000..059186eade --- /dev/null +++ b/libs/common-utils/src/fetch/index.ts @@ -0,0 +1,22 @@ +import { getTimeoutAbortController } from '../request' + +export const TIMEOUT_ERROR_MESSAGE = 'Request timed out. Please try again.' + +interface FetchTimeoutOptions extends RequestInit { + timeout?: number + timeoutMessage?: string +} + +export async function fetchWithTimeout(url: string, options: FetchTimeoutOptions = {}) { + const { timeout = 30000, timeoutMessage = TIMEOUT_ERROR_MESSAGE, ...fetchOptions } = options + + try { + const response = await fetch(url, { signal: getTimeoutAbortController(timeout).signal, ...fetchOptions }) + return response + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(timeoutMessage) + } + throw error + } +} diff --git a/libs/common-utils/src/index.ts b/libs/common-utils/src/index.ts index a12b84eb1d..575fb3b392 100644 --- a/libs/common-utils/src/index.ts +++ b/libs/common-utils/src/index.ts @@ -57,3 +57,4 @@ export * from './uriToHttp' export * from './userAgent' export * from './getCurrencyAddress' export * from './errorToString' +export * from './fetch' diff --git a/libs/tokens/src/hooks/tokens/unsupported/useUnsupportedTokens.ts b/libs/tokens/src/hooks/tokens/unsupported/useUnsupportedTokens.ts index 9452713858..0159c37d06 100644 --- a/libs/tokens/src/hooks/tokens/unsupported/useUnsupportedTokens.ts +++ b/libs/tokens/src/hooks/tokens/unsupported/useUnsupportedTokens.ts @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai' import { currentUnsupportedTokensAtom } from '../../../state/tokens/unsupportedTokensAtom' -import { UnsupportedTokensState } from '@cowprotocol/tokens' +import { UnsupportedTokensState } from '../../../types'; export function useUnsupportedTokens(): UnsupportedTokensState { return useAtomValue(currentUnsupportedTokensAtom) || {} diff --git a/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts b/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts index 5f6ec77259..c3136f4cd2 100644 --- a/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts +++ b/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts @@ -3,10 +3,10 @@ import { atomWithStorage } from 'jotai/utils' import { getJotaiMergerStorage } from '@cowprotocol/core' import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { UnsupportedTokensState } from '../../types' import { environmentAtom } from '../environmentAtom' -import { PersistentStateByChain } from '@cowprotocol/types' export const unsupportedTokensAtom = atomWithStorage>( 'unsupportedTokensAtom:v2', diff --git a/libs/tokens/src/state/tokens/userAddedTokensAtom.ts b/libs/tokens/src/state/tokens/userAddedTokensAtom.ts index 15b184fac6..f71c634380 100644 --- a/libs/tokens/src/state/tokens/userAddedTokensAtom.ts +++ b/libs/tokens/src/state/tokens/userAddedTokensAtom.ts @@ -3,12 +3,12 @@ import { atomWithStorage } from 'jotai/utils' import { TokenWithLogo } from '@cowprotocol/common-const' import { getJotaiMergerStorage } from '@cowprotocol/core' -import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' +import { mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import { Token } from '@uniswap/sdk-core' import { TokensMap } from '../../types' import { environmentAtom } from '../environmentAtom' -import { PersistentStateByChain } from '@cowprotocol/types' export const userAddedTokensAtom = atomWithStorage>( 'userAddedTokensAtom:v1', diff --git a/libs/tokens/src/types.ts b/libs/tokens/src/types.ts index c0ae2628f4..d7c35db575 100644 --- a/libs/tokens/src/types.ts +++ b/libs/tokens/src/types.ts @@ -1,4 +1,3 @@ -import { SupportedChainId } from '@cowprotocol/cow-sdk' import { LpTokenProvider, PersistentStateByChain, TokenInfo } from '@cowprotocol/types' import type { TokenList as UniTokenList } from '@uniswap/token-lists' diff --git a/libs/tokens/src/updaters/TokensListsUpdater/index.ts b/libs/tokens/src/updaters/TokensListsUpdater/index.ts index 7e8294bf7b..0fa849dc8c 100644 --- a/libs/tokens/src/updaters/TokensListsUpdater/index.ts +++ b/libs/tokens/src/updaters/TokensListsUpdater/index.ts @@ -5,6 +5,7 @@ import { useEffect } from 'react' import { atomWithPartialUpdate, isInjectedWidget } from '@cowprotocol/common-utils' import { getJotaiMergerStorage } from '@cowprotocol/core' import { SupportedChainId, mapSupportedNetworks } from '@cowprotocol/cow-sdk' +import { PersistentStateByChain } from '@cowprotocol/types' import * as Sentry from '@sentry/browser' import useSWR, { SWRConfiguration } from 'swr' @@ -16,7 +17,6 @@ import { environmentAtom, updateEnvironmentAtom } from '../../state/environmentA import { upsertListsAtom } from '../../state/tokenLists/tokenListsActionsAtom' import { allListsSourcesAtom, tokenListsUpdatingAtom } from '../../state/tokenLists/tokenListsStateAtom' import { ListState } from '../../types' -import { PersistentStateByChain } from '@cowprotocol/types' const LAST_UPDATE_TIME_DEFAULT = 0