diff --git a/.github/workflows/tokenlist.yml b/.github/workflows/tokenlist.yml new file mode 100644 index 000000000..dbee13c5b --- /dev/null +++ b/.github/workflows/tokenlist.yml @@ -0,0 +1,27 @@ +name: Verify token lists + +on: + pull_request: + paths: + - "packages/token-lists/src/tokens/**" + - "packages/token-lists/lists/**" + +jobs: + verifyTokenLists: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node + uses: actions/setup-node@v2.1.2 + with: + node-version: 14.x + + - name: Install dependencies + run: yarn install + + - name: Check if tokenlists were updated correctly + working-directory: ./packages/token-lists + run: yarn ci-check diff --git a/packages/token-lists/README.md b/packages/token-lists/README.md index 3d0eba8df..7accc43f0 100644 --- a/packages/token-lists/README.md +++ b/packages/token-lists/README.md @@ -10,16 +10,18 @@ URLs to external lists are stored in `token-lists.json`, if you want your list t - Add an array of tokens under `src/tokens` - Add `checksum:newlistname`, `generate:newlistname`, `makelist:newlistname` command to `package.json` analogous to PancakeSwap default and extended list scripts. -- Modify `checksum.ts`, `buildList.ts` and `default.test.ts` to handle new list +- Modify `checksum.ts`, `buildList.ts`, `ci-check.ts`, and `default.test.ts` to handle new list ## How to add new tokens to PancakeSwap (extended) token list Note - this is not something we expect pull requests for. Unless you've been specifically asked by someone from PCS team please do no submit PRs to be listed on default PCS list. You can still trade your tokens on PCS exchange by pasting your address into the token field. -- Update version in `package.json` - Add new tokens to `src/tokens/pancakeswap-extended.json` file - Run `yarn makelist:pcs-extended` + - By default new list will have patch version number bumped by 1 (e.g. `2.0.1` -> `2.0.2`). + - If you want to bump minor version add `minor` after makelist command `yarn makelist:pcs-extended minor` + - If you want to bump major version add `major` after makelist command `yarn makelist:pcs-extended major` - If tests pass - new token list will be created under `lists` directory For list to be considered valid it need to satisfy the following criteria: @@ -30,7 +32,7 @@ For list to be considered valid it need to satisfy the following criteria: ## How to update Top100 Token list -Note - this is not something we expect pull requests for. +Note - this is not something we expect pull requests for. ```shell script # Fetch the Top100 Tokens on PancakeSwap v2, and update list. diff --git a/packages/token-lists/lists/images/0x07AaA29E63FFEB2EBf59B33eE61437E1a91A3bb2.png b/packages/token-lists/lists/images/0x07aaa29e63ffeb2ebf59b33ee61437e1a91a3bb2.png similarity index 100% rename from packages/token-lists/lists/images/0x07AaA29E63FFEB2EBf59B33eE61437E1a91A3bb2.png rename to packages/token-lists/lists/images/0x07aaa29e63ffeb2ebf59b33ee61437e1a91a3bb2.png diff --git a/packages/token-lists/lists/images/0x431e0cd023a32532bf3969cddfc002c00e98429d.png b/packages/token-lists/lists/images/0x431e0cd023a32532bf3969cddfc002c00e98429d.png new file mode 100644 index 000000000..c509ae648 Binary files /dev/null and b/packages/token-lists/lists/images/0x431e0cd023a32532bf3969cddfc002c00e98429d.png differ diff --git a/packages/token-lists/lists/images/0x4FA7163E153419E0E1064e418dd7A99314Ed27b6.png b/packages/token-lists/lists/images/0x4FA7163E153419E0E1064e418dd7A99314Ed27b6.png new file mode 100644 index 000000000..6c7d127ac Binary files /dev/null and b/packages/token-lists/lists/images/0x4FA7163E153419E0E1064e418dd7A99314Ed27b6.png differ diff --git a/packages/token-lists/lists/images/0x4e6415a5727ea08aAE4580057187923aeC331227.png b/packages/token-lists/lists/images/0x4e6415a5727ea08aae4580057187923aec331227.png similarity index 100% rename from packages/token-lists/lists/images/0x4e6415a5727ea08aAE4580057187923aeC331227.png rename to packages/token-lists/lists/images/0x4e6415a5727ea08aae4580057187923aec331227.png diff --git a/packages/token-lists/lists/images/0x5F84ce30DC3cF7909101C69086c50De191895883.png b/packages/token-lists/lists/images/0x5F84ce30DC3cF7909101C69086c50De191895883.png new file mode 100644 index 000000000..fd11e1f3c Binary files /dev/null and b/packages/token-lists/lists/images/0x5F84ce30DC3cF7909101C69086c50De191895883.png differ diff --git a/packages/token-lists/lists/images/0x7e396bfc8a2f84748701167c2d622f041a1d7a17.png b/packages/token-lists/lists/images/0x7e396bfc8a2f84748701167c2d622f041a1d7a17.png new file mode 100644 index 000000000..7e93be986 Binary files /dev/null and b/packages/token-lists/lists/images/0x7e396bfc8a2f84748701167c2d622f041a1d7a17.png differ diff --git a/packages/token-lists/lists/images/0x8595f9da7b868b1822194faed312235e43007b49.png b/packages/token-lists/lists/images/0x8595f9da7b868b1822194faed312235e43007b49.png new file mode 100644 index 000000000..75fb510bb Binary files /dev/null and b/packages/token-lists/lists/images/0x8595f9da7b868b1822194faed312235e43007b49.png differ diff --git a/packages/token-lists/lists/images/0x85eac5ac2f758618dfa09bdbe0cf174e7d574d5b.png b/packages/token-lists/lists/images/0x85eac5ac2f758618dfa09bdbe0cf174e7d574d5b.png new file mode 100644 index 000000000..74ec9785f Binary files /dev/null and b/packages/token-lists/lists/images/0x85eac5ac2f758618dfa09bdbe0cf174e7d574d5b.png differ diff --git a/packages/token-lists/lists/images/0xaef0d72a118ce24fee3cd1d43d383897d05b4e99.png b/packages/token-lists/lists/images/0xaef0d72a118ce24fee3cd1d43d383897d05b4e99.png new file mode 100644 index 000000000..981c0e371 Binary files /dev/null and b/packages/token-lists/lists/images/0xaef0d72a118ce24fee3cd1d43d383897d05b4e99.png differ diff --git a/packages/token-lists/lists/images/0xc5a49b4cbe004b6fd55b30ba1de6ac360ff9765d.png b/packages/token-lists/lists/images/0xc5a49b4cbe004b6fd55b30ba1de6ac360ff9765d.png new file mode 100644 index 000000000..811ae4602 Binary files /dev/null and b/packages/token-lists/lists/images/0xc5a49b4cbe004b6fd55b30ba1de6ac360ff9765d.png differ diff --git a/packages/token-lists/lists/images/0xe550a593d09fbc8dcd557b5c88cea6946a8b404a.png b/packages/token-lists/lists/images/0xe550a593d09fbc8dcd557b5c88cea6946a8b404a.png new file mode 100644 index 000000000..727559b31 Binary files /dev/null and b/packages/token-lists/lists/images/0xe550a593d09fbc8dcd557b5c88cea6946a8b404a.png differ diff --git a/packages/token-lists/package.json b/packages/token-lists/package.json index 01e879c03..85d57684c 100644 --- a/packages/token-lists/package.json +++ b/packages/token-lists/package.json @@ -20,7 +20,8 @@ "generate:pcs-top-15": "yarn test && yarn build && node ./dist generate pancakeswap-top-15", "makelist:pcs-top-15": "yarn checksum:pcs-top-15 && yarn generate:pcs-top-15", "fetch:pcs-top-100": "yarn build && node ./dist fetch", - "test": "jest" + "test": "jest", + "ci-check": "yarn build && node ./dist ci-check" }, "dependencies": { "@ethersproject/address": "^5.1.0", diff --git a/packages/token-lists/src/buildList.ts b/packages/token-lists/src/buildList.ts index c0ddba983..151359e62 100644 --- a/packages/token-lists/src/buildList.ts +++ b/packages/token-lists/src/buildList.ts @@ -1,12 +1,27 @@ import fs from "fs"; import path from "path"; import { TokenList } from "@uniswap/token-lists"; -import { version } from "../package.json"; +import { version as pancakeswapDefaultVersion } from "../lists/pancakeswap-default.json"; +import { version as pancakeswapExtendedVersion } from "../lists/pancakeswap-extended.json"; +import { version as pancakeswapTop15Version } from "../lists/pancakeswap-top-15.json"; +import { version as pancakeswapTop100Version } from "../lists/pancakeswap-top-100.json"; import pancakeswapDefault from "./tokens/pancakeswap-default.json"; import pancakeswapExtended from "./tokens/pancakeswap-extended.json"; import pancakeswapTop100 from "./tokens/pancakeswap-top-100.json"; import pancakeswapTop15 from "./tokens/pancakeswap-top-15.json"; +export enum VersionBump { + "major" = "major", + "minor" = "minor", + "patch" = "patch", +} + +type Version = { + major: number; + minor: number; + patch: number; +}; + const lists = { "pancakeswap-default": { list: pancakeswapDefault, @@ -15,6 +30,7 @@ const lists = { logoURI: "https://assets.trustwalletapp.com/blockchains/smartchain/assets/0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82/logo.png", sort: false, + currentVersion: pancakeswapDefaultVersion, }, "pancakeswap-extended": { list: pancakeswapExtended, @@ -23,6 +39,7 @@ const lists = { logoURI: "https://assets.trustwalletapp.com/blockchains/smartchain/assets/0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82/logo.png", sort: true, + currentVersion: pancakeswapExtendedVersion, }, "pancakeswap-top-100": { list: pancakeswapTop100, @@ -31,6 +48,7 @@ const lists = { logoURI: "https://assets.trustwalletapp.com/blockchains/smartchain/assets/0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82/logo.png", sort: true, + currentVersion: pancakeswapTop100Version, }, "pancakeswap-top-15": { list: pancakeswapTop15, @@ -39,20 +57,30 @@ const lists = { logoURI: "https://assets.trustwalletapp.com/blockchains/smartchain/assets/0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82/logo.png", sort: true, + currentVersion: pancakeswapTop15Version, }, }; -export const buildList = (listName: string): TokenList => { - const [major, minor, patch] = version.split(".").map((versionNumber) => parseInt(versionNumber, 10)); - const { list, name, keywords, logoURI, sort } = lists[listName]; +const getNextVersion = (currentVersion: Version, versionBump?: VersionBump) => { + const { major, minor, patch } = currentVersion; + switch (versionBump) { + case VersionBump.major: + return { major: major + 1, minor, patch }; + case VersionBump.minor: + return { major, minor: minor + 1, patch }; + case VersionBump.patch: + default: + return { major, minor, patch: patch + 1 }; + } +}; + +export const buildList = (listName: string, versionBump?: VersionBump): TokenList => { + const { list, name, keywords, logoURI, sort, currentVersion } = lists[listName]; + const version = getNextVersion(currentVersion, versionBump); return { name, timestamp: new Date().toISOString(), - version: { - major, - minor, - patch, - }, + version, logoURI, keywords, // sort them by symbol for easy readability (not applied to default list) diff --git a/packages/token-lists/src/ci-check.ts b/packages/token-lists/src/ci-check.ts new file mode 100644 index 000000000..07559b496 --- /dev/null +++ b/packages/token-lists/src/ci-check.ts @@ -0,0 +1,62 @@ +import srcDefault from "./tokens/pancakeswap-default.json"; +import srcExtended from "./tokens/pancakeswap-extended.json"; +import srcTop100 from "./tokens/pancakeswap-top-100.json"; +import srcTop15 from "./tokens/pancakeswap-top-15.json"; +import defaultList from "../lists/pancakeswap-default.json"; +import extendedtList from "../lists/pancakeswap-extended.json"; +import top15List from "../lists/pancakeswap-top-15.json"; +import top100tList from "../lists/pancakeswap-top-100.json"; + +const lists = [ + { + name: "pancakeswap-default", + src: srcDefault, + actual: defaultList, + }, + { + name: "pancakeswap-extended", + src: srcExtended, + actual: extendedtList, + }, + { + name: "pancakeswap-top-15", + src: srcTop15, + actual: top15List, + }, + { + name: "pancakeswap-top-100", + src: srcTop100, + actual: top100tList, + }, +]; + +const compareLists = (listPair) => { + const { name, src, actual } = listPair; + if (src.length !== actual.tokens.length) { + throw Error( + `List ${name} seems to be not properly regenerated. Soure file has ${src.length} tokens but actual list has ${actual.tokens.length}. Did you forget to run yarn makelist?` + ); + } + src.sort((t1, t2) => (t1.address < t2.address ? -1 : 1)); + actual.tokens.sort((t1, t2) => (t1.address < t2.address ? -1 : 1)); + src.forEach((srcToken, index) => { + if (JSON.stringify(srcToken) !== JSON.stringify(actual.tokens[index])) { + throw Error( + `List ${name} seems to be not properly regenerated. Tokens from src/tokens directory don't match up with the final list. Did you forget to run yarn makelist?` + ); + } + }); +}; + +/** + * Check in CI that author properly updated token list + * i.e. not just changed token list in src/tokens but also regenerated lists with yarn makelist command. + * Github Action runs only on change in src/tokens directory. + */ +const ciCheck = (): void => { + lists.forEach((listPair) => { + compareLists(listPair); + }); +}; + +export default ciCheck; diff --git a/packages/token-lists/src/index.ts b/packages/token-lists/src/index.ts index 04942fa8e..e182c79a8 100644 --- a/packages/token-lists/src/index.ts +++ b/packages/token-lists/src/index.ts @@ -1,20 +1,25 @@ -import { buildList, saveList } from "./buildList"; +import { buildList, saveList, VersionBump } from "./buildList"; import checksumAddresses from "./checksum"; +import ciCheck from "./ci-check"; import topTokens from "./top-100"; const command = process.argv[2]; const listName = process.argv[3]; +const versionBump = process.argv[4]; switch (command) { case "checksum": checksumAddresses(listName); break; case "generate": - saveList(buildList(listName), listName); + saveList(buildList(listName, versionBump as VersionBump), listName); break; case "fetch": topTokens(); break; + case "ci-check": + ciCheck(); + break; default: console.info("Unknown command"); break; diff --git a/packages/token-lists/test/default.test.ts b/packages/token-lists/test/default.test.ts index b4adef98c..2375eda87 100644 --- a/packages/token-lists/test/default.test.ts +++ b/packages/token-lists/test/default.test.ts @@ -1,16 +1,48 @@ /* eslint-disable no-restricted-syntax */ import Ajv from "ajv"; +import fs from "fs"; +import path from "path"; import { getAddress } from "@ethersproject/address"; import { schema } from "@uniswap/token-lists"; -import packageJson from "../package.json"; -import { buildList } from "../src/buildList"; +import currentPancakeswapDefaultList from "../lists/pancakeswap-default.json"; +import currentPancakeswapExtendedtList from "../lists/pancakeswap-extended.json"; +import currentPancakeswapTop15List from "../lists/pancakeswap-top-15.json"; +import currentPancakeswapTop100tList from "../lists/pancakeswap-top-100.json"; +import { buildList, VersionBump } from "../src/buildList"; + +const currentLists = { + "pancakeswap-default": currentPancakeswapDefaultList, + "pancakeswap-extended": currentPancakeswapExtendedtList, + "pancakeswap-top-100": currentPancakeswapTop100tList, + "pancakeswap-top-15": currentPancakeswapTop15List, +}; + +const ajv = new Ajv({ allErrors: true, format: "full" }); +const validate = ajv.compile(schema); + +const pathToImages = process.env.CI + ? path.join(process.env.GITHUB_WORKSPACE, "packages", "token-lists", "lists", "images") + : path.join(path.resolve(), "lists", "images"); +const logoFiles = fs.readdirSync(pathToImages); + +// Modified https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get +const getByAjvPath = (obj, propertyPath: string, defaultValue = undefined) => { + const travel = (regexp) => + String.prototype.split + .call(propertyPath.substring(1), regexp) + .filter(Boolean) + .reduce((res, key) => (res !== null && res !== undefined ? res[key] : res), obj); + const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/); + return result === undefined || result === obj ? defaultValue : result; +}; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface Matchers { toBeDeclaredOnce(type: string, parameter: string, chainId: number): CustomMatcherResult; - toBeValid(validationErrors): CustomMatcherResult; + toBeValidTokenList(): CustomMatcherResult; + toBeValidLogo(): CustomMatcherResult; } } } @@ -28,69 +60,129 @@ expect.extend({ pass: false, }; }, - toBeValid(received, validationErrors) { - if (received) { + toBeValidTokenList(tokenList) { + const isValid = validate(tokenList); + if (isValid) { + return { + message: () => ``, + pass: true, + }; + } + const validationSummary = validate.errors + .map((error) => { + const value = getByAjvPath(tokenList, error.dataPath); + return `- ${error.dataPath.split(".").pop()} ${value} ${error.message}`; + }) + .join("\n"); + return { + message: () => `Validation failed:\n${validationSummary}`, + pass: false, + }; + }, + toBeValidLogo(token) { + // TW logos are always checksummed + const hasTWLogo = + token.logoURI === `https://assets.trustwalletapp.com/blockchains/smartchain/assets/${token.address}/logo.png`; + let hasLocalLogo = false; + const refersToLocalLogo = + token.logoURI === `https://tokens.pancakeswap.finance/images/${token.address}.png` || + token.logoURI === `https://tokens.pancakeswap.finance/images/${token.address.toLowerCase()}.png`; + if (token.logoURI === "https://tokens.pancakeswap.finance/images/0x4e6415a5727ea08aae4580057187923aec331227.png") { + console.log("refersToLocalLogo", refersToLocalLogo); + } + if (refersToLocalLogo) { + const fileName = token.logoURI.split("/").pop(); + // Note: fs.existsSync can't be used here because its not case sensetive + hasLocalLogo = logoFiles.includes(fileName); + } + if (hasTWLogo || hasLocalLogo) { return { message: () => ``, pass: true, }; } return { - message: () => `Validation failed: ${JSON.stringify(validationErrors, null, 2)}`, + message: () => `Token ${token.symbol} (${token.address}) has invalid logo: ${token.logoURI}`, pass: false, }; }, }); -const ajv = new Ajv({ allErrors: true, format: "full" }); -const validate = ajv.compile(schema); +describe.each([["pancakeswap-default"], ["pancakeswap-extended"], ["pancakeswap-top-100"], ["pancakeswap-top-15"]])( + "buildList %s", + (listName) => { + const defaultTokenList = buildList(listName); -describe.each([["pancakeswap-default"], ["pancakeswap-extended"], ["pancakeswap-top-100"], ["pancakeswap-top-15"]])("buildList %s", (listName) => { - const defaultTokenList = buildList(listName); + it("validates", () => { + expect(defaultTokenList).toBeValidTokenList(); + }); - it("validates", () => { - expect(validate(defaultTokenList)).toBeValid(validate.errors); - }); + it("contains no duplicate addresses", () => { + const map = {}; + for (const token of defaultTokenList.tokens) { + const key = `${token.chainId}-${token.address.toLowerCase()}`; + expect(map[key]).toBeDeclaredOnce("address", token.address.toLowerCase(), token.chainId); + map[key] = true; + } + }); - it("contains no duplicate addresses", () => { - const map = {}; - for (const token of defaultTokenList.tokens) { - const key = `${token.chainId}-${token.address.toLowerCase()}`; - expect(map[key]).toBeDeclaredOnce("address", token.address.toLowerCase(), token.chainId); - map[key] = true; - } - }); - - // Commented out since we now have duplicate symbols ("ONE") on exchange - // doesn't seem to affect any functionality at the moment though - // it("contains no duplicate symbols", () => { - // const map = {}; - // for (const token of defaultTokenList.tokens) { - // const key = `${token.chainId}-${token.symbol.toLowerCase()}`; - // expect(map[key]).toBeDeclaredOnce("symbol", token.symbol.toLowerCase(), token.chainId); - // map[key] = true; - // } - // }); - - it("contains no duplicate names", () => { - const map = {}; - for (const token of defaultTokenList.tokens) { - const key = `${token.chainId}-${token.name.toLowerCase()}`; - expect(map[key]).toBeDeclaredOnce("name", token.name.toLowerCase(), token.chainId); - map[key] = true; - } - }); + // Commented out since we now have duplicate symbols ("ONE") on exchange + // doesn't seem to affect any functionality at the moment though + // it("contains no duplicate symbols", () => { + // const map = {}; + // for (const token of defaultTokenList.tokens) { + // const key = `${token.chainId}-${token.symbol.toLowerCase()}`; + // expect(map[key]).toBeDeclaredOnce("symbol", token.symbol.toLowerCase(), token.chainId); + // map[key] = true; + // } + // }); - it("all addresses are valid and checksummed", () => { - for (const token of defaultTokenList.tokens) { - expect(getAddress(token.address)).toBe(token.address); - } - }); - - it("version matches package.json", () => { - expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/); - expect(packageJson.version).toBe( - `${defaultTokenList.version.major}.${defaultTokenList.version.minor}.${defaultTokenList.version.patch}` - ); - }); -}); + it("contains no duplicate names", () => { + const map = {}; + for (const token of defaultTokenList.tokens) { + const key = `${token.chainId}-${token.name.toLowerCase()}`; + expect(map[key]).toBeDeclaredOnce("name", token.name.toLowerCase(), token.chainId); + map[key] = true; + } + }); + + it("all addresses are valid and checksummed", () => { + for (const token of defaultTokenList.tokens) { + expect(getAddress(token.address)).toBe(token.address); + } + }); + + it("all tokens have correct logos", () => { + for (const token of defaultTokenList.tokens) { + expect(token).toBeValidLogo(); + } + }); + + it("version gets patch bump if no versionBump sepcified", () => { + expect(defaultTokenList.version.major).toBe(currentLists[listName].version.major); + expect(defaultTokenList.version.minor).toBe(currentLists[listName].version.minor); + expect(defaultTokenList.version.patch).toBe(currentLists[listName].version.patch + 1); + }); + + it("version gets patch bump if patch versionBump is sepcified", () => { + const defaultTokenListPatchBump = buildList(listName, VersionBump.patch); + expect(defaultTokenListPatchBump.version.major).toBe(currentLists[listName].version.major); + expect(defaultTokenListPatchBump.version.minor).toBe(currentLists[listName].version.minor); + expect(defaultTokenListPatchBump.version.patch).toBe(currentLists[listName].version.patch + 1); + }); + + it("version gets minor bump if minor versionBump is sepcified", () => { + const defaultTokenListMinorBump = buildList(listName, VersionBump.minor); + expect(defaultTokenListMinorBump.version.major).toBe(currentLists[listName].version.major); + expect(defaultTokenListMinorBump.version.minor).toBe(currentLists[listName].version.minor + 1); + expect(defaultTokenListMinorBump.version.patch).toBe(currentLists[listName].version.patch); + }); + + it("version gets minor bump if major versionBump is sepcified", () => { + const defaultTokenListMajorBump = buildList(listName, VersionBump.major); + expect(defaultTokenListMajorBump.version.major).toBe(currentLists[listName].version.major + 1); + expect(defaultTokenListMajorBump.version.minor).toBe(currentLists[listName].version.minor); + expect(defaultTokenListMajorBump.version.patch).toBe(currentLists[listName].version.patch); + }); + } +);