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:
parent
4f896361be
commit
90dfdc6bef
17
package.json
17
package.json
@ -52,14 +52,11 @@
|
|||||||
"@types/wcag-contrast": "^3.0.0",
|
"@types/wcag-contrast": "^3.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.1.0",
|
"@typescript-eslint/eslint-plugin": "^4.1.0",
|
||||||
"@typescript-eslint/parser": "^4.1.0",
|
"@typescript-eslint/parser": "^4.1.0",
|
||||||
"@uniswap/default-token-list": "^3.0.0",
|
|
||||||
"@uniswap/governance": "^1.0.2",
|
"@uniswap/governance": "^1.0.2",
|
||||||
"@uniswap/liquidity-staker": "^1.0.2",
|
"@uniswap/liquidity-staker": "^1.0.2",
|
||||||
"@uniswap/merkle-distributor": "1.0.1",
|
"@uniswap/merkle-distributor": "1.0.1",
|
||||||
"@uniswap/router-sdk": "^1.0.3",
|
"@uniswap/router-sdk": "^1.0.3",
|
||||||
"@uniswap/sdk-core": "^3.0.1",
|
|
||||||
"@uniswap/smart-order-router": "^2.5.9",
|
"@uniswap/smart-order-router": "^2.5.9",
|
||||||
"@uniswap/token-lists": "^1.0.0-beta.27",
|
|
||||||
"@uniswap/v2-core": "1.0.0",
|
"@uniswap/v2-core": "1.0.0",
|
||||||
"@uniswap/v2-periphery": "^1.1.0-beta.0",
|
"@uniswap/v2-periphery": "^1.1.0-beta.0",
|
||||||
"@uniswap/v2-sdk": "^3.0.1",
|
"@uniswap/v2-sdk": "^3.0.1",
|
||||||
@ -72,10 +69,8 @@
|
|||||||
"@web3-react/portis-connector": "^6.0.9",
|
"@web3-react/portis-connector": "^6.0.9",
|
||||||
"@web3-react/walletconnect-connector": "^7.0.2-alpha.0",
|
"@web3-react/walletconnect-connector": "^7.0.2-alpha.0",
|
||||||
"@web3-react/walletlink-connector": "^6.2.8",
|
"@web3-react/walletlink-connector": "^6.2.8",
|
||||||
"ajv": "^6.12.3",
|
|
||||||
"array.prototype.flat": "^1.2.4",
|
"array.prototype.flat": "^1.2.4",
|
||||||
"array.prototype.flatmap": "^1.2.4",
|
"array.prototype.flatmap": "^1.2.4",
|
||||||
"cids": "^1.0.0",
|
|
||||||
"copy-to-clipboard": "^3.2.0",
|
"copy-to-clipboard": "^3.2.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"cypress": "^7.7.0",
|
"cypress": "^7.7.0",
|
||||||
@ -96,8 +91,6 @@
|
|||||||
"jest-styled-components": "^7.0.5",
|
"jest-styled-components": "^7.0.5",
|
||||||
"microbundle": "^0.13.3",
|
"microbundle": "^0.13.3",
|
||||||
"ms.macro": "^2.0.0",
|
"ms.macro": "^2.0.0",
|
||||||
"multicodec": "^3.0.1",
|
|
||||||
"multihashes": "^4.0.2",
|
|
||||||
"polyfill-object.fromentries": "^1.0.1",
|
"polyfill-object.fromentries": "^1.0.1",
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"qs": "^6.9.4",
|
"qs": "^6.9.4",
|
||||||
@ -164,6 +157,10 @@
|
|||||||
},
|
},
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"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/ibm-plex-mono": "^4.5.1",
|
||||||
"@fontsource/inter": "^4.5.1",
|
"@fontsource/inter": "^4.5.1",
|
||||||
"@lingui/core": "^3.9.0",
|
"@lingui/core": "^3.9.0",
|
||||||
@ -171,10 +168,16 @@
|
|||||||
"@lingui/react": "^3.9.0",
|
"@lingui/react": "^3.9.0",
|
||||||
"@popperjs/core": "^2.4.4",
|
"@popperjs/core": "^2.4.4",
|
||||||
"@uniswap/redux-multicall": "^1.0.0",
|
"@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",
|
"immer": "^9.0.6",
|
||||||
"jotai": "^1.3.7",
|
"jotai": "^1.3.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"make-plural": "^7.0.0",
|
"make-plural": "^7.0.0",
|
||||||
|
"multicodec": "^3.0.1",
|
||||||
|
"multihashes": "^4.0.2",
|
||||||
"node-vibrant": "^3.2.1-alpha.1",
|
"node-vibrant": "^3.2.1-alpha.1",
|
||||||
"polished": "^3.3.2",
|
"polished": "^3.3.2",
|
||||||
"popper-max-size-modifier": "^0.2.0",
|
"popper-max-size-modifier": "^0.2.0",
|
||||||
|
@ -5,6 +5,8 @@ import Card from 'components/Card'
|
|||||||
import { UNSUPPORTED_LIST_URLS } from 'constants/lists'
|
import { UNSUPPORTED_LIST_URLS } from 'constants/lists'
|
||||||
import { useListColor } from 'hooks/useColor'
|
import { useListColor } from 'hooks/useColor'
|
||||||
import { useActiveWeb3React } from 'hooks/web3'
|
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 { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { CheckCircle, Settings } from 'react-feather'
|
import { CheckCircle, Settings } from 'react-feather'
|
||||||
import ReactGA from 'react-ga'
|
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 { useActiveListUrls, useAllLists, useIsListActive } from '../../state/lists/hooks'
|
||||||
import { ExternalLink, IconWrapper, LinkStyledButton, ThemedText } from '../../theme'
|
import { ExternalLink, IconWrapper, LinkStyledButton, ThemedText } from '../../theme'
|
||||||
import listVersionLabel from '../../utils/listVersionLabel'
|
import listVersionLabel from '../../utils/listVersionLabel'
|
||||||
import { parseENSAddress } from '../../utils/parseENSAddress'
|
|
||||||
import uriToHttp from '../../utils/uriToHttp'
|
|
||||||
import { ButtonEmpty, ButtonPrimary } from '../Button'
|
import { ButtonEmpty, ButtonPrimary } from '../Button'
|
||||||
import Column, { AutoColumn } from '../Column'
|
import Column, { AutoColumn } from '../Column'
|
||||||
import ListLogo from '../ListLogo'
|
import ListLogo from '../ListLogo'
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { Token } from '@uniswap/sdk-core'
|
import { Token } from '@uniswap/sdk-core'
|
||||||
import { SupportedChainId } from 'constants/chains'
|
import { SupportedChainId } from 'constants/chains'
|
||||||
|
import uriToHttp from 'lib/utils/uriToHttp'
|
||||||
import Vibrant from 'node-vibrant/lib/bundle'
|
import Vibrant from 'node-vibrant/lib/bundle'
|
||||||
import { shade } from 'polished'
|
import { shade } from 'polished'
|
||||||
import { useLayoutEffect, useState } from 'react'
|
import { useLayoutEffect, useState } from 'react'
|
||||||
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
|
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
|
||||||
import uriToHttp from 'utils/uriToHttp'
|
|
||||||
import { hex } from 'wcag-contrast'
|
import { hex } from 'wcag-contrast'
|
||||||
|
|
||||||
function URIForEthToken(address: string) {
|
function URIForEthToken(address: string) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { BigNumber } from '@ethersproject/bignumber'
|
import { BigNumber } from '@ethersproject/bignumber'
|
||||||
import { hexZeroPad } from '@ethersproject/bytes'
|
import { hexZeroPad } from '@ethersproject/bytes'
|
||||||
import { namehash } from '@ethersproject/hash'
|
import { namehash } from '@ethersproject/hash'
|
||||||
|
import uriToHttp from 'lib/utils/uriToHttp'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { safeNamehash } from 'utils/safeNamehash'
|
import { safeNamehash } from 'utils/safeNamehash'
|
||||||
import uriToHttp from 'utils/uriToHttp'
|
|
||||||
|
|
||||||
import { useSingleCallResult } from '../state/multicall/hooks'
|
import { useSingleCallResult } from '../state/multicall/hooks'
|
||||||
import { isAddress } from '../utils'
|
import { isAddress } from '../utils'
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { nanoid } from '@reduxjs/toolkit'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
import { TokenList } from '@uniswap/token-lists'
|
import { TokenList } from '@uniswap/token-lists'
|
||||||
|
import getTokenList from 'lib/hooks/useTokenList/fetchTokenList'
|
||||||
|
import resolveENSContentHash from 'lib/utils/resolveENSContentHash'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useAppDispatch } from 'state/hooks'
|
import { useAppDispatch } from 'state/hooks'
|
||||||
|
|
||||||
import { getNetworkLibrary } from '../connectors'
|
import { getNetworkLibrary } from '../connectors'
|
||||||
import { fetchTokenList } from '../state/lists/actions'
|
import { fetchTokenList } from '../state/lists/actions'
|
||||||
import getTokenList from '../utils/getTokenList'
|
|
||||||
import resolveENSContentHash from '../utils/resolveENSContentHash'
|
|
||||||
import { useActiveWeb3React } from './web3'
|
import { useActiveWeb3React } from './web3'
|
||||||
|
|
||||||
export function useFetchListCallback(): (listUrl: string, sendDispatch?: boolean) => Promise<TokenList> {
|
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 { useMemo } from 'react'
|
||||||
|
|
||||||
import contenthashToUri from '../utils/contenthashToUri'
|
|
||||||
import { parseENSAddress } from '../utils/parseENSAddress'
|
|
||||||
import uriToHttp from '../utils/uriToHttp'
|
|
||||||
import useENSContentHash from './useENSContentHash'
|
import useENSContentHash from './useENSContentHash'
|
||||||
|
|
||||||
export default function useHttpLocations(uri: string | undefined): string[] {
|
export default function useHttpLocations(uri: string | undefined): string[] {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Trans } from '@lingui/macro'
|
import { Trans } from '@lingui/macro'
|
||||||
|
import { TokenInfo } from '@uniswap/token-lists'
|
||||||
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
|
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 Header from '../Header'
|
||||||
import { BoundaryProvider } from '../Popover'
|
import { BoundaryProvider } from '../Popover'
|
||||||
@ -12,7 +14,38 @@ import Settings from './Settings'
|
|||||||
import SwapButton from './SwapButton'
|
import SwapButton from './SwapButton'
|
||||||
import Toolbar from './Toolbar'
|
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 [boundary, setBoundary] = useState<HTMLDivElement | null>(null)
|
||||||
const { active, account } = useActiveWeb3React()
|
const { active, account } = useActiveWeb3React()
|
||||||
return (
|
return (
|
||||||
|
@ -1,16 +1,21 @@
|
|||||||
import useNativeEvent from 'lib/hooks/useNativeEvent'
|
import useNativeEvent from 'lib/hooks/useNativeEvent'
|
||||||
import styled from 'lib/theme'
|
import styled from 'lib/theme'
|
||||||
import { Token } from 'lib/types'
|
import uriToHttp from 'lib/utils/uriToHttp'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
interface TokenImgProps {
|
interface TokenImgProps {
|
||||||
className?: string
|
className?: string
|
||||||
token: Token
|
token: {
|
||||||
|
name?: string
|
||||||
|
symbol: string
|
||||||
|
logoURI?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const TRANSPARENT_SRC = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
|
const TRANSPARENT_SRC = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
|
||||||
|
|
||||||
function TokenImg({ className, token }: TokenImgProps) {
|
function TokenImg({ className, token }: TokenImgProps) {
|
||||||
const [img, setImg] = useState<HTMLImageElement | null>(null)
|
const [img, setImg] = useState<HTMLImageElement | null>(null)
|
||||||
|
const src = token.logoURI ? uriToHttp(token.logoURI)[0] : TRANSPARENT_SRC
|
||||||
useNativeEvent(img, 'error', () => {
|
useNativeEvent(img, 'error', () => {
|
||||||
if (img) {
|
if (img) {
|
||||||
// Use a local transparent gif to avoid the browser-dependent broken img icon.
|
// 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
|
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 }>`
|
export default styled(TokenImg)<{ size?: number }>`
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
|
import useTokenList, { DEFAULT_TOKEN_LIST } from 'lib/hooks/useTokenList'
|
||||||
|
|
||||||
import { Modal } from './Dialog'
|
import { Modal } from './Dialog'
|
||||||
import { TokenSelectDialog } from './TokenSelect'
|
import { TokenSelectDialog } from './TokenSelect'
|
||||||
|
|
||||||
export default (
|
export default function Fixture() {
|
||||||
<Modal color="module">
|
useTokenList(DEFAULT_TOKEN_LIST)
|
||||||
<TokenSelectDialog onSelect={() => void 0} />
|
|
||||||
</Modal>
|
return (
|
||||||
)
|
<Modal color="module">
|
||||||
|
<TokenSelectDialog onSelect={() => void 0} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import useNativeEvent from 'lib/hooks/useNativeEvent'
|
import useNativeEvent from 'lib/hooks/useNativeEvent'
|
||||||
import useScrollbar from 'lib/hooks/useScrollbar'
|
import useScrollbar from 'lib/hooks/useScrollbar'
|
||||||
import styled, { ThemedText } from 'lib/theme'
|
import styled, { ThemedText } from 'lib/theme'
|
||||||
import { Token } from 'lib/types'
|
|
||||||
import {
|
import {
|
||||||
ComponentClass,
|
ComponentClass,
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
@ -17,6 +16,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer'
|
import AutoSizer from 'react-virtualized-auto-sizer'
|
||||||
import { areEqual, FixedSizeList, FixedSizeListProps } from 'react-window'
|
import { areEqual, FixedSizeList, FixedSizeListProps } from 'react-window'
|
||||||
|
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
|
||||||
import invariant from 'tiny-invariant'
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
import { BaseButton } from '../Button'
|
import { BaseButton } from '../Button'
|
||||||
@ -31,7 +31,7 @@ const TokenButton = styled(BaseButton)`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const ITEM_SIZE = 56
|
const ITEM_SIZE = 56
|
||||||
type ItemData = Token[]
|
type ItemData = WrappedTokenInfo[]
|
||||||
interface FixedSizeTokenList extends FixedSizeList<ItemData>, ComponentClass<FixedSizeListProps<ItemData>> {}
|
interface FixedSizeTokenList extends FixedSizeList<ItemData>, ComponentClass<FixedSizeListProps<ItemData>> {}
|
||||||
const TokenList = styled(FixedSizeList as unknown as FixedSizeTokenList)<{
|
const TokenList = styled(FixedSizeList as unknown as FixedSizeTokenList)<{
|
||||||
hover: number
|
hover: number
|
||||||
@ -55,13 +55,13 @@ const OnHover = styled.div<{ hover: number }>`
|
|||||||
|
|
||||||
interface TokenOptionProps {
|
interface TokenOptionProps {
|
||||||
index: number
|
index: number
|
||||||
value: Token
|
value: WrappedTokenInfo
|
||||||
style: CSSProperties
|
style: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BubbledEvent extends SyntheticEvent {
|
interface BubbledEvent extends SyntheticEvent {
|
||||||
index?: number
|
index?: number
|
||||||
token?: Token
|
token?: WrappedTokenInfo
|
||||||
ref?: HTMLButtonElement
|
ref?: HTMLButtonElement
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,8 +121,8 @@ interface TokenOptionsHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TokenOptionsProps {
|
interface TokenOptionsProps {
|
||||||
tokens: Token[]
|
tokens: WrappedTokenInfo[]
|
||||||
onSelect: (token: Token) => void
|
onSelect: (token: WrappedTokenInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TokenOptions = forwardRef<TokenOptionsHandle, TokenOptionsProps>(function TokenOptions(
|
const TokenOptions = forwardRef<TokenOptionsHandle, TokenOptionsProps>(function TokenOptions(
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { t, Trans } from '@lingui/macro'
|
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 styled, { ThemedText } from 'lib/theme'
|
||||||
import { Token } from 'lib/types'
|
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 Column from '../Column'
|
||||||
import Dialog, { Header } from '../Dialog'
|
import Dialog, { Header } from '../Dialog'
|
||||||
@ -13,17 +13,21 @@ import TokenBase from './TokenBase'
|
|||||||
import TokenButton from './TokenButton'
|
import TokenButton from './TokenButton'
|
||||||
import TokenOptions from './TokenOptions'
|
import TokenOptions from './TokenOptions'
|
||||||
|
|
||||||
// TODO: integrate with web3-react context
|
|
||||||
const mockTokens = [DAI, ETH, UNI, USDC]
|
|
||||||
|
|
||||||
const SearchInput = styled(StringInput)`
|
const SearchInput = styled(StringInput)`
|
||||||
${inputCss}
|
${inputCss}
|
||||||
`
|
`
|
||||||
|
|
||||||
export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => void }) {
|
export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => void }) {
|
||||||
const baseTokens = [DAI, ETH, UNI, USDC]
|
const tokenMap = useTokenList()
|
||||||
const tokens = mockTokens
|
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 [search, setSearch] = useState('')
|
||||||
|
|
||||||
const input = useRef<HTMLInputElement>(null)
|
const input = useRef<HTMLInputElement>(null)
|
||||||
@ -48,15 +52,13 @@ export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => vo
|
|||||||
</ThemedText.Body1>
|
</ThemedText.Body1>
|
||||||
</Row>
|
</Row>
|
||||||
{Boolean(baseTokens.length) && (
|
{Boolean(baseTokens.length) && (
|
||||||
<>
|
<Row pad={0.75} gap={0.25} justify="flex-start" flex>
|
||||||
<Row pad={0.75} gap={0.25} justify="flex-start" flex>
|
{baseTokens.map((token) => (
|
||||||
{baseTokens.map((token) => (
|
<TokenBase value={token} onClick={onSelect} key={token.address} />
|
||||||
<TokenBase value={token} onClick={onSelect} key={token.address} />
|
))}
|
||||||
))}
|
</Row>
|
||||||
</Row>
|
|
||||||
<Rule padded />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<Rule padded />
|
||||||
</Column>
|
</Column>
|
||||||
<TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} />
|
<TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} />
|
||||||
</>
|
</>
|
||||||
|
@ -3,7 +3,7 @@ import { Provider as AtomProvider } from 'jotai'
|
|||||||
import { UNMOUNTING } from 'lib/hooks/useUnmount'
|
import { UNMOUNTING } from 'lib/hooks/useUnmount'
|
||||||
import { Provider as I18nProvider } from 'lib/i18n'
|
import { Provider as I18nProvider } from 'lib/i18n'
|
||||||
import styled, { keyframes, Theme, ThemeProvider } from 'lib/theme'
|
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 EthProvider } from 'widgets-web3-react/types'
|
||||||
|
|
||||||
import { Provider as DialogProvider } from './Dialog'
|
import { Provider as DialogProvider } from './Dialog'
|
||||||
@ -64,8 +64,7 @@ const WidgetWrapper = styled.div<{ width?: number | string }>`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export interface WidgetProps {
|
export type WidgetProps<T extends JSXElementConstructor<any> | undefined = undefined> = {
|
||||||
children: ReactNode
|
|
||||||
theme?: Theme
|
theme?: Theme
|
||||||
locale?: SupportedLocale
|
locale?: SupportedLocale
|
||||||
provider?: EthProvider
|
provider?: EthProvider
|
||||||
@ -74,7 +73,10 @@ export interface WidgetProps {
|
|||||||
dialog?: HTMLElement | null
|
dialog?: HTMLElement | null
|
||||||
className?: string
|
className?: string
|
||||||
onError?: ErrorHandler
|
onError?: ErrorHandler
|
||||||
}
|
} & (T extends JSXElementConstructor<any>
|
||||||
|
? ComponentProps<T>
|
||||||
|
: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
{})
|
||||||
|
|
||||||
export default function Widget({
|
export default function Widget({
|
||||||
children,
|
children,
|
||||||
@ -86,7 +88,7 @@ export default function Widget({
|
|||||||
dialog,
|
dialog,
|
||||||
className,
|
className,
|
||||||
onError,
|
onError,
|
||||||
}: WidgetProps) {
|
}: PropsWithChildren<WidgetProps>) {
|
||||||
const wrapper = useRef<HTMLDivElement>(null)
|
const wrapper = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -4,7 +4,7 @@ import uriToHttp from 'lib/utils/uriToHttp'
|
|||||||
import Vibrant from 'node-vibrant/lib/bundle'
|
import Vibrant from 'node-vibrant/lib/bundle'
|
||||||
import { useLayoutEffect, useState } from 'react'
|
import { useLayoutEffect, useState } from 'react'
|
||||||
|
|
||||||
const colors = new Map<string, string>()
|
const colors = new Map<string, string | undefined>()
|
||||||
|
|
||||||
function UriForEthToken(address: string) {
|
function UriForEthToken(address: string) {
|
||||||
return `https://raw.githubusercontent.com/uniswap/assets/master/blockchains/ethereum/assets/${address}/logo.png?color`
|
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) {
|
async function getColorFromToken(token: Token, cb: (color: string | undefined) => void = () => void 0) {
|
||||||
const { address, chainId, logoURI } = token
|
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.
|
if (!color && logoURI) {
|
||||||
// Add a dummy parameter to force a different browser resource cache entry.
|
// Color extraction must use a CORS-compatible resource, but the resource is already cached.
|
||||||
// Without this, color extraction prevents resource caching.
|
// Add a dummy parameter to force a different browser resource cache entry.
|
||||||
const uri = uriToHttp(logoURI)[0] + '?color'
|
// Without this, color extraction prevents resource caching.
|
||||||
|
const uri = uriToHttp(logoURI)[0] + '?color'
|
||||||
let color = colors.get(uri)
|
color = await getColorFromUriPath(uri)
|
||||||
if (color) {
|
|
||||||
return cb(color)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
color = await getColorFromUriPath(uri)
|
|
||||||
if (!color && chainId === 1) {
|
if (!color && chainId === 1) {
|
||||||
const fallbackUri = UriForEthToken(address)
|
const fallbackUri = UriForEthToken(address)
|
||||||
color = await getColorFromUriPath(fallbackUri)
|
color = await getColorFromUriPath(fallbackUri)
|
||||||
}
|
}
|
||||||
if (color) {
|
|
||||||
colors.set(uri, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
colors.set(key, color)
|
||||||
return cb(color)
|
return cb(color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
25
src/lib/hooks/useTokenList/fetchTokenList.test.ts
Normal file
25
src/lib/hooks/useTokenList/fetchTokenList.test.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
73
src/lib/hooks/useTokenList/fetchTokenList.ts
Normal file
73
src/lib/hooks/useTokenList/fetchTokenList.ts
Normal file
@ -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.')
|
||||||
|
}
|
43
src/lib/hooks/useTokenList/index.ts
Normal file
43
src/lib/hooks/useTokenList/index.ts
Normal file
@ -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])
|
||||||
|
}
|
32
src/lib/hooks/useTokenList/utils.ts
Normal file
32
src/lib/hooks/useTokenList/utils.ts
Normal file
@ -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
|
||||||
|
}
|
42
src/lib/hooks/useTokenList/validateTokenList.test.ts
Normal file
42
src/lib/hooks/useTokenList/validateTokenList.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
54
src/lib/hooks/useTokenList/validateTokenList.ts
Normal file
54
src/lib/hooks/useTokenList/validateTokenList.ts
Normal file
@ -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 Swap from './components/Swap'
|
||||||
import Widget, { WidgetProps } from './components/Widget'
|
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 (
|
return (
|
||||||
<Widget {...props}>
|
<Widget {...props}>
|
||||||
<Swap />
|
<Swap {...props} />
|
||||||
</Widget>
|
</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 { mix, transparentize } from 'polished'
|
||||||
import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
|
import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
|
||||||
|
2
src/lib/types.d.ts
vendored
2
src/lib/types.d.ts
vendored
@ -4,5 +4,5 @@ export interface Token {
|
|||||||
chainId: number
|
chainId: number
|
||||||
decimals: number
|
decimals: number
|
||||||
address: string
|
address: string
|
||||||
logoURI: string
|
logoURI?: string
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { parseENSAddress } from './parseENSAddress'
|
import parseENSAddress from './parseENSAddress'
|
||||||
|
|
||||||
describe('parseENSAddress', () => {
|
describe('parseENSAddress', () => {
|
||||||
it('test cases', () => {
|
it('test cases', () => {
|
@ -1,6 +1,8 @@
|
|||||||
const ENS_NAME_REGEX = /^(([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+)eth(\/.*)?$/
|
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)
|
const match = ensAddress.match(ENS_NAME_REGEX)
|
||||||
if (!match) return undefined
|
if (!match) return undefined
|
||||||
return { ensName: `${match[1].toLowerCase()}eth`, ensPath: match[4] }
|
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
|
* @param uri to convert to fetch-able http url
|
||||||
*/
|
*/
|
||||||
export default function uriToHttp(uri: string): string[] {
|
export default function uriToHttp(uri: string): string[] {
|
||||||
const protocol = uri.split(':')[0].toLowerCase()
|
const protocol = uri.split(':')[0].toLowerCase()
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
|
case 'data':
|
||||||
|
return [uri]
|
||||||
case 'https':
|
case 'https':
|
||||||
return [uri]
|
return [uri]
|
||||||
case 'http':
|
case 'http':
|
||||||
@ -15,6 +17,9 @@ export default function uriToHttp(uri: string): string[] {
|
|||||||
case 'ipns':
|
case 'ipns':
|
||||||
const name = uri.match(/^ipns:(\/\/)?(.*)$/i)?.[2]
|
const name = uri.match(/^ipns:(\/\/)?(.*)$/i)?.[2]
|
||||||
return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.io/ipns/${name}/`]
|
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:
|
default:
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TokenList } from '@uniswap/token-lists'
|
import { ChainTokenMap, tokensToChainTokenMap } from 'lib/hooks/useTokenList/utils'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useAppSelector } from 'state/hooks'
|
import { useAppSelector } from 'state/hooks'
|
||||||
import sortByListPriority from 'utils/listSort'
|
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 UNSUPPORTED_TOKEN_LIST from '../../constants/tokenLists/unsupported.tokenlist.json'
|
||||||
import { AppState } from '../index'
|
import { AppState } from '../index'
|
||||||
import { UNSUPPORTED_LIST_URLS } from './../../constants/lists'
|
import { UNSUPPORTED_LIST_URLS } from './../../constants/lists'
|
||||||
import { WrappedTokenInfo } from './wrappedTokenInfo'
|
|
||||||
|
|
||||||
export type TokenAddressMap = Readonly<{
|
export type TokenAddressMap = ChainTokenMap
|
||||||
[chainId: number]: Readonly<{ [tokenAddress: string]: { token: WrappedTokenInfo; list: TokenList } }>
|
|
||||||
}>
|
|
||||||
|
|
||||||
type Mutable<T> = {
|
type Mutable<T> = {
|
||||||
-readonly [P in keyof T]: Mutable<T[P]>
|
-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'] {
|
export function useAllLists(): AppState['lists']['byUrl'] {
|
||||||
return useAppSelector((state) => state.lists.byUrl)
|
return useAppSelector((state) => state.lists.byUrl)
|
||||||
}
|
}
|
||||||
@ -84,7 +57,7 @@ function useCombinedTokenMapFromUrls(urls: string[] | undefined): TokenAddressMa
|
|||||||
const current = lists[currentUrl]?.current
|
const current = lists[currentUrl]?.current
|
||||||
if (!current) return allTokens
|
if (!current) return allTokens
|
||||||
try {
|
try {
|
||||||
return combineMaps(allTokens, listToTokenMap(current))
|
return combineMaps(allTokens, tokensToChainTokenMap(current))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Could not show token list due to error', error)
|
console.error('Could not show token list due to error', error)
|
||||||
return allTokens
|
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
|
// list of tokens not supported on interface for various reasons, used to show warnings and prevent swaps and adds
|
||||||
export function useUnsupportedTokenList(): TokenAddressMap {
|
export function useUnsupportedTokenList(): TokenAddressMap {
|
||||||
// get hard-coded broken tokens
|
// get hard-coded broken tokens
|
||||||
const brokenListMap = useMemo(() => listToTokenMap(BROKEN_LIST), [])
|
const brokenListMap = useMemo(() => tokensToChainTokenMap(BROKEN_LIST), [])
|
||||||
|
|
||||||
// get hard-coded list of unsupported tokens
|
// 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
|
// get dynamic list of unsupported tokens
|
||||||
const loadedUnsupportedListMap = useCombinedTokenMapFromUrls(UNSUPPORTED_LIST_URLS)
|
const loadedUnsupportedListMap = useCombinedTokenMapFromUrls(UNSUPPORTED_LIST_URLS)
|
||||||
|
@ -13,11 +13,11 @@ interface TagInfo extends TagDetails {
|
|||||||
export class WrappedTokenInfo implements Token {
|
export class WrappedTokenInfo implements Token {
|
||||||
public readonly isNative: false = false
|
public readonly isNative: false = false
|
||||||
public readonly isToken: true = true
|
public readonly isToken: true = true
|
||||||
public readonly list: TokenList
|
public readonly list?: TokenList
|
||||||
|
|
||||||
public readonly tokenInfo: TokenInfo
|
public readonly tokenInfo: TokenInfo
|
||||||
|
|
||||||
constructor(tokenInfo: TokenInfo, list: TokenList) {
|
constructor(tokenInfo: TokenInfo, list?: TokenList) {
|
||||||
this.tokenInfo = tokenInfo
|
this.tokenInfo = tokenInfo
|
||||||
this.list = list
|
this.list = list
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ export class WrappedTokenInfo implements Token {
|
|||||||
public get tags(): TagInfo[] {
|
public get tags(): TagInfo[] {
|
||||||
if (this._tags !== null) return this._tags
|
if (this._tags !== null) return this._tags
|
||||||
if (!this.tokenInfo.tags) 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 = [])
|
if (!listTags) return (this._tags = [])
|
||||||
|
|
||||||
return (this._tags = this.tokenInfo.tags.map((tagId) => {
|
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/*": ["./*"],
|
"lib/*": ["./*"],
|
||||||
"constants/*": ["../constants/*"],
|
"constants/*": ["../constants/*"],
|
||||||
"hooks/*": ["../hooks/*"],
|
"hooks/*": ["../hooks/*"],
|
||||||
|
"state/*": ["../state/*"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "src/lib/**/*.test.*"],
|
"exclude": ["node_modules", "src/lib/**/*.test.*"],
|
||||||
|
Loading…
Reference in New Issue
Block a user