From 042cea150f2798a4d3fec2147fd84695f416d2f3 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Thu, 29 Aug 2024 16:30:43 -0300 Subject: [PATCH] feat: pricing provider factory --- apps/api/src/common/config/index.ts | 26 ++++-- apps/api/src/common/config/schemas.ts | 35 ++++++-- apps/api/src/index.ts | 14 ++-- packages/pricing/src/external.ts | 11 ++- packages/pricing/src/factory/index.ts | 45 ++++++++++ packages/pricing/src/interfaces/index.ts | 1 + .../src/interfaces/pricing.interface.ts | 2 +- .../src/interfaces/pricingConfig.interface.ts | 19 +++++ packages/pricing/src/internal.ts | 1 + .../test/unit/factory/pricingFactory.spec.ts | 83 +++++++++++++++++++ 10 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 packages/pricing/src/factory/index.ts create mode 100644 packages/pricing/src/interfaces/pricingConfig.interface.ts create mode 100644 packages/pricing/test/unit/factory/pricingFactory.spec.ts diff --git a/apps/api/src/common/config/index.ts b/apps/api/src/common/config/index.ts index c03ea76..a836e90 100644 --- a/apps/api/src/common/config/index.ts +++ b/apps/api/src/common/config/index.ts @@ -3,6 +3,7 @@ import { Address } from "viem"; import { mainnet, zksync } from "viem/chains"; import { MetadataConfig } from "@zkchainhub/metadata"; +import { PricingConfig } from "@zkchainhub/pricing"; import { Logger } from "@zkchainhub/shared"; import { validationSchema } from "./schemas.js"; @@ -41,8 +42,27 @@ const createMetadataConfig = ( } }; +const createPricingConfig = (env: typeof envData): PricingConfig => { + switch (env.PRICING_SOURCE) { + case "dummy": + return { + source: "dummy", + dummyPrice: env.DUMMY_PRICE, + }; + case "coingecko": + return { + source: "coingecko", + apiKey: env.COINGECKO_API_KEY, + apiBaseUrl: env.COINGECKO_BASE_URL, + apiType: env.COINGECKO_API_TYPE, + }; + } +}; + export const config = { port: envData.PORT, + environment: envData.ENVIRONMENT, + l1: { rpcUrls: envData.L1_RPC_URLS, chain: mainnet, @@ -61,11 +81,7 @@ export const config = { cacheOptions: { ttl: envData.CACHE_TTL, }, - pricingOptions: { - apiKey: envData.COINGECKO_API_KEY, - apiBaseUrl: envData.COINGECKO_BASE_URL, - apiType: envData.COINGECKO_API_TYPE, - }, + ...createPricingConfig(envData), }, metadata: createMetadataConfig(envData), } as const; diff --git a/apps/api/src/common/config/schemas.ts b/apps/api/src/common/config/schemas.ts index 8feb7f7..6d036b3 100644 --- a/apps/api/src/common/config/schemas.ts +++ b/apps/api/src/common/config/schemas.ts @@ -23,6 +23,7 @@ const baseSchema = z.object({ BRIDGE_HUB_ADDRESS: addressSchema, SHARED_BRIDGE_ADDRESS: addressSchema, STATE_MANAGER_ADDRESSES: addressArraySchema, + ENVIRONMENT: z.enum(["mainnet", "testnet", "local"]).default("mainnet"), L1_RPC_URLS: urlArraySchema, L2_RPC_URLS: z .union([z.literal(""), urlArraySchema]) @@ -31,7 +32,9 @@ const baseSchema = z.object({ if (val === undefined || val === "") return []; return val; }), - COINGECKO_API_KEY: z.string(), + PRICING_SOURCE: z.enum(["dummy", "coingecko"]).default("dummy"), + DUMMY_PRICE: z.coerce.number().optional(), + COINGECKO_API_KEY: z.string().optional(), COINGECKO_BASE_URL: z.string().url().default("https://api.coingecko.com/api/v3/"), COINGECKO_API_TYPE: z.enum(["demo", "pro"]).default("demo"), CACHE_TTL: z.coerce.number().positive().default(60), @@ -75,8 +78,28 @@ const staticSchema = baseSchema METADATA_CHAIN_JSON_PATH: true, }); -export const validationSchema = z.discriminatedUnion("METADATA_SOURCE", [ - githubSchema, - localSchema, - staticSchema, -]); +const dummyPricingSchema = baseSchema + .extend({ + PRICING_SOURCE: z.literal("dummy"), + DUMMY_PRICE: z.coerce.number().optional(), + }) + .omit({ + COINGECKO_API_KEY: true, + COINGECKO_BASE_URL: true, + COINGECKO_API_TYPE: true, + }); + +const coingeckoPricingSchema = baseSchema + .extend({ + PRICING_SOURCE: z.literal("coingecko"), + COINGECKO_API_KEY: z.string(), + COINGECKO_BASE_URL: z.string().url().default("https://api.coingecko.com/api/v3/"), + COINGECKO_API_TYPE: z.enum(["demo", "pro"]).default("demo"), + }) + .omit({ + DUMMY_PRICE: true, + }); + +export const validationSchema = z + .discriminatedUnion("METADATA_SOURCE", [githubSchema, localSchema, staticSchema]) + .and(z.discriminatedUnion("PRICING_SOURCE", [dummyPricingSchema, coingeckoPricingSchema])); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4361684..c533c95 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,7 +4,7 @@ import { caching } from "cache-manager"; import { EvmProvider } from "@zkchainhub/chain-providers"; import { MetadataProviderFactory } from "@zkchainhub/metadata"; import { L1MetricsService } from "@zkchainhub/metrics"; -import { CoingeckoProvider } from "@zkchainhub/pricing"; +import { PricingProviderFactory } from "@zkchainhub/pricing"; import { Logger } from "@zkchainhub/shared"; import { App } from "./app.js"; @@ -20,15 +20,11 @@ const main = async (): Promise => { }); const evmProvider = new EvmProvider(config.l1.rpcUrls, config.l1.chain, logger); - const pricingProvider = new CoingeckoProvider( - { - apiBaseUrl: config.pricing.pricingOptions.apiBaseUrl, - apiKey: config.pricing.pricingOptions.apiKey, - apiType: config.pricing.pricingOptions.apiType, - }, - memoryCache, + + const pricingProvider = PricingProviderFactory.create(config.pricing, { + cache: memoryCache, logger, - ); + }); const metadataProvider = MetadataProviderFactory.create(config.metadata, { logger, diff --git a/packages/pricing/src/external.ts b/packages/pricing/src/external.ts index 7e4e301..39a7a21 100644 --- a/packages/pricing/src/external.ts +++ b/packages/pricing/src/external.ts @@ -1,5 +1,14 @@ -export type { IPricingProvider, PriceResponse } from "./internal.js"; +export type { + IPricingProvider, + PriceResponse, + PricingConfig, + PricingProvider, + DummyPricingConfig, + CoingeckoPricingConfig, +} from "./internal.js"; export { RateLimitExceeded, ApiNotAvailable } from "./internal.js"; export { CoingeckoProvider, DummyPricingProvider } from "./internal.js"; + +export { PricingProviderFactory } from "./internal.js"; diff --git a/packages/pricing/src/factory/index.ts b/packages/pricing/src/factory/index.ts new file mode 100644 index 0000000..b403c3e --- /dev/null +++ b/packages/pricing/src/factory/index.ts @@ -0,0 +1,45 @@ +import { Cache, ILogger } from "@zkchainhub/shared"; + +import { + CoingeckoProvider, + DummyPricingProvider, + IPricingProvider, + PricingConfig, + PricingProvider, +} from "../internal.js"; + +export class PricingProviderFactory { + static create( + options: PricingConfig, + deps?: { + logger?: ILogger; + cache?: Cache; + }, + ): IPricingProvider { + let pricingProvider: IPricingProvider; + + switch (options.source) { + case "dummy": + pricingProvider = new DummyPricingProvider(options.dummyPrice); + break; + case "coingecko": + if (!deps?.cache || !deps?.logger) { + throw new Error("Missing dependencies"); + } + pricingProvider = new CoingeckoProvider( + { + apiBaseUrl: options.apiBaseUrl, + apiKey: options.apiKey, + apiType: options.apiType, + }, + deps.cache, + deps.logger, + ); + break; + default: + throw new Error("Invalid pricing source"); + } + + return pricingProvider; + } +} diff --git a/packages/pricing/src/interfaces/index.ts b/packages/pricing/src/interfaces/index.ts index 13271cd..6cd31b7 100644 --- a/packages/pricing/src/interfaces/index.ts +++ b/packages/pricing/src/interfaces/index.ts @@ -1 +1,2 @@ export * from "./pricing.interface.js"; +export * from "./pricingConfig.interface.js"; diff --git a/packages/pricing/src/interfaces/pricing.interface.ts b/packages/pricing/src/interfaces/pricing.interface.ts index 54a6149..e46c15e 100644 --- a/packages/pricing/src/interfaces/pricing.interface.ts +++ b/packages/pricing/src/interfaces/pricing.interface.ts @@ -3,7 +3,7 @@ import { Address } from "@zkchainhub/shared"; import { PriceResponse } from "../internal.js"; // providers -export type PricingProvider = "coingecko"; +export type PricingProvider = "coingecko" | "dummy"; /** * Represents a pricing service that retrieves token prices. diff --git a/packages/pricing/src/interfaces/pricingConfig.interface.ts b/packages/pricing/src/interfaces/pricingConfig.interface.ts new file mode 100644 index 0000000..9113188 --- /dev/null +++ b/packages/pricing/src/interfaces/pricingConfig.interface.ts @@ -0,0 +1,19 @@ +import { PricingProvider } from "./pricing.interface.js"; + +export interface DummyPricingConfig { + source: "dummy"; + dummyPrice?: number; +} + +export interface CoingeckoPricingConfig { + source: "coingecko"; + apiKey: string; + apiBaseUrl: string; + apiType: "demo" | "pro"; +} + +export type PricingConfig = Source extends "dummy" + ? DummyPricingConfig + : Source extends "coingecko" + ? CoingeckoPricingConfig + : never; diff --git a/packages/pricing/src/internal.ts b/packages/pricing/src/internal.ts index ec8c916..ea82e2c 100644 --- a/packages/pricing/src/internal.ts +++ b/packages/pricing/src/internal.ts @@ -3,3 +3,4 @@ export * from "./interfaces/index.js"; export * from "./exceptions/index.js"; export * from "./mappings/index.js"; export * from "./providers/index.js"; +export * from "./factory/index.js"; diff --git a/packages/pricing/test/unit/factory/pricingFactory.spec.ts b/packages/pricing/test/unit/factory/pricingFactory.spec.ts new file mode 100644 index 0000000..3228723 --- /dev/null +++ b/packages/pricing/test/unit/factory/pricingFactory.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + CoingeckoProvider, + DummyPricingProvider, + PricingConfig, + PricingProviderFactory, +} from "../../../src/internal.js"; + +describe("PricingProviderFactory", () => { + it("create a DummyPricingProvider", () => { + const options: PricingConfig<"dummy"> = { + source: "dummy", + dummyPrice: 1, + }; + + const pricingProvider = PricingProviderFactory.create(options); + + expect(pricingProvider).toBeInstanceOf(DummyPricingProvider); + expect(pricingProvider["dummyPrice"]).toBe(1); + }); + + it("create a CoingeckoProvider", () => { + const options: PricingConfig<"coingecko"> = { + source: "coingecko", + apiKey: "some-api-key", + apiBaseUrl: "some-base-url", + apiType: "demo", + }; + + const pricingProvider = PricingProviderFactory.create(options, { + logger: {} as any, + cache: {} as any, + }); + + expect(pricingProvider).toBeInstanceOf(CoingeckoProvider); + expect(pricingProvider["options"]).toEqual({ + apiKey: "some-api-key", + apiBaseUrl: "some-base-url", + apiType: "demo", + }); + }); + + it("throws if cache instance is not provided for CoingeckoProvider", () => { + const options: PricingConfig<"coingecko"> = { + source: "coingecko", + apiKey: "some-api-key", + apiBaseUrl: "some-base-url", + apiType: "demo", + }; + + expect(() => + PricingProviderFactory.create(options, { + logger: {} as any, + }), + ).toThrowError("Missing dependencies"); + }); + + it("throws if logger instance is not provided for CoingeckoProvider", () => { + const options: PricingConfig<"coingecko"> = { + source: "coingecko", + apiKey: "some-api-key", + apiBaseUrl: "some-base-url", + apiType: "demo", + }; + + expect(() => + PricingProviderFactory.create(options, { + cache: {} as any, + }), + ).toThrowError("Missing dependencies"); + }); + + it("should throw an error for invalid pricing source", () => { + const options = { + source: "invalid", + }; + + expect(() => { + PricingProviderFactory.create(options as any); + }).toThrowError("Invalid pricing source"); + }); +});