diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3f496ea5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release +on: + # manual trigger + workflow_dispatch: + +jobs: + deploy: + name: release + runs-on: + group: npm-deploy + environment: + name: release + permissions: + id-token: write + contents: write + steps: + - name: Load secret + uses: 1password/load-secrets-action@581a835fb51b8e7ec56b71cf2ffddd7e68bb25e0 + with: + # Export loaded secrets as environment variables + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + # You may need to change this to your vault name and secret name + # Refer to it by calling env.NPM_TOKEN + # This token is also limited by IP to ONLY work on the runner + NPM_TOKEN: op://npm-deploy/npm-runner-token/secret + + - name: Checkout + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + + - name: Setup Node + uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 + with: + cache: yarn + node-version: 18 + + - name: Install dependencies + run: yarn install --immutable --immutable-cache + + - name: Release + env: + NPM_CONFIG_USERCONFIG: /dev/null + NPM_TOKEN: ${{ env.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Optional + run: yarn g:release + diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 00000000..c773069b --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,22 @@ +name: Semgrep +on: + workflow_dispatch: {} + pull_request: {} + push: + branches: + - main + schedule: + # random HH:MM to avoid a load spike on GitHub Actions at 00:00 + - cron: '35 11 * * *' +jobs: + semgrep: + name: semgrep/ci + runs-on: ubuntu-20.04 + env: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + container: + image: returntocorp/semgrep + if: (github.actor != 'dependabot[bot]') + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + - run: semgrep ci diff --git a/README.md b/README.md index 9698fee8..97f13e8a 100644 --- a/README.md +++ b/README.md @@ -29,19 +29,36 @@ This package does not include code for token list validation. You can easily do for ease of use. ```typescript -import Ajv from 'ajv'; -import { schema } from '@uniswap/token-lists' -const ajv = new Ajv({ allErrors: true }); -const validate = ajv.compile(schema); +import { schema } from '@uniswap/token-lists' +import Ajv from 'ajv' +import addFormats from 'ajv-formats' +import fetch from 'node-fetch' + +const ARBITRUM_LIST = 'https://bridge.arbitrum.io/token-list-42161.json' + +async function validate() { + const ajv = new Ajv({ allErrors: true, verbose: true }) + addFormats(ajv) + const validator = ajv.compile(schema); + const response = await fetch(ARBITRUM_LIST) + const data = await response.json() + const valid = validator(data) + if (valid) { + return valid + } + if (validator.errors) { + throw validator.errors.map(error => { + delete error.data + return error + }) + } +} -const response = await fetch('https://bridge.arbitrum.io/token-list-42161.json') -const listData = await response.json() +validate() + .then(console.log("Valid List.")) + .catch(console.error) -const valid = validate(listData) -if (!valid) { - // oh no! -} ``` ## Authoring token lists diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..034e8480 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/package.json b/package.json index 48a442fa..8cfbb2e6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://uniswap.org" }, "description": "📚 The Token Lists specification", - "version": "1.0.0-beta.27", + "version": "1.0.0-beta.34", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -29,6 +29,10 @@ "lint": "tsdx lint src test", "prepublishOnly": "yarn test && yarn build" }, + "publishConfig" : { + "access": "public", + "provenance": true + }, "peerDependencies": {}, "husky": { "hooks": { diff --git a/src/tokenlist.schema.json b/src/tokenlist.schema.json index 1303044d..3fdc4e2f 100644 --- a/src/tokenlist.schema.json +++ b/src/tokenlist.schema.json @@ -231,19 +231,33 @@ "name": { "type": "string", "description": "The name of the token", - "minLength": 1, - "maxLength": 40, - "pattern": "^[ \\w.'+\\-%/À-ÖØ-öø-ÿ:&\\[\\]\\(\\)]+$", + "minLength": 0, + "maxLength": 60, + "anyOf": [ + { + "const": "" + }, + { + "pattern": "^[ \\S+]+$" + } + ], "examples": [ "USD Coin" ] }, "symbol": { "type": "string", - "description": "The symbol for the token; must be alphanumeric", - "pattern": "^[a-zA-Z0-9+\\-%/$.]+$", - "minLength": 1, + "description": "The symbol for the token", + "minLength": 0, "maxLength": 20, + "anyOf": [ + { + "const": "" + }, + { + "pattern": "^\\S+$" + } + ], "examples": [ "USDC" ] @@ -282,13 +296,12 @@ } }, "type": "object", - "additionalProperties": false, "properties": { "name": { "type": "string", "description": "The name of the token list", "minLength": 1, - "maxLength": 20, + "maxLength": 30, "pattern": "^[\\w ]+$", "examples": [ "My Token List" @@ -311,6 +324,30 @@ "minItems": 1, "maxItems": 10000 }, + "tokenMap": { + "type": "object", + "description": "A mapping of key 'chainId_tokenAddress' to its corresponding token object", + "minProperties": 1, + "maxProperties": 10000, + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/definitions/TokenInfo" + }, + "examples": [ + { + "4_0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": { + "name": "Uniswap", + "address": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "symbol": "UNI", + "decimals": 18, + "chainId": 4, + "logoURI": "ipfs://QmXttGpZrECX5qCyXbBQiqgQNytVGeZW5Anewvh2jc4psg" + } + } + ] + }, "keywords": { "type": "array", "description": "Keywords associated with the contents of the list; may be used in list discoverability", @@ -363,4 +400,4 @@ "version", "tokens" ] -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 08e5be37..9bbeb89f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +type ExtensionValue = string | number | boolean | null | undefined; + export interface TokenInfo { readonly chainId: number; readonly address: string; @@ -7,7 +9,15 @@ export interface TokenInfo { readonly logoURI?: string; readonly tags?: string[]; readonly extensions?: { - readonly [key: string]: string | number | boolean | null; + readonly [key: string]: + | { + [key: string]: + | { + [key: string]: ExtensionValue; + } + | ExtensionValue; + } + | ExtensionValue; }; } @@ -29,6 +39,9 @@ export interface TokenList { readonly timestamp: string; readonly version: Version; readonly tokens: TokenInfo[]; + readonly tokenMap?: { + readonly [key: string]: TokenInfo; + }; readonly keywords?: string[]; readonly tags?: Tags; readonly logoURI?: string; diff --git a/test/__snapshots__/tokenlist.schema.test.ts.snap b/test/__snapshots__/tokenlist.schema.test.ts.snap index ecc36943..a4bdf669 100644 --- a/test/__snapshots__/tokenlist.schema.test.ts.snap +++ b/test/__snapshots__/tokenlist.schema.test.ts.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`schema allows additional top-level fields 1`] = `null`; + exports[`schema allows up to 10k tokens 1`] = `null`; exports[`schema checks extensions 1`] = `null`; @@ -437,9 +439,9 @@ Array [ Object { "instancePath": "/tokens/0/name", "keyword": "maxLength", - "message": "must NOT have more than 40 characters", + "message": "must NOT have more than 60 characters", "params": Object { - "limit": 40, + "limit": 60, }, "schemaPath": "#/properties/name/maxLength", }, @@ -579,6 +581,8 @@ exports[`schema token symbols may contain periods 1`] = `null`; exports[`schema works for big example schema 1`] = `null`; +exports[`schema works for empty names and symbols 1`] = `null`; + exports[`schema works for example schema 1`] = `null`; exports[`schema works for special characters schema 1`] = `null`; diff --git a/test/schema/bigwords.tokenlist.json b/test/schema/bigwords.tokenlist.json index b02121aa..27f3a854 100644 --- a/test/schema/bigwords.tokenlist.json +++ b/test/schema/bigwords.tokenlist.json @@ -12,7 +12,7 @@ "timestamp": "2018-11-13T20:20:39+00:00", "tokens": [ { - "name": "blah blah blah blah blah blah blah blah blah", + "name": "blah blah blah blah blah blah blah blah blah blah blah blah b", "address": "0x0000000000000000000000000000000000000000", "chainId": 1, "decimals": 18, diff --git a/test/schema/empty-name-symbol.tokenlist.json b/test/schema/empty-name-symbol.tokenlist.json new file mode 100644 index 00000000..517ee01a --- /dev/null +++ b/test/schema/empty-name-symbol.tokenlist.json @@ -0,0 +1,25 @@ +{ + "name": "My Token List", + "timestamp": "2020-06-12T00:00:00+00:00", + "tokens": [ + { + "chainId": 137, + "address": "0xc31C535F4d9A789df0c16D461B4F811543b72FEb", + "decimals": 0, + "name": "", + "symbol": "" + }, + { + "chainId": 137, + "address": "0xF336f5624D34c3Be82eF3EFc4978bd2397B1524A", + "decimals": 0, + "name": "", + "symbol": "" + } + ], + "version": { + "major": 1, + "minor": 0, + "patch": 0 + } +} diff --git a/test/schema/example-name-symbol-special-characters.tokenlist.json b/test/schema/example-name-symbol-special-characters.tokenlist.json index 88667ada..1f62d332 100644 --- a/test/schema/example-name-symbol-special-characters.tokenlist.json +++ b/test/schema/example-name-symbol-special-characters.tokenlist.json @@ -43,6 +43,20 @@ "decimals": 18, "name": "[brackets]&(parenthesis)", "symbol": "symbol" + }, + { + "chainId": 1, + "address": "0x76ee13b775331eeae2bc5e3b67f4a44101b27a94", + "decimals": 18, + "name": "Amatsukami", + "symbol": "天津神" + }, + { + "chainId": 137, + "address": "0x841120E51aD43EfE489244728532854A352073aD", + "decimals": 6, + "name": "Timeless ERC4626-Wrapped", + "symbol": "∞-waUSDC-xPYT" } ], "version": { diff --git a/test/schema/example.tokenlist.json b/test/schema/example.tokenlist.json index 11cb901f..6c1ec5cf 100644 --- a/test/schema/example.tokenlist.json +++ b/test/schema/example.tokenlist.json @@ -41,6 +41,30 @@ ] } ], + "tokenMap": { + "1_0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { + "chainId": 1, + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "logoURI": "ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM", + "tags": [ + "stablecoin" + ] + }, + "1_0x39AA39c021dfbaE8faC545936693aC917d5E7563": { + "chainId": 1, + "address": "0x39AA39c021dfbaE8faC545936693aC917d5E7563", + "symbol": "cUSDC", + "name": "Compound USD Coin", + "decimals": 8, + "logoURI": "ipfs://QmUSNbwUxUYNMvMksKypkgWs8unSm8dX2GjCPBVGZ7GGMr", + "tags": [ + "compound" + ] + } + }, "version": { "major": 1, "minor": 0, diff --git a/test/tokenlist.schema.test.ts b/test/tokenlist.schema.test.ts index 09b6d62e..22558f11 100644 --- a/test/tokenlist.schema.test.ts +++ b/test/tokenlist.schema.test.ts @@ -5,6 +5,7 @@ import exampleNameSymbolSpecialCharacters from './schema/example-name-symbol-spe import bigExampleList from './schema/bigexample.tokenlist.json'; import exampleListMinimum from './schema/exampleminimum.tokenlist.json'; import emptyList from './schema/empty.tokenlist.json'; +import emptyNameSymbol from './schema/empty-name-symbol.tokenlist.json'; import bigWords from './schema/bigwords.tokenlist.json'; import invalidTokenAddress from './schema/invalidtokenaddress.tokenlist.json'; import invalidTimestamp from './schema/invalidtimestamp.tokenlist.json'; @@ -63,6 +64,10 @@ describe('schema', () => { checkSchema(emptyList, false); }); + it('works for empty names and symbols', () => { + checkSchema(emptyNameSymbol, true); + }); + it('fails with big names', () => { checkSchema(bigWords, false); }); @@ -131,4 +136,12 @@ describe('schema', () => { }; checkSchema(exampleListWith10kTokensPlusOne, false); }); + + it('allows additional top-level fields', () => { + const exampleListWithUnknownField = { + ...exampleList, + unknownField: 'foo', + }; + checkSchema(exampleListWithUnknownField, true); + }); });