From 709a70652f1869c75eb872df47121787aba8aba8 Mon Sep 17 00:00:00 2001 From: matteen <105068213+matteenm@users.noreply.github.com> Date: Tue, 9 May 2023 16:34:15 -0400 Subject: [PATCH] feat: add tokenlist validation (#6504) * feat: add tokenlist validation * use alternative for spread operator * maintain tokenlists version and use original ajv version * bump ajv * Revert "bump ajv" This reverts commit b9d2dd61c61f4aeedc00faf034c5a6e89b0d28fc. * rename vars in validator * update gitignore * nit fixes * test * add ^ back * remove ^ * removed and readded ajv * try require.resolve * Revert "try require.resolve" This reverts commit 62f58bcb7f02f1ead430a4c94ca3428a60d4d4c5. * bump eslint-config * yarn lock merge conflict * bring back spread operator * remove redundant lint ignore --- .gitignore | 6 +- package.json | 7 ++- scripts/compile-ajv-validators.js | 20 +++++++ src/lib/hooks/useTokenList/fetchTokenList.ts | 2 +- src/utils/__generated__/.gitkeep | 0 src/utils/validateTokenList.test.ts | 42 ++++++++++++++ src/utils/validateTokenList.ts | 61 ++++++++++++++++++++ yarn.lock | 8 +-- 8 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 scripts/compile-ajv-validators.js create mode 100644 src/utils/__generated__/.gitkeep create mode 100644 src/utils/validateTokenList.test.ts create mode 100644 src/utils/validateTokenList.ts diff --git a/.gitignore b/.gitignore index 27be3ed9f6..cfbf170e17 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,10 @@ /src/locales/**/en-US.po /src/locales/**/pseudo.po -# generated graphql types -/src/graphql/**/__generated__ +# generated files +/src/**/__generated__ + +# schema schema.graphql # dependencies diff --git a/package.json b/package.json index 1dc8bb26d1..d26a4d8778 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "i18n:extract": "lingui extract --locale en-US", "i18n:compile": "yarn i18n:extract && lingui compile", "i18n:pseudo": "lingui extract --locale pseudo && lingui compile", - "prepare": "yarn contracts:compile && yarn graphql:fetch && yarn graphql:generate && yarn i18n:compile", + "ajv:compile": "node scripts/compile-ajv-validators.js", + "prepare": "yarn contracts:compile && yarn graphql:fetch && yarn graphql:generate && yarn i18n:compile && yarn ajv:compile", "start": "craco start", "build": "craco build", "build:e2e": "REACT_APP_CSP_ALLOW_UNSAFE_EVAL=true REACT_APP_ADD_COVERAGE_INSTRUMENTATION=true craco build", @@ -96,8 +97,8 @@ "@types/ua-parser-js": "^0.7.35", "@types/uuid": "^8.3.4", "@types/wcag-contrast": "^3.0.0", + "@uniswap/eslint-config": "^1.2.0", "@uniswap/default-token-list": "^9.4.0", - "@uniswap/eslint-config": "^1.1.1", "@vanilla-extract/babel-plugin": "^1.1.7", "@vanilla-extract/jest-transform": "^1.1.1", "@vanilla-extract/webpack-plugin": "^2.1.11", @@ -190,6 +191,8 @@ "@web3-react/types": "^8.2.0", "@web3-react/url": "^8.2.0", "@web3-react/walletconnect": "^8.2.0", + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", "array.prototype.flat": "^1.2.4", "array.prototype.flatmap": "^1.2.4", "cids": "^1.0.0", diff --git a/scripts/compile-ajv-validators.js b/scripts/compile-ajv-validators.js new file mode 100644 index 0000000000..965a479d48 --- /dev/null +++ b/scripts/compile-ajv-validators.js @@ -0,0 +1,20 @@ +/* eslint-env node */ + +const fs = require('fs') +const path = require('path') +const Ajv = require('ajv') +const standaloneCode = require('ajv/dist/standalone').default +const addFormats = require('ajv-formats') +const schema = require('@uniswap/token-lists/dist/tokenlist.schema.json') + +const tokenListAjv = new Ajv({ code: { source: true, esm: true } }) +addFormats(tokenListAjv) +const validateTokenList = tokenListAjv.compile(schema) +let tokenListModuleCode = standaloneCode(tokenListAjv, validateTokenList) +fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokenList.js'), tokenListModuleCode) + +const tokensAjv = new Ajv({ code: { source: true, esm: true } }) +addFormats(tokensAjv) +const validateTokens = tokensAjv.compile({ ...schema, required: ['tokens'] }) +let tokensModuleCode = standaloneCode(tokensAjv, validateTokens) +fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokens.js'), tokensModuleCode) diff --git a/src/lib/hooks/useTokenList/fetchTokenList.ts b/src/lib/hooks/useTokenList/fetchTokenList.ts index 3b8b77d571..3d46b578fe 100644 --- a/src/lib/hooks/useTokenList/fetchTokenList.ts +++ b/src/lib/hooks/useTokenList/fetchTokenList.ts @@ -1,8 +1,8 @@ import type { TokenList } from '@uniswap/token-lists' -import { validateTokenList } from '@uniswap/widgets' import contenthashToUri from 'lib/utils/contenthashToUri' import parseENSAddress from 'lib/utils/parseENSAddress' import uriToHttp from 'lib/utils/uriToHttp' +import { validateTokenList } from 'utils/validateTokenList' export const DEFAULT_TOKEN_LIST = 'https://gateway.ipfs.io/ipns/tokens.uniswap.org' diff --git a/src/utils/__generated__/.gitkeep b/src/utils/__generated__/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/utils/validateTokenList.test.ts b/src/utils/validateTokenList.test.ts new file mode 100644 index 0000000000..41693e8e44 --- /dev/null +++ b/src/utils/validateTokenList.test.ts @@ -0,0 +1,42 @@ +import { TokenInfo } from '@uniswap/token-lists' + +import { validateTokens } from './validateTokenList' + +const INVALID_TOKEN: TokenInfo = { + name: 'Dai Stablecoin', + address: '0xD3ADB33F', + symbol: 'DAI', + decimals: 18, + chainId: 1, +} + +const INLINE_TOKEN_LIST = [ + { + name: 'Dai Stablecoin', + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + symbol: 'DAI', + decimals: 18, + chainId: 1, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + }, + { + name: 'USDCoin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + chainId: 1, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, +] + +describe('validateTokens', () => { + it('throws on invalid tokens', async () => { + await expect(validateTokens([INVALID_TOKEN])).rejects.toThrow(/^Tokens failed validation:.*address/) + }) + + it('validates the passed token info', async () => { + await expect(validateTokens(INLINE_TOKEN_LIST)).resolves.toBe(INLINE_TOKEN_LIST) + }) +}) diff --git a/src/utils/validateTokenList.ts b/src/utils/validateTokenList.ts new file mode 100644 index 0000000000..700e5c94f6 --- /dev/null +++ b/src/utils/validateTokenList.ts @@ -0,0 +1,61 @@ +import type { TokenInfo, TokenList } from '@uniswap/token-lists' +import type { ValidateFunction } from 'ajv' + +enum ValidationSchema { + LIST = 'list', + TOKENS = 'tokens', +} + +function getValidationErrors(validate: ValidateFunction | undefined): string { + return ( + validate?.errors?.map((error) => [error.instancePath, error.message].filter(Boolean).join(' ')).join('; ') ?? + 'unknown error' + ) +} + +async function validate(schema: ValidationSchema, data: unknown): Promise { + let validatorImport + switch (schema) { + case ValidationSchema.LIST: + validatorImport = import('utils/__generated__/validateTokenList') + break + case ValidationSchema.TOKENS: + validatorImport = import('utils/__generated__/validateTokens') + break + default: + throw new Error('No validation function specified for token list schema') + } + + const [, validatorModule] = await Promise.all([import('ajv'), validatorImport]) + const validator = (await validatorModule.default) as ValidateFunction + if (validator?.(data)) { + return data + } + throw new Error(getValidationErrors(validator)) +} + +/** + * Validates an array of tokens. + * @param json the TokenInfo[] to validate + */ +export async function validateTokens(json: TokenInfo[]): Promise { + try { + await validate(ValidationSchema.TOKENS, { tokens: json }) + return json + } catch (error) { + throw new Error(`Tokens failed validation: ${error.message}`) + } +} + +/** + * Validates a token list. + * @param json the TokenList to validate + */ +export async function validateTokenList(json: TokenList): Promise { + try { + await validate(ValidationSchema.LIST, json) + return json + } catch (error) { + throw new Error(`Token list failed validation: ${error.message}`) + } +} diff --git a/yarn.lock b/yarn.lock index 06f75a7db4..7e90752790 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5172,10 +5172,10 @@ resolved "https://registry.yarnpkg.com/@uniswap/default-token-list/-/default-token-list-9.4.0.tgz#60c4d249b3be5d0123480e5a4fd092a1fd10ef0c" integrity sha512-PsYsEPpFs0CIjWHel2FDPo6T/EkZ+RuH9NfX55FbMbZMTuw9TSQEfl8p1fYt6KAMWi0Zy/Mfqp16y+OiYJyoBA== -"@uniswap/eslint-config@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@uniswap/eslint-config/-/eslint-config-1.1.1.tgz#83d2633dad6507ca58bc11c8892035812059488e" - integrity sha512-vND/oJTmGCxYpY0E+iXWZT0mJT21/Ryf48yLP0kyWNnXzB5WyJuqei2gHx3nr+HWwRNF6aEbToRr9jNh+J3uBg== +"@uniswap/eslint-config@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@uniswap/eslint-config/-/eslint-config-1.2.0.tgz#1d9565c5ae5982fc0e3585b272e15b89b64b2db0" + integrity sha512-hxCmKRbCckQql+/ZKiZBpIm7btexTZ2SRq3lcg3MHDB3VurdIycKSIBGSyxJxAryMC2b83/SubiZ8wNCjP55sQ== dependencies: "@rushstack/eslint-patch" "^1.2.0" "@typescript-eslint/eslint-plugin" "^5.45.1"