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: add script for bulk adding tokens #324

Merged
merged 16 commits into from
Jan 10, 2024
Merged
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"json-diff": "^1.0.6",
"semver": "^7.5.4",
"typescript": "~5.3.3",
"viem": "^2.0.0",
"yargs": "^17.7.2"
},
"devDependencies": {
Expand All @@ -65,6 +66,7 @@
"husky": "^8.0.3",
"image-size": "^1.1.1",
"jest": "^29.7.0",
"jimp": "^0.22.10",
"prettier": "^3.1.1",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2"
Expand Down
211 changes: 211 additions & 0 deletions scripts/add-erc20-tokens-coingecko.ts
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',
}),
])
Copy link
Member

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

Suggested change
const [symbol, name] = await Promise.all([
client.readContract({
address,
abi: erc20Abi,
functionName: 'symbol',
}),
client.readContract({
address,
abi: erc20Abi,
functionName: 'name',
}),
])
const [symbol, name] = await client.multicall({
contracts: [
{
address,
abi: erc20Abi,
functionName: 'symbol',
},
{
address,
abi: erc20Abi,
functionName: 'name',
}
],
allowFailure: false,
})


// 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)
Copy link
Member

Choose a reason for hiding this comment

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

Could we add a comment about the limitation around transparent backgrounds?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

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

Does CoinGecko only return square images?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)

Copy link
Member

Choose a reason for hiding this comment

The 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.
That one image will become slightly distorted then, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

The 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).

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

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

Could we show a warning message if upscaling?
Just so we don't blindly add low quality images.
Though the reviewer of the changed PR should hopefully catch that too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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`
Copy link
Member

Choose a reason for hiding this comment

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

This could possibly overwrite existing images.
Should we log a warning if that's the case?
Though it will be clear in the diff.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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}`,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
`⚠️ Encountered error fetching image, skipping ${id}. ${error}`,
`⚠️ Encountered error fetching/resizing/writing image, skipping ${id}. ${error}`,

)
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
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return yargs
return yargs
.usage('Usage: $0\n\nAdd new tokens using CoinGecko as a data source.'

.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)
})
Loading