From 41731c64a737ec5b2ea217c3447f42f3fc1e401a Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 23 Aug 2024 15:49:31 -0300 Subject: [PATCH] feat: github metadata provider (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Closes ZKS-205 ## Description Implement GithubMetadataProvider --- apps/api/src/index.ts | 2 +- .../src/metrics/dto/response/metadata.dto.ts | 4 +- packages/metadata/package.json | 7 +- .../src/exceptions/fetchError.exception.ts | 6 + packages/metadata/src/exceptions/index.ts | 2 + .../src/exceptions/invalidSchema.exception.ts | 6 + packages/metadata/src/external.ts | 2 + packages/metadata/src/internal.ts | 1 + .../src/providers/githubMetadata.provider.ts | 68 ++++++- packages/metadata/src/schemas/index.ts | 28 +++ .../test/fixtures/metadata.fixtures.ts | 65 +++++++ .../providers/githubMetadata.provider.spec.ts | 178 ++++++++++++++++++ .../staticMetadata.provider.spec.ts} | 0 .../services/githubMetadata.service.spec.ts | 11 -- packages/metrics/src/l1/l1MetricsService.ts | 2 +- .../test/unit/l1/l1MetricsService.spec.ts | 2 +- packages/shared/src/internal.ts | 1 + packages/shared/src/types/zkchain.type.ts | 4 +- pnpm-lock.yaml | 10 + 19 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 packages/metadata/src/exceptions/fetchError.exception.ts create mode 100644 packages/metadata/src/exceptions/index.ts create mode 100644 packages/metadata/src/exceptions/invalidSchema.exception.ts create mode 100644 packages/metadata/src/schemas/index.ts create mode 100644 packages/metadata/test/fixtures/metadata.fixtures.ts create mode 100644 packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts rename packages/metadata/test/unit/{services/staticMetadata.service.spec.ts => providers/staticMetadata.provider.spec.ts} (100%) delete mode 100644 packages/metadata/test/unit/services/githubMetadata.service.spec.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 056ec4a..6b36210 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,7 @@ import { inspect } from "util"; import { caching } from "cache-manager"; -import { EvmProvider } from "@zkchainhub/chain-providers/dist/src/index.js"; +import { EvmProvider } from "@zkchainhub/chain-providers"; import { L1MetricsService } from "@zkchainhub/metrics"; import { CoingeckoProvider } from "@zkchainhub/pricing"; import { Logger } from "@zkchainhub/shared"; diff --git a/apps/api/src/metrics/dto/response/metadata.dto.ts b/apps/api/src/metrics/dto/response/metadata.dto.ts index 1975660..4ecc90f 100644 --- a/apps/api/src/metrics/dto/response/metadata.dto.ts +++ b/apps/api/src/metrics/dto/response/metadata.dto.ts @@ -7,7 +7,7 @@ export class ZkChainMetadata { * @type {string} * @memberof Metadata */ - iconUrl: string; + iconUrl?: string; /** * The name of the chain. @@ -28,7 +28,7 @@ export class ZkChainMetadata { * @type {string} * @memberof Metadata */ - explorerUrl: string; + explorerUrl?: string; /** * The launch date of the chain (timestamp). diff --git a/packages/metadata/package.json b/packages/metadata/package.json index f68c0dc..94d92ee 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -24,6 +24,11 @@ "test:cov": "vitest run --config vitest.config.ts --coverage" }, "dependencies": { - "@zkchainhub/shared": "workspace:*" + "@zkchainhub/shared": "workspace:*", + "axios": "1.7.4", + "zod": "3.23.8" + }, + "devDependencies": { + "axios-mock-adapter": "2.0.0" } } diff --git a/packages/metadata/src/exceptions/fetchError.exception.ts b/packages/metadata/src/exceptions/fetchError.exception.ts new file mode 100644 index 0000000..7a66576 --- /dev/null +++ b/packages/metadata/src/exceptions/fetchError.exception.ts @@ -0,0 +1,6 @@ +export class FetchError extends Error { + constructor(message: string) { + super(message); + this.name = "FetchError"; + } +} diff --git a/packages/metadata/src/exceptions/index.ts b/packages/metadata/src/exceptions/index.ts new file mode 100644 index 0000000..b630137 --- /dev/null +++ b/packages/metadata/src/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from "./invalidSchema.exception.js"; +export * from "./fetchError.exception.js"; diff --git a/packages/metadata/src/exceptions/invalidSchema.exception.ts b/packages/metadata/src/exceptions/invalidSchema.exception.ts new file mode 100644 index 0000000..617cc36 --- /dev/null +++ b/packages/metadata/src/exceptions/invalidSchema.exception.ts @@ -0,0 +1,6 @@ +export class InvalidSchema extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidSchema"; + } +} diff --git a/packages/metadata/src/external.ts b/packages/metadata/src/external.ts index 3c84672..5d2f0ff 100644 --- a/packages/metadata/src/external.ts +++ b/packages/metadata/src/external.ts @@ -1,3 +1,5 @@ export type { IMetadataProvider } from "./internal.js"; +export { InvalidSchema, FetchError } from "./internal.js"; + export { StaticMetadataProvider, GithubMetadataProvider } from "./internal.js"; diff --git a/packages/metadata/src/internal.ts b/packages/metadata/src/internal.ts index 2c8a6ce..245cb7b 100644 --- a/packages/metadata/src/internal.ts +++ b/packages/metadata/src/internal.ts @@ -1,2 +1,3 @@ export * from "./interfaces/index.js"; export * from "./providers/index.js"; +export * from "./exceptions/index.js"; diff --git a/packages/metadata/src/providers/githubMetadata.provider.ts b/packages/metadata/src/providers/githubMetadata.provider.ts index 427f04e..4c533e5 100644 --- a/packages/metadata/src/providers/githubMetadata.provider.ts +++ b/packages/metadata/src/providers/githubMetadata.provider.ts @@ -1,15 +1,73 @@ -import { Token, TokenType, ZKChainMetadata } from "@zkchainhub/shared"; +import axios, { AxiosInstance } from "axios"; +import { z } from "zod"; + +import { + ILogger, + Token, + TokenType, + ZKChainMetadata, + ZKChainMetadataItem, +} from "@zkchainhub/shared"; import { IMetadataProvider } from "../interfaces/index.js"; +import { FetchError, InvalidSchema } from "../internal.js"; +import { ChainSchema, TokenSchema } from "../schemas/index.js"; +/** + * Represents a provider for retrieving metadata from GitHub. + */ export class GithubMetadataProvider implements IMetadataProvider { + private readonly axios: AxiosInstance; + constructor( + private readonly tokenJsonUrl: string, + private readonly chainJsonUrl: string, + private readonly logger: ILogger, + ) { + this.axios = axios.create({ + headers: { + Accept: "application/json", + }, + }); + } + async getChainsMetadata(): Promise { - //TODO: Implement this method - throw new Error("Method not implemented."); + const { data } = await this.axios.get(this.chainJsonUrl).catch((e) => { + this.logger.error( + `Failed to fetch chains metadata from ${this.chainJsonUrl}: ${e.message}`, + ); + throw new FetchError(`Failed to fetch chains metadata: ${e.message}`); + }); + + const validatedData = z.array(ChainSchema).safeParse(data); + + if (!validatedData.success) { + this.logger.error(`Invalid ZKChain metadata: ${validatedData.error.errors}`); + throw new InvalidSchema("Invalid ZKChain metadata"); + } + + return validatedData.data.reduce((acc, chain) => { + const { chainId, ...rest } = chain; + const chainIdBn = BigInt(chainId); + acc.set(chainIdBn, { ...rest, chainId: chainIdBn }); + return acc; + }, new Map()); } async getTokensMetadata(): Promise[]> { - //TODO: Implement this method - throw new Error("Method not implemented."); + const { data } = await this.axios.get(this.tokenJsonUrl).catch((e) => { + this.logger.error( + `Failed to fetch chains metadata from ${this.chainJsonUrl}: ${e.message}`, + ); + throw new FetchError(`Failed to fetch chains metadata: ${e.message}`); + }); + + const validatedData = z.array(TokenSchema).safeParse(data); + + if (!validatedData.success) { + this.logger.error(`Invalid Token metadata: ${validatedData.error.errors}`); + throw new InvalidSchema("Invalid Token metadata"); + } + + return validatedData.data; } } diff --git a/packages/metadata/src/schemas/index.ts b/packages/metadata/src/schemas/index.ts new file mode 100644 index 0000000..940a288 --- /dev/null +++ b/packages/metadata/src/schemas/index.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { Address } from "@zkchainhub/shared"; + +export const TokenSchema = z.object({ + name: z.string(), + symbol: z.string(), + coingeckoId: z.string(), // FIXME: on pricing refactor, this should not be part of the token metadata + type: z.union([z.literal("erc20"), z.literal("native")]), + contractAddress: z + .custom
((val) => { + return typeof val === "string" && /^0x[a-fA-F0-9]{40}$/.test(val); + }, "Invalid Ethereum address") + .nullable(), + decimals: z.number(), + imageUrl: z.string().optional(), +}); + +export const ChainSchema = z.object({ + chainId: z.number().positive(), + name: z.string(), + iconUrl: z.string().url().optional(), + publicRpcs: z.array(z.string().url()).default([]), + explorerUrl: z.string().url().optional(), + launchDate: z.number().positive(), + chainType: z.union([z.literal("Rollup"), z.literal("Validium")]), + baseToken: TokenSchema, +}); diff --git a/packages/metadata/test/fixtures/metadata.fixtures.ts b/packages/metadata/test/fixtures/metadata.fixtures.ts new file mode 100644 index 0000000..2e5422e --- /dev/null +++ b/packages/metadata/test/fixtures/metadata.fixtures.ts @@ -0,0 +1,65 @@ +export const tokenJsonUrl = "https://example.com/tokens.json"; +export const chainJsonUrl = "https://example.com/chains.json"; +export const mockTokenData = [ + { + name: "Ethereum", + symbol: "ETH", + contractAddress: null, + coingeckoId: "ethereum", + type: "native", + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + decimals: 18, + }, + { + name: "Wrapped Ether", + symbol: "WETH", + contractAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + coingeckoId: "weth", + imageUrl: "https://coin-images.coingecko.com/coins/images/2518/large/weth.png?1696503332", + type: "erc20", + decimals: 18, + }, +]; +export const mockChainData = [ + { + chainId: 324, + name: "ZKsyncERA", + iconUrl: "https://s2.coinmarketcap.com/static/img/coins/64x64/24091.png", + publicRpcs: [ + "https://mainnet.era.zksync.io", + "https://zksync.drpc.org", + "https://zksync.meowrpc.com", + ], + explorerUrl: "https://explorer.zksync.io/", + launchDate: 1679626800, + chainType: "Rollup", + baseToken: { + name: "Ethereum", + symbol: "ETH", + contractAddress: null, + coingeckoId: "ethereum", + type: "native", + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + decimals: 18, + }, + }, + { + chainId: 388, + name: "Cronos", + chainType: "Validium", + publicRpcs: ["https://mainnet.zkevm.cronos.org"], + explorerUrl: "https://explorer.zkevm.cronos.org/", + baseToken: { + symbol: "zkCRO", + name: "zkCRO", + contractAddress: "0x28Ff2E4dD1B58efEB0fC138602A28D5aE81e44e2", + coingeckoId: "unknown", + type: "erc20", + imageUrl: "https://zkevm.cronos.org/images/chains/zkevm.svg", + decimals: 18, + }, + launchDate: 1679626800, + }, +]; diff --git a/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts b/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts new file mode 100644 index 0000000..725b07b --- /dev/null +++ b/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts @@ -0,0 +1,178 @@ +import MockAdapter from "axios-mock-adapter"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ILogger, ZKChainMetadataItem } from "@zkchainhub/shared"; + +import { FetchError, InvalidSchema } from "../../../src/internal"; +import { GithubMetadataProvider } from "../../../src/providers/githubMetadata.provider"; +import { + chainJsonUrl, + mockChainData, + mockTokenData, + tokenJsonUrl, +} from "../../fixtures/metadata.fixtures"; + +const mockLogger: ILogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +describe("GithubMetadataProvider", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("getChainsMetadata", () => { + it("should return the chains metadata", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = provider["axios"]; + const axiosGetMock = vi + .spyOn(axios, "get") + .mockResolvedValueOnce({ data: mockChainData }); + + const result = await provider.getChainsMetadata(); + + expect(axiosGetMock).toHaveBeenCalledWith(chainJsonUrl); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + + const chain1 = result.get(324n) as ZKChainMetadataItem; + expect(chain1).toBeDefined(); + expect(chain1).toMatchObject({ + chainId: 324n, + name: "ZKsyncERA", + iconUrl: "https://s2.coinmarketcap.com/static/img/coins/64x64/24091.png", + publicRpcs: [ + "https://mainnet.era.zksync.io", + "https://zksync.drpc.org", + "https://zksync.meowrpc.com", + ], + explorerUrl: "https://explorer.zksync.io/", + launchDate: 1679626800, + chainType: "Rollup", + baseToken: { + name: "Ethereum", + symbol: "ETH", + coingeckoId: "ethereum", + type: "native", + contractAddress: null, + decimals: 18, + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + }, + }); + + const chain2 = result.get(388n) as ZKChainMetadataItem; + expect(chain2).toBeDefined(); + expect(chain2).toMatchObject({ + chainId: 388n, + name: "Cronos", + chainType: "Validium", + publicRpcs: ["https://mainnet.zkevm.cronos.org"], + explorerUrl: "https://explorer.zkevm.cronos.org/", + launchDate: 1679626800, + baseToken: { + name: "zkCRO", + symbol: "zkCRO", + coingeckoId: "unknown", + type: "erc20", + contractAddress: "0x28Ff2E4dD1B58efEB0fC138602A28D5aE81e44e2", + decimals: 18, + imageUrl: "https://zkevm.cronos.org/images/chains/zkevm.svg", + }, + }); + }); + + it("should throw an error if the schema is invalid", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = provider["axios"]; + + const axiosGetMock = vi + .spyOn(axios, "get") + .mockResolvedValueOnce({ data: [{ invalid: "data" }] }); + + await expect(provider.getChainsMetadata()).rejects.toThrow(InvalidSchema); + expect(axiosGetMock).toHaveBeenCalledWith(chainJsonUrl); + }); + + it("should throw an error if the fetch fails with 404 error", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = new MockAdapter(provider["axios"]); + + axios.onGet().replyOnce(404, { + data: {}, + status: 404, + statusText: "Not found", + }); + + await expect(provider.getChainsMetadata()).rejects.toThrow(FetchError); + }); + + it("should throw an error if the fetch fails with 500 error", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = new MockAdapter(provider["axios"]); + + axios.onGet().replyOnce(500, { + data: {}, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(provider.getChainsMetadata()).rejects.toThrow(FetchError); + }); + }); + + describe("getTokensMetadata", () => { + it("should return the tokens metadata", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = provider["axios"]; + const axiosGetMock = vi + .spyOn(axios, "get") + .mockResolvedValueOnce({ data: mockTokenData }); + + const result = await provider.getTokensMetadata(); + + expect(axiosGetMock).toHaveBeenCalledWith(tokenJsonUrl); + expect(result).toEqual(mockTokenData); + }); + + it("should throw an error if the schema is invalid", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = provider["axios"]; + const axiosGetMock = vi + .spyOn(axios, "get") + .mockResolvedValueOnce({ data: [{ invalid: "data" }] }); + + await expect(provider.getTokensMetadata()).rejects.toThrow(InvalidSchema); + expect(axiosGetMock).toHaveBeenCalledWith(tokenJsonUrl); + }); + + it("should throw an error if the fetch fails with 404 error", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = new MockAdapter(provider["axios"]); + + axios.onGet().replyOnce(404, { + data: {}, + status: 404, + statusText: "Not found", + }); + + await expect(provider.getTokensMetadata()).rejects.toThrow(FetchError); + }); + + it("should throw an error if the fetch fails with 500 error", async () => { + const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const axios = new MockAdapter(provider["axios"]); + + axios.onGet().replyOnce(500, { + data: {}, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(provider.getTokensMetadata()).rejects.toThrow(FetchError); + }); + }); +}); diff --git a/packages/metadata/test/unit/services/staticMetadata.service.spec.ts b/packages/metadata/test/unit/providers/staticMetadata.provider.spec.ts similarity index 100% rename from packages/metadata/test/unit/services/staticMetadata.service.spec.ts rename to packages/metadata/test/unit/providers/staticMetadata.provider.spec.ts diff --git a/packages/metadata/test/unit/services/githubMetadata.service.spec.ts b/packages/metadata/test/unit/services/githubMetadata.service.spec.ts deleted file mode 100644 index 9a117c3..0000000 --- a/packages/metadata/test/unit/services/githubMetadata.service.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it } from "vitest"; - -describe("GithubMetadataService", () => { - describe("getChainsMetadata", () => { - it.skip("returns the ZKChainMetadata"); - }); - - describe("getTokensMetadata", () => { - it.skip("return an array of Token objects"); - }); -}); diff --git a/packages/metrics/src/l1/l1MetricsService.ts b/packages/metrics/src/l1/l1MetricsService.ts index 5e3bfcb..27afdd0 100644 --- a/packages/metrics/src/l1/l1MetricsService.ts +++ b/packages/metrics/src/l1/l1MetricsService.ts @@ -11,7 +11,7 @@ import { zeroAddress, } from "viem"; -import { EvmProvider } from "@zkchainhub/chain-providers/dist/src/index.js"; +import { EvmProvider } from "@zkchainhub/chain-providers"; import { IPricingProvider } from "@zkchainhub/pricing"; import { BatchesInfo, diff --git a/packages/metrics/test/unit/l1/l1MetricsService.spec.ts b/packages/metrics/test/unit/l1/l1MetricsService.spec.ts index 72be3fa..6780aab 100644 --- a/packages/metrics/test/unit/l1/l1MetricsService.spec.ts +++ b/packages/metrics/test/unit/l1/l1MetricsService.spec.ts @@ -1,7 +1,7 @@ import { Address, encodeFunctionData, erc20Abi, parseEther, zeroAddress } from "viem"; import { afterEach, describe, expect, it, Mocked, vi } from "vitest"; -import { EvmProvider, MulticallNotFound } from "@zkchainhub/chain-providers/dist/src/index.js"; +import { EvmProvider, MulticallNotFound } from "@zkchainhub/chain-providers"; import { IPricingProvider } from "@zkchainhub/pricing"; import { BatchesInfo, diff --git a/packages/shared/src/internal.ts b/packages/shared/src/internal.ts index 2a24219..8c12429 100644 --- a/packages/shared/src/internal.ts +++ b/packages/shared/src/internal.ts @@ -4,3 +4,4 @@ export * from "./utils/index.js"; export * from "./metadata/index.js"; export * from "./logger/index.js"; export * from "./cache/index.js"; +export type { Address } from "abitype"; diff --git a/packages/shared/src/types/zkchain.type.ts b/packages/shared/src/types/zkchain.type.ts index 192a140..0e1d3b8 100644 --- a/packages/shared/src/types/zkchain.type.ts +++ b/packages/shared/src/types/zkchain.type.ts @@ -3,11 +3,11 @@ import { ChainId, ChainType, Token } from "./index.js"; export type ZKChainMetadataItem = { chainId: ChainId; name: string; - iconUrl: string; + iconUrl?: string; chainType: ChainType; baseToken: Token<"erc20" | "native">; publicRpcs: string[]; - explorerUrl: string; + explorerUrl?: string; launchDate: number; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13eec1e..02ef3ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,16 @@ importers: "@zkchainhub/shared": specifier: workspace:* version: link:../shared + axios: + specifier: 1.7.4 + version: 1.7.4 + zod: + specifier: 3.23.8 + version: 3.23.8 + devDependencies: + axios-mock-adapter: + specifier: 2.0.0 + version: 2.0.0(axios@1.7.4) packages/metrics: dependencies: