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",
|
||||
"@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 (
|
||||
<Modal color="module">
|
||||
<TokenSelectDialog onSelect={() => void 0} />
|
||||
</Modal>
|
||||
)
|
||||
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 />
|
||||
</>
|
||||
<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 />
|
||||
</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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
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 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
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.*"],
|
||||
|
Loading…
Reference in New Issue
Block a user