diff --git a/apps/cowswap-frontend/tsconfig.json b/apps/cowswap-frontend/tsconfig.json index fd5681c653..2dae219c87 100644 --- a/apps/cowswap-frontend/tsconfig.json +++ b/apps/cowswap-frontend/tsconfig.json @@ -15,6 +15,7 @@ "@cowprotocol/widget-lib": ["../../../libs/widget-lib/src/index.ts"], "@cowprotocol/widget-react": ["../../../libs/widget-react/src/index.ts"], "@cowprotocol/snackbars": ["../../../libs/snackbars/src/index.ts"], + "@cowprotocol/tokens": ["../../../libs/tokens/src/index.ts"], "@cowprotocol/wallet": ["../../../libs/wallet/src/index.ts"], "@cowprotocol/assets/*": ["../../../libs/assets/src/*"], "@cowprotocol/common-const": ["../../../libs/common-const/src/index.ts"], diff --git a/libs/tokens/.babelrc b/libs/tokens/.babelrc new file mode 100644 index 0000000000..ef4889c1ab --- /dev/null +++ b/libs/tokens/.babelrc @@ -0,0 +1,20 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [ + [ + "styled-components", + { + "pure": true, + "ssr": true + } + ] + ] +} diff --git a/libs/tokens/.eslintrc.json b/libs/tokens/.eslintrc.json new file mode 100644 index 0000000000..a39ac5d057 --- /dev/null +++ b/libs/tokens/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/tokens/README.md b/libs/tokens/README.md new file mode 100644 index 0000000000..80a877f3e3 --- /dev/null +++ b/libs/tokens/README.md @@ -0,0 +1 @@ +# Tokens diff --git a/libs/tokens/jest.config.ts b/libs/tokens/jest.config.ts new file mode 100644 index 0000000000..3b936b28d0 --- /dev/null +++ b/libs/tokens/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'tokens', + preset: '../../jest.preset.js', + transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/tokens', +} diff --git a/libs/tokens/package.json b/libs/tokens/package.json new file mode 100644 index 0000000000..de2f653d20 --- /dev/null +++ b/libs/tokens/package.json @@ -0,0 +1,12 @@ +{ + "name": "@cowprotocol/tokens", + "version": "0.0.1", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.js" + } + } +} diff --git a/libs/tokens/project.json b/libs/tokens/project.json new file mode 100644 index 0000000000..e249f28f4e --- /dev/null +++ b/libs/tokens/project.json @@ -0,0 +1,46 @@ +{ + "name": "@cowprotocol/tokens", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/tokens/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/tokens/**/*.{ts,tsx,js,jsx}"] + } + }, + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/libs/tokens" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/tokens/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + } +} diff --git a/libs/tokens/src/const/tokensList.json b/libs/tokens/src/const/tokensList.json new file mode 100644 index 0000000000..616275f99e --- /dev/null +++ b/libs/tokens/src/const/tokensList.json @@ -0,0 +1,93 @@ +{ + "1": [ + { + "id": "hc_zMahDEXc_WMKgPAUpK", + "priority": 1, + "enabledByDefault": true, + "url": "https://files.cow.fi/tokens/CowSwap.json" + }, + { + "id": "yi4K5DrkWkF3oiWweRjt3", + "priority": 2, + "enabledByDefault": true, + "url": "https://files.cow.fi/tokens/CoinGecko.json" + }, + { + "id": "uJcgDR4A3MLQTrDsO34bA", + "priority": 3, + "url": "https://raw.githubusercontent.com/compound-finance/token-list/master/compound.tokenlist.json" + }, + { + "id": "ePv0T2u5t0omW7HjPxGCL", + "priority": 4, + "ensName": "tokenlist.aave.eth" + }, + { + "id": "vcO-K1VshU97uUeao5p_i", + "priority": 5, + "ensName": "synths.snx.eth" + }, + { + "id": "pBJ4ZL5jeZ0DcLw4S-_85", + "priority": 6, + "ensName": "wrapped.tokensoft.eth" + }, + { + "id": "4nKiWPc0hM6NgkZgTUnsy", + "priority": 7, + "url": "https://raw.githubusercontent.com/SetProtocol/uniswap-tokenlist/main/set.tokenlist.json" + }, + { + "id": "5anQfdMTp5WyI2jklNvFB", + "priority": 8, + "url": "https://raw.githubusercontent.com/opynfinance/opyn-tokenlist/master/opyn-squeeth-tokenlist.json" + }, + { + "id": "YBalvtWm1YVzQoe8J2AJa", + "priority": 9, + "url": "https://app.tryroll.com/tokens.json" + }, + { + "id": "Pl-AdOGqGki43XDf7EoGV", + "priority": 10, + "ensName": "defi.cmc.eth" + }, + { + "id": "JXGSqoOJDDY9hsY0Zn-Ue", + "priority": 11, + "ensName": "stablecoin.cmc.eth" + }, + { + "id": "bffPi82n3c4zBzAbUQiV7", + "priority": 12, + "ensName": "t2crtokens.eth" + } + ], + "5": [ + { + "id": "VZ--hetNymtoeZ5QUVhRT", + "priority": 1, + "enabledByDefault": true, + "url": "https://raw.githubusercontent.com/cowprotocol/token-lists/main/src/public/CowSwapGoerli.json" + }, + { + "id": "uJcgDR4A3MLQTrDsO34bA", + "priority": 2, + "url": "https://raw.githubusercontent.com/compound-finance/token-list/master/compound.tokenlist.json" + } + ], + "100": [ + { + "id": "hc_zMahDEXc_WMKgPAUpK", + "priority": 1, + "enabledByDefault": true, + "url": "https://files.cow.fi/tokens/CowSwap.json" + }, + { + "id": "hMVOQpr3d-b-lCpYDDyIM", + "priority": 2, + "enabledByDefault": true, + "url": "https://tokens.honeyswap.org" + } + ] +} diff --git a/libs/tokens/src/const/tokensLists.ts b/libs/tokens/src/const/tokensLists.ts new file mode 100644 index 0000000000..2a41b27745 --- /dev/null +++ b/libs/tokens/src/const/tokensLists.ts @@ -0,0 +1,4 @@ +import { TokenListsByNetwork } from '../types' +import tokensList from './tokensList.json' + +export const DEFAULT_TOKENS_LISTS: TokenListsByNetwork = tokensList diff --git a/libs/tokens/src/hooks/useActiveTokenListsIds.ts b/libs/tokens/src/hooks/useActiveTokenListsIds.ts new file mode 100644 index 0000000000..72aa43cd7e --- /dev/null +++ b/libs/tokens/src/hooks/useActiveTokenListsIds.ts @@ -0,0 +1,6 @@ +import { useAtomValue } from 'jotai' +import { activeTokenListsMapAtom } from '../state/tokenLists/tokenListsStateAtom' + +export function useActiveTokenListsIds() { + return useAtomValue(activeTokenListsMapAtom) +} diff --git a/libs/tokens/src/hooks/useAddCustomTokenLists.ts b/libs/tokens/src/hooks/useAddCustomTokenLists.ts new file mode 100644 index 0000000000..809d2b2847 --- /dev/null +++ b/libs/tokens/src/hooks/useAddCustomTokenLists.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { addTokenListAtom } from '../state/tokenLists/tokenListsActionsAtom' + +export function useAddCustomTokenLists() { + return useSetAtom(addTokenListAtom) +} diff --git a/libs/tokens/src/hooks/useAddUnsupportedToken.ts b/libs/tokens/src/hooks/useAddUnsupportedToken.ts new file mode 100644 index 0000000000..f3247cbb4f --- /dev/null +++ b/libs/tokens/src/hooks/useAddUnsupportedToken.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { addUnsupportedTokenAtom } from '../state/tokens/unsupportedTokensAtom' + +export function useAddUnsupportedToken() { + return useSetAtom(addUnsupportedTokenAtom) +} diff --git a/libs/tokens/src/hooks/useAllTokenListsInfo.ts b/libs/tokens/src/hooks/useAllTokenListsInfo.ts new file mode 100644 index 0000000000..003af0d1c0 --- /dev/null +++ b/libs/tokens/src/hooks/useAllTokenListsInfo.ts @@ -0,0 +1,7 @@ +import { useAtomValue } from 'jotai' +import { allTokenListsInfoAtom } from '../state/tokenLists/tokenListsStateAtom' +import { TokenListInfo } from '../types' + +export function useAllTokenListsInfo(): TokenListInfo[] { + return useAtomValue(allTokenListsInfoAtom) +} diff --git a/libs/tokens/src/hooks/useAllTokens.ts b/libs/tokens/src/hooks/useAllTokens.ts new file mode 100644 index 0000000000..acc858c0e3 --- /dev/null +++ b/libs/tokens/src/hooks/useAllTokens.ts @@ -0,0 +1,8 @@ +import { useAtomValue } from 'jotai' +import { activeTokensAtom } from '../state/tokens/tokensAtom' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +export function useAllTokens(): TokenWithLogo[] { + return useAtomValue(activeTokensAtom) +} diff --git a/libs/tokens/src/hooks/useAreThereTokensWithSameSymbol.ts b/libs/tokens/src/hooks/useAreThereTokensWithSameSymbol.ts new file mode 100644 index 0000000000..b61c1e2171 --- /dev/null +++ b/libs/tokens/src/hooks/useAreThereTokensWithSameSymbol.ts @@ -0,0 +1,18 @@ +import { useAtomValue } from 'jotai' +import { useCallback } from 'react' + +import { isAddress } from '@cowprotocol/common-utils' +import { tokensBySymbolAtom } from '../state/tokens/tokensAtom' + +export function useAreThereTokensWithSameSymbol(): (tokenAddressOrSymbol: string | null | undefined) => boolean { + const tokensBySymbol = useAtomValue(tokensBySymbolAtom) + + return useCallback( + (tokenAddressOrSymbol: string | null | undefined) => { + if (!tokenAddressOrSymbol || isAddress(tokenAddressOrSymbol)) return false + + return tokensBySymbol[tokenAddressOrSymbol.toLowerCase()]?.length > 1 + }, + [tokensBySymbol] + ) +} diff --git a/libs/tokens/src/hooks/useFavouriteTokens.ts b/libs/tokens/src/hooks/useFavouriteTokens.ts new file mode 100644 index 0000000000..4ffae1d333 --- /dev/null +++ b/libs/tokens/src/hooks/useFavouriteTokens.ts @@ -0,0 +1,7 @@ +import { useAtomValue } from 'jotai' +import { favouriteTokensListAtom } from '../state/tokens/favouriteTokensAtom' +import { TokenWithLogo } from '@cowprotocol/common-const' + +export function useFavouriteTokens(): TokenWithLogo[] { + return useAtomValue(favouriteTokensListAtom) +} diff --git a/libs/tokens/src/hooks/useImportTokenCallback.ts b/libs/tokens/src/hooks/useImportTokenCallback.ts new file mode 100644 index 0000000000..269bbe52a5 --- /dev/null +++ b/libs/tokens/src/hooks/useImportTokenCallback.ts @@ -0,0 +1,7 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { useSetAtom } from 'jotai' +import { addUserTokenAtom } from '../state/tokens/userAddedTokensAtom' + +export function useImportTokenCallback(): (tokens: TokenWithLogo[]) => void { + return useSetAtom(addUserTokenAtom) +} diff --git a/libs/tokens/src/hooks/useIsTradeUnsupported.ts b/libs/tokens/src/hooks/useIsTradeUnsupported.ts new file mode 100644 index 0000000000..eea2591c6a --- /dev/null +++ b/libs/tokens/src/hooks/useIsTradeUnsupported.ts @@ -0,0 +1,13 @@ +import { Currency } from '@uniswap/sdk-core' +import { useIsUnsupportedToken } from './useIsUnsupportedToken' + +export function useIsTradeUnsupported( + inputCurrency: Currency | null | undefined, + outputCurrency: Currency | null | undefined +): boolean { + const isUnsupportedToken = useIsUnsupportedToken() + const isInputCurrencyUnsupported = inputCurrency?.isNative ? false : !!isUnsupportedToken(inputCurrency?.address) + const isOutputCurrencyUnsupported = outputCurrency?.isNative ? false : !!isUnsupportedToken(outputCurrency?.address) + + return isInputCurrencyUnsupported || isOutputCurrencyUnsupported +} diff --git a/libs/tokens/src/hooks/useIsUnsupportedToken.ts b/libs/tokens/src/hooks/useIsUnsupportedToken.ts new file mode 100644 index 0000000000..6bb2fd09b4 --- /dev/null +++ b/libs/tokens/src/hooks/useIsUnsupportedToken.ts @@ -0,0 +1,21 @@ +import { useUnsupportedTokens } from './useUnsupportedTokens' +import { useCallback } from 'react' + +export function useIsUnsupportedToken() { + const unsupportedTokens = useUnsupportedTokens() + + return useCallback( + (address?: string) => { + const state = address && unsupportedTokens[address.toLowerCase()] + + if (state) { + return { + ...state, + address, + } + } + return false + }, + [unsupportedTokens] + ) +} diff --git a/libs/tokens/src/hooks/useIsUnsupportedTokens.ts b/libs/tokens/src/hooks/useIsUnsupportedTokens.ts new file mode 100644 index 0000000000..b574177b20 --- /dev/null +++ b/libs/tokens/src/hooks/useIsUnsupportedTokens.ts @@ -0,0 +1,18 @@ +import { useUnsupportedTokens } from './useUnsupportedTokens' +import { useCallback } from 'react' + +export function useIsUnsupportedTokens() { + const unsupportedTokens = useUnsupportedTokens() + + return useCallback( + ({ sellToken, buyToken }: { sellToken: string | null | undefined; buyToken: string | null | undefined }) => { + if (!unsupportedTokens) return false + + return !!( + (sellToken && unsupportedTokens[sellToken.toLowerCase()]) || + (buyToken && unsupportedTokens[buyToken.toLowerCase()]) + ) + }, + [unsupportedTokens] + ) +} diff --git a/libs/tokens/src/hooks/useRemoveTokenCallback.ts b/libs/tokens/src/hooks/useRemoveTokenCallback.ts new file mode 100644 index 0000000000..37846b6e92 --- /dev/null +++ b/libs/tokens/src/hooks/useRemoveTokenCallback.ts @@ -0,0 +1,11 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { useSetAtom } from 'jotai' +import { removeUserTokenAtom } from '../state/tokens/userAddedTokensAtom' + +export function useRemoveTokenCallback(): (token: TokenWithLogo) => void { + const removeUserToken = useSetAtom(removeUserTokenAtom) + + return (token: TokenWithLogo) => { + removeUserToken(token) + } +} diff --git a/libs/tokens/src/hooks/useRemoveTokenList.ts b/libs/tokens/src/hooks/useRemoveTokenList.ts new file mode 100644 index 0000000000..4802345e9f --- /dev/null +++ b/libs/tokens/src/hooks/useRemoveTokenList.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { removeTokenListAtom } from '../state/tokenLists/tokenListsActionsAtom' + +export function useRemoveTokenList() { + return useSetAtom(removeTokenListAtom) +} diff --git a/libs/tokens/src/hooks/useRemoveUnsupportedToken.ts b/libs/tokens/src/hooks/useRemoveUnsupportedToken.ts new file mode 100644 index 0000000000..3f0f8a1fa8 --- /dev/null +++ b/libs/tokens/src/hooks/useRemoveUnsupportedToken.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { removeUnsupportedTokenAtom } from '../state/tokens/unsupportedTokensAtom' + +export function useRemoveUnsupportedToken() { + return useSetAtom(removeUnsupportedTokenAtom) +} diff --git a/libs/tokens/src/hooks/useResetFavouriteTokens.ts b/libs/tokens/src/hooks/useResetFavouriteTokens.ts new file mode 100644 index 0000000000..0c085cd2bf --- /dev/null +++ b/libs/tokens/src/hooks/useResetFavouriteTokens.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { resetFavouriteTokensAtom } from '../state/tokens/favouriteTokensAtom' + +export function useResetFavouriteTokens() { + return useSetAtom(resetFavouriteTokensAtom) +} diff --git a/libs/tokens/src/hooks/useResetUserTokensCallback.ts b/libs/tokens/src/hooks/useResetUserTokensCallback.ts new file mode 100644 index 0000000000..38e51ceec6 --- /dev/null +++ b/libs/tokens/src/hooks/useResetUserTokensCallback.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { resetUserTokenAtom } from '../state/tokens/userAddedTokensAtom' + +export function useResetUserTokensCallback(): () => void { + return useSetAtom(resetUserTokenAtom) +} diff --git a/libs/tokens/src/hooks/useSearchList.ts b/libs/tokens/src/hooks/useSearchList.ts new file mode 100644 index 0000000000..e9db48f22f --- /dev/null +++ b/libs/tokens/src/hooks/useSearchList.ts @@ -0,0 +1,55 @@ +import useSWR, { SWRResponse } from 'swr' +import { fetchTokenList } from '../services/fetchTokenList' +import { parseENSAddress } from '@cowprotocol/common-utils' +import { useAtomValue } from 'jotai' +import { allTokenListsInfoAtom, allTokenListsAtom } from '../state/tokenLists/tokenListsStateAtom' +import { useMemo } from 'react' +import { getIsTokenListWithUrl } from '../utils/getIsTokenListWithUrl' +import { TokenListInfo } from '../types' +import { buildTokenListInfo } from '../utils/buildTokenListInfo' + +export type ListSearchResponse = + | { + source: 'existing' + response: TokenListInfo + } + | { + source: 'external' + response: SWRResponse + } + +export function useSearchList(input: string | null): ListSearchResponse { + const allTokensLists = useAtomValue(allTokenListsAtom) + const allTokensListsInfo = useAtomValue(allTokenListsInfoAtom) + + const listSource = useMemo(() => { + if (!input) return null + + const id = 'search' + const isENS = !!parseENSAddress(input) + + return isENS ? { id, ensName: input } : { id, url: input } + }, [input]) + + const existingList = useMemo(() => { + const inputLowerCase = input?.toLowerCase() + + const list = allTokensLists.find((list) => { + return getIsTokenListWithUrl(list) ? list.url === inputLowerCase : list.ensName === inputLowerCase + }) + + return list ? allTokensListsInfo.find((info) => info.id === list.id) : undefined + }, [allTokensLists, allTokensListsInfo, input]) + + const response = useSWR( + ['useSearchList', listSource, existingList], + () => { + if (!listSource || existingList) return null + + return fetchTokenList(listSource).then(buildTokenListInfo) + }, + {} + ) + + return existingList ? { source: 'existing', response: existingList } : { source: 'external', response } +} diff --git a/libs/tokens/src/hooks/useSearchNonExistentToken.ts b/libs/tokens/src/hooks/useSearchNonExistentToken.ts new file mode 100644 index 0000000000..883470624a --- /dev/null +++ b/libs/tokens/src/hooks/useSearchNonExistentToken.ts @@ -0,0 +1,32 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { isAddress, isTruthy } from '@cowprotocol/common-utils' + +import { tokenListsUpdatingAtom } from '../state/tokenLists/tokenListsStateAtom' +import { useTokensByAddressMap } from './useTokensByAddressMap' +import { useSearchToken } from './useSearchToken' + +export function useSearchNonExistentToken(tokenAddress: string | null): TokenWithLogo | null { + const tokenListsUpdating = useAtomValue(tokenListsUpdatingAtom) + const allTokens = useTokensByAddressMap() + + const isNotAddress = !isAddress(tokenAddress) + const existingToken = tokenAddress ? allTokens[tokenAddress.toLowerCase()] : null + + const inputTokenToSearch = tokenListsUpdating || existingToken || !tokenAddress || isNotAddress ? null : tokenAddress + + const foundToken = useSearchToken(inputTokenToSearch) + + return useMemo(() => { + if (!inputTokenToSearch) return null + + return ( + [foundToken.inactiveListsResult, foundToken.externalApiResult, foundToken.blockchainResult] + .filter(isTruthy) + .flat() + .filter(isTruthy)[0] || null + ) + }, [inputTokenToSearch, foundToken]) +} diff --git a/libs/tokens/src/hooks/useSearchToken.ts b/libs/tokens/src/hooks/useSearchToken.ts new file mode 100644 index 0000000000..529bd9b505 --- /dev/null +++ b/libs/tokens/src/hooks/useSearchToken.ts @@ -0,0 +1,123 @@ +import { useMemo } from 'react' +import { useAtomValue } from 'jotai' +import { activeTokensAtom, inactiveTokensAtom } from '../state/tokens/tokensAtom' +import { useDebounce } from '@cowprotocol/common-hooks' +import { useWeb3React } from '@web3-react/core' +import { TokenInfo } from '@uniswap/token-lists' +import { isAddress } from '@cowprotocol/common-utils' +import ms from 'ms.macro' +import { getTokenSearchFilter } from '../utils/getTokenSearchFilter' +import useSWR from 'swr' +import { searchTokensInApi, TokenSearchFromApiResult } from '../services/searchTokensInApi' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { environmentAtom } from '../state/environmentAtom' +import { parseTokensFromApi } from '../utils/parseTokensFromApi' +import { fetchTokenFromBlockchain } from '../utils/fetchTokenFromBlockchain' + +const IN_LISTS_DEBOUNCE_TIME = ms`100ms` +const IN_EXTERNALS_DEBOUNCE_TIME = ms`1000ms` + +export type TokenSearchResponse = { + isLoading: boolean + blockchainResult: TokenWithLogo[] | null + externalApiResult: TokenWithLogo[] | null + activeListsResult: TokenWithLogo[] | null + inactiveListsResult: TokenWithLogo[] | null +} + +const emptyResponse: TokenSearchResponse = { + isLoading: false, + blockchainResult: null, + externalApiResult: null, + activeListsResult: null, + inactiveListsResult: null, +} + +// TODO: implement search with debouncing and caching +export function useSearchToken(input: string | null): TokenSearchResponse { + const { provider } = useWeb3React() + + const debouncedInputInList = useDebounce(input?.toLowerCase(), IN_LISTS_DEBOUNCE_TIME) + const debouncedInputInExternals = useDebounce(input?.toLowerCase(), IN_EXTERNALS_DEBOUNCE_TIME) + + const { chainId } = useAtomValue(environmentAtom) + const activeTokens = useAtomValue(activeTokensAtom) + const inactiveTokens = useAtomValue(inactiveTokensAtom) + + const { data: inListsResult } = useSWR<{ + tokensFromActiveLists: TokenWithLogo[] + tokensFromInactiveLists: TokenWithLogo[] + } | null>(['searchTokensInLists', debouncedInputInList], () => { + if (!debouncedInputInList) return null + + const tokensFromActiveLists = activeTokens.filter(getTokenSearchFilter(debouncedInputInList)) + const tokensFromInactiveLists = inactiveTokens.filter(getTokenSearchFilter(debouncedInputInList)) + + return { tokensFromActiveLists, tokensFromInactiveLists } + }) + + const { data: apiResult, isLoading: apiIsLoading } = useSWR( + ['searchTokensInApi', debouncedInputInExternals], + () => { + if (!debouncedInputInExternals) return null + + return searchTokensInApi(debouncedInputInExternals) + } + ) + + const { data: blockchainResult, isLoading: blockchainIsLoading } = useSWR( + ['fetchTokenFromBlockchain', debouncedInputInExternals], + () => { + if (!debouncedInputInExternals || !provider || !isAddress(debouncedInputInExternals)) return null + + return fetchTokenFromBlockchain(debouncedInputInExternals, chainId, provider) + } + ) + + const apiResultTokens = useMemo(() => { + if (!apiResult?.length) return null + + return parseTokensFromApi(apiResult, chainId) + }, [apiResult, chainId]) + + const tokenFromBlockChain = useMemo(() => { + if (!blockchainResult) return null + + return new TokenWithLogo( + undefined, + blockchainResult.chainId, + blockchainResult.address, + blockchainResult.decimals, + blockchainResult.symbol, + blockchainResult.name + ) + }, [blockchainResult]) + + return useMemo(() => { + if (!input) { + return emptyResponse + } + + const isTokenAlreadyFound = inListsResult?.tokensFromActiveLists.find( + (token) => token.address.toLowerCase() === input.toLowerCase() + ) + + if (isTokenAlreadyFound) { + return { + isLoading: apiIsLoading || blockchainIsLoading, + activeListsResult: inListsResult?.tokensFromActiveLists || null, + blockchainResult: null, + inactiveListsResult: null, + externalApiResult: null, + } + } + + return { + isLoading: apiIsLoading || blockchainIsLoading, + blockchainResult: tokenFromBlockChain ? [tokenFromBlockChain] : null, + activeListsResult: inListsResult?.tokensFromActiveLists || null, + inactiveListsResult: inListsResult?.tokensFromInactiveLists || null, + externalApiResult: apiResultTokens, + } + }, [input, inListsResult, apiResultTokens, tokenFromBlockChain, apiIsLoading, blockchainIsLoading]) +} diff --git a/libs/tokens/src/hooks/useToggleFavouriteToken.ts b/libs/tokens/src/hooks/useToggleFavouriteToken.ts new file mode 100644 index 0000000000..beb5bbd103 --- /dev/null +++ b/libs/tokens/src/hooks/useToggleFavouriteToken.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { toggleFavouriteTokenAtom } from '../state/tokens/favouriteTokensAtom' + +export function useToggleFavouriteToken() { + return useSetAtom(toggleFavouriteTokenAtom) +} diff --git a/libs/tokens/src/hooks/useToggleListCallback.ts b/libs/tokens/src/hooks/useToggleListCallback.ts new file mode 100644 index 0000000000..6180557536 --- /dev/null +++ b/libs/tokens/src/hooks/useToggleListCallback.ts @@ -0,0 +1,6 @@ +import { useSetAtom } from 'jotai' +import { toggleListAtom } from '../state/tokenLists/tokenListsActionsAtom' + +export function useToggleListCallback() { + return useSetAtom(toggleListAtom) +} diff --git a/libs/tokens/src/hooks/useTokenBySymbolOrAddress.ts b/libs/tokens/src/hooks/useTokenBySymbolOrAddress.ts new file mode 100644 index 0000000000..9af4c6d357 --- /dev/null +++ b/libs/tokens/src/hooks/useTokenBySymbolOrAddress.ts @@ -0,0 +1,28 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { tokensByAddressAtom, tokensBySymbolAtom } from '../state/tokens/tokensAtom' + +export function useTokenBySymbolOrAddress(symbolOrAddress?: string | null): TokenWithLogo | null { + const tokensByAddress = useAtomValue(tokensByAddressAtom) + const tokensBySymbol = useAtomValue(tokensBySymbolAtom) + + return useMemo(() => { + if (!symbolOrAddress) { + return null + } + + const symbolOrAddressLowerCase = symbolOrAddress.toLowerCase() + + const foundByAddress = tokensByAddress[symbolOrAddressLowerCase] + + if (foundByAddress) return foundByAddress + + const foundBySymbol = tokensBySymbol[symbolOrAddressLowerCase] + + if (foundBySymbol) return foundBySymbol[0] + + return null + }, [symbolOrAddress, tokensByAddress, tokensBySymbol]) +} diff --git a/libs/tokens/src/hooks/useTokensByAddressMap.ts b/libs/tokens/src/hooks/useTokensByAddressMap.ts new file mode 100644 index 0000000000..35df04953f --- /dev/null +++ b/libs/tokens/src/hooks/useTokensByAddressMap.ts @@ -0,0 +1,6 @@ +import { TokensByAddress, tokensByAddressAtom } from '../state/tokens/tokensAtom' +import { useAtomValue } from 'jotai' + +export function useTokensByAddressMap(): TokensByAddress { + return useAtomValue(tokensByAddressAtom) +} diff --git a/libs/tokens/src/hooks/useUnsupportedTokens.ts b/libs/tokens/src/hooks/useUnsupportedTokens.ts new file mode 100644 index 0000000000..de9fa8ce26 --- /dev/null +++ b/libs/tokens/src/hooks/useUnsupportedTokens.ts @@ -0,0 +1,6 @@ +import { useAtomValue } from 'jotai' +import { currentUnsupportedTokensAtom } from '../state/tokens/unsupportedTokensAtom' + +export function useUnsupportedTokens() { + return useAtomValue(currentUnsupportedTokensAtom) +} diff --git a/libs/tokens/src/hooks/useUserAddedTokens.ts b/libs/tokens/src/hooks/useUserAddedTokens.ts new file mode 100644 index 0000000000..2ac246db2d --- /dev/null +++ b/libs/tokens/src/hooks/useUserAddedTokens.ts @@ -0,0 +1,7 @@ +import { useAtomValue } from 'jotai' +import { userAddedTokensListAtom } from '../state/tokens/userAddedTokensAtom' +import { TokenWithLogo } from '@cowprotocol/common-const' + +export function useUserAddedTokens(): TokenWithLogo[] { + return useAtomValue(userAddedTokensListAtom) +} diff --git a/libs/tokens/src/index.ts b/libs/tokens/src/index.ts new file mode 100644 index 0000000000..74831614b7 --- /dev/null +++ b/libs/tokens/src/index.ts @@ -0,0 +1,46 @@ +export * from './types' +export { validateTokenList, validateTokens } from './utils/validateTokenList' +export { TokensListsUpdater } from './updaters/TokensListsUpdater' + +// Types +export type { TokensByAddress, TokensBySymbol } from './state/tokens/tokensAtom' +export type { ListSearchResponse } from './hooks/useSearchList' +export type { TokenSearchResponse } from './hooks/useSearchToken' + +// Hooks +export { useAllTokenListsInfo } from './hooks/useAllTokenListsInfo' +export { useAddCustomTokenLists } from './hooks/useAddCustomTokenLists' +export { useAllTokens } from './hooks/useAllTokens' +export { useFavouriteTokens } from './hooks/useFavouriteTokens' +export { useUserAddedTokens } from './hooks/useUserAddedTokens' +export { useImportTokenCallback } from './hooks/useImportTokenCallback' +export { useRemoveTokenCallback } from './hooks/useRemoveTokenCallback' +export { useResetUserTokensCallback } from './hooks/useResetUserTokensCallback' +export { useRemoveTokenList } from './hooks/useRemoveTokenList' +export { useToggleListCallback } from './hooks/useToggleListCallback' +export { useActiveTokenListsIds } from './hooks/useActiveTokenListsIds' +export { useAddUnsupportedToken } from './hooks/useAddUnsupportedToken' +export { useRemoveUnsupportedToken } from './hooks/useRemoveUnsupportedToken' +export { useUnsupportedTokens } from './hooks/useUnsupportedTokens' +export { useIsTradeUnsupported } from './hooks/useIsTradeUnsupported' +export { useIsUnsupportedToken } from './hooks/useIsUnsupportedToken' +export { useIsUnsupportedTokens } from './hooks/useIsUnsupportedTokens' +export { useResetFavouriteTokens } from './hooks/useResetFavouriteTokens' +export { useToggleFavouriteToken } from './hooks/useToggleFavouriteToken' +export { useTokensByAddressMap } from './hooks/useTokensByAddressMap' +export { useTokenBySymbolOrAddress } from './hooks/useTokenBySymbolOrAddress' +export { useAreThereTokensWithSameSymbol } from './hooks/useAreThereTokensWithSameSymbol' +export { useSearchList } from './hooks/useSearchList' +export { useSearchToken } from './hooks/useSearchToken' +export { useSearchNonExistentToken } from './hooks/useSearchNonExistentToken' + +// Services +export { searchTokensInApi } from './services/searchTokensInApi' + +// Utils +export { getTokenListViewLink } from './utils/getTokenListViewLink' +export { getTokenSearchFilter } from './utils/getTokenSearchFilter' +export { getTokenLogoUrls } from './utils/getTokenLogoUrls' + +// Pure components +export { TokenLogo } from './pure/TokenLogo' diff --git a/libs/tokens/src/pure/TokenLogo/index.tsx b/libs/tokens/src/pure/TokenLogo/index.tsx new file mode 100644 index 0000000000..099bc7e417 --- /dev/null +++ b/libs/tokens/src/pure/TokenLogo/index.tsx @@ -0,0 +1,62 @@ +import { atom, useAtom } from 'jotai' +import { useMemo } from 'react' + +import { cowprotocolTokenUrl, NATIVE_CURRENCY_BUY_ADDRESS, TokenWithLogo } from '@cowprotocol/common-const' +import { uriToHttp } from '@cowprotocol/common-utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { Currency, NativeCurrency } from '@uniswap/sdk-core' + +import { Slash } from 'react-feather' +import styled from 'styled-components/macro' + +import { getTokenLogoUrls } from '../../utils/getTokenLogoUrls' + +const invalidUrlsAtom = atom<{ [url: string]: boolean }>({}) + +const TokenLogoWrapper = styled.div` + display: inline-block; + background: var(--cow-container-bg-01); + border-radius: 50%; +` + +export interface TokenLogoProps { + token?: TokenWithLogo | Currency | null + logoURI?: string + className?: string + size?: number +} + +export function TokenLogo({ logoURI, token, className, size = 36 }: TokenLogoProps) { + const [invalidUrls, setInvalidUrls] = useAtom(invalidUrlsAtom) + + const urls = useMemo(() => { + // TODO: get rid of Currency usage and remove type casting + if (token) { + if (token instanceof NativeCurrency) { + return [cowprotocolTokenUrl(NATIVE_CURRENCY_BUY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)] + } + + return getTokenLogoUrls(token as TokenWithLogo) + } + + return logoURI ? uriToHttp(logoURI) : [] + }, [logoURI, token]) + + const validUrls = useMemo(() => urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls]) + + const currentUrl = validUrls[0] + + const onError = () => { + setInvalidUrls((state) => ({ ...state, [currentUrl]: true })) + } + + return ( + + {!currentUrl ? ( + + ) : ( + + )} + + ) +} diff --git a/libs/tokens/src/services/fetchTokenList.ts b/libs/tokens/src/services/fetchTokenList.ts new file mode 100644 index 0000000000..0b204398ff --- /dev/null +++ b/libs/tokens/src/services/fetchTokenList.ts @@ -0,0 +1,88 @@ +import type { TokenList as UniTokenList } from '@uniswap/token-lists' + +import { MAINNET_PROVIDER } from '@cowprotocol/common-const' +import { contenthashToUri, resolveENSContentHash, uriToHttp } from '@cowprotocol/common-utils' + +import { validateTokenList } from '../utils/validateTokenList' +import { TokenList, TokenListSource, TokenListWithEnsName, TokenListWithUrl } from '../types' +import { getIsTokenListWithUrl } from '../utils/getIsTokenListWithUrl' + +export interface TokenListResult { + id: string + priority?: number + source: TokenListSource + list: UniTokenList +} + +/** + * Refactored version of apps/cowswap-frontend/src/lib/hooks/useTokenList/fetchTokenList.ts + */ +export function fetchTokenList(list: TokenList): Promise { + return getIsTokenListWithUrl(list) ? fetchTokenListByUrl(list) : fetchTokenListByEnsName(list) +} + +async function fetchTokenListByUrl(list: TokenListWithUrl): Promise { + return _fetchTokenList(list.id, [list.url]).then((result) => { + return { + ...result, + priority: list.priority, + source: { + url: list.url, + }, + } + }) +} + +async function fetchTokenListByEnsName(list: TokenListWithEnsName): Promise { + const contentHashUri = await resolveENSContentHash(list.ensName, MAINNET_PROVIDER) + const translatedUri = contenthashToUri(contentHashUri) + const urls = uriToHttp(translatedUri) + + return _fetchTokenList(list.id, urls).then((result) => { + return { + ...result, + priority: list.priority, + source: { + ensName: list.ensName, + }, + } + }) +} + +async function _fetchTokenList(id: string, urls: string[]): Promise> { + for (let i = 0; i < urls.length; i++) { + const url = urls[i] + const isLast = i === urls.length - 1 + + let response + + try { + response = await fetch(url, { credentials: 'omit' }) + } catch (error) { + const message = `failed to fetch list: ${url}` + + console.debug(message, error) + if (isLast) throw new Error(message) + + continue + } + + if (!response.ok) { + const message = `failed to fetch list: ${url}` + + console.debug(message, response.statusText) + if (isLast) throw new Error(message) + + continue + } + + const json = await response.json() + + return { + id, + list: await validateTokenList(json), + } + } + + throw new Error('Unrecognized list URL protocol.') +} diff --git a/libs/tokens/src/services/searchTokensInApi.ts b/libs/tokens/src/services/searchTokensInApi.ts new file mode 100644 index 0000000000..782132364f --- /dev/null +++ b/libs/tokens/src/services/searchTokensInApi.ts @@ -0,0 +1,70 @@ +import { gql, GraphQLClient } from 'graphql-request' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +type Address = `0x${string}` + +type Chain = 'ARBITRUM' | 'ETHEREUM' | 'ETHEREUM_GOERLI' | 'OPTIMISM' | 'POLYGON' | 'CELO' | 'BNB' | 'UNKNOWN_CHAIN' + +const CHAIN_TO_CHAIN_ID: { [key: string]: SupportedChainId } = { + ETHEREUM: SupportedChainId.MAINNET, + ETHEREUM_GOERLI: SupportedChainId.GOERLI, +} + +interface FetchTokensResult { + id: string + decimals: number + name: string + chain: Chain + standard: string + address: Address + symbol: string + project: { + id: string + logoUrl: string + safetyLevel: string + } +} + +interface FetchTokensApiResult { + searchTokens: FetchTokensResult[] +} + +export interface TokenSearchFromApiResult extends FetchTokensResult { + chainId: SupportedChainId +} + +const SEARCH_TOKENS = gql` + query SearchTokens($searchQuery: String!) { + searchTokens(searchQuery: $searchQuery) { + id + decimals + name + chain + standard + address + symbol + project { + id + logoUrl + safetyLevel + __typename + } + __typename + } + } +` + +const BASE_URL = 'https://cow-web-services.vercel.app/api/serverless/proxy' +const GQL_CLIENT = new GraphQLClient(BASE_URL) + +export async function searchTokensInApi(searchQuery: string): Promise { + return await GQL_CLIENT.request(SEARCH_TOKENS, { + searchQuery, + }).then((result) => { + if (!result?.searchTokens?.length) { + return [] + } + + return result.searchTokens.map((token) => ({ ...token, chainId: CHAIN_TO_CHAIN_ID[token.chain] })) + }) +} diff --git a/libs/tokens/src/state/environmentAtom.ts b/libs/tokens/src/state/environmentAtom.ts new file mode 100644 index 0000000000..fe30bb543e --- /dev/null +++ b/libs/tokens/src/state/environmentAtom.ts @@ -0,0 +1,7 @@ +import { atom } from 'jotai' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { getCurrentChainIdFromUrl } from '@cowprotocol/common-utils' + +export const environmentAtom = atom<{ chainId: SupportedChainId }>({ + chainId: getCurrentChainIdFromUrl(), +}) diff --git a/libs/tokens/src/state/tokenLists/tokenListsActionsAtom.ts b/libs/tokens/src/state/tokenLists/tokenListsActionsAtom.ts new file mode 100644 index 0000000000..a17d9d9a7b --- /dev/null +++ b/libs/tokens/src/state/tokenLists/tokenListsActionsAtom.ts @@ -0,0 +1,69 @@ +import { atom } from 'jotai' +import { nanoid } from '@reduxjs/toolkit' + +import { environmentAtom } from '../environmentAtom' +import { + activeTokenListsIdsAtom, + removeListFromAllTokenListsInfoAtom, + upsertAllTokenListsInfoAtom, + userAddedTokenListsAtom, +} from './tokenListsStateAtom' +import { TokenListInfo } from '../../types' + +export const addTokenListAtom = atom(null, (get, set, tokenList: TokenListInfo) => { + const { chainId } = get(environmentAtom) + const userAddedTokenLists = get(userAddedTokenListsAtom) + const activeTokenListsIds = get(activeTokenListsIdsAtom) + const id = nanoid() + + tokenList.id = id + + set(userAddedTokenListsAtom, { + ...userAddedTokenLists, + [chainId]: userAddedTokenLists[chainId].concat({ ...tokenList.source, id }), + }) + + set(activeTokenListsIdsAtom, { + ...activeTokenListsIds, + [chainId]: { + ...activeTokenListsIds[chainId], + [id]: true, + }, + }) + + set(upsertAllTokenListsInfoAtom, chainId, { [id]: tokenList }) +}) + +export const removeTokenListAtom = atom(null, (get, set, id: string) => { + const { chainId } = get(environmentAtom) + const userAddedTokenLists = get(userAddedTokenListsAtom) + const activeTokenListsIds = get(activeTokenListsIdsAtom) + const activeTokenListsState = { ...activeTokenListsIds[chainId] } + + delete activeTokenListsState[id] + + set(userAddedTokenListsAtom, { + ...userAddedTokenLists, + [chainId]: userAddedTokenLists[chainId].filter((item) => item.id !== id), + }) + + set(activeTokenListsIdsAtom, { + ...activeTokenListsIds, + [chainId]: activeTokenListsState, + }) + + set(removeListFromAllTokenListsInfoAtom, id) +}) + +export const toggleListAtom = atom(null, (get, set, id: string) => { + const { chainId } = get(environmentAtom) + const activeTokenListsIds = get(activeTokenListsIdsAtom) + const activeTokenListsState = { ...activeTokenListsIds[chainId] } + + activeTokenListsState[id] = !activeTokenListsState[id] + + set(activeTokenListsIdsAtom, { + ...activeTokenListsIds, + [chainId]: activeTokenListsState, + }) +}) diff --git a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts new file mode 100644 index 0000000000..f0e03df414 --- /dev/null +++ b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts @@ -0,0 +1,87 @@ +import { atom } from 'jotai' +import { TokenListInfo, TokenListsByNetwork } from '../../types' +import { DEFAULT_TOKENS_LISTS } from '../../const/tokensLists' +import { atomWithStorage } from 'jotai/utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { environmentAtom } from '../environmentAtom' + +const defaultTokensListsAtom = atom(DEFAULT_TOKENS_LISTS) + +const allTokenListsInfoByChainAtom = atomWithStorage>( + 'allTokenListsInfoAtom:v1', + { + [SupportedChainId.MAINNET]: {}, + [SupportedChainId.GNOSIS_CHAIN]: {}, + [SupportedChainId.GOERLI]: {}, + } +) + +export const tokenListsUpdatingAtom = atom(false) + +export const userAddedTokenListsAtom = atomWithStorage('userAddedTokenListsAtom:v1', { + [SupportedChainId.MAINNET]: [], + [SupportedChainId.GNOSIS_CHAIN]: [], + [SupportedChainId.GOERLI]: [], +}) + +export const activeTokenListsIdsAtom = atomWithStorage>( + 'activeTokenListsAtom:v1', + { + [SupportedChainId.MAINNET]: {}, + [SupportedChainId.GNOSIS_CHAIN]: {}, + [SupportedChainId.GOERLI]: {}, + } +) + +export const allTokenListsInfoAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const allTokenListsInfo = get(allTokenListsInfoByChainAtom) + + return Object.values(allTokenListsInfo[chainId]) +}) + +export const upsertAllTokenListsInfoAtom = atom( + null, + (get, set, chainId: SupportedChainId, update: { [id: string]: TokenListInfo }) => { + const state = get(allTokenListsInfoByChainAtom) + + set(allTokenListsInfoByChainAtom, { + ...state, + [chainId]: { + ...state[chainId], + ...update, + }, + }) + } +) +export const removeListFromAllTokenListsInfoAtom = atom(null, (get, set, id: string) => { + const { chainId } = get(environmentAtom) + const stateCopy = { ...get(allTokenListsInfoByChainAtom) } + + delete stateCopy[chainId][id] + + set(allTokenListsInfoByChainAtom, stateCopy) +}) + +export const allTokenListsAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const defaultTokensLists = get(defaultTokensListsAtom) + const userAddedTokenLists = get(userAddedTokenListsAtom) + + return [...defaultTokensLists[chainId], ...userAddedTokenLists[chainId]] +}) + +export const activeTokenListsMapAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const allTokensLists = get(allTokenListsAtom) + const activeTokenLists = get(activeTokenListsIdsAtom) + const tokenListsActive = activeTokenLists[chainId] + + return allTokensLists.reduce<{ [listId: string]: boolean }>((acc, tokenList) => { + const isActive = tokenListsActive[tokenList.id] + + acc[tokenList.id] = typeof isActive === 'boolean' ? isActive : !!tokenList.enabledByDefault + + return acc + }, {}) +}) diff --git a/libs/tokens/src/state/tokens/favouriteTokensAtom.ts b/libs/tokens/src/state/tokens/favouriteTokensAtom.ts new file mode 100644 index 0000000000..8c5313cefa --- /dev/null +++ b/libs/tokens/src/state/tokens/favouriteTokensAtom.ts @@ -0,0 +1,95 @@ +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { TokensMap } from '../../types' +import { environmentAtom } from '../environmentAtom' +import { + COW, + DAI, + DAI_GOERLI, + EURE_GNOSIS_CHAIN, + TokenWithLogo, + USDC_GNOSIS_CHAIN, + USDC_GOERLI, + USDC_MAINNET, + USDT, + WBTC, + WBTC_GNOSIS_CHAIN, + WETH_GNOSIS_CHAIN, + WRAPPED_NATIVE_CURRENCY, +} from '@cowprotocol/common-const' + +const tokensListToMap = (list: TokenWithLogo[]) => + list.reduce((acc, token) => { + acc[token.address.toLowerCase()] = { + chainId: token.chainId, + address: token.address, + name: token.name || '', + decimals: token.decimals, + symbol: token.symbol || '', + logoURI: token.logoURI, + } + return acc + }, {}) + +export const DEFAULT_FAVOURITE_TOKENS: Record = { + [SupportedChainId.MAINNET]: tokensListToMap([ + DAI, + COW[SupportedChainId.MAINNET], + USDC_MAINNET, + USDT, + WBTC, + WRAPPED_NATIVE_CURRENCY[SupportedChainId.MAINNET], + ]), + [SupportedChainId.GNOSIS_CHAIN]: tokensListToMap([ + USDC_GNOSIS_CHAIN, + COW[SupportedChainId.GNOSIS_CHAIN], + EURE_GNOSIS_CHAIN, + WRAPPED_NATIVE_CURRENCY[SupportedChainId.GNOSIS_CHAIN], + WETH_GNOSIS_CHAIN, + WBTC_GNOSIS_CHAIN, + ]), + [SupportedChainId.GOERLI]: tokensListToMap([ + WRAPPED_NATIVE_CURRENCY[SupportedChainId.GOERLI], + COW[SupportedChainId.GOERLI], + DAI_GOERLI, + USDC_GOERLI, + ]), +} + +export const favouriteTokensAtom = atomWithStorage>( + 'favouriteTokensAtom:v1', + DEFAULT_FAVOURITE_TOKENS +) + +export const favouriteTokensListAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const favouriteTokensState = get(favouriteTokensAtom) + + return Object.values(favouriteTokensState[chainId]).map( + (token) => new TokenWithLogo(token.logoURI, token.chainId, token.address, token.decimals, token.symbol, token.name) + ) +}) + +export const resetFavouriteTokensAtom = atom(null, (get, set) => { + set(favouriteTokensAtom, { ...DEFAULT_FAVOURITE_TOKENS }) +}) + +export const toggleFavouriteTokenAtom = atom(null, (get, set, token: TokenWithLogo) => { + const { chainId } = get(environmentAtom) + const favouriteTokensState = get(favouriteTokensAtom) + const state = { ...favouriteTokensState[chainId] } + const tokenKey = token.address.toLowerCase() + + if (state[tokenKey]) { + delete state[tokenKey] + } else { + state[tokenKey] = { ...token, name: token.name || '', symbol: token.symbol || '' } + } + + set(favouriteTokensAtom, { + ...favouriteTokensState, + [chainId]: state, + }) +}) diff --git a/libs/tokens/src/state/tokens/tokensAtom.ts b/libs/tokens/src/state/tokens/tokensAtom.ts new file mode 100644 index 0000000000..b47076cf20 --- /dev/null +++ b/libs/tokens/src/state/tokens/tokensAtom.ts @@ -0,0 +1,79 @@ +import { atomWithStorage } from 'jotai/utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { atom } from 'jotai' +import { environmentAtom } from '../environmentAtom' +import { TokensMap } from '../../types' +import { NATIVE_CURRENCY_BUY_TOKEN, TokenWithLogo } from '@cowprotocol/common-const' +import { tokenMapToList } from '../../utils/tokenMapToList' +import { userAddedTokensAtom } from './userAddedTokensAtom' +import { atomWithPartialUpdate } from '@cowprotocol/common-utils' +import { favouriteTokensAtom } from './favouriteTokensAtom' + +export type TokensByAddress = { [address: string]: TokenWithLogo } + +export type TokensBySymbol = { [address: string]: TokenWithLogo[] } + +export type TokensState = { activeTokens: TokensMap; inactiveTokens: TokensMap } + +const defaultState: TokensState = { activeTokens: {}, inactiveTokens: {} } + +const { atom: tokensAtomsByChainId, updateAtom: updateTokensAtom } = atomWithPartialUpdate( + atomWithStorage>('tokensAtomsByChainId:v1', { + [SupportedChainId.MAINNET]: { ...defaultState }, + [SupportedChainId.GNOSIS_CHAIN]: { ...defaultState }, + [SupportedChainId.GOERLI]: { ...defaultState }, + }) +) + +export const activeTokensAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const userAddedTokens = get(userAddedTokensAtom) + const favouriteTokensState = get(favouriteTokensAtom) + + const tokensMap = get(tokensAtomsByChainId)[chainId] + const nativeToken = NATIVE_CURRENCY_BUY_TOKEN[chainId] + + const tokens = tokenMapToList({ + ...tokensMap.activeTokens, + ...userAddedTokens[chainId], + ...favouriteTokensState[chainId], + }) + + tokens.unshift(nativeToken) + + return tokens +}) + +export const inactiveTokensAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const tokensMap = get(tokensAtomsByChainId)[chainId] + + return tokenMapToList(tokensMap.inactiveTokens) +}) + +export const setTokensAtom = atom(null, (get, set, state: TokensState) => { + const { chainId } = get(environmentAtom) + + set(updateTokensAtom, { [chainId]: state }) +}) + +export const tokensByAddressAtom = atom((get) => { + return get(activeTokensAtom).reduce((acc, token) => { + acc[token.address.toLowerCase()] = token + return acc + }, {}) +}) + +export const tokensBySymbolAtom = atom((get) => { + return get(activeTokensAtom).reduce((acc, token) => { + if (!token.symbol) return acc + + const symbol = token.symbol.toLowerCase() + + acc[symbol] = acc[symbol] || [] + + acc[symbol].push(token) + + return acc + }, {}) +}) diff --git a/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts b/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts new file mode 100644 index 0000000000..8136fc7343 --- /dev/null +++ b/libs/tokens/src/state/tokens/unsupportedTokensAtom.ts @@ -0,0 +1,46 @@ +import { atomWithStorage } from 'jotai/utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { atom } from 'jotai' +import { environmentAtom } from '../environmentAtom' + +export const unsupportedTokensAtom = atomWithStorage< + Record +>('unsupportedTokensAtom:v1', { + [SupportedChainId.MAINNET]: {}, + [SupportedChainId.GNOSIS_CHAIN]: {}, + [SupportedChainId.GOERLI]: {}, +}) + +export const currentUnsupportedTokensAtom = atom((get) => { + const { chainId } = get(environmentAtom) + + return get(unsupportedTokensAtom)[chainId] +}) + +export const addUnsupportedTokenAtom = atom(null, (get, set, tokenAddress: string) => { + const { chainId } = get(environmentAtom) + const tokenId = tokenAddress.toLowerCase() + const tokenList = get(unsupportedTokensAtom) + + if (!tokenList[chainId][tokenId]) { + set(unsupportedTokensAtom, { + ...tokenList, + [chainId]: { + ...tokenList[chainId], + [tokenId]: Date.now(), + }, + }) + } +}) + +export const removeUnsupportedTokenAtom = atom(null, (get, set, tokenAddress: string) => { + const { chainId } = get(environmentAtom) + const tokenId = tokenAddress.toLowerCase() + const tokenList = { ...get(unsupportedTokensAtom) } + + if (tokenList[chainId][tokenId]) { + delete tokenList[chainId][tokenId] + + set(unsupportedTokensAtom, tokenList) + } +}) diff --git a/libs/tokens/src/state/tokens/userAddedTokensAtom.ts b/libs/tokens/src/state/tokens/userAddedTokensAtom.ts new file mode 100644 index 0000000000..438cc7df35 --- /dev/null +++ b/libs/tokens/src/state/tokens/userAddedTokensAtom.ts @@ -0,0 +1,61 @@ +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { TokensMap } from '../../types' +import { environmentAtom } from '../environmentAtom' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { Token } from '@uniswap/sdk-core' + +export const userAddedTokensAtom = atomWithStorage>('userAddedTokensAtom:v1', { + [SupportedChainId.MAINNET]: {}, + [SupportedChainId.GNOSIS_CHAIN]: {}, + [SupportedChainId.GOERLI]: {}, +}) + +export const userAddedTokensListAtom = atom((get) => { + const { chainId } = get(environmentAtom) + const userAddedTokensState = get(userAddedTokensAtom) + + return Object.values(userAddedTokensState[chainId]).map( + (token) => new TokenWithLogo(token.logoURI, token.chainId, token.address, token.decimals, token.symbol, token.name) + ) +}) + +export const addUserTokenAtom = atom(null, (get, set, tokens: TokenWithLogo[]) => { + const { chainId } = get(environmentAtom) + const userAddedTokensState = get(userAddedTokensAtom) + + set(userAddedTokensAtom, { + ...userAddedTokensState, + [chainId]: { + ...userAddedTokensState[chainId], + ...tokens.reduce<{ [key: string]: Token }>((acc, token) => { + acc[token.address.toLowerCase()] = token + return acc + }, {}), + }, + }) +}) + +export const removeUserTokenAtom = atom(null, (get, set, token: TokenWithLogo) => { + const { chainId } = get(environmentAtom) + const userAddedTokensState = get(userAddedTokensAtom) + const stateCopy = { ...userAddedTokensState[chainId] } + + delete stateCopy[token.address.toLowerCase()] + + set(userAddedTokensAtom, { + ...userAddedTokensState, + [chainId]: stateCopy, + }) +}) + +export const resetUserTokenAtom = atom(null, (get, set) => { + const { chainId } = get(environmentAtom) + const userAddedTokensState = get(userAddedTokensAtom) + + set(userAddedTokensAtom, { + ...userAddedTokensState, + [chainId]: {}, + }) +}) diff --git a/libs/tokens/src/types.ts b/libs/tokens/src/types.ts new file mode 100644 index 0000000000..e42035e204 --- /dev/null +++ b/libs/tokens/src/types.ts @@ -0,0 +1,35 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import type { TokenInfo } from '@uniswap/token-lists' + +export type TokenListSource = { ensName: string } | { url: string } + +export interface TokenListWithUrl { + id: string // nanoid + priority?: number + enabledByDefault?: boolean + url: string +} + +export interface TokenListWithEnsName { + id: string // nanoid + priority?: number + enabledByDefault?: boolean + ensName: string +} + +export type TokenList = TokenListWithUrl | TokenListWithEnsName + +export type TokenListsByNetwork = Record> + +export interface TokenListInfo { + source: TokenListSource + id: string + name: string + timestamp: string + version: string + priority?: number + logoUrl?: string + tokensCount: number +} + +export type TokensMap = { [address: string]: TokenInfo } diff --git a/libs/tokens/src/updaters/TokensListsUpdater/index.ts b/libs/tokens/src/updaters/TokensListsUpdater/index.ts new file mode 100644 index 0000000000..980dcae23a --- /dev/null +++ b/libs/tokens/src/updaters/TokensListsUpdater/index.ts @@ -0,0 +1,113 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { + allTokenListsAtom, + tokenListsUpdatingAtom, + upsertAllTokenListsInfoAtom, +} from '../../state/tokenLists/tokenListsStateAtom' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import useSWR, { SWRConfiguration } from 'swr' +import ms from 'ms.macro' +import { useEffect } from 'react' +import { fetchTokenList, TokenListResult } from '../../services/fetchTokenList' +import { setTokensAtom } from '../../state/tokens/tokensAtom' +import { environmentAtom } from '../../state/environmentAtom' +import { TokenListInfo, TokensMap } from '../../types' +import { buildTokenListInfo } from '../../utils/buildTokenListInfo' +import { useActiveTokenListsIds } from '../../hooks/useActiveTokenListsIds' + +type TokensAndListsUpdate = { + activeTokens: TokensMap + inactiveTokens: TokensMap + lists: { [id: string]: TokenListInfo } +} + +const TOKENS_LISTS_UPDATER_INTERVAL = ms`6h` + +const swrOptions: SWRConfiguration = { + refreshInterval: TOKENS_LISTS_UPDATER_INTERVAL, + revalidateOnFocus: false, +} + +const LAST_UPDATE_TIME_KEY = (chainId: SupportedChainId) => `tokens-lists-updater:last-update-time[${chainId}]` + +export function TokensListsUpdater({ chainId: currentChainId }: { chainId: SupportedChainId }) { + const [{ chainId }, setEnvironment] = useAtom(environmentAtom) + const setTokens = useSetAtom(setTokensAtom) + const setTokenListsUpdating = useSetAtom(tokenListsUpdatingAtom) + const setTokenLists = useSetAtom(upsertAllTokenListsInfoAtom) + const allTokensLists = useAtomValue(allTokenListsAtom) + const activeTokensListsMap = useActiveTokenListsIds() + + useEffect(() => { + setEnvironment({ chainId: currentChainId }) + }, [setEnvironment, currentChainId]) + + // Fetch tokens lists once in 6 hours + const swrResponse = useSWR( + ['TokensListsUpdater', allTokensLists, chainId], + () => { + if (!getIsTimeToUpdate(chainId)) return null + + return Promise.allSettled(allTokensLists.map(fetchTokenList)).then(getFulfilledResults) + }, + swrOptions + ) + + // Fullfil tokens map with tokens from fetched lists + useEffect(() => { + const { data, isLoading, error } = swrResponse + + setTokenListsUpdating(isLoading) + + if (isLoading || error || !data) return + + const { activeTokens, inactiveTokens, lists } = data.reduce( + (acc, val) => { + const isListEnabled = activeTokensListsMap[val.id] + + acc.lists[val.id] = buildTokenListInfo(val) + + val.list.tokens.forEach((token) => { + if (token.chainId === chainId) { + const tokenAddress = token.address.toLowerCase() + + if (isListEnabled) { + acc.activeTokens[tokenAddress] = token + } else { + acc.inactiveTokens[tokenAddress] = token + } + } + }) + + return acc + }, + { activeTokens: {}, inactiveTokens: {}, lists: {} } + ) + + localStorage.setItem(LAST_UPDATE_TIME_KEY(chainId), Date.now().toString()) + + setTokenLists(chainId, lists) + setTokens({ activeTokens, inactiveTokens }) + }, [swrResponse, chainId, setTokens, setTokenLists, activeTokensListsMap, setTokenListsUpdating]) + + return null +} + +const getIsTimeToUpdate = (chainId: SupportedChainId): boolean => { + const lastUpdateTime = +(localStorage.getItem(LAST_UPDATE_TIME_KEY(chainId)) || 0) + + if (!lastUpdateTime) return true + + return Date.now() - lastUpdateTime > TOKENS_LISTS_UPDATER_INTERVAL +} + +const getFulfilledResults = (results: PromiseSettledResult[]) => { + return results.reduce((acc, val) => { + if (val.status === 'fulfilled') { + acc.push(val.value) + } + + return acc + }, []) +} diff --git a/libs/tokens/src/utils/buildTokenListInfo.ts b/libs/tokens/src/utils/buildTokenListInfo.ts new file mode 100644 index 0000000000..8abcbe4f8e --- /dev/null +++ b/libs/tokens/src/utils/buildTokenListInfo.ts @@ -0,0 +1,17 @@ +import { TokenListInfo } from '../types' +import { TokenListResult } from '../services/fetchTokenList' + +export function buildTokenListInfo(val: TokenListResult): TokenListInfo { + const { major, minor, patch } = val.list.version + + return { + id: val.id, + source: val.source, + name: val.list.name, + timestamp: val.list.timestamp, + version: `v${major}.${minor}.${patch}`, + logoUrl: val.list.logoURI, + tokensCount: val.list.tokens.length, + priority: val.priority, + } +} diff --git a/libs/tokens/src/utils/fetchTokenFromBlockchain.ts b/libs/tokens/src/utils/fetchTokenFromBlockchain.ts new file mode 100644 index 0000000000..8eb651f82e --- /dev/null +++ b/libs/tokens/src/utils/fetchTokenFromBlockchain.ts @@ -0,0 +1,29 @@ +import { getContract } from '@cowprotocol/common-utils' +import { Erc20, Erc20Abi } from '@cowprotocol/abis' +import type { JsonRpcProvider } from '@ethersproject/providers' +import { getAddress } from '@ethersproject/address' +import { TokenInfo } from '@uniswap/token-lists' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +export async function fetchTokenFromBlockchain( + tokenAddress: string, + chainId: SupportedChainId, + provider: JsonRpcProvider +): Promise { + const formattedAddress = getAddress(tokenAddress) + const erc20Contract = getContract(formattedAddress, Erc20Abi, provider) as Erc20 + + const [name, symbol, decimals] = await Promise.all([ + erc20Contract.callStatic.name(), + erc20Contract.callStatic.symbol(), + erc20Contract.callStatic.decimals(), + ]) + + return { + chainId, + address: formattedAddress, + name, + symbol, + decimals, + } +} diff --git a/libs/tokens/src/utils/getIsTokenListWithUrl.ts b/libs/tokens/src/utils/getIsTokenListWithUrl.ts new file mode 100644 index 0000000000..80dae5c43f --- /dev/null +++ b/libs/tokens/src/utils/getIsTokenListWithUrl.ts @@ -0,0 +1,3 @@ +import { TokenList, TokenListWithUrl } from '../types' + +export const getIsTokenListWithUrl = (tokenList: TokenList): tokenList is TokenListWithUrl => 'url' in tokenList diff --git a/libs/tokens/src/utils/getTokenListViewLink.ts b/libs/tokens/src/utils/getTokenListViewLink.ts new file mode 100644 index 0000000000..6566df18fc --- /dev/null +++ b/libs/tokens/src/utils/getTokenListViewLink.ts @@ -0,0 +1,7 @@ +import { TokenListSource } from '../types' + +export function getTokenListViewLink(source: TokenListSource): string { + const url = 'ensName' in source ? source.ensName : source.url + + return `https://tokenlists.org/token-list?url=${url}` +} diff --git a/libs/tokens/src/utils/getTokenLogoUrls.ts b/libs/tokens/src/utils/getTokenLogoUrls.ts new file mode 100644 index 0000000000..226ce3c463 --- /dev/null +++ b/libs/tokens/src/utils/getTokenLogoUrls.ts @@ -0,0 +1,25 @@ +import { cowprotocolTokenUrl, TokenWithLogo } from '@cowprotocol/common-const' +import { uriToHttp } from '@cowprotocol/common-utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +export function getTokenLogoUrls(token: TokenWithLogo | undefined): string[] { + const fallbackUrls = token?.address + ? [ + cowprotocolTokenUrl(token.address, token.chainId as SupportedChainId), + cowprotocolTokenUrl(token.address.toLowerCase(), token.chainId as SupportedChainId), + cowprotocolTokenUrl(token.address.toLowerCase(), SupportedChainId.MAINNET), + ] + : [] + + if (!token?.logoURI) { + return fallbackUrls + } + + const urls = uriToHttp(token.logoURI) + + if (fallbackUrls.length) { + urls.push(...fallbackUrls.filter((url) => !urls.includes(url))) + } + + return urls +} diff --git a/libs/tokens/src/utils/getTokenSearchFilter.ts b/libs/tokens/src/utils/getTokenSearchFilter.ts new file mode 100644 index 0000000000..8c69232be1 --- /dev/null +++ b/libs/tokens/src/utils/getTokenSearchFilter.ts @@ -0,0 +1,35 @@ +import { NativeCurrency, Token } from '@uniswap/sdk-core' +import { TokenInfo } from '@uniswap/token-lists' +import { isAddress } from '@cowprotocol/common-utils' + +const alwaysTrue = () => true + +/** Creates a filter function that filters tokens that do not match the query. */ +export function getTokenSearchFilter( + query: string +): (token: T | NativeCurrency) => boolean { + const searchingAddress = isAddress(query) + + if (searchingAddress) { + const address = searchingAddress.toLowerCase() + return (t: T | NativeCurrency) => 'address' in t && address === t.address.toLowerCase() + } + + const queryParts = query + .toLowerCase() + .split(/\s+/) + .filter((s) => s.length > 0) + + if (queryParts.length === 0) return alwaysTrue + + const match = (s: string): boolean => { + const parts = s + .toLowerCase() + .split(/\s+/) + .filter((s) => s.length > 0) + + return queryParts.every((p) => p.length === 0 || parts.some((sp) => sp.startsWith(p) || sp.endsWith(p))) + } + + return ({ name, symbol }: T | NativeCurrency): boolean => Boolean((symbol && match(symbol)) || (name && match(name))) +} diff --git a/libs/tokens/src/utils/parseTokensFromApi.ts b/libs/tokens/src/utils/parseTokensFromApi.ts new file mode 100644 index 0000000000..10b053699f --- /dev/null +++ b/libs/tokens/src/utils/parseTokensFromApi.ts @@ -0,0 +1,27 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { TokenSearchFromApiResult } from '../services/searchTokensInApi' +import { isTruthy } from '@cowprotocol/common-utils' + +export function parseTokensFromApi(apiResult: TokenSearchFromApiResult[], chainId: SupportedChainId): TokenWithLogo[] { + return apiResult + .filter((token) => token.chainId === chainId) + .map((token) => { + try { + return new TokenWithLogo( + token.project.logoUrl, + token.chainId, + token.address, + token.decimals, + token.symbol, + token.name + ) + } catch (e) { + console.error('parseTokensFromApi error', e) + + return null + } + }) + .filter(isTruthy) +} diff --git a/libs/tokens/src/utils/tokenMapToList.ts b/libs/tokens/src/utils/tokenMapToList.ts new file mode 100644 index 0000000000..ba548456ee --- /dev/null +++ b/libs/tokens/src/utils/tokenMapToList.ts @@ -0,0 +1,12 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { TokensMap } from '../types' + +export function tokenMapToList(tokenMap: TokensMap): TokenWithLogo[] { + return Object.values(tokenMap) + .sort((a, b) => (a.symbol > b.symbol ? 1 : -1)) + .map( + (token) => + new TokenWithLogo(token.logoURI, token.chainId, token.address, token.decimals, token.symbol, token.name) + ) +} diff --git a/libs/tokens/src/utils/validateTokenList.test.ts b/libs/tokens/src/utils/validateTokenList.test.ts new file mode 100644 index 0000000000..5a4d9ec509 --- /dev/null +++ b/libs/tokens/src/utils/validateTokenList.test.ts @@ -0,0 +1,42 @@ +import type { TokenInfo } from '@uniswap/token-lists' + +import { validateTokens } from './validateTokenList' + +const INVALID_TOKEN: TokenInfo = { + name: 'Dai Stablecoin', + address: '0xD3ADB33F', + symbol: 'DAI', + decimals: 18, + chainId: 1, +} + +const INLINE_TOKEN_LIST = [ + { + name: 'Dai Stablecoin', + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + symbol: 'DAI', + decimals: 18, + chainId: 1, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + }, + { + name: 'USDCoin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + chainId: 1, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, +] + +describe('validateTokens', () => { + it('throws on invalid tokens', async () => { + await expect(validateTokens([INVALID_TOKEN])).rejects.toThrowError(/^Token list failed validation:.*address/) + }) + + it('validates the passed token info', async () => { + await expect(validateTokens(INLINE_TOKEN_LIST)).resolves.toBe(INLINE_TOKEN_LIST) + }) +}) diff --git a/libs/tokens/src/utils/validateTokenList.ts b/libs/tokens/src/utils/validateTokenList.ts new file mode 100644 index 0000000000..d076dba5c1 --- /dev/null +++ b/libs/tokens/src/utils/validateTokenList.ts @@ -0,0 +1,56 @@ +import type { TokenInfo, TokenList } from '@uniswap/token-lists' + +import type { Ajv, ValidateFunction } from 'ajv' + +enum ValidationSchema { + LIST = 'list', + TOKENS = 'tokens', +} + +const validator = new Promise((resolve) => { + Promise.all([import('ajv'), import('@uniswap/token-lists/src/tokenlist.schema.json')]).then(([ajv, schema]) => { + const validator = new ajv.default({ allErrors: true }) + .addSchema(schema, ValidationSchema.LIST) + // Adds a meta scheme of Pick + .addSchema( + { + ...schema, + $id: schema.$id + '#tokens', + required: ['tokens'], + }, + ValidationSchema.TOKENS + ) + resolve(validator) + }) +}) + +function getValidationErrors(validate: ValidateFunction | undefined): string { + return ( + validate?.errors?.map((error) => [error.dataPath, error.message].filter(Boolean).join(' ')).join('; ') ?? + 'unknown error' + ) +} + +/** + * Validates an array of tokens. + * @param json the TokenInfo[] to validate + */ +export async function validateTokens(json: TokenInfo[]): Promise { + const validate = (await validator).getSchema(ValidationSchema.TOKENS) + if (validate?.({ tokens: json })) { + return json + } + throw new Error(`Token list failed validation: ${getValidationErrors(validate)}`) +} + +/** + * Validates a token list. + * @param json the TokenList to validate + */ +export async function validateTokenList(json: TokenList): Promise { + const validate = (await validator).getSchema(ValidationSchema.LIST) + if (validate?.(json)) { + return json + } + throw new Error(`Token list failed validation: ${getValidationErrors(validate)}`) +} diff --git a/libs/tokens/tsconfig.json b/libs/tokens/tsconfig.json new file mode 100644 index 0000000000..bab74ff2e6 --- /dev/null +++ b/libs/tokens/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/libs/tokens/tsconfig.lib.json b/libs/tokens/tsconfig.lib.json new file mode 100644 index 0000000000..1b2b0c12df --- /dev/null +++ b/libs/tokens/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node", "vite/client"] + }, + "files": ["../../node_modules/@nx/react/typings/cssmodule.d.ts", "../../node_modules/@nx/react/typings/image.d.ts"], + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] +} diff --git a/libs/tokens/tsconfig.spec.json b/libs/tokens/tsconfig.spec.json new file mode 100644 index 0000000000..26ef046ac5 --- /dev/null +++ b/libs/tokens/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/tokens/vite.config.ts b/libs/tokens/vite.config.ts new file mode 100644 index 0000000000..959e23a847 --- /dev/null +++ b/libs/tokens/vite.config.ts @@ -0,0 +1,49 @@ +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import dts from 'vite-plugin-dts' +import * as path from 'path' + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/tokens', + + plugins: [ + dts({ + entryRoot: 'src', + tsConfigFilePath: path.join(__dirname, 'tsconfig.lib.json'), + skipDiagnostics: true, + }), + react(), + viteTsConfigPaths({ + root: '../../', + }), + ], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ + // viteTsConfigPaths({ + // root: '../../', + // }), + // ], + // }, + + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: 'tokens', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es', 'cjs'], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }, +}) diff --git a/tsconfig.base.json b/tsconfig.base.json index dfa00a1964..acfb541c2c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -40,6 +40,7 @@ "@cowprotocol/core": ["libs/core/src/index.ts"], "@cowprotocol/ens": ["libs/ens/src/index.ts"], "@cowprotocol/snackbars": ["libs/snackbars/src/index.ts"], + "@cowprotocol/tokens": ["libs/tokens/src/index.ts"], "@cowprotocol/ui": ["libs/ui/src/index.ts"], "@cowprotocol/ui-utils": ["libs/ui-utils/src/index.ts"], "@cowprotocol/wallet": ["libs/wallet/src/index.ts"],