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', () => {
fetch.mockOnceIf(url, () => {
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(resolver).not.toHaveBeenCalled()
})
......@@ -33,9 +35,63 @@ describe('fetchTokenList', () => {
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 () => {
fetch.mockOnceIf(DEFAULT_TOKEN_LIST, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(DEFAULT_TOKEN_LIST, resolver)).resolves.toStrictEqual(defaultTokenList)
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
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(
listUrl: string,
resolveENSContentHash: (ensName: string) => Promise<string>,
......@@ -43,31 +47,38 @@ export default async function fetchTokenList(
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++) {
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)
console.debug(`failed to fetch list: ${listUrl} (${url})`, error)
continue
}
if (!response.ok) {
const message = `failed to fetch list: ${listUrl}`
console.debug(message, response.statusText)
if (isLast) throw new Error(message)
console.debug(`failed to fetch list ${listUrl} (${url})`, response.statusText)
continue
}
try {
// The content of the result is sometimes invalid even with a 200 status code.
// A response can be invalid if it's not a valid JSON or if it doesn't match the TokenList schema.
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