From 90dfdc6befeec3bc4dc087c5df0cbcbd1b12f6c3 Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Tue, 11 Jan 2022 09:28:02 -0800 Subject: [PATCH] feat: populate the widget token selector (#3080) * feat: swap defaults * refactor: mv token list utils to lib * feat: expand fetchTokenList to include inlined * feat: simple widget token list * fix: token img props * feat: use token list in selector * fix: update useColor for optional logoURI * fix: avoid leaking deps * chore: add state to lib build * chore: mv devDeps to deps for lib * fix: microbundle css import * fix: match ethers versions * fix: use color callback * chore: clean up token info type * chore: widget type simplification * refactor: share token map code * test: include list in token select fixture * fix: no tokens without chain id --- package.json | 17 ++-- src/components/SearchModal/ManageLists.tsx | 4 +- src/hooks/useColor.ts | 2 +- src/hooks/useENSAvatar.ts | 2 +- src/hooks/useFetchListCallback.ts | 4 +- src/hooks/useHttpLocations.ts | 6 +- src/lib/components/Swap/index.tsx | 37 +++++++- src/lib/components/TokenImg.tsx | 11 ++- src/lib/components/TokenSelect.fixture.tsx | 16 ++-- .../components/TokenSelect/TokenOptions.tsx | 12 +-- src/lib/components/TokenSelect/index.tsx | 32 +++---- src/lib/components/Widget.tsx | 12 +-- src/lib/hooks/useColor.ts | 23 +++-- .../hooks/useTokenList/fetchTokenList.test.ts | 25 ++++++ src/lib/hooks/useTokenList/fetchTokenList.ts | 73 ++++++++++++++++ src/lib/hooks/useTokenList/index.ts | 43 ++++++++++ src/lib/hooks/useTokenList/utils.ts | 32 +++++++ .../useTokenList/validateTokenList.test.ts | 42 +++++++++ .../hooks/useTokenList/validateTokenList.ts | 54 ++++++++++++ src/lib/index.tsx | 6 +- src/lib/theme/index.tsx | 2 +- src/lib/types.d.ts | 2 +- .../utils/contenthashToUri.test.skip.ts | 0 src/{ => lib}/utils/contenthashToUri.ts | 0 src/{ => lib}/utils/parseENSAddress.test.ts | 2 +- src/{ => lib}/utils/parseENSAddress.ts | 4 +- src/{ => lib}/utils/resolveENSContentHash.ts | 0 src/lib/utils/uriToHttp.ts | 7 +- src/state/lists/hooks.ts | 37 ++------ src/state/lists/wrappedTokenInfo.ts | 6 +- src/utils/getTokenList.ts | 86 ------------------- src/utils/uriToHttp.test.ts | 28 ------ src/utils/uriToHttp.ts | 26 ------ tsconfig.lib.json | 1 + 34 files changed, 406 insertions(+), 248 deletions(-) create mode 100644 src/lib/hooks/useTokenList/fetchTokenList.test.ts create mode 100644 src/lib/hooks/useTokenList/fetchTokenList.ts create mode 100644 src/lib/hooks/useTokenList/index.ts create mode 100644 src/lib/hooks/useTokenList/utils.ts create mode 100644 src/lib/hooks/useTokenList/validateTokenList.test.ts create mode 100644 src/lib/hooks/useTokenList/validateTokenList.ts rename src/{ => lib}/utils/contenthashToUri.test.skip.ts (100%) rename src/{ => lib}/utils/contenthashToUri.ts (100%) rename src/{ => lib}/utils/parseENSAddress.test.ts (95%) rename src/{ => lib}/utils/parseENSAddress.ts (64%) rename src/{ => lib}/utils/resolveENSContentHash.ts (100%) delete mode 100644 src/utils/getTokenList.ts delete mode 100644 src/utils/uriToHttp.test.ts delete mode 100644 src/utils/uriToHttp.ts diff --git a/package.json b/package.json index 586fd4f9fd..b2ce4c9fbb 100644 --- a/package.json +++ b/package.json @@ -52,14 +52,11 @@ "@types/wcag-contrast": "^3.0.0", "@typescript-eslint/eslint-plugin": "^4.1.0", "@typescript-eslint/parser": "^4.1.0", - "@uniswap/default-token-list": "^3.0.0", "@uniswap/governance": "^1.0.2", "@uniswap/liquidity-staker": "^1.0.2", "@uniswap/merkle-distributor": "1.0.1", "@uniswap/router-sdk": "^1.0.3", - "@uniswap/sdk-core": "^3.0.1", "@uniswap/smart-order-router": "^2.5.9", - "@uniswap/token-lists": "^1.0.0-beta.27", "@uniswap/v2-core": "1.0.0", "@uniswap/v2-periphery": "^1.1.0-beta.0", "@uniswap/v2-sdk": "^3.0.1", @@ -72,10 +69,8 @@ "@web3-react/portis-connector": "^6.0.9", "@web3-react/walletconnect-connector": "^7.0.2-alpha.0", "@web3-react/walletlink-connector": "^6.2.8", - "ajv": "^6.12.3", "array.prototype.flat": "^1.2.4", "array.prototype.flatmap": "^1.2.4", - "cids": "^1.0.0", "copy-to-clipboard": "^3.2.0", "cross-env": "^7.0.3", "cypress": "^7.7.0", @@ -96,8 +91,6 @@ "jest-styled-components": "^7.0.5", "microbundle": "^0.13.3", "ms.macro": "^2.0.0", - "multicodec": "^3.0.1", - "multihashes": "^4.0.2", "polyfill-object.fromentries": "^1.0.1", "prettier": "^2.2.1", "qs": "^6.9.4", @@ -164,6 +157,10 @@ }, "license": "GPL-3.0-or-later", "dependencies": { + "@ethersproject/contracts": "5.4.1", + "@ethersproject/hash": "5.4.0", + "@ethersproject/address": "5.4.0", + "@ethersproject/constants": "5.4.0", "@fontsource/ibm-plex-mono": "^4.5.1", "@fontsource/inter": "^4.5.1", "@lingui/core": "^3.9.0", @@ -171,10 +168,16 @@ "@lingui/react": "^3.9.0", "@popperjs/core": "^2.4.4", "@uniswap/redux-multicall": "^1.0.0", + "@uniswap/sdk-core": "^3.0.1", + "@uniswap/token-lists": "^1.0.0-beta.27", + "ajv": "^6.12.3", + "cids": "^1.0.0", "immer": "^9.0.6", "jotai": "^1.3.7", "lodash": "^4.17.21", "make-plural": "^7.0.0", + "multicodec": "^3.0.1", + "multihashes": "^4.0.2", "node-vibrant": "^3.2.1-alpha.1", "polished": "^3.3.2", "popper-max-size-modifier": "^0.2.0", diff --git a/src/components/SearchModal/ManageLists.tsx b/src/components/SearchModal/ManageLists.tsx index cf1f5923b6..798b03344c 100644 --- a/src/components/SearchModal/ManageLists.tsx +++ b/src/components/SearchModal/ManageLists.tsx @@ -5,6 +5,8 @@ import Card from 'components/Card' import { UNSUPPORTED_LIST_URLS } from 'constants/lists' import { useListColor } from 'hooks/useColor' import { useActiveWeb3React } from 'hooks/web3' +import parseENSAddress from 'lib/utils/parseENSAddress' +import uriToHttp from 'lib/utils/uriToHttp' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CheckCircle, Settings } from 'react-feather' import ReactGA from 'react-ga' @@ -20,8 +22,6 @@ import { acceptListUpdate, disableList, enableList, removeList } from '../../sta import { useActiveListUrls, useAllLists, useIsListActive } from '../../state/lists/hooks' import { ExternalLink, IconWrapper, LinkStyledButton, ThemedText } from '../../theme' import listVersionLabel from '../../utils/listVersionLabel' -import { parseENSAddress } from '../../utils/parseENSAddress' -import uriToHttp from '../../utils/uriToHttp' import { ButtonEmpty, ButtonPrimary } from '../Button' import Column, { AutoColumn } from '../Column' import ListLogo from '../ListLogo' diff --git a/src/hooks/useColor.ts b/src/hooks/useColor.ts index bb60c8ff69..4fa714bdf2 100644 --- a/src/hooks/useColor.ts +++ b/src/hooks/useColor.ts @@ -1,10 +1,10 @@ import { Token } from '@uniswap/sdk-core' import { SupportedChainId } from 'constants/chains' +import uriToHttp from 'lib/utils/uriToHttp' import Vibrant from 'node-vibrant/lib/bundle' import { shade } from 'polished' import { useLayoutEffect, useState } from 'react' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' -import uriToHttp from 'utils/uriToHttp' import { hex } from 'wcag-contrast' function URIForEthToken(address: string) { diff --git a/src/hooks/useENSAvatar.ts b/src/hooks/useENSAvatar.ts index 0d812afb5e..ef28554efd 100644 --- a/src/hooks/useENSAvatar.ts +++ b/src/hooks/useENSAvatar.ts @@ -1,9 +1,9 @@ import { BigNumber } from '@ethersproject/bignumber' import { hexZeroPad } from '@ethersproject/bytes' import { namehash } from '@ethersproject/hash' +import uriToHttp from 'lib/utils/uriToHttp' import { useEffect, useMemo, useState } from 'react' import { safeNamehash } from 'utils/safeNamehash' -import uriToHttp from 'utils/uriToHttp' import { useSingleCallResult } from '../state/multicall/hooks' import { isAddress } from '../utils' diff --git a/src/hooks/useFetchListCallback.ts b/src/hooks/useFetchListCallback.ts index 12cb4df60c..99a56e1347 100644 --- a/src/hooks/useFetchListCallback.ts +++ b/src/hooks/useFetchListCallback.ts @@ -1,12 +1,12 @@ import { nanoid } from '@reduxjs/toolkit' import { TokenList } from '@uniswap/token-lists' +import getTokenList from 'lib/hooks/useTokenList/fetchTokenList' +import resolveENSContentHash from 'lib/utils/resolveENSContentHash' import { useCallback } from 'react' import { useAppDispatch } from 'state/hooks' import { getNetworkLibrary } from '../connectors' import { fetchTokenList } from '../state/lists/actions' -import getTokenList from '../utils/getTokenList' -import resolveENSContentHash from '../utils/resolveENSContentHash' import { useActiveWeb3React } from './web3' export function useFetchListCallback(): (listUrl: string, sendDispatch?: boolean) => Promise { diff --git a/src/hooks/useHttpLocations.ts b/src/hooks/useHttpLocations.ts index 37c49b7b82..82e0d16bb2 100644 --- a/src/hooks/useHttpLocations.ts +++ b/src/hooks/useHttpLocations.ts @@ -1,8 +1,8 @@ +import contenthashToUri from 'lib/utils/contenthashToUri' +import parseENSAddress from 'lib/utils/parseENSAddress' +import uriToHttp from 'lib/utils/uriToHttp' import { useMemo } from 'react' -import contenthashToUri from '../utils/contenthashToUri' -import { parseENSAddress } from '../utils/parseENSAddress' -import uriToHttp from '../utils/uriToHttp' import useENSContentHash from './useENSContentHash' export default function useHttpLocations(uri: string | undefined): string[] { diff --git a/src/lib/components/Swap/index.tsx b/src/lib/components/Swap/index.tsx index 0d4389247e..1dc1e50049 100644 --- a/src/lib/components/Swap/index.tsx +++ b/src/lib/components/Swap/index.tsx @@ -1,6 +1,8 @@ import { Trans } from '@lingui/macro' +import { TokenInfo } from '@uniswap/token-lists' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' -import { useState } from 'react' +import useTokenList, { DEFAULT_TOKEN_LIST } from 'lib/hooks/useTokenList' +import { useMemo, useState } from 'react' import Header from '../Header' import { BoundaryProvider } from '../Popover' @@ -12,7 +14,38 @@ import Settings from './Settings' import SwapButton from './SwapButton' import Toolbar from './Toolbar' -export default function Swap() { +interface DefaultTokenAmount { + address?: string | { [chainId: number]: string } + amount?: number +} + +interface SwapDefaults { + tokenList: string | TokenInfo[] + input: DefaultTokenAmount + output: DefaultTokenAmount +} + +const DEFAULT_INPUT: DefaultTokenAmount = { address: 'ETH' } +const DEFAULT_OUTPUT: DefaultTokenAmount = {} + +function useSwapDefaults(defaults: Partial = {}): SwapDefaults { + const tokenList = defaults.tokenList || DEFAULT_TOKEN_LIST + const input: DefaultTokenAmount = defaults.input || DEFAULT_INPUT + const output: DefaultTokenAmount = defaults.output || DEFAULT_OUTPUT + input.amount = input.amount || 0 + output.amount = output.amount || 0 + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => ({ tokenList, input, output }), []) +} + +export interface SwapProps { + defaults?: Partial +} + +export default function Swap({ defaults }: SwapProps) { + const { tokenList } = useSwapDefaults(defaults) + useTokenList(tokenList) + const [boundary, setBoundary] = useState(null) const { active, account } = useActiveWeb3React() return ( diff --git a/src/lib/components/TokenImg.tsx b/src/lib/components/TokenImg.tsx index 077f59e480..242c5c834a 100644 --- a/src/lib/components/TokenImg.tsx +++ b/src/lib/components/TokenImg.tsx @@ -1,16 +1,21 @@ import useNativeEvent from 'lib/hooks/useNativeEvent' import styled from 'lib/theme' -import { Token } from 'lib/types' +import uriToHttp from 'lib/utils/uriToHttp' import { useState } from 'react' interface TokenImgProps { className?: string - token: Token + token: { + name?: string + symbol: string + logoURI?: string + } } const TRANSPARENT_SRC = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' function TokenImg({ className, token }: TokenImgProps) { const [img, setImg] = useState(null) + const src = token.logoURI ? uriToHttp(token.logoURI)[0] : TRANSPARENT_SRC useNativeEvent(img, 'error', () => { if (img) { // Use a local transparent gif to avoid the browser-dependent broken img icon. @@ -18,7 +23,7 @@ function TokenImg({ className, token }: TokenImgProps) { img.src = TRANSPARENT_SRC } }) - return {token.name + return {token.name } export default styled(TokenImg)<{ size?: number }>` diff --git a/src/lib/components/TokenSelect.fixture.tsx b/src/lib/components/TokenSelect.fixture.tsx index 1e6e07403c..07a15cdbda 100644 --- a/src/lib/components/TokenSelect.fixture.tsx +++ b/src/lib/components/TokenSelect.fixture.tsx @@ -1,8 +1,14 @@ +import useTokenList, { DEFAULT_TOKEN_LIST } from 'lib/hooks/useTokenList' + import { Modal } from './Dialog' import { TokenSelectDialog } from './TokenSelect' -export default ( - - void 0} /> - -) +export default function Fixture() { + useTokenList(DEFAULT_TOKEN_LIST) + + return ( + + void 0} /> + + ) +} diff --git a/src/lib/components/TokenSelect/TokenOptions.tsx b/src/lib/components/TokenSelect/TokenOptions.tsx index beb74bc29d..62c96d1833 100644 --- a/src/lib/components/TokenSelect/TokenOptions.tsx +++ b/src/lib/components/TokenSelect/TokenOptions.tsx @@ -1,7 +1,6 @@ import useNativeEvent from 'lib/hooks/useNativeEvent' import useScrollbar from 'lib/hooks/useScrollbar' import styled, { ThemedText } from 'lib/theme' -import { Token } from 'lib/types' import { ComponentClass, CSSProperties, @@ -17,6 +16,7 @@ import { } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' import { areEqual, FixedSizeList, FixedSizeListProps } from 'react-window' +import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import invariant from 'tiny-invariant' import { BaseButton } from '../Button' @@ -31,7 +31,7 @@ const TokenButton = styled(BaseButton)` ` const ITEM_SIZE = 56 -type ItemData = Token[] +type ItemData = WrappedTokenInfo[] interface FixedSizeTokenList extends FixedSizeList, ComponentClass> {} const TokenList = styled(FixedSizeList as unknown as FixedSizeTokenList)<{ hover: number @@ -55,13 +55,13 @@ const OnHover = styled.div<{ hover: number }>` interface TokenOptionProps { index: number - value: Token + value: WrappedTokenInfo style: CSSProperties } interface BubbledEvent extends SyntheticEvent { index?: number - token?: Token + token?: WrappedTokenInfo ref?: HTMLButtonElement } @@ -121,8 +121,8 @@ interface TokenOptionsHandle { } interface TokenOptionsProps { - tokens: Token[] - onSelect: (token: Token) => void + tokens: WrappedTokenInfo[] + onSelect: (token: WrappedTokenInfo) => void } const TokenOptions = forwardRef(function TokenOptions( diff --git a/src/lib/components/TokenSelect/index.tsx b/src/lib/components/TokenSelect/index.tsx index 46a60141ba..27660c9ab9 100644 --- a/src/lib/components/TokenSelect/index.tsx +++ b/src/lib/components/TokenSelect/index.tsx @@ -1,8 +1,8 @@ import { t, Trans } from '@lingui/macro' -import { DAI, ETH, UNI, USDC } from 'lib/mocks' +import useTokenList from 'lib/hooks/useTokenList' import styled, { ThemedText } from 'lib/theme' import { Token } from 'lib/types' -import { ElementRef, useCallback, useEffect, useRef, useState } from 'react' +import { ElementRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import Column from '../Column' import Dialog, { Header } from '../Dialog' @@ -13,17 +13,21 @@ import TokenBase from './TokenBase' import TokenButton from './TokenButton' import TokenOptions from './TokenOptions' -// TODO: integrate with web3-react context -const mockTokens = [DAI, ETH, UNI, USDC] - const SearchInput = styled(StringInput)` ${inputCss} ` export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => void }) { - const baseTokens = [DAI, ETH, UNI, USDC] - const tokens = mockTokens + const tokenMap = useTokenList() + const tokens = useMemo(() => Object.values(tokenMap).map(({ token }) => token), [tokenMap]) + const baseTokens: Token[] = [] // TODO(zzmp): Add base tokens to token list functionality + // TODO(zzmp): Load token balances + // TODO(zzmp): Sort tokens + // TODO(zzmp): Disable already selected tokens + // TODO(zzmp): Include native Currency + + // TODO(zzmp): Filter tokens by search const [search, setSearch] = useState('') const input = useRef(null) @@ -48,15 +52,13 @@ export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => vo {Boolean(baseTokens.length) && ( - <> - - {baseTokens.map((token) => ( - - ))} - - - + + {baseTokens.map((token) => ( + + ))} + )} + diff --git a/src/lib/components/Widget.tsx b/src/lib/components/Widget.tsx index 140441919e..6691a75727 100644 --- a/src/lib/components/Widget.tsx +++ b/src/lib/components/Widget.tsx @@ -3,7 +3,7 @@ import { Provider as AtomProvider } from 'jotai' import { UNMOUNTING } from 'lib/hooks/useUnmount' import { Provider as I18nProvider } from 'lib/i18n' import styled, { keyframes, Theme, ThemeProvider } from 'lib/theme' -import { ReactNode, StrictMode, useRef } from 'react' +import { ComponentProps, JSXElementConstructor, PropsWithChildren, StrictMode, useRef } from 'react' import { Provider as EthProvider } from 'widgets-web3-react/types' import { Provider as DialogProvider } from './Dialog' @@ -64,8 +64,7 @@ const WidgetWrapper = styled.div<{ width?: number | string }>` } ` -export interface WidgetProps { - children: ReactNode +export type WidgetProps | undefined = undefined> = { theme?: Theme locale?: SupportedLocale provider?: EthProvider @@ -74,7 +73,10 @@ export interface WidgetProps { dialog?: HTMLElement | null className?: string onError?: ErrorHandler -} +} & (T extends JSXElementConstructor + ? ComponentProps + : // eslint-disable-next-line @typescript-eslint/ban-types + {}) export default function Widget({ children, @@ -86,7 +88,7 @@ export default function Widget({ dialog, className, onError, -}: WidgetProps) { +}: PropsWithChildren) { const wrapper = useRef(null) return ( diff --git a/src/lib/hooks/useColor.ts b/src/lib/hooks/useColor.ts index 08436434b6..5c2736ffca 100644 --- a/src/lib/hooks/useColor.ts +++ b/src/lib/hooks/useColor.ts @@ -4,7 +4,7 @@ import uriToHttp from 'lib/utils/uriToHttp' import Vibrant from 'node-vibrant/lib/bundle' import { useLayoutEffect, useState } from 'react' -const colors = new Map() +const colors = new Map() function UriForEthToken(address: string) { return `https://raw.githubusercontent.com/uniswap/assets/master/blockchains/ethereum/assets/${address}/logo.png?color` @@ -16,26 +16,23 @@ function UriForEthToken(address: string) { */ async function getColorFromToken(token: Token, cb: (color: string | undefined) => void = () => void 0) { const { address, chainId, logoURI } = token + const key = chainId + address + let color = colors.get(key) - // Color extraction must use a CORS-compatible resource, but the resource is already cached. - // Add a dummy parameter to force a different browser resource cache entry. - // Without this, color extraction prevents resource caching. - const uri = uriToHttp(logoURI)[0] + '?color' - - let color = colors.get(uri) - if (color) { - return cb(color) + if (!color && logoURI) { + // Color extraction must use a CORS-compatible resource, but the resource is already cached. + // Add a dummy parameter to force a different browser resource cache entry. + // Without this, color extraction prevents resource caching. + const uri = uriToHttp(logoURI)[0] + '?color' + color = await getColorFromUriPath(uri) } - color = await getColorFromUriPath(uri) if (!color && chainId === 1) { const fallbackUri = UriForEthToken(address) color = await getColorFromUriPath(fallbackUri) } - if (color) { - colors.set(uri, color) - } + colors.set(key, color) return cb(color) } diff --git a/src/lib/hooks/useTokenList/fetchTokenList.test.ts b/src/lib/hooks/useTokenList/fetchTokenList.test.ts new file mode 100644 index 0000000000..5f4d540234 --- /dev/null +++ b/src/lib/hooks/useTokenList/fetchTokenList.test.ts @@ -0,0 +1,25 @@ +import fetchTokenList, { DEFAULT_TOKEN_LIST } from './fetchTokenList' + +describe('fetchTokenList', () => { + const resolver = jest.fn() + + it('throws on an invalid list url', async () => { + const url = 'https://example.com' + await expect(fetchTokenList(url, resolver)).rejects.toThrowError(`failed to fetch list: ${url}`) + expect(resolver).not.toHaveBeenCalled() + }) + + it('tries to fetch an ENS address using the passed resolver', async () => { + const url = 'example.eth' + const contenthash = '0xD3ADB33F' + resolver.mockResolvedValue(contenthash) + await expect(fetchTokenList(url, resolver)).rejects.toThrow() + expect(resolver).toHaveBeenCalledWith(url) + }) + + it('fetches and validates the default token list', async () => { + const list = await (await fetch(DEFAULT_TOKEN_LIST)).json() + await expect(fetchTokenList(DEFAULT_TOKEN_LIST, resolver)).resolves.toStrictEqual(list) + expect(resolver).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/hooks/useTokenList/fetchTokenList.ts b/src/lib/hooks/useTokenList/fetchTokenList.ts new file mode 100644 index 0000000000..2a9e73802a --- /dev/null +++ b/src/lib/hooks/useTokenList/fetchTokenList.ts @@ -0,0 +1,73 @@ +import type { TokenList } from '@uniswap/token-lists' +import contenthashToUri from 'lib/utils/contenthashToUri' +import parseENSAddress from 'lib/utils/parseENSAddress' +import uriToHttp from 'lib/utils/uriToHttp' + +import validateTokenList from './validateTokenList' + +export const DEFAULT_TOKEN_LIST = 'https://gateway.ipfs.io/ipns/tokens.uniswap.org' + +const listCache = new Map() + +/** Fetches and validates a token list. */ +export default async function fetchTokenList( + listUrl: string, + resolveENSContentHash: (ensName: string) => Promise +): Promise { + const cached = listCache?.get(listUrl) // avoid spurious re-fetches + if (cached) { + return cached + } + + let urls: string[] + const parsedENS = parseENSAddress(listUrl) + if (parsedENS) { + let contentHashUri + try { + contentHashUri = await resolveENSContentHash(parsedENS.ensName) + } catch (error) { + const message = `failed to resolve ENS name: ${parsedENS.ensName}` + console.debug(message, error) + throw new Error(message) + } + let translatedUri + try { + translatedUri = contenthashToUri(contentHashUri) + } catch (error) { + const message = `failed to translate contenthash to URI: ${contentHashUri}` + console.debug(message, error) + throw new Error(message) + } + urls = uriToHttp(`${translatedUri}${parsedENS.ensPath ?? ''}`) + } else { + urls = uriToHttp(listUrl) + } + + 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: ${listUrl}` + console.debug(message, error) + if (isLast) throw new Error(message) + continue + } + + if (!response.ok) { + const message = `failed to fetch list: ${listUrl}` + console.debug(message, response.statusText) + if (isLast) throw new Error(message) + continue + } + + const json = await response.json() + const list = await validateTokenList(json) + listCache?.set(listUrl, list) + return list + } + + throw new Error('Unrecognized list URL protocol.') +} diff --git a/src/lib/hooks/useTokenList/index.ts b/src/lib/hooks/useTokenList/index.ts new file mode 100644 index 0000000000..5d93d60394 --- /dev/null +++ b/src/lib/hooks/useTokenList/index.ts @@ -0,0 +1,43 @@ +import { TokenInfo, TokenList } from '@uniswap/token-lists' +import { atom, useAtom } from 'jotai' +import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' +import resolveENSContentHash from 'lib/utils/resolveENSContentHash' +import { useEffect, useMemo, useState } from 'react' + +import fetchTokenList from './fetchTokenList' +import { ChainTokenMap, TokenMap, tokensToChainTokenMap } from './utils' +import { validateTokens } from './validateTokenList' + +export { DEFAULT_TOKEN_LIST } from './fetchTokenList' + +const chainTokenMapAtom = atom({}) + +export default function useTokenList(list?: string | TokenInfo[]): TokenMap { + const { chainId, library } = useActiveWeb3React() + const [chainTokenMap, setChainTokenMap] = useAtom(chainTokenMapAtom) + + // Error boundaries will not catch (non-rendering) async errors, but it should still be shown + const [error, setError] = useState() + if (error) throw error + + useEffect(() => { + if (list !== undefined) { + let tokens: Promise + if (typeof list === 'string') { + tokens = fetchTokenList(list, (ensName: string) => { + if (library && chainId === 1) { + return resolveENSContentHash(ensName, library) + } + throw new Error('Could not construct mainnet ENS resolver') + }) + } else { + tokens = validateTokens(list) + } + tokens.then(tokensToChainTokenMap).then(setChainTokenMap).catch(setError) + } + }, [chainId, library, list, setChainTokenMap]) + + return useMemo(() => { + return (chainId && chainTokenMap[chainId]) || {} + }, [chainId, chainTokenMap]) +} diff --git a/src/lib/hooks/useTokenList/utils.ts b/src/lib/hooks/useTokenList/utils.ts new file mode 100644 index 0000000000..8de236b7a4 --- /dev/null +++ b/src/lib/hooks/useTokenList/utils.ts @@ -0,0 +1,32 @@ +import { TokenInfo, TokenList } from '@uniswap/token-lists' +import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' + +export type TokenMap = Readonly<{ [tokenAddress: string]: { token: WrappedTokenInfo; list?: TokenList } }> +export type ChainTokenMap = Readonly<{ [chainId: number]: TokenMap }> + +type Mutable = { + -readonly [P in keyof T]: Mutable +} + +const mapCache = typeof WeakMap !== 'undefined' ? new WeakMap() : null + +export function tokensToChainTokenMap(tokens: TokenList | TokenInfo[]): ChainTokenMap { + const cached = mapCache?.get(tokens) + if (cached) return cached + + const [list, infos] = Array.isArray(tokens) ? [undefined, tokens] : [tokens, tokens.tokens] + const map = infos.reduce>((map, info) => { + const token = new WrappedTokenInfo(info, list) + if (map[token.chainId]?.[token.address] !== undefined) { + console.warn(`Duplicate token skipped: ${token.address}`) + return map + } + if (!map[token.chainId]) { + map[token.chainId] = {} + } + map[token.chainId][token.address] = { token, list } + return map + }, {}) as ChainTokenMap + mapCache?.set(tokens, map) + return map +} diff --git a/src/lib/hooks/useTokenList/validateTokenList.test.ts b/src/lib/hooks/useTokenList/validateTokenList.test.ts new file mode 100644 index 0000000000..92735cbe46 --- /dev/null +++ b/src/lib/hooks/useTokenList/validateTokenList.test.ts @@ -0,0 +1,42 @@ +import { 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/src/lib/hooks/useTokenList/validateTokenList.ts b/src/lib/hooks/useTokenList/validateTokenList.ts new file mode 100644 index 0000000000..9ba86e8e30 --- /dev/null +++ b/src/lib/hooks/useTokenList/validateTokenList.ts @@ -0,0 +1,54 @@ +import type { TokenInfo, TokenList } from '@uniswap/token-lists' +import type { Ajv, ValidateFunction } from 'ajv' + +enum ValidationSchema { + LIST = 'list', + TOKENS = 'tokens', +} + +const validator = new Promise(async (resolve) => { + const [ajv, schema] = await Promise.all([import('ajv'), import('@uniswap/token-lists/src/tokenlist.schema.json')]) + 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 default 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/src/lib/index.tsx b/src/lib/index.tsx index c39bf406f8..4c31928be6 100644 --- a/src/lib/index.tsx +++ b/src/lib/index.tsx @@ -1,12 +1,12 @@ import Swap from './components/Swap' import Widget, { WidgetProps } from './components/Widget' -type SwapWidgetProps = Omit +export type SwapWidgetProps = WidgetProps -export function SwapWidget(props: SwapWidgetProps) { +export function SwapWidget({ ...props }: SwapWidgetProps) { return ( - + ) } diff --git a/src/lib/theme/index.tsx b/src/lib/theme/index.tsx index 2dad518de3..26583babca 100644 --- a/src/lib/theme/index.tsx +++ b/src/lib/theme/index.tsx @@ -1,4 +1,4 @@ -import 'lib/assets/fonts/index.css' +import '../assets/fonts/index.css' // microbundle requires relative css paths import { mix, transparentize } from 'polished' import { createContext, ReactNode, useContext, useMemo, useState } from 'react' diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index b82ba5c5fb..c0d684edcd 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -4,5 +4,5 @@ export interface Token { chainId: number decimals: number address: string - logoURI: string + logoURI?: string } diff --git a/src/utils/contenthashToUri.test.skip.ts b/src/lib/utils/contenthashToUri.test.skip.ts similarity index 100% rename from src/utils/contenthashToUri.test.skip.ts rename to src/lib/utils/contenthashToUri.test.skip.ts diff --git a/src/utils/contenthashToUri.ts b/src/lib/utils/contenthashToUri.ts similarity index 100% rename from src/utils/contenthashToUri.ts rename to src/lib/utils/contenthashToUri.ts diff --git a/src/utils/parseENSAddress.test.ts b/src/lib/utils/parseENSAddress.test.ts similarity index 95% rename from src/utils/parseENSAddress.test.ts rename to src/lib/utils/parseENSAddress.test.ts index 975449b601..9a3f5fc946 100644 --- a/src/utils/parseENSAddress.test.ts +++ b/src/lib/utils/parseENSAddress.test.ts @@ -1,4 +1,4 @@ -import { parseENSAddress } from './parseENSAddress' +import parseENSAddress from './parseENSAddress' describe('parseENSAddress', () => { it('test cases', () => { diff --git a/src/utils/parseENSAddress.ts b/src/lib/utils/parseENSAddress.ts similarity index 64% rename from src/utils/parseENSAddress.ts rename to src/lib/utils/parseENSAddress.ts index 68818595fc..ababade998 100644 --- a/src/utils/parseENSAddress.ts +++ b/src/lib/utils/parseENSAddress.ts @@ -1,6 +1,8 @@ const ENS_NAME_REGEX = /^(([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+)eth(\/.*)?$/ -export function parseENSAddress(ensAddress: string): { ensName: string; ensPath: string | undefined } | undefined { +export default function parseENSAddress( + ensAddress: string +): { ensName: string; ensPath: string | undefined } | undefined { const match = ensAddress.match(ENS_NAME_REGEX) if (!match) return undefined return { ensName: `${match[1].toLowerCase()}eth`, ensPath: match[4] } diff --git a/src/utils/resolveENSContentHash.ts b/src/lib/utils/resolveENSContentHash.ts similarity index 100% rename from src/utils/resolveENSContentHash.ts rename to src/lib/utils/resolveENSContentHash.ts diff --git a/src/lib/utils/uriToHttp.ts b/src/lib/utils/uriToHttp.ts index 8d334b0f87..c6db147a0e 100644 --- a/src/lib/utils/uriToHttp.ts +++ b/src/lib/utils/uriToHttp.ts @@ -1,10 +1,12 @@ /** - * Given a URI that may be ipfs, ipns, http, or https protocol, return the fetch-able http(s) URLs for the same content + * Given a URI that may be ipfs, ipns, http, https, ar, or data protocol, return the fetch-able http(s) URLs for the same content * @param uri to convert to fetch-able http url */ export default function uriToHttp(uri: string): string[] { const protocol = uri.split(':')[0].toLowerCase() switch (protocol) { + case 'data': + return [uri] case 'https': return [uri] case 'http': @@ -15,6 +17,9 @@ export default function uriToHttp(uri: string): string[] { case 'ipns': const name = uri.match(/^ipns:(\/\/)?(.*)$/i)?.[2] return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.io/ipns/${name}/`] + case 'ar': + const tx = uri.match(/^ar:(\/\/)?(.*)$/i)?.[2] + return [`https://arweave.net/${tx}`] default: return [] } diff --git a/src/state/lists/hooks.ts b/src/state/lists/hooks.ts index edd4230a7c..b6c6d85015 100644 --- a/src/state/lists/hooks.ts +++ b/src/state/lists/hooks.ts @@ -1,4 +1,4 @@ -import { TokenList } from '@uniswap/token-lists' +import { ChainTokenMap, tokensToChainTokenMap } from 'lib/hooks/useTokenList/utils' import { useMemo } from 'react' import { useAppSelector } from 'state/hooks' import sortByListPriority from 'utils/listSort' @@ -7,40 +7,13 @@ import BROKEN_LIST from '../../constants/tokenLists/broken.tokenlist.json' import UNSUPPORTED_TOKEN_LIST from '../../constants/tokenLists/unsupported.tokenlist.json' import { AppState } from '../index' import { UNSUPPORTED_LIST_URLS } from './../../constants/lists' -import { WrappedTokenInfo } from './wrappedTokenInfo' -export type TokenAddressMap = Readonly<{ - [chainId: number]: Readonly<{ [tokenAddress: string]: { token: WrappedTokenInfo; list: TokenList } }> -}> +export type TokenAddressMap = ChainTokenMap type Mutable = { -readonly [P in keyof T]: Mutable } -const listCache: WeakMap | null = - typeof WeakMap !== 'undefined' ? new WeakMap() : null - -function listToTokenMap(list: TokenList): TokenAddressMap { - const result = listCache?.get(list) - if (result) return result - - const map = list.tokens.reduce>((tokenMap, tokenInfo) => { - const token = new WrappedTokenInfo(tokenInfo, list) - if (tokenMap[token.chainId]?.[token.address] !== undefined) { - console.error(`Duplicate token! ${token.address}`) - return tokenMap - } - if (!tokenMap[token.chainId]) tokenMap[token.chainId] = {} - tokenMap[token.chainId][token.address] = { - token, - list, - } - return tokenMap - }, {}) as TokenAddressMap - listCache?.set(list, map) - return map -} - export function useAllLists(): AppState['lists']['byUrl'] { return useAppSelector((state) => state.lists.byUrl) } @@ -84,7 +57,7 @@ function useCombinedTokenMapFromUrls(urls: string[] | undefined): TokenAddressMa const current = lists[currentUrl]?.current if (!current) return allTokens try { - return combineMaps(allTokens, listToTokenMap(current)) + return combineMaps(allTokens, tokensToChainTokenMap(current)) } catch (error) { console.error('Could not show token list due to error', error) return allTokens @@ -119,10 +92,10 @@ export function useCombinedActiveList(): TokenAddressMap { // list of tokens not supported on interface for various reasons, used to show warnings and prevent swaps and adds export function useUnsupportedTokenList(): TokenAddressMap { // get hard-coded broken tokens - const brokenListMap = useMemo(() => listToTokenMap(BROKEN_LIST), []) + const brokenListMap = useMemo(() => tokensToChainTokenMap(BROKEN_LIST), []) // get hard-coded list of unsupported tokens - const localUnsupportedListMap = useMemo(() => listToTokenMap(UNSUPPORTED_TOKEN_LIST), []) + const localUnsupportedListMap = useMemo(() => tokensToChainTokenMap(UNSUPPORTED_TOKEN_LIST), []) // get dynamic list of unsupported tokens const loadedUnsupportedListMap = useCombinedTokenMapFromUrls(UNSUPPORTED_LIST_URLS) diff --git a/src/state/lists/wrappedTokenInfo.ts b/src/state/lists/wrappedTokenInfo.ts index 6c6e5ed200..e07c823dce 100644 --- a/src/state/lists/wrappedTokenInfo.ts +++ b/src/state/lists/wrappedTokenInfo.ts @@ -13,11 +13,11 @@ interface TagInfo extends TagDetails { export class WrappedTokenInfo implements Token { public readonly isNative: false = false public readonly isToken: true = true - public readonly list: TokenList + public readonly list?: TokenList public readonly tokenInfo: TokenInfo - constructor(tokenInfo: TokenInfo, list: TokenList) { + constructor(tokenInfo: TokenInfo, list?: TokenList) { this.tokenInfo = tokenInfo this.list = list } @@ -55,7 +55,7 @@ export class WrappedTokenInfo implements Token { public get tags(): TagInfo[] { if (this._tags !== null) return this._tags if (!this.tokenInfo.tags) return (this._tags = []) - const listTags = this.list.tags + const listTags = this.list?.tags if (!listTags) return (this._tags = []) return (this._tags = this.tokenInfo.tags.map((tagId) => { diff --git a/src/utils/getTokenList.ts b/src/utils/getTokenList.ts deleted file mode 100644 index 747ecb628b..0000000000 --- a/src/utils/getTokenList.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { TokenList } from '@uniswap/token-lists' -import { ValidateFunction } from 'ajv' - -import contenthashToUri from './contenthashToUri' -import { parseENSAddress } from './parseENSAddress' -import uriToHttp from './uriToHttp' - -// lazily get the validator the first time it is used -const getTokenListValidator = (() => { - let tokenListValidator: Promise - return () => { - if (!tokenListValidator) { - tokenListValidator = new Promise(async (resolve) => { - const [ajv, schema] = await Promise.all([ - import('ajv'), - import('@uniswap/token-lists/src/tokenlist.schema.json'), - ]) - const validator = new ajv.default({ allErrors: true }).compile(schema) - resolve(validator) - }) - } - return tokenListValidator - } -})() - -/** - * Contains the logic for resolving a list URL to a validated token list - * @param listUrl list url - * @param resolveENSContentHash resolves an ens name to a contenthash - */ -export default async function getTokenList( - listUrl: string, - resolveENSContentHash: (ensName: string) => Promise -): Promise { - const tokenListValidator = getTokenListValidator() - const parsedENS = parseENSAddress(listUrl) - let urls: string[] - if (parsedENS) { - let contentHashUri - try { - contentHashUri = await resolveENSContentHash(parsedENS.ensName) - } catch (error) { - console.debug(`Failed to resolve ENS name: ${parsedENS.ensName}`, error) - throw new Error(`Failed to resolve ENS name: ${parsedENS.ensName}`) - } - let translatedUri - try { - translatedUri = contenthashToUri(contentHashUri) - } catch (error) { - console.debug('Failed to translate contenthash to URI', contentHashUri) - throw new Error(`Failed to translate contenthash to URI: ${contentHashUri}`) - } - urls = uriToHttp(`${translatedUri}${parsedENS.ensPath ?? ''}`) - } else { - urls = uriToHttp(listUrl) - } - 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) { - console.debug('Failed to fetch list', listUrl, error) - if (isLast) throw new Error(`Failed to download list ${listUrl}`) - continue - } - - if (!response.ok) { - if (isLast) throw new Error(`Failed to download list ${listUrl}`) - continue - } - - const [json, validator] = await Promise.all([response.json(), tokenListValidator]) - if (!validator(json)) { - const validationErrors: string = - validator.errors?.reduce((memo, error) => { - const add = `${error.dataPath} ${error.message ?? ''}` - return memo.length > 0 ? `${memo}; ${add}` : `${add}` - }, '') ?? 'unknown error' - throw new Error(`Token list failed validation: ${validationErrors}`) - } - return json - } - throw new Error('Unrecognized list URL protocol.') -} diff --git a/src/utils/uriToHttp.test.ts b/src/utils/uriToHttp.test.ts deleted file mode 100644 index 2e922cf9fa..0000000000 --- a/src/utils/uriToHttp.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import uriToHttp from './uriToHttp' - -describe('uriToHttp', () => { - it('returns .eth.link for ens names', () => { - expect(uriToHttp('t2crtokens.eth')).toEqual([]) - }) - it('returns https first for http', () => { - expect(uriToHttp('http://test.com')).toEqual(['https://test.com', 'http://test.com']) - }) - it('returns https for https', () => { - expect(uriToHttp('https://test.com')).toEqual(['https://test.com']) - }) - it('returns ipfs gateways for ipfs:// urls', () => { - expect(uriToHttp('ipfs://QmV8AfDE8GFSGQvt3vck8EwAzsPuNTmtP8VcQJE3qxRPaZ')).toEqual([ - 'https://cloudflare-ipfs.com/ipfs/QmV8AfDE8GFSGQvt3vck8EwAzsPuNTmtP8VcQJE3qxRPaZ/', - 'https://ipfs.io/ipfs/QmV8AfDE8GFSGQvt3vck8EwAzsPuNTmtP8VcQJE3qxRPaZ/', - ]) - }) - it('returns ipns gateways for ipns:// urls', () => { - expect(uriToHttp('ipns://app.uniswap.org')).toEqual([ - 'https://cloudflare-ipfs.com/ipns/app.uniswap.org/', - 'https://ipfs.io/ipns/app.uniswap.org/', - ]) - }) - it('returns empty array for invalid scheme', () => { - expect(uriToHttp('blah:test')).toEqual([]) - }) -}) diff --git a/src/utils/uriToHttp.ts b/src/utils/uriToHttp.ts deleted file mode 100644 index 2882903b8b..0000000000 --- a/src/utils/uriToHttp.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Given a URI that may be ipfs, ipns, http, https, or data protocol, return the fetch-able http(s) URLs for the same content - * @param uri to convert to fetch-able http url - */ -export default function uriToHttp(uri: string): string[] { - const protocol = uri.split(':')[0].toLowerCase() - switch (protocol) { - case 'data': - return [uri] - case 'https': - return [uri] - case 'http': - return ['https' + uri.substr(4), uri] - case 'ipfs': - const hash = uri.match(/^ipfs:(\/\/)?(.*)$/i)?.[2] - return [`https://cloudflare-ipfs.com/ipfs/${hash}/`, `https://ipfs.io/ipfs/${hash}/`] - case 'ipns': - const name = uri.match(/^ipns:(\/\/)?(.*)$/i)?.[2] - return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.io/ipns/${name}/`] - case 'ar': - const tx = uri.match(/^ar:(\/\/)?(.*)$/i)?.[2] - return [`https://arweave.net/${tx}`] - default: - return [] - } -} diff --git a/tsconfig.lib.json b/tsconfig.lib.json index f08ded8174..0ff68094ca 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -7,6 +7,7 @@ "lib/*": ["./*"], "constants/*": ["../constants/*"], "hooks/*": ["../hooks/*"], + "state/*": ["../state/*"], }, }, "exclude": ["node_modules", "src/lib/**/*.test.*"],