-
Notifications
You must be signed in to change notification settings - Fork 22
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: add script for bulk adding tokens #324
Changes from 11 commits
f3b4915
38df4f9
7fce14b
621caa0
4802d53
17bbc1e
95f5e1a
6ecd9cb
f18a2df
1ce99a7
f06b010
4345665
62ff2f4
cc84115
f4ef5cc
af7913a
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,211 @@ | ||||||||
/* eslint-disable no-console */ | ||||||||
import axios from 'axios' | ||||||||
import fs from 'fs' | ||||||||
import Jimp from 'jimp' | ||||||||
import { createPublicClient, erc20Abi, http } from 'viem' | ||||||||
import { mainnet } from 'viem/chains' | ||||||||
import yargs from 'yargs' | ||||||||
|
||||||||
async function main(args: ReturnType<typeof parseArgs>) { | ||||||||
const { categoryId, platformId, numberOfResults, tokensInfoFilePath } = args | ||||||||
|
||||||||
console.log('Reading existing tokens info from ', tokensInfoFilePath) | ||||||||
const existingTokensInfo = require(`../${tokensInfoFilePath}`) | ||||||||
const existingLowerCaseTokenSymbols = new Set( | ||||||||
existingTokensInfo.map((token: any) => token.symbol.toLowerCase()), | ||||||||
) | ||||||||
|
||||||||
const client = createPublicClient({ | ||||||||
chain: mainnet, // TODO this needs to be updated manually | ||||||||
transport: http(), | ||||||||
}) | ||||||||
|
||||||||
console.log('Fetching tokens list by market cap from Coingecko') | ||||||||
const coingeckoResponse = await axios.get( | ||||||||
'https://api.coingecko.com/api/v3/coins/markets', | ||||||||
{ | ||||||||
params: { | ||||||||
vs_currency: 'usd', | ||||||||
category: categoryId, | ||||||||
order: 'market_cap_desc', | ||||||||
per_page: numberOfResults, | ||||||||
page: 1, | ||||||||
sparkline: false, | ||||||||
locale: 'en', | ||||||||
}, | ||||||||
}, | ||||||||
) | ||||||||
if (coingeckoResponse.status !== 200 || !coingeckoResponse.data) { | ||||||||
throw new Error(`Encountered error fetching tokens list from Coingecko`) | ||||||||
} | ||||||||
|
||||||||
const newTokensInfo = [...existingTokensInfo] | ||||||||
const fetchFailedTokenIds = [] | ||||||||
for (let i = 0; i < coingeckoResponse.data.length; i++) { | ||||||||
const token = coingeckoResponse.data[i] | ||||||||
const { id, image } = token | ||||||||
if (!id) { | ||||||||
console.warn(`⚠️ No id found for token ${token}`) | ||||||||
continue | ||||||||
} | ||||||||
|
||||||||
console.log( | ||||||||
`(${i + 1}/${coingeckoResponse.data.length}) Processing token ${id}...`, | ||||||||
) | ||||||||
|
||||||||
if (existingLowerCaseTokenSymbols.has(token.symbol.toLowerCase())) { | ||||||||
console.log(`Token ${id} already exists`) | ||||||||
continue | ||||||||
} | ||||||||
|
||||||||
// avoid rate limit 10-30 requests / minute | ||||||||
// https://apiguide.coingecko.com/getting-started/error-and-rate-limit#rate-limit | ||||||||
await new Promise((resolve) => setTimeout(resolve, 10000)) | ||||||||
|
||||||||
if (!image) { | ||||||||
console.warn(`⚠️ No id or image found for token ${token}`) | ||||||||
fetchFailedTokenIds.push(id) | ||||||||
continue | ||||||||
} | ||||||||
|
||||||||
// get token address from coingecko /coins/{id} endpoint, annoyingly this is | ||||||||
// not returned in the /coins/markets response | ||||||||
let address = undefined | ||||||||
let decimals = undefined | ||||||||
try { | ||||||||
console.log('Fetching token details from CoinGecko...') | ||||||||
const coinDetailResponse = await axios.get( | ||||||||
`https://api.coingecko.com/api/v3/coins/${id}`, | ||||||||
{ | ||||||||
params: { | ||||||||
localization: false, | ||||||||
tickers: false, | ||||||||
market_data: false, | ||||||||
community_data: false, | ||||||||
developer_data: false, | ||||||||
sparkline: false, | ||||||||
}, | ||||||||
}, | ||||||||
) | ||||||||
decimals = | ||||||||
coinDetailResponse.data.detail_platforms[platformId]?.decimal_place | ||||||||
address = | ||||||||
coinDetailResponse.data.detail_platforms[platformId]?.contract_address | ||||||||
if (!address) { | ||||||||
throw new Error(`No token address found for token ${id}`) | ||||||||
} | ||||||||
} catch (error) { | ||||||||
console.warn( | ||||||||
`⚠️ Encountered error fetching token address for ${id} from Coingecko: ${error}`, | ||||||||
) | ||||||||
fetchFailedTokenIds.push(id) | ||||||||
continue | ||||||||
} | ||||||||
|
||||||||
// get token metadata from token contract directly. Coingecko manually adds | ||||||||
// some info to their token list and the token symbol in particular is | ||||||||
// always (incorrectly) lowercased. | ||||||||
console.log(`Fetching token details from the contract ${address}...`) | ||||||||
const [symbol, name] = await Promise.all([ | ||||||||
client.readContract({ | ||||||||
address, | ||||||||
abi: erc20Abi, | ||||||||
functionName: 'symbol', | ||||||||
}), | ||||||||
client.readContract({ | ||||||||
address, | ||||||||
abi: erc20Abi, | ||||||||
functionName: 'name', | ||||||||
}), | ||||||||
]) | ||||||||
|
||||||||
// read the token image from coingecko and resize before saving. continue if | ||||||||
// the image cannot be saved successfully, we don't want imageless tokens. | ||||||||
try { | ||||||||
console.log('Fetching token image from Coingecko...') | ||||||||
const response = await axios.get(image, { | ||||||||
responseType: 'arraybuffer', | ||||||||
responseEncoding: 'binary', | ||||||||
}) | ||||||||
|
||||||||
console.log('Resizing image...') | ||||||||
const imageFile = await Jimp.read(response.data) | ||||||||
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. Could we add a comment about the limitation around transparent backgrounds? 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. i didn't end up doing this as it's not a limitation of the script but rather the original image. there are some images with transparent backgrounds on coingecko that will work as expected, there are also images that exist already that don't have transparent backgrounds. we could take a pass through all the image assets at some point to remove backgrounds, but i also think the UI handles these relatively gracefully at the moment anyway |
||||||||
const resizedImage = await imageFile | ||||||||
.resize(256, 256) | ||||||||
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. Does CoinGecko only return square images? 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. i added a warning if they are too much of a non-square, only 1 image was like that. it seems like coingecko has made the images square themselves (they're not pixel perfect square, but square enough i think) 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. Ok too bad jimp doesn't support a resize option to fit a square then. 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. yes the jimp resize will distort the image. the script logs a warning, and we can always manually download / update the image if we need to. for this particular case, the automatically resized image looks good enough to me (https://assets.coingecko.com/coins/images/913/large/LRC.png?1696502034) |
||||||||
.quality(100) | ||||||||
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. Nothing to do here, but this reminds me it would be nice to have an automated check that ensures the images we have in the repo are optimized (with max compression, for instance with ImageOptim or similar). 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. i can take a pass through all the images with this in a follow up |
||||||||
.getBufferAsync(Jimp.MIME_PNG) | ||||||||
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. Could we show a warning message if upscaling? 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. yes good idea, i've added some log warnings for image stretching / upscaling |
||||||||
|
||||||||
console.log('Saving image...') | ||||||||
const filePath = `./assets/tokens/${symbol}.png` | ||||||||
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. This could possibly overwrite existing images. 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. i added a log warning about this, and i think it's acceptable to pick this up from the warning / git diff. doesn't seem like something that should prevent the token from being added |
||||||||
fs.writeFileSync(filePath, resizedImage, 'binary') | ||||||||
} catch (error) { | ||||||||
console.warn( | ||||||||
`⚠️ Encountered error fetching image, skipping ${id}. ${error}`, | ||||||||
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.
Suggested change
|
||||||||
) | ||||||||
fetchFailedTokenIds.push(id) | ||||||||
continue | ||||||||
} | ||||||||
|
||||||||
newTokensInfo.push({ | ||||||||
name, | ||||||||
symbol, | ||||||||
decimals, | ||||||||
address, | ||||||||
imageUrl: `https://raw.githubusercontent.com/valora-inc/address-metadata/main/assets/tokens/${symbol}.png`, | ||||||||
isNative: false, | ||||||||
showZeroBalance: false, | ||||||||
infoUrl: `https://www.coingecko.com/en/coins/${id}`, | ||||||||
minimumAppVersionToSwap: '1.72.0', | ||||||||
isCashInEligible: false, | ||||||||
}) | ||||||||
|
||||||||
// update the file after every token is fetched because the coingecko rate | ||||||||
// limit can be unpredictable and we don't want to lose progress | ||||||||
console.log('Updating tokens info file with new token...') | ||||||||
const newTokensInfoString = JSON.stringify(newTokensInfo, null, 2) | ||||||||
// our lint rules require a newline at the end of the file | ||||||||
fs.writeFileSync(tokensInfoFilePath, `${newTokensInfoString}\n`) | ||||||||
} | ||||||||
|
||||||||
console.log('✨ Success ✨') | ||||||||
console.log( | ||||||||
`The following token ids failed to be added: ${fetchFailedTokenIds.join( | ||||||||
', ', | ||||||||
)}`, | ||||||||
) | ||||||||
} | ||||||||
|
||||||||
function parseArgs() { | ||||||||
return yargs | ||||||||
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.
Suggested change
|
||||||||
.option('category-id', { | ||||||||
description: | ||||||||
'The category id of the chain can be found from https://api.coingecko.com/api/v3/coins/categories/list', | ||||||||
type: 'string', | ||||||||
default: 'ethereum-ecosystem', | ||||||||
}) | ||||||||
.option('platform-id', { | ||||||||
description: | ||||||||
'The platform id of the chain can be found from https://api.coingecko.com/api/v3/asset_platforms', | ||||||||
type: 'string', | ||||||||
default: 'ethereum', | ||||||||
}) | ||||||||
.option('number-of-results', { | ||||||||
description: 'Number of tokens requested', | ||||||||
type: 'number', | ||||||||
default: 100, | ||||||||
}) | ||||||||
.option('tokens-info-file-path', { | ||||||||
description: 'Path of the tokens info file relative to the root folder', | ||||||||
type: 'string', | ||||||||
default: 'src/data/mainnet/ethereum-tokens-info.json', | ||||||||
}) | ||||||||
.parseSync() | ||||||||
} | ||||||||
|
||||||||
main(parseArgs()) | ||||||||
.then(() => process.exit(0)) | ||||||||
.catch((error) => { | ||||||||
const message = (error as any)?.message | ||||||||
console.log(`Error updating tokens: ${message}`) | ||||||||
process.exit(1) | ||||||||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: doesn't really matter here, but just for the sake of getting in the habit of minimizing RPC calls