-
Notifications
You must be signed in to change notification settings - Fork 18
feat: tokenlists resolver #282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 30 commits
faa9c41
bc0321a
ed233a7
a97a80a
b0b4e86
6b11cd3
9300e3f
2e48ec6
014b9f0
1388001
ad2f62d
c98a1b4
45b03d7
2f6091f
d905720
8e206a3
87cbbf5
527895f
c7da141
58cf1a3
226a3a1
e1697d9
8422fba
dc6aa6b
62180b7
bd130be
743fefd
630644f
ba51a78
6fd9219
8202138
25f3bf2
0b070a4
8521425
c7d2949
9d6ceae
269dbf1
9c1c84c
2e0d09f
edfedb8
0b5a6b3
0ee529e
c0f8a9c
51bff9d
12e86d8
19f2fcb
22892b2
6ea0de3
b90f9fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { getAddress } from '@ethersproject/address'; | ||
|
|
||
| type TokenlistToken = { | ||
| chainId: number; | ||
| address: string; | ||
| symbol: string; | ||
| name: string; | ||
| logoURI: string; | ||
| decimals: number; | ||
| }; | ||
|
|
||
| type AggregatedTokenList = TokenlistToken[]; | ||
|
|
||
| const TOKENLISTS_URL = | ||
| 'https://raw.githubusercontent.com/Uniswap/tokenlists-org/master/src/token-lists.json'; | ||
| const REQUEST_TIMEOUT = 2500; | ||
| const TTL = 1000 * 60 * 60 * 24; | ||
| let aggregatedTokenList: AggregatedTokenList = []; | ||
| let lastUpdateTimestamp: number | undefined; | ||
|
|
||
| function isExpired() { | ||
| return !lastUpdateTimestamp || Date.now() - lastUpdateTimestamp > TTL; | ||
| } | ||
|
|
||
| function normalizeTokenListUri(tokenListUri: string) { | ||
| if (!tokenListUri.startsWith('http') && tokenListUri.endsWith('.eth')) { | ||
| tokenListUri = `https://${tokenListUri}.limo`; | ||
| } | ||
| return tokenListUri; | ||
| } | ||
|
|
||
| function normalizeTokenLogoUri(logoUri: string) { | ||
| if (logoUri.startsWith('ipfs://')) { | ||
| logoUri = `https://ipfs.io/ipfs/${logoUri.slice(7)}`; | ||
| } | ||
| return logoUri; | ||
| } | ||
|
|
||
| async function fetchListUris() { | ||
| const response = await fetch(TOKENLISTS_URL, { | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT) | ||
| }); | ||
| const tokenLists = await response.json(); | ||
| const uris = Object.keys(tokenLists); | ||
|
|
||
| return uris; | ||
| } | ||
|
|
||
| async function fetchTokens(tokenListUri: string) { | ||
| tokenListUri = normalizeTokenListUri(tokenListUri); | ||
|
|
||
| try { | ||
| const response = await fetch(tokenListUri, { | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT) | ||
| }); | ||
| const tokens = await response.json(); | ||
| if (!tokens.tokens || !Array.isArray(tokens.tokens)) { | ||
| throw new Error('Invalid token list'); | ||
| } | ||
|
|
||
| const tokensWithLogoUri = tokens.tokens.filter((token: any) => token.logoURI); | ||
|
|
||
| return tokensWithLogoUri.map((token: any) => { | ||
| return { | ||
| ...token, | ||
| logoURI: normalizeTokenLogoUri(token.logoURI) | ||
| }; | ||
| }); | ||
| } catch (e) { | ||
| console.warn(`Failed to fetch token list from ${tokenListUri}`); | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| const REPLACE_SIZE_REGEX: { pattern: RegExp; replacement: string }[] = [ | ||
| { | ||
| pattern: /assets.coingecko.com\/coins\/images\/(\d+)\/(thumb|small)/, | ||
| replacement: 'assets.coingecko.com/coins/images/$1/large' | ||
| } | ||
| ]; | ||
|
|
||
| export function replaceSizePartsInImageUrls(list: AggregatedTokenList) { | ||
| return list.map(token => { | ||
| token.logoURI = REPLACE_SIZE_REGEX.reduce((acc, { pattern, replacement }) => { | ||
| return acc.replace(pattern, replacement); | ||
| }, token.logoURI); | ||
| return token; | ||
| }); | ||
| } | ||
|
|
||
| export async function updateExpiredAggregatedTokenList() { | ||
| if (!isExpired()) { | ||
| return; | ||
| } | ||
|
|
||
| const list: AggregatedTokenList = []; | ||
|
|
||
| const uris = await fetchListUris(); | ||
|
|
||
| await Promise.all( | ||
| uris.map(async tokenListUri => { | ||
| const tokens = await fetchTokens(tokenListUri); | ||
| list.push(...tokens); | ||
| }) | ||
| ); | ||
|
|
||
| aggregatedTokenList = replaceSizePartsInImageUrls(list); | ||
| lastUpdateTimestamp = Date.now(); | ||
| } | ||
|
|
||
| export function findImageUrl(address: string, chainId: string) { | ||
| const checksum = getAddress(address); | ||
|
|
||
| const token = aggregatedTokenList.find(token => { | ||
| return token.chainId === parseInt(chainId) && getAddress(token.address) === checksum; | ||
| }); | ||
|
|
||
| if (!token) { | ||
| throw new Error('Token not found'); | ||
| } | ||
|
|
||
| return token.logoURI; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { resize } from '../utils'; | ||
| import { max } from '../constants.json'; | ||
| import { fetchHttpImage } from './utils'; | ||
| import { findImageUrl, updateExpiredAggregatedTokenList } from '../helpers/tokenlists'; | ||
|
|
||
| export default async function resolve(address: string, chainId: string) { | ||
| try { | ||
| await updateExpiredAggregatedTokenList(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we remove the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's the details I tried to clarify but without success. I still feel like my approach of completely decoupling the list updates and the resolver is the better one but I'm not making the decisions here. Please just take from this PR what makes sense to you. |
||
| const url = findImageUrl(address, chainId); | ||
| const image = await fetchHttpImage(url); | ||
|
|
||
| return await resize(image, max, max); | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { replaceSizePartsInImageUrls } from '../../src/helpers/tokenlists'; | ||
|
|
||
| test('replaceSizePartsInImageUrls should replace image size parts in URLs', () => { | ||
| const tokenList = [ | ||
| { | ||
| chainId: 1, | ||
| address: '0x1234567890abcdef', | ||
| symbol: 'ABC', | ||
| name: 'Token ABC', | ||
| logoURI: 'https://assets.coingecko.com/coins/images/123/thumb', | ||
| decimals: 18 | ||
| }, | ||
| { | ||
| chainId: 1, | ||
| address: '0xabcdef1234567890', | ||
| symbol: 'DEF', | ||
| name: 'Token DEF', | ||
| logoURI: 'https://assets.coingecko.com/coins/images/456/small', | ||
| decimals: 18 | ||
| } | ||
| ]; | ||
|
|
||
| const expectedTokenList = [ | ||
| { | ||
| chainId: 1, | ||
| address: '0x1234567890abcdef', | ||
| symbol: 'ABC', | ||
| name: 'Token ABC', | ||
| logoURI: 'https://assets.coingecko.com/coins/images/123/large', | ||
| decimals: 18 | ||
| }, | ||
| { | ||
| chainId: 1, | ||
| address: '0xabcdef1234567890', | ||
| symbol: 'DEF', | ||
| name: 'Token DEF', | ||
| logoURI: 'https://assets.coingecko.com/coins/images/456/large', | ||
| decimals: 18 | ||
| } | ||
| ]; | ||
|
|
||
| const result = replaceSizePartsInImageUrls(tokenList); | ||
|
|
||
| expect(result).toEqual(expectedTokenList); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.