Commit 39a212f7 authored by eddie's avatar eddie Committed by GitHub

fix: catch json parse error in fetchTokenList (#6278)

* fix: catch json parse error in fetchTokenList

* fix: refactor fetchTokenList and add more tests

* fix: import in test

* fix: comments and names

* fix: comment format

* fix: comment formatting
parent c362f4fe
...@@ -18,7 +18,9 @@ describe('fetchTokenList', () => { ...@@ -18,7 +18,9 @@ describe('fetchTokenList', () => {
fetch.mockOnceIf(url, () => { fetch.mockOnceIf(url, () => {
throw new Error() throw new Error()
}) })
await expect(fetchTokenList(url, resolver)).rejects.toThrow(`failed to fetch list: ${url}`) await expect(fetchTokenList(url, resolver)).rejects.toThrow(
`No valid token list found at any URLs derived from ${url}.`
)
expect(console.debug).toHaveBeenCalled() expect(console.debug).toHaveBeenCalled()
expect(resolver).not.toHaveBeenCalled() expect(resolver).not.toHaveBeenCalled()
}) })
...@@ -33,9 +35,63 @@ describe('fetchTokenList', () => { ...@@ -33,9 +35,63 @@ describe('fetchTokenList', () => {
expect(resolver).toHaveBeenCalledWith(url) expect(resolver).toHaveBeenCalledWith(url)
}) })
it('throws an error when the ENS resolver throws', async () => {
const url = 'example.eth'
const error = new Error('ENS resolver error')
resolver.mockRejectedValue(error)
await expect(fetchTokenList(url, resolver)).rejects.toThrow(`failed to resolve ENS name: ${url}`)
expect(resolver).toHaveBeenCalledWith(url)
})
it('fetches and validates a list from an ENS address', async () => {
jest.mock('../../utils/contenthashToUri', () =>
jest.fn().mockImplementation(() => 'ipfs://QmPgEqyV3m8SB52BS2j2mJpu9zGprhj2BGCHtRiiw2fdM1')
)
const url = 'example.eth'
const contenthash = '0xe3010170122013e051d1cfff20606de36845d4fe28deb9861a319a5bc8596fa4e610e8803918'
const translatedUri = 'https://cloudflare-ipfs.com/ipfs/QmPgEqyV3m8SB52BS2j2mJpu9zGprhj2BGCHtRiiw2fdM1/'
resolver.mockResolvedValue(contenthash)
fetch.mockOnceIf(translatedUri, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(url, resolver)).resolves.toStrictEqual(defaultTokenList)
})
it('throws for an unrecognized list URL protocol', async () => {
const url = 'unknown://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve(''))
await expect(fetchTokenList(url, resolver)).rejects.toThrow(`Unrecognized list URL protocol.`)
})
it('logs a debug statement if the response is not successful', async () => {
const url = 'https://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve({ status: 404 }))
await expect(fetchTokenList(url, resolver)).rejects.toThrow(
`No valid token list found at any URLs derived from ${url}.`
)
expect(console.debug).toHaveBeenCalled()
expect(resolver).not.toHaveBeenCalled()
})
it('fetches and validates the default token list', async () => { it('fetches and validates the default token list', async () => {
fetch.mockOnceIf(DEFAULT_TOKEN_LIST, () => Promise.resolve(JSON.stringify(defaultTokenList))) fetch.mockOnceIf(DEFAULT_TOKEN_LIST, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(DEFAULT_TOKEN_LIST, resolver)).resolves.toStrictEqual(defaultTokenList) await expect(fetchTokenList(DEFAULT_TOKEN_LIST, resolver)).resolves.toStrictEqual(defaultTokenList)
expect(resolver).not.toHaveBeenCalled() expect(resolver).not.toHaveBeenCalled()
}) })
it('throws for a list with invalid json response', async () => {
const url = 'https://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve('invalid json'))
await expect(fetchTokenList(url, resolver)).rejects.toThrow(
`No valid token list found at any URLs derived from ${url}.`
)
expect(console.debug).toHaveBeenCalled()
expect(resolver).not.toHaveBeenCalled()
})
it('uses cached value the second time', async () => {
const url = 'https://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(url, resolver)).resolves.toStrictEqual(defaultTokenList)
await expect(fetchTokenList(url, resolver)).resolves.toStrictEqual(defaultTokenList)
expect(fetch).toHaveBeenCalledTimes(1)
})
}) })
...@@ -8,7 +8,11 @@ export const DEFAULT_TOKEN_LIST = 'https://gateway.ipfs.io/ipns/tokens.uniswap.o ...@@ -8,7 +8,11 @@ export const DEFAULT_TOKEN_LIST = 'https://gateway.ipfs.io/ipns/tokens.uniswap.o
const listCache = new Map<string, TokenList>() const listCache = new Map<string, TokenList>()
/** Fetches and validates a token list. */ /**
* Fetches and validates a token list.
* For a given token list URL, we try to fetch the list from all the possible HTTP URLs.
* For example, IPFS URLs can be fetched through multiple gateways.
*/
export default async function fetchTokenList( export default async function fetchTokenList(
listUrl: string, listUrl: string,
resolveENSContentHash: (ensName: string) => Promise<string>, resolveENSContentHash: (ensName: string) => Promise<string>,
...@@ -43,31 +47,38 @@ export default async function fetchTokenList( ...@@ -43,31 +47,38 @@ export default async function fetchTokenList(
urls = uriToHttp(listUrl) urls = uriToHttp(listUrl)
} }
if (urls.length === 0) {
throw new Error('Unrecognized list URL protocol.')
}
// Try each of the derived URLs until one succeeds.
for (let i = 0; i < urls.length; i++) { for (let i = 0; i < urls.length; i++) {
const url = urls[i] const url = urls[i]
const isLast = i === urls.length - 1
let response let response
try { try {
response = await fetch(url, { credentials: 'omit' }) response = await fetch(url, { credentials: 'omit' })
} catch (error) { } catch (error) {
const message = `failed to fetch list: ${listUrl}` console.debug(`failed to fetch list: ${listUrl} (${url})`, error)
console.debug(message, error)
if (isLast) throw new Error(message)
continue continue
} }
if (!response.ok) { if (!response.ok) {
const message = `failed to fetch list: ${listUrl}` console.debug(`failed to fetch list ${listUrl} (${url})`, response.statusText)
console.debug(message, response.statusText)
if (isLast) throw new Error(message)
continue continue
} }
const json = await response.json() try {
const list = skipValidation ? json : await validateTokenList(json) // The content of the result is sometimes invalid even with a 200 status code.
listCache?.set(listUrl, list) // A response can be invalid if it's not a valid JSON or if it doesn't match the TokenList schema.
return list const json = await response.json()
const list = skipValidation ? json : await validateTokenList(json)
listCache?.set(listUrl, list)
return list
} catch (error) {
console.debug(`failed to parse and validate list response: ${listUrl} (${url})`, error)
continue
}
} }
throw new Error('Unrecognized list URL protocol.') throw new Error(`No valid token list found at any URLs derived from ${listUrl}.`)
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment