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
This commit is contained in:
Zach Pomerantz 2022-01-11 09:28:02 -08:00 committed by GitHub
parent 4f896361be
commit 90dfdc6bef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 406 additions and 248 deletions

@ -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",

@ -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'

@ -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) {

@ -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'

@ -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<TokenList> {

@ -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[] {

@ -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> = {}): 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<SwapDefaults>
}
export default function Swap({ defaults }: SwapProps) {
const { tokenList } = useSwapDefaults(defaults)
useTokenList(tokenList)
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null)
const { active, account } = useActiveWeb3React()
return (

@ -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<HTMLImageElement | null>(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 <img className={className} src={token.logoURI} alt={token.name || token.symbol} ref={setImg} />
return <img className={className} src={src} alt={token.name || token.symbol} ref={setImg} />
}
export default styled(TokenImg)<{ size?: number }>`

@ -1,8 +1,14 @@
import useTokenList, { DEFAULT_TOKEN_LIST } from 'lib/hooks/useTokenList'
import { Modal } from './Dialog'
import { TokenSelectDialog } from './TokenSelect'
export default (
export default function Fixture() {
useTokenList(DEFAULT_TOKEN_LIST)
return (
<Modal color="module">
<TokenSelectDialog onSelect={() => void 0} />
</Modal>
)
)
}

@ -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<ItemData>, ComponentClass<FixedSizeListProps<ItemData>> {}
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<TokenOptionsHandle, TokenOptionsProps>(function TokenOptions(

@ -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<HTMLInputElement>(null)
@ -48,15 +52,13 @@ export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => vo
</ThemedText.Body1>
</Row>
{Boolean(baseTokens.length) && (
<>
<Row pad={0.75} gap={0.25} justify="flex-start" flex>
{baseTokens.map((token) => (
<TokenBase value={token} onClick={onSelect} key={token.address} />
))}
</Row>
<Rule padded />
</>
)}
<Rule padded />
</Column>
<TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} />
</>

@ -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<T extends JSXElementConstructor<any> | 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<any>
? ComponentProps<T>
: // 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<WidgetProps>) {
const wrapper = useRef<HTMLDivElement>(null)
return (

@ -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<string, string>()
const colors = new Map<string, string | undefined>()
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)
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'
let color = colors.get(uri)
if (color) {
return cb(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)
}

@ -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()
})
})

@ -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<string, TokenList>()
/** Fetches and validates a token list. */
export default async function fetchTokenList(
listUrl: string,
resolveENSContentHash: (ensName: string) => Promise<string>
): Promise<TokenList> {
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.')
}

@ -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<ChainTokenMap>({})
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<Error>()
if (error) throw error
useEffect(() => {
if (list !== undefined) {
let tokens: Promise<TokenList | TokenInfo[]>
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])
}

@ -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<T> = {
-readonly [P in keyof T]: Mutable<T[P]>
}
const mapCache = typeof WeakMap !== 'undefined' ? new WeakMap<TokenList | TokenInfo[], ChainTokenMap>() : 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<Mutable<ChainTokenMap>>((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
}

@ -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)
})
})

@ -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<Ajv>(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<TokenList, 'tokens'>
.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<TokenInfo[]> {
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<TokenList> {
const validate = (await validator).getSchema(ValidationSchema.LIST)
if (validate?.(json)) {
return json
}
throw new Error(`Token list failed validation: ${getValidationErrors(validate)}`)
}

@ -1,12 +1,12 @@
import Swap from './components/Swap'
import Widget, { WidgetProps } from './components/Widget'
type SwapWidgetProps = Omit<WidgetProps, 'children'>
export type SwapWidgetProps = WidgetProps<typeof Swap>
export function SwapWidget(props: SwapWidgetProps) {
export function SwapWidget({ ...props }: SwapWidgetProps) {
return (
<Widget {...props}>
<Swap />
<Swap {...props} />
</Widget>
)
}

@ -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'

2
src/lib/types.d.ts vendored

@ -4,5 +4,5 @@ export interface Token {
chainId: number
decimals: number
address: string
logoURI: string
logoURI?: string
}

@ -1,4 +1,4 @@
import { parseENSAddress } from './parseENSAddress'
import parseENSAddress from './parseENSAddress'
describe('parseENSAddress', () => {
it('test cases', () => {

@ -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] }

@ -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 []
}

@ -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<T> = {
-readonly [P in keyof T]: Mutable<T[P]>
}
const listCache: WeakMap<TokenList, TokenAddressMap> | null =
typeof WeakMap !== 'undefined' ? new WeakMap<TokenList, TokenAddressMap>() : null
function listToTokenMap(list: TokenList): TokenAddressMap {
const result = listCache?.get(list)
if (result) return result
const map = list.tokens.reduce<Mutable<TokenAddressMap>>((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)

@ -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) => {

@ -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<ValidateFunction>
return () => {
if (!tokenListValidator) {
tokenListValidator = new Promise<ValidateFunction>(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<string>
): Promise<TokenList> {
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<string>((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.')
}

@ -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([])
})
})

@ -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 []
}
}

@ -7,6 +7,7 @@
"lib/*": ["./*"],
"constants/*": ["../constants/*"],
"hooks/*": ["../hooks/*"],
"state/*": ["../state/*"],
},
},
"exclude": ["node_modules", "src/lib/**/*.test.*"],