From bc906a362c7d222bc4c38f125e2760d5e692157d Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:25:10 -0300 Subject: [PATCH 1/2] feat: add coinpaprika pricing provider --- apps/processing/.env.example | 9 +- apps/processing/src/config/env.ts | 25 +- packages/pricing/src/external.ts | 8 +- packages/pricing/src/factory/index.ts | 14 + .../src/interfaces/pricing.interface.ts | 2 +- .../src/interfaces/pricingConfig.interface.ts | 10 +- .../src/providers/coinpaprika.provider.ts | 450 ++++++++++++++++++ packages/pricing/src/providers/index.ts | 1 + .../pricing/src/types/coinpaprika.types.ts | 17 + packages/pricing/src/types/index.ts | 1 + packages/pricing/src/types/pricing.types.ts | 4 +- scripts/bootstrap/.env.example | 6 +- scripts/bootstrap/src/schemas/index.ts | 25 +- 13 files changed, 560 insertions(+), 12 deletions(-) create mode 100644 packages/pricing/src/providers/coinpaprika.provider.ts create mode 100644 packages/pricing/src/types/coinpaprika.types.ts diff --git a/apps/processing/.env.example b/apps/processing/.env.example index f29450b4..5ad62338 100644 --- a/apps/processing/.env.example +++ b/apps/processing/.env.example @@ -10,10 +10,13 @@ INDEXER_ADMIN_SECRET=testing IPFS_GATEWAYS_URL=["https://ipfs.io","https://gateway.pinata.cloud","https://dweb.link", "https://ipfs.eth.aragon.network"] -PRICING_SOURCE= # 'coingecko' or 'dummy' +PRICING_SOURCE= # 'coingecko' or 'dummy' or 'coinpaprika' -COINGECKO_API_KEY={{YOUR_KEY}} -COINGECKO_API_TYPE=demo +COINGECKO_API_KEY={{YOUR_KEY}} # empty string for demo tier +COINGECKO_API_TYPE=demo # 'demo' or 'pro' + +COINPAPRIKA_API_KEY={{YOUR_KEY}} # empty string for free tier +COINPAPRIKA_API_TYPE=free # 'free' or 'starter' or 'pro' or 'business' or 'enterprise' RETRY_MAX_ATTEMPTS=3 RETRY_BASE_DELAY_MS=3000 diff --git a/apps/processing/src/config/env.ts b/apps/processing/src/config/env.ts index 4a56053d..836681aa 100644 --- a/apps/processing/src/config/env.ts +++ b/apps/processing/src/config/env.ts @@ -33,7 +33,7 @@ const baseSchema = z.object({ DATABASE_SCHEMA: z.string().default("public"), INDEXER_GRAPHQL_URL: z.string().url(), INDEXER_ADMIN_SECRET: z.string().optional(), - PRICING_SOURCE: z.enum(["dummy", "coingecko"]).default("coingecko"), + PRICING_SOURCE: z.enum(["dummy", "coingecko", "coinpaprika"]).default("coingecko"), METADATA_SOURCE: z.enum(["dummy", "public-gateway"]).default("dummy"), RETRY_MAX_ATTEMPTS: z.coerce.number().int().min(1).default(3), RETRY_BASE_DELAY_MS: z.coerce.number().int().min(1).default(3000), // 3 seconds @@ -55,6 +55,14 @@ const coingeckoPricingSchema = baseSchema.extend({ COINGECKO_API_TYPE: z.enum(["demo", "pro"]).default("pro"), }); +const coinpaprikaPricingSchema = baseSchema.extend({ + PRICING_SOURCE: z.literal("coinpaprika"), + COINPAPRIKA_API_KEY: z.string().default(""), + COINPAPRIKA_API_TYPE: z + .enum(["free", "starter", "pro", "business", "enterprise"]) + .default("free"), +}); + const dummyMetadataSchema = baseSchema.extend({ METADATA_SOURCE: z.literal("dummy"), }); @@ -65,12 +73,25 @@ const publicGatewayMetadataSchema = baseSchema.extend({ }); const validationSchema = z - .discriminatedUnion("PRICING_SOURCE", [dummyPricingSchema, coingeckoPricingSchema]) + .discriminatedUnion("PRICING_SOURCE", [ + dummyPricingSchema, + coingeckoPricingSchema, + coinpaprikaPricingSchema, + ]) .transform((val) => { if (val.PRICING_SOURCE === "dummy") { return { pricingSource: val.PRICING_SOURCE, dummyPrice: val.DUMMY_PRICE, ...val }; } + if (val.PRICING_SOURCE === "coinpaprika") { + return { + pricingSource: val.PRICING_SOURCE, + apiKey: val.COINPAPRIKA_API_KEY, + apiType: val.COINPAPRIKA_API_TYPE, + ...val, + }; + } + return { pricingSource: val.PRICING_SOURCE, apiKey: val.COINGECKO_API_KEY, diff --git a/packages/pricing/src/external.ts b/packages/pricing/src/external.ts index f1ee9394..3865ab5b 100644 --- a/packages/pricing/src/external.ts +++ b/packages/pricing/src/external.ts @@ -1,6 +1,11 @@ export type { TokenPrice, IPricingProvider } from "./internal.js"; -export { CoingeckoProvider, DummyPricingProvider, CachingPricingProvider } from "./internal.js"; +export { + CoingeckoProvider, + DummyPricingProvider, + CachingPricingProvider, + CoinPaprikaProvider, +} from "./internal.js"; export { PricingProviderFactory } from "./internal.js"; export type { @@ -8,6 +13,7 @@ export type { PricingProvider, DummyPricingConfig, CoingeckoPricingConfig, + CoinPaprikaPricingConfig, } from "./internal.js"; export { diff --git a/packages/pricing/src/factory/index.ts b/packages/pricing/src/factory/index.ts index 7050d95f..ce556178 100644 --- a/packages/pricing/src/factory/index.ts +++ b/packages/pricing/src/factory/index.ts @@ -2,6 +2,7 @@ import { ILogger } from "@grants-stack-indexer/shared"; import { CoingeckoProvider, + CoinPaprikaProvider, DummyPricingProvider, InvalidPricingSource, IPricingProvider, @@ -47,6 +48,19 @@ export class PricingProviderFactory { deps.logger, ); break; + case "coinpaprika": + if (!deps?.logger) { + throw new MissingDependencies(); + } + + pricingProvider = new CoinPaprikaProvider( + { + apiKey: options.apiKey, + apiType: options.apiType, + }, + deps.logger, + ); + break; default: throw new InvalidPricingSource(); } diff --git a/packages/pricing/src/interfaces/pricing.interface.ts b/packages/pricing/src/interfaces/pricing.interface.ts index 92537e2d..f0960d69 100644 --- a/packages/pricing/src/interfaces/pricing.interface.ts +++ b/packages/pricing/src/interfaces/pricing.interface.ts @@ -3,7 +3,7 @@ import { TimestampMs, TokenCode } from "@grants-stack-indexer/shared"; import { TokenPrice } from "../internal.js"; // available pricing providers -export type PricingProvider = "coingecko" | "dummy"; +export type PricingProvider = "coingecko" | "dummy" | "coinpaprika"; /** * 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 index ce9c6082..bcb00c5b 100644 --- a/packages/pricing/src/interfaces/pricingConfig.interface.ts +++ b/packages/pricing/src/interfaces/pricingConfig.interface.ts @@ -11,8 +11,16 @@ export type CoingeckoPricingConfig = { apiType: "demo" | "pro"; }; +export type CoinPaprikaPricingConfig = { + pricingSource: "coinpaprika"; + apiKey: string; + apiType: "free" | "starter" | "pro" | "business" | "enterprise"; +}; + export type PricingConfig = Source extends "dummy" ? DummyPricingConfig : Source extends "coingecko" ? CoingeckoPricingConfig - : never; + : Source extends "coinpaprika" + ? CoinPaprikaPricingConfig + : never; diff --git a/packages/pricing/src/providers/coinpaprika.provider.ts b/packages/pricing/src/providers/coinpaprika.provider.ts new file mode 100644 index 00000000..4c8b326d --- /dev/null +++ b/packages/pricing/src/providers/coinpaprika.provider.ts @@ -0,0 +1,450 @@ +import axios, { AxiosError, AxiosInstance, isAxiosError } from "axios"; + +import { + ILogger, + NetworkError, + NonRetriableError, + RateLimitError, + stringify, + TimestampMs, + TokenCode, +} from "@grants-stack-indexer/shared"; + +import type { + CoinPaprikaErrorResponse, + CoinPaprikaHistoricalResponse, + CoinPaprikaTokenId, + IPricingProvider, + TokenPrice, +} from "../internal.js"; +import { + NoClosePriceFound, + UnknownPricingException, + UnsupportedToken, +} from "../exceptions/index.js"; + +// CoinMarketCap API configuration +type CoinPaprikaOptions = { + apiKey: string; + apiType: "free" | "starter" | "pro" | "business" | "enterprise"; +}; + +const TokenMapping: { [key: string]: CoinPaprikaTokenId | undefined } = { + USDC: "usdc-usdc" as CoinPaprikaTokenId, + DAI: "dai-dai" as CoinPaprikaTokenId, + ETH: "eth-ethereum" as CoinPaprikaTokenId, + eBTC: undefined, + USDGLO: "usdglo-glo-dollar" as CoinPaprikaTokenId, + // GIST: undefined, + OP: "op-optimism" as CoinPaprikaTokenId, + LYX: "lyx-lukso" as CoinPaprikaTokenId, + WLYX: undefined, + XDAI: "xdai-xdai" as CoinPaprikaTokenId, + MATIC: "pol-polygon-ecosystem-token" as CoinPaprikaTokenId, + DATA: "data-streamr-datacoin" as CoinPaprikaTokenId, + FTM: "ftm-fantom" as CoinPaprikaTokenId, // this is Sonic because of migration + // GcV: undefined, + USDT: "usdt-tether" as CoinPaprikaTokenId, + LUSD: "lusd-liquity-usd" as CoinPaprikaTokenId, + MUTE: undefined, + GTC: "gtc-gitcoin" as CoinPaprikaTokenId, + METIS: "metis-metis-token" as CoinPaprikaTokenId, + SEI: "sei-sei" as CoinPaprikaTokenId, + ARB: "arb-arbitrum" as CoinPaprikaTokenId, + CELO: "celo-celo" as CoinPaprikaTokenId, + CUSD: "cusd-celo-dollar" as CoinPaprikaTokenId, + AVAX: "avax-avalanche" as CoinPaprikaTokenId, + // MTK: undefined, + WSEI: undefined, + HBAR: "hbar-hedera-hashgraph" as CoinPaprikaTokenId, +}; + +// Time delta for getTokenPrice +const TIME_DELTA = 2 * 60 * 60 * 1000; // 2 hours +const MAX_LIMIT = 5000; + +// Tier limitations in days +type TierLimits = { + daily: number; // in days + hourly: number; // in days + minutes: number; // in days +}; + +// Maps API tiers to their respective time limits (in days) +const TIER_LIMITS: Record = { + free: { + daily: 365, // 1 year + hourly: 1, // 1 day + minutes: 0, // none + }, + starter: { + daily: 365 * 5, // 5 years + hourly: 30, // 30 days + minutes: 7, // 7 days + }, + pro: { + daily: Infinity, // unlimited + hourly: 90, // 90 days + minutes: 30, // 30 days + }, + business: { + daily: Infinity, // unlimited + hourly: 365, // 365 days + minutes: 365, // 365 days + }, + enterprise: { + daily: Infinity, // unlimited + hourly: Infinity, // unlimited + minutes: Infinity, // unlimited + }, +}; + +/** + * The CoinPaprika provider is a pricing provider that uses the CoinPaprika API to get the price of a token. + * @see https://api.coinpaprika.com/#section/Introduction + */ +export class CoinPaprikaProvider implements IPricingProvider { + private readonly axios: AxiosInstance; + private readonly tierLimits: TierLimits; + + /** + * @param options.apiKey - CoinPaprika API key. + * @param options.apiType - CoinPaprika API tier type. + * @param logger - Logger instance. + */ + constructor( + options: CoinPaprikaOptions, + private readonly logger: ILogger, + ) { + const { apiKey, apiType } = options; + this.tierLimits = TIER_LIMITS[apiType]; + + this.axios = axios.create({ + baseURL: CoinPaprikaProvider.getBaseUrl(apiType), + headers: { + ...(apiType !== "free" ? { Authorization: apiKey } : {}), + Accept: "application/json", + }, + }); + } + + static getBaseUrl(apiType: CoinPaprikaOptions["apiType"]): string { + switch (apiType) { + case "free": + return "https://api.coinpaprika.com/v1/"; + case "starter": + case "pro": + case "business": + case "enterprise": + return "https://pro-api.coinpaprika.com/v1/"; + } + } + + /** + * Determines the best interval to use based on timestamp range and tier limits + * @param startTimestampMs - Start timestamp in milliseconds + * @param endTimestampMs - End timestamp in milliseconds + * @returns The best interval to use + */ + private determineInterval(startTimestampMs: TimestampMs, endTimestampMs: TimestampMs): string { + const now = Date.now(); + const rangeMs = endTimestampMs - startTimestampMs; + const startAgeMs = now - startTimestampMs; + + // Convert to days for easier comparison with tier limits + const rangeInDays = rangeMs / (24 * 60 * 60 * 1000); + const startAgeInDays = startAgeMs / (24 * 60 * 60 * 1000); + + // If the data is recent enough, try to use minute-level granularity + if (startAgeInDays <= this.tierLimits.minutes && rangeInDays <= 7) { + return "15m"; + } + + // If the data is within hourly limits, use hourly granularity + if (startAgeInDays <= this.tierLimits.hourly && rangeInDays <= 30) { + return "1h"; + } + + // Fall back to daily granularity + this.logger.debug("Using 1-day interval based on tier limits", { + tierLimits: this.tierLimits, + startAgeInDays, + rangeInDays, + }); + return "1d"; + } + + /** + * Checks if a timestamp is within the limits of the current tier + * @param timestamp - The timestamp to check + * @param granularity - The granularity level ("minutes", "hourly", or "daily") + * @returns true if the timestamp is within limits, false otherwise + */ + private isTimestampWithinTierLimits( + timestamp: TimestampMs, + granularity: keyof TierLimits, + ): boolean { + const now = Date.now(); + const ageInMs = now - timestamp; + const ageInDays = ageInMs / (24 * 60 * 60 * 1000); + + // Check if age exceeds the tier limit for the given granularity + return ageInDays <= this.tierLimits[granularity]; + } + + /** + * @inheritdoc + * @see https://api.coinpaprika.com/#tag/Tickers/operation/getTickersHistoricalById + */ + async getTokenPrice( + tokenCode: TokenCode, + startTimestampMs: TimestampMs, + endTimestampMs?: TimestampMs, + ): Promise { + const tokenId = TokenMapping[tokenCode]; + if (!tokenId) { + throw new UnsupportedToken(tokenCode, { + className: CoinPaprikaProvider.name, + methodName: "getTokenPrice", + }); + } + + if (!endTimestampMs) { + endTimestampMs = (startTimestampMs + TIME_DELTA) as TimestampMs; + } + + if (startTimestampMs > endTimestampMs) { + return undefined; + } + + // Determine the best interval based on the request and tier limits + const interval = this.determineInterval(startTimestampMs, endTimestampMs); + + // Check if the timestamp is within tier limits based on the determined interval + let granularity: keyof TierLimits; + if (interval.includes("m")) { + granularity = "minutes"; + } else if (interval.includes("h")) { + granularity = "hourly"; + } else { + granularity = "daily"; + } + + if (!this.isTimestampWithinTierLimits(startTimestampMs, granularity)) { + throw new NoClosePriceFound(); + } + + const startDate = startTimestampMs / 1000; + const endDate = endTimestampMs / 1000; + + const prices = await this.getHistoricalPrices(tokenId, startDate, endDate, interval); + if (prices.length === 0) { + return undefined; + } + + return prices.at(0); + } + + /** + * @inheritdoc + * @see https://api.coinpaprika.com/#tag/Tickers/operation/getTickersHistoricalById + */ + async getTokenPrices(tokenCode: TokenCode, timestamps: TimestampMs[]): Promise { + if (timestamps.length === 0) { + return []; + } + + const tokenId = TokenMapping[tokenCode]; + if (!tokenId) { + throw new UnsupportedToken(tokenCode, { + className: CoinPaprikaProvider.name, + methodName: "getTokenPrices", + }); + } + + // Sort and find min/max timestamps + const sortedTimestamps = [...timestamps].sort((a, b) => a - b); + const minTimestamp = sortedTimestamps[0]!; + const maxTimestamp = sortedTimestamps[sortedTimestamps.length - 1]!; + + // Determine best interval based on timestamp range + const interval = this.determineInterval(minTimestamp, maxTimestamp); + + // Determine granularity based on interval + let granularity: keyof TierLimits; + if (interval.includes("m")) { + granularity = "minutes"; + } else if (interval.includes("h")) { + granularity = "hourly"; + } else { + granularity = "daily"; + } + + // Filter timestamps based on tier limits + const validTimestamps = sortedTimestamps.filter((ts) => + this.isTimestampWithinTierLimits(ts, granularity), + ); + + if (validTimestamps.length === 0) { + return []; + } + + // Use the filtered timestamps for min/max + const validMinTimestamp = validTimestamps[0]!; + const validMaxTimestamp = validTimestamps[validTimestamps.length - 1]!; + + return this.getTokenPricesWithBatching( + tokenId, + validMinTimestamp, + validMaxTimestamp, + interval, + ); + } + + /** + * Get token prices using batching for large time ranges + */ + private async getTokenPricesWithBatching( + tokenId: CoinPaprikaTokenId, + minTimestamp: TimestampMs, + maxTimestamp: TimestampMs, + interval: string, + ): Promise { + // Group into 90-day batches + const BATCH_SIZE_DAYS = 90; + const BATCH_SIZE_MS = BATCH_SIZE_DAYS * 24 * 60 * 60 * 1000; + + // Create batches + const batches: { start: TimestampMs; end: TimestampMs }[] = []; + let currentStart = minTimestamp; + + while (currentStart < maxTimestamp) { + const batchEnd = Math.min(currentStart + BATCH_SIZE_MS, maxTimestamp) as TimestampMs; + batches.push({ + start: currentStart, + end: batchEnd, + }); + currentStart = (batchEnd + 1) as TimestampMs; + } + + // Process batches in parallel + const batchResults = await Promise.all( + batches.map(async (batch) => { + const startDate = batch.start / 1000; + const endDate = batch.end / 1000; + + return this.getHistoricalPrices(tokenId, startDate, endDate, interval); + }), + ); + + // Flatten all batch results into a single array + return batchResults.flat(); + } + + private async getHistoricalPrices( + tokenId: CoinPaprikaTokenId, + startDateSecs: number, + endDateSecs: number, + interval: string, + ): Promise { + const path = `/tickers/${tokenId}/historical`; + const params = { + start: startDateSecs, + end: endDateSecs, + interval, + quote: "usd", + limit: MAX_LIMIT, + }; + + try { + const { data } = await this.axios.get(path, { + params, + }); + + if (!data || data.length === 0) { + return []; + } + + return data.map((item) => ({ + timestampMs: new Date(item.timestamp).getTime() as TimestampMs, + priceUsd: item.price, + })); + } catch (error) { + if (isAxiosError(error)) { + this.handleAxiosError(error, path, "getHistoricalPrices"); + } + + const errorMessage = + `Unknown CoinPaprika API error: failed to fetch token price ` + + stringify(error, Object.getOwnPropertyNames(error)); + + throw new UnknownPricingException(errorMessage, { + className: CoinPaprikaProvider.name, + methodName: "getHistoricalPrices", + additionalData: { + path, + }, + }); + } + } + + /** + * Handle Axios errors from CoinPaprika API + */ + private handleAxiosError( + error: AxiosError, + path: string, + methodName: string, + ): void { + const errorContext = { + className: CoinPaprikaProvider.name, + methodName, + additionalData: { + path, + }, + }; + + const status = error.status!; + const errorMsg = error.response?.data.error || error.message; + + // Handle rate limiting + if (status === 429) { + this.logger.debug("CoinPaprika API rate limit exceeded", { + status, + path, + }); + + throw new RateLimitError(errorContext); + } + + // Handle auth errors + if (status >= 400 && status < 500) { + throw new NonRetriableError( + `CoinPaprika API ${status} error: ${errorMsg}`, + errorContext, + ); + } + + // Handle server errors + if ( + status >= 500 || + error.code === "ECONNABORTED" || + error.code === "ETIMEDOUT" || + error.code === "ENOTFOUND" + ) { + this.logger.error("CoinPaprika API server error", { + status, + path, + }); + + throw new NetworkError( + errorContext, + { + statusCode: status, + failureReason: errorMsg, + }, + error, + ); + } + } +} diff --git a/packages/pricing/src/providers/index.ts b/packages/pricing/src/providers/index.ts index 2829e17a..51ff8c2e 100644 --- a/packages/pricing/src/providers/index.ts +++ b/packages/pricing/src/providers/index.ts @@ -1,3 +1,4 @@ export * from "./coingecko.provider.js"; export * from "./dummy.provider.js"; export * from "./cachingProxy.provider.js"; +export * from "./coinpaprika.provider.js"; diff --git a/packages/pricing/src/types/coinpaprika.types.ts b/packages/pricing/src/types/coinpaprika.types.ts new file mode 100644 index 00000000..6b27d00d --- /dev/null +++ b/packages/pricing/src/types/coinpaprika.types.ts @@ -0,0 +1,17 @@ +import { Branded } from "@grants-stack-indexer/shared"; + +import { TimestampISO8601 } from "./index.js"; + +export type CoinPaprikaTokenId = Branded; + +// CoinPaprika API response types +export type CoinPaprikaHistoricalResponse = { + timestamp: TimestampISO8601; + price: number; + volume_24h: number; + market_cap: number; +}; + +export type CoinPaprikaErrorResponse = { + error: string; +}; diff --git a/packages/pricing/src/types/index.ts b/packages/pricing/src/types/index.ts index b018325d..e51446e7 100644 --- a/packages/pricing/src/types/index.ts +++ b/packages/pricing/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./coingecko.types.js"; export * from "./pricing.types.js"; +export * from "./coinpaprika.types.js"; diff --git a/packages/pricing/src/types/pricing.types.ts b/packages/pricing/src/types/pricing.types.ts index b04430b3..ee6ae886 100644 --- a/packages/pricing/src/types/pricing.types.ts +++ b/packages/pricing/src/types/pricing.types.ts @@ -1,4 +1,4 @@ -import { TimestampMs } from "@grants-stack-indexer/shared"; +import { Branded, TimestampMs } from "@grants-stack-indexer/shared"; /** * @timestampMs - The timestamp in milliseconds @@ -8,3 +8,5 @@ export type TokenPrice = { timestampMs: TimestampMs; priceUsd: number; }; + +export type TimestampISO8601 = Branded; // yyyy-mm-ddThh:mm:ss.mmmZ diff --git a/scripts/bootstrap/.env.example b/scripts/bootstrap/.env.example index 46f52547..ae773a13 100644 --- a/scripts/bootstrap/.env.example +++ b/scripts/bootstrap/.env.example @@ -4,6 +4,10 @@ INDEXER_URL="http://localhost:8080/v1/graphql" PUBLIC_GATEWAY_URLS=["https://gitcoin.mypinata.cloud", "https://ipfs.io", "https://dweb.link", "https://cloudflare-ipfs.com", "https://gateway.pinata.cloud", "https://ipfs.infura.io", "https://ipfs.fleek.co", "https://ipfs.eth.aragon.network", "https://ipfs.jes.xxx", "https://ipfs.lol", "https://ipfs.mle.party"] CHAIN_IDS=[10] LOG_LEVEL=info -PRICING_SOURCE=coingecko +PRICING_SOURCE=coingecko # 'coingecko' or 'dummy' or 'coinpaprika' + COINGECKO_API_KEY={{YOUR_PRO_KEY}} COINGECKO_API_TYPE=pro + +COINPAPRIKA_API_KEY={{YOUR_KEY}} +COINPAPRIKA_API_TYPE=pro # 'free' or 'starter' or 'pro' or 'business' or 'enterprise' \ No newline at end of file diff --git a/scripts/bootstrap/src/schemas/index.ts b/scripts/bootstrap/src/schemas/index.ts index 0066e45d..52a61c36 100644 --- a/scripts/bootstrap/src/schemas/index.ts +++ b/scripts/bootstrap/src/schemas/index.ts @@ -20,7 +20,7 @@ const dbEnvSchema = z.object({ PUBLIC_GATEWAY_URLS: stringToJSONSchema.pipe(z.array(z.string().url())), CHAIN_IDS: stringToJSONSchema.pipe(z.array(z.number())), NODE_ENV: z.enum(["development", "staging", "production"]).default("development"), - PRICING_SOURCE: z.enum(["dummy", "coingecko"]).default("coingecko"), + PRICING_SOURCE: z.enum(["dummy", "coingecko", "coinpaprika"]).default("coingecko"), }); const dummyPricingSchema = dbEnvSchema.extend({ @@ -34,13 +34,34 @@ const coingeckoPricingSchema = dbEnvSchema.extend({ COINGECKO_API_TYPE: z.enum(["demo", "pro"]).default("pro"), }); +const coinpaprikaPricingSchema = dbEnvSchema.extend({ + PRICING_SOURCE: z.literal("coinpaprika"), + COINPAPRIKA_API_KEY: z.string().default(""), + COINPAPRIKA_API_TYPE: z + .enum(["free", "starter", "pro", "business", "enterprise"]) + .default("free"), +}); + const validationSchema = z - .discriminatedUnion("PRICING_SOURCE", [dummyPricingSchema, coingeckoPricingSchema]) + .discriminatedUnion("PRICING_SOURCE", [ + dummyPricingSchema, + coingeckoPricingSchema, + coinpaprikaPricingSchema, + ]) .transform((val) => { if (val.PRICING_SOURCE === "dummy") { return { pricingSource: val.PRICING_SOURCE, dummyPrice: val.DUMMY_PRICE, ...val }; } + if (val.PRICING_SOURCE === "coinpaprika") { + return { + pricingSource: val.PRICING_SOURCE, + apiKey: val.COINPAPRIKA_API_KEY, + apiType: val.COINPAPRIKA_API_TYPE, + ...val, + }; + } + return { pricingSource: val.PRICING_SOURCE, apiKey: val.COINGECKO_API_KEY, From 8e98595d75f4ad5591805a8d9f7046887f8ba43d Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:49:07 -0300 Subject: [PATCH 2/2] test: add unit tests --- .../test/factory/pricingFactory.spec.ts | 20 +- .../providers/coinpaprika.provider.spec.ts | 208 ++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/pricing/test/providers/coinpaprika.provider.spec.ts diff --git a/packages/pricing/test/factory/pricingFactory.spec.ts b/packages/pricing/test/factory/pricingFactory.spec.ts index 8d466c3e..8048845b 100644 --- a/packages/pricing/test/factory/pricingFactory.spec.ts +++ b/packages/pricing/test/factory/pricingFactory.spec.ts @@ -4,7 +4,11 @@ import { ILogger } from "@grants-stack-indexer/shared"; import { CoingeckoProvider, PricingConfig } from "../../src/external.js"; import { PricingProviderFactory } from "../../src/factory/index.js"; -import { InvalidPricingSource, MissingDependencies } from "../../src/internal.js"; +import { + CoinPaprikaProvider, + InvalidPricingSource, + MissingDependencies, +} from "../../src/internal.js"; import { DummyPricingProvider } from "../../src/providers/dummy.provider.js"; describe("PricingProviderFactory", () => { @@ -34,6 +38,20 @@ describe("PricingProviderFactory", () => { expect(pricingProvider).toBeInstanceOf(CoingeckoProvider); }); + it("create a CoinPaprikaProvider", () => { + const options: PricingConfig<"coinpaprika"> = { + pricingSource: "coinpaprika", + apiKey: "some-api-key", + apiType: "pro", + }; + + const pricingProvider = PricingProviderFactory.create(options, { + logger: {} as unknown as ILogger, + }); + + expect(pricingProvider).toBeInstanceOf(CoinPaprikaProvider); + }); + it("throws if logger instance is not provided for CoingeckoProvider", () => { const options: PricingConfig<"coingecko"> = { pricingSource: "coingecko", diff --git a/packages/pricing/test/providers/coinpaprika.provider.spec.ts b/packages/pricing/test/providers/coinpaprika.provider.spec.ts new file mode 100644 index 00000000..6a4f2729 --- /dev/null +++ b/packages/pricing/test/providers/coinpaprika.provider.spec.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ILogger, + NetworkError, + NonRetriableError, + RateLimitError, + TimestampMs, + TokenCode, +} from "@grants-stack-indexer/shared"; + +import type { TokenPrice } from "../../src/external.js"; +import { CoinPaprikaProvider, UnsupportedToken } from "../../src/external.js"; + +// Mock axios +const mockGet = vi.fn(); +const mockPost = vi.fn(); + +vi.mock("axios", async (importActual) => { + const actual = await importActual(); + + const mockAxios = { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + get: mockGet, + post: mockPost, + })), + }, + isAxiosError: actual.isAxiosError, // Return it directly from the mock + }; + + return mockAxios; +}); + +// Mock current date for tier limit testing +const CURRENT_DATE = new Date("2023-01-01T00:00:00Z"); + +describe("CoinPaprikaProvider", () => { + let provider: CoinPaprikaProvider; + const logger: ILogger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + + beforeEach(() => { + provider = new CoinPaprikaProvider( + { + apiKey: "test-api-key", + apiType: "pro", + }, + logger, + ); + vi.spyOn(global.Date, "now").mockImplementation(() => CURRENT_DATE.getTime()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getTokenPrice", () => { + it("returns token price for a supported token", async () => { + const mockResponse = [ + { + timestamp: "2023-01-01T00:00:00Z", + price: 1500, + volume_24h: 1000000, + market_cap: 10000000, + }, + ]; + mockGet.mockResolvedValueOnce({ status: 200, data: mockResponse }); + + const result = await provider.getTokenPrice( + "ETH" as TokenCode, + 1672531200000 as TimestampMs, // 2023-01-01 + 1672617600000 as TimestampMs, // 2023-01-02 + ); + + const expectedPrice: TokenPrice = { + timestampMs: 1672531200000 as TimestampMs, + priceUsd: 1500, + }; + + expect(result).toEqual(expectedPrice); + expect(mockGet).toHaveBeenCalledOnce(); + }); + + it("returns undefined if no price data is available", async () => { + mockGet.mockResolvedValueOnce({ status: 200, data: [] }); + + const result = await provider.getTokenPrice( + "ETH" as TokenCode, + 1672531200000 as TimestampMs, + 1672617600000 as TimestampMs, + ); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when endTimestamp is less than startTimestamp", async () => { + const result = await provider.getTokenPrice( + "ETH" as TokenCode, + 1672617600000 as TimestampMs, // later timestamp + 1672531200000 as TimestampMs, // earlier timestamp + ); + + expect(result).toBeUndefined(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("throws UnsupportedToken for unsupported tokens", async () => { + await expect(() => + provider.getTokenPrice( + "UNSUPPORTED" as TokenCode, + 1672531200000 as TimestampMs, + 1672617600000 as TimestampMs, + ), + ).rejects.toThrow(UnsupportedToken); + }); + }); + + describe("error handling", () => { + it("handles rate limiting errors (429)", async () => { + mockGet.mockRejectedValueOnce({ + status: 429, + response: { + status: 429, + data: { error: "Too many requests" }, + }, + isAxiosError: true, + }); + + await expect( + provider.getTokenPrice("ETH" as TokenCode, 1672531200000 as TimestampMs), + ).rejects.toThrow(RateLimitError); + }); + + it("handles authentication errors (401)", async () => { + mockGet.mockRejectedValueOnce({ + status: 401, + response: { + status: 401, + data: { error: "Invalid API key" }, + }, + isAxiosError: true, + }); + + await expect( + provider.getTokenPrice("ETH" as TokenCode, 1672531200000 as TimestampMs), + ).rejects.toThrow(NonRetriableError); + }); + + it("handles network errors", async () => { + mockGet.mockRejectedValueOnce({ + code: "ECONNABORTED", + isAxiosError: true, + }); + + await expect( + provider.getTokenPrice("ETH" as TokenCode, 1672531200000 as TimestampMs), + ).rejects.toThrow(NetworkError); + }); + }); + + describe("getTokenPrices", () => { + it("handles empty timestamps array", async () => { + const result = await provider.getTokenPrices("ETH" as TokenCode, []); + expect(result).toEqual([]); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("throws UnsupportedToken for unknown token", async () => { + await expect( + provider.getTokenPrices("UNKNOWN" as TokenCode, [1672531200000 as TimestampMs]), + ).rejects.toThrow(UnsupportedToken); + }); + + it("returns prices for valid timestamps", async () => { + const mockResponse = [ + { + timestamp: "2023-01-01T00:00:00Z", + price: 1500, + volume_24h: 1000000, + market_cap: 10000000, + }, + { + timestamp: "2023-01-02T00:00:00Z", + price: 1600, + volume_24h: 1100000, + market_cap: 11000000, + }, + ]; + mockGet.mockResolvedValueOnce({ status: 200, data: mockResponse }); + + const result = await provider.getTokenPrices("ETH" as TokenCode, [ + 1672531200000 as TimestampMs, // 2023-01-01 + 1672617600000 as TimestampMs, // 2023-01-02 + ]); + + expect(result).toHaveLength(2); + expect(result[0]?.priceUsd).toBe(1500); + expect(result[1]?.priceUsd).toBe(1600); + }); + }); +});