Skip to content
Open
Show file tree
Hide file tree
Changes from 30 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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"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:unit": "yarn jest --runInBand --collectCoverage=false test/unit"
},
"dependencies": {
"@adraffy/ens-normalize": "^1.10.0",
Expand Down Expand Up @@ -57,4 +58,4 @@
"start-server-and-test": "^2.0.3",
"ts-jest": "^28.0.4"
}
}
}
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-sx": ["space-sx"],
"space-cover-sx": ["space-cover-sx"]
Expand Down
123 changes: 123 additions & 0 deletions src/helpers/tokenlists.ts
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;
}
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) => {
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 @@ -10,6 +10,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 @@ -25,5 +26,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;
}
}
13 changes: 13 additions & 0 deletions test/e2e/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import redis from '../../src/helpers/redis';
import { KEY_PREFIX } from '../../src/addressResolvers/cache';

const HOST = `http://localhost:${process.env.PORT || 3003}`;
const cUSDC_TOKEN_ADDRESS_ON_MAIN = '0x39AA39c021dfbaE8faC545936693aC917d5E7563';

async function purge(): Promise<void> {
if (!redis) return;
Expand All @@ -14,8 +15,20 @@ async function purge(): Promise<void> {
transaction.exec();
}

// for token resolver, which needs a moment on first run
// I think the tests need to return promises.
jest.setTimeout(60_000);

describe('E2E api', () => {
describe('GET type/TYPE/ID', () => {
it('returns an image for tokenlists resolver', async () => {
const response = await axios.get(
`${HOST}/token/${cUSDC_TOKEN_ADDRESS_ON_MAIN}?resolver=tokenlists`
);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('image/webp');
});

it.todo('returns a 500 status on invalid query');

describe('when the image is not cached', () => {
Expand Down
45 changes: 45 additions & 0 deletions test/unit/tokenlists.test.ts
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);
});