Skip to content
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

feat: tokenlists resolver #282

Open
wants to merge 49 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
faa9c41
feat: add test for token list resolver
mktcode Aug 5, 2024
bc0321a
feat: allow additional headers for image responses, add fallback header
mktcode Aug 5, 2024
ed233a7
feat: add "empty" tokenlists resolver
mktcode Aug 5, 2024
a97a80a
refactor: improve error handling in new tokenlists resolver (still wip)
mktcode Aug 5, 2024
b0b4e86
feat: Improve handling of additional headers in setHeader function
mktcode Aug 5, 2024
6b11cd3
feat: set resolver headers in setHeader function
mktcode Aug 5, 2024
9300e3f
feat: Add check for extraHeaders in setHeader function
mktcode Aug 5, 2024
2e48ec6
feat: Add aggregated token list initialization
mktcode Aug 5, 2024
014b9f0
feat: Initialize aggregated token list from multiple sources
mktcode Aug 6, 2024
1388001
feat: add todo comment
mktcode Aug 7, 2024
ad2f62d
refactor: init tokens from tokenlists in resolver
mktcode Aug 7, 2024
c98a1b4
chore: comment
mktcode Aug 7, 2024
45b03d7
feat: parallelize tokenlist fetching
mktcode Aug 7, 2024
2f6091f
refactor: removed addition headers
mktcode Aug 7, 2024
d905720
refactor: reduce timeout in test to more reasonable value
mktcode Aug 7, 2024
8e206a3
feat: Update token list initialization for improved performance
mktcode Aug 7, 2024
87cbbf5
feat: Replace thumbnail image URLs with larger versions in token list
mktcode Aug 7, 2024
527895f
chore: updated comment
mktcode Aug 7, 2024
c7da141
refactor: remove variable assignment
mktcode Aug 7, 2024
58cf1a3
Merge branch 'master' into tokenlists
mktcode Aug 9, 2024
226a3a1
Update test/e2e/api.test.ts
mktcode Aug 9, 2024
e1697d9
refactor(tokenlists): roughly validate list response
mktcode Aug 14, 2024
8422fba
refactor(tokenlists): move back to on-boot approach
mktcode Aug 14, 2024
dc6aa6b
fix(tokenlists): regex to capture variabe part in url replacements
mktcode Aug 14, 2024
62180b7
refactor(tokenlists): upsi
mktcode Aug 14, 2024
bd130be
refactor(tokenlists): map ipfs logoUris to http gateway
mktcode Aug 14, 2024
743fefd
refactor: add unit test for replaceSizePartsInImageUrls function
mktcode Aug 14, 2024
630644f
refactor(tokenlists): update in resolver
mktcode Aug 15, 2024
ba51a78
fix(tokenlists): npe caused by normalizeTokenLogoUri
mktcode Aug 15, 2024
6fd9219
refactor(tokenlists): set reasonable timeout for requests
mktcode Aug 15, 2024
8202138
Merge branch 'master' into tokenlists
mktcode Aug 22, 2024
25f3bf2
chore(deps): update semver to version 7.6.3
mktcode Aug 23, 2024
0b070a4
chore: add e2e test for tokenlists
mktcode Aug 23, 2024
8521425
refactor: improve tokenlists resolver and add tests
mktcode Aug 23, 2024
c7d2949
refactor: improve tokenlists resolver and add tests
mktcode Aug 23, 2024
9d6ceae
refactor: remove test code, update expected fingerprint
mktcode Aug 23, 2024
269dbf1
Merge branch 'master' into tokenlists
mktcode Aug 24, 2024
9c1c84c
refactor: remove unnecessary code in tokenlists.ts
mktcode Aug 24, 2024
2e0d09f
refactor(tokenlists): sort logoURIs by size keywords
mktcode Aug 24, 2024
edfedb8
refactor(tokenlists): use Map instead of Array
mktcode Aug 24, 2024
0b5a6b3
refactor(tokenlists): remove unused data from aggregated tokenlist
mktcode Aug 24, 2024
0ee529e
refactor(tokenlists): update AggregatedTokenList type to use string a…
mktcode Aug 24, 2024
c0f8a9c
chore(tokenlists): add comment
mktcode Aug 24, 2024
51bff9d
refactor(tokenlists): add isUpdating flag to prevent concurrent updates
mktcode Aug 24, 2024
12e86d8
Merge branch 'master' into tokenlists
wa0x6e Sep 5, 2024
19f2fcb
Merge branch 'master' into tokenlists
mktcode Sep 8, 2024
22892b2
Merge branch 'master' into tokenlists
mktcode Sep 15, 2024
6ea0de3
Merge branch 'master' into tokenlists
mktcode Oct 4, 2024
b90f9fe
Merge branch 'master' into tokenlists
bonustrack Nov 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"start:test": "dotenv -e test/.env.test yarn dev",
"test": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test yarn jest'",
"test:integration": "dotenv -e test/.env.test yarn jest --runInBand --collectCoverage=false test/integration",
"test:e2e": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand --collectCoverage=false test/e2e/'"
"test:e2e": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand --collectCoverage=false test/e2e/'",
"test:e2e:tokenlists": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand --collectCoverage=false test/e2e/tokenlists.test.ts'",
"test:unit": "yarn jest --runInBand --collectCoverage=false test/unit"
},
"dependencies": {
"@adraffy/ens-normalize": "^1.10.0",
Expand Down
2 changes: 1 addition & 1 deletion src/constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"resolvers": {
"avatar": ["snapshot", "ens", "lens", "farcaster", "starknet"],
"user-cover": ["user-cover"],
"token": ["trustwallet", "zapper"],
"token": ["trustwallet", "zapper", "tokenlists"],
"space": ["space"],
"space-cover": ["space-cover"],
"space-sx": ["space-sx"],
Expand Down
177 changes: 177 additions & 0 deletions src/helpers/tokenlists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { getAddress } from '@ethersproject/address';

type TokenlistToken = {
chainId: number;
address: string;
logoURI: string;
};

type AggregatedTokenList = Map<string, string[]>;

const TOKENLISTS_URL =
'https://raw.githubusercontent.com/Uniswap/tokenlists-org/master/src/token-lists.json';
const REQUEST_TIMEOUT = 5000;
const TTL = 1000 * 60 * 60 * 24;
let aggregatedTokenList: AggregatedTokenList = new Map();
let lastUpdateTimestamp: number | undefined;
let isUpdating = false;

function isTokenlistToken(token: unknown): token is TokenlistToken {
if (typeof token !== 'object' || token === null) {
return false;
}

const { chainId, address, logoURI } = token as TokenlistToken;

return typeof chainId === 'number' && typeof address === 'string' && typeof logoURI === 'string';
}

function isExpired() {
return !lastUpdateTimestamp || Date.now() - lastUpdateTimestamp > TTL;
}

function normalizeUri(uri: string) {
if (!uri.startsWith('http') && uri.endsWith('.eth')) {
uri = `https://${uri}.limo`;
}
if (uri.startsWith('ipfs://')) {
uri = `https://ipfs.io/ipfs/${uri.slice(7)}`;
}
return uri;
}

async function fetchUri(uri: string) {
return await fetch(normalizeUri(uri), { signal: AbortSignal.timeout(REQUEST_TIMEOUT) });
}

async function fetchListUris() {
try {
const response = await fetchUri(TOKENLISTS_URL);
const tokenLists = await response.json();
const uris = Object.keys(tokenLists);

return uris;
} catch (e) {
return [];
}
}

export async function fetchTokens(listUri: string) {
try {
const response = await fetchUri(listUri);
const { tokens } = await response.json();
if (!tokens || !Array.isArray(tokens)) {
throw new Error('Invalid token list');
}
return tokens.filter(isTokenlistToken);
} catch (e) {
return [];
}
}

const REPLACE_SIZE_REGEXES: { pattern: RegExp; replacement: string }[] = [
{
pattern: /assets.coingecko.com\/coins\/images\/(\d+)\/(thumb|small)/,
replacement: 'assets.coingecko.com/coins/images/$1/large'
}
];

// TODO: Since we do the sorting by keyword match, we should probably not change the URLs in place but add the "large" version to the list of URIs.
// Might be better for fallback mechanisms, instead of overwriting the version that was fetched.
export function replaceURIPatterns(uri: string) {
for (const { pattern, replacement } of REPLACE_SIZE_REGEXES) {
uri = uri.replace(pattern, replacement);
}
return uri;
}

const sizeKeywords = [
'xxl',
'xl',
'large',
'lg',
'big',
'medium',
'md',
'small',
'sm',
'thumb',
'icon',
'xs',
'xxs'
];

/**
* Sorts URIs by the size keyword in the URI. The order in the array above is the order of the sort.
*/
export function sortByKeywordMatch(a: string, b: string) {
try {
const aPath = new URL(a).pathname;
const bPath = new URL(b).pathname;

const keywordRegex = new RegExp(`\\b(${sizeKeywords.join('|')})\\b`);

const aMatch = aPath.match(keywordRegex);
const bMatch = bPath.match(keywordRegex);

if (aMatch && bMatch) {
return sizeKeywords.indexOf(aMatch[1]) - sizeKeywords.indexOf(bMatch[1]);
} else if (aMatch) {
return -1;
} else if (bMatch) {
return 1;
} else {
return a.localeCompare(b);
}
} catch (e) {
return 0;
}
}

function getTokenKey(address: string, chainId: string) {
return `${chainId}-${getAddress(address)}`;
}

export async function updateExpiredAggregatedTokenList() {
if (!isExpired() || isUpdating) {
return;
}

isUpdating = true;

const newTokenMap = new Map<string, string[]>();

const tokenListUris = await fetchListUris();
const tokenLists = await Promise.all(tokenListUris.map(fetchTokens));

for (const tokens of tokenLists) {
for (const token of tokens) {
const logoURI = normalizeUri(replaceURIPatterns(token.logoURI));
const tokenKey = getTokenKey(token.address, token.chainId.toString());

const existingToken = newTokenMap.get(tokenKey);
if (existingToken) {
existingToken.push(logoURI);
} else {
newTokenMap.set(tokenKey, [logoURI]);
}
}
}

newTokenMap.forEach(token => token.sort(sortByKeywordMatch));

aggregatedTokenList = newTokenMap;
lastUpdateTimestamp = Date.now();
isUpdating = false;
}

export function findImageUrl(address: string, chainId: string) {
const tokenKey = getTokenKey(address, chainId);
const token = aggregatedTokenList.get(tokenKey);

if (!token) {
throw new Error('Token not found in aggregated tokenlist');
}

return token[0];
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ app.use(cors({ maxAge: 86400 }));
app.use(compression());
app.use('/', api);

app.get('/', (req, res) => {
app.get('/', (_req, res) => {
mktcode marked this conversation as resolved.
Show resolved Hide resolved
const commit = process.env.COMMIT_HASH ?? undefined;
res.json({ name, version, commit });
});
Expand Down
4 changes: 3 additions & 1 deletion src/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import lens from './lens';
import zapper from './zapper';
import starknet from './starknet';
import farcaster from './farcaster';
import tokenlists from './tokenlists';

export default {
blockie,
Expand All @@ -30,5 +31,6 @@ export default {
lens,
zapper,
starknet,
farcaster
farcaster,
tokenlists
};
16 changes: 16 additions & 0 deletions src/resolvers/tokenlists.ts
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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we remove the await here:

  • the first request triggering the token list refresh will always return immediately with nothing, with the list refreshing async
  • a request waiting for list refresh will return immediately with current list, instead of waiting for new list, which is refreshing async

Copy link
Contributor Author

@mktcode mktcode Sep 8, 2024

Choose a reason for hiding this comment

The 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;
}
}
67 changes: 67 additions & 0 deletions test/e2e/tokenlists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import sharp from 'sharp';
import axios from 'axios';
import crypto from 'crypto';

const HOST = `http://localhost:${process.env.PORT || 3003}`;
const cUSDC_TOKEN_ADDRESS_ON_MAIN = '0x39AA39c021dfbaE8faC545936693aC917d5E7563';
const ERC3770_ADDRESS = 'oeth:0xe0BB0D3DE8c10976511e5030cA403dBf4c25165B';
const EIP155_ADDRESS = 'eip155:1:0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e';

function getImageFingerprint(input: string) {
return crypto
.createHash('sha256')
.update(input)
.digest('hex');
}

function getImageResponse(identifier: string) {
return axios.get(`${HOST}/token/${identifier}?resolver=tokenlists`, {
responseType: 'arraybuffer',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
Expires: '0'
}
});
}

// tokenlists resolver needs a moment on first run
// there's probably a better way to handle this
jest.setTimeout(60_000);

describe('tokenlist resolver', () => {
it('returns an image for standard address', async () => {
const response = await getImageResponse(cUSDC_TOKEN_ADDRESS_ON_MAIN);

expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('image/webp');
});

it('returns correct image for ERC3770 address', async () => {
const response = await getImageResponse(ERC3770_ADDRESS);

const image = sharp(response.data);
const imageBuffer = await image.raw().toBuffer();

const fingerprint = getImageFingerprint(imageBuffer.toString('hex'));
const expectedFingerprint = 'ac601f072065d4d03e6ef906c1dc3074d7ad52b9c715d0db6941ec89bf2073a1';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned here #282 (comment) this is not the expected image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not working due to something unrelated to this PR: the shortname oeth is not supported:

stamp/src/utils.ts

Lines 50 to 58 in 3b50d97

export function shortNameToChainId(shortName: string) {
if (shortName === 'eth') return '1';
if (shortName === 'bsc') return '56';
if (shortName === 'ftm') return '250';
if (shortName === 'matic') return '137';
if (shortName === 'arb1') return '42161';
return null;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not the expected image

yes, you'll have to decide what images make most sense to test.


expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('image/webp');
expect(fingerprint).toBe(expectedFingerprint);
});

it('returns an image for EIP155 address', async () => {
const response = await getImageResponse(EIP155_ADDRESS);

const image = sharp(response.data);
const imageBuffer = await image.raw().toBuffer();

const fingerprint = getImageFingerprint(imageBuffer.toString('hex'));
const expectedFingerprint = '8118786398e4756b2b7e8e224ec2bb5cbe3b26ee93ceff3b19d40f81c8ce45a2';

expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('image/webp');
expect(fingerprint).toBe(expectedFingerprint);
});
});
53 changes: 53 additions & 0 deletions test/unit/tokenlists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { replaceURIPatterns, sortByKeywordMatch } from '../../src/helpers/tokenlists';

jest.setTimeout(60_000);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit tests does not need such high timeout

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. Please just take over this branch. I won't make any more changes to it.


describe('tokenlists helper', () => {
it('replaceURIPatterns should replace known image size related parts in URLs', () => {
const uris = [
'https://assets.coingecko.com/coins/images/123/thumb',
'https://assets.coingecko.com/coins/images/456/small'
];

const expectedUris = [
'https://assets.coingecko.com/coins/images/123/large',
'https://assets.coingecko.com/coins/images/456/large'
];

uris.forEach((uri, i) => {
expect(replaceURIPatterns(uri)).toBe(expectedUris[i]);
});
});

it('sortByKeywordMatch should sort URIs by size keywords', () => {
const uris = [
'https://assets.coingecko.com/coins/images/123/thumb',
'https://assets.coingecko.com/coins/images/2021/xxs',
'https://assets.coingecko.com/coins/images/456-small',
'https://assets.coingecko.com/coins/images/789/medium',
'https://assets.coingecko.com/coins/images/1011/large',
'https://assets.xl.coingecko.com/coins/images/1213',
'https://assets.coingecko.com/coins/images/1415/xxl',
'https://assets.coingecko.com/coins/images/1617/icon',
'https://assets.coingecko.com/coins/images/2021/lg/logo.png',
'https://assets.coingecko.com/coins/images/2021/md-logo.png'
];

const expectedUris = [
'https://assets.coingecko.com/coins/images/1415/xxl',
'https://assets.coingecko.com/coins/images/1011/large',
'https://assets.coingecko.com/coins/images/2021/lg/logo.png',
'https://assets.coingecko.com/coins/images/789/medium',
'https://assets.coingecko.com/coins/images/2021/md-logo.png',
'https://assets.coingecko.com/coins/images/456-small',
'https://assets.coingecko.com/coins/images/123/thumb',
'https://assets.coingecko.com/coins/images/1617/icon',
'https://assets.coingecko.com/coins/images/2021/xxs',

// no keyword, should be at the end (domain part should be ignored)
'https://assets.xl.coingecko.com/coins/images/1213'
];

expect(uris.sort(sortByKeywordMatch)).toEqual(expectedUris);
});
});
7 changes: 6 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6290,7 +6290,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312"
integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==

[email protected], semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3:
[email protected], semver@^7.3.2, semver@^7.3.5, semver@^7.5.3:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there a yarn.lock update, without a dependencies update?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk. Maybe because it hasn't been pushed before. Might be my mistake as well. Just delete it and run yarn again.

version "7.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
Expand All @@ -6307,6 +6307,11 @@ semver@^6.0.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==

semver@^7.3.7:
version "7.6.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==

semver@~7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
Expand Down
Loading