From 052935795e7aa18a6a1aae3e827c7768554ccfb0 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Tue, 8 Oct 2024 13:59:47 -0300 Subject: [PATCH 1/3] feat: add coingecko pricing provider --- .prettierrc | 3 + packages/pricing/README.md | 62 +++++ packages/pricing/package.json | 34 +++ packages/pricing/src/exceptions/index.ts | 3 + .../src/exceptions/network.exception.ts | 8 + .../exceptions/unknownPricing.exception.ts | 6 + .../exceptions/unsupportedChain.exception.ts | 5 + packages/pricing/src/external.ts | 8 + packages/pricing/src/index.ts | 1 + packages/pricing/src/interfaces/index.ts | 1 + .../src/interfaces/pricing.interface.ts | 28 ++ packages/pricing/src/internal.ts | 4 + .../src/providers/coingecko.provider.ts | 150 ++++++++++ packages/pricing/src/providers/index.ts | 1 + packages/pricing/src/types/coingecko.types.ts | 23 ++ packages/pricing/src/types/index.ts | 2 + packages/pricing/src/types/pricing.types.ts | 8 + packages/pricing/tsconfig.build.json | 13 + packages/pricing/tsconfig.json | 4 + packages/pricing/vitest.config.ts | 22 ++ packages/shared/src/constants/address.ts | 7 + packages/shared/src/constants/index.ts | 1 + packages/shared/src/external.ts | 3 +- packages/shared/src/internal.ts | 1 + packages/shared/src/types/brand.ts | 5 + packages/shared/src/types/index.ts | 1 + pnpm-lock.yaml | 259 +++++++----------- 27 files changed, 507 insertions(+), 156 deletions(-) create mode 100644 packages/pricing/README.md create mode 100644 packages/pricing/package.json create mode 100644 packages/pricing/src/exceptions/index.ts create mode 100644 packages/pricing/src/exceptions/network.exception.ts create mode 100644 packages/pricing/src/exceptions/unknownPricing.exception.ts create mode 100644 packages/pricing/src/exceptions/unsupportedChain.exception.ts create mode 100644 packages/pricing/src/external.ts create mode 100644 packages/pricing/src/index.ts create mode 100644 packages/pricing/src/interfaces/index.ts create mode 100644 packages/pricing/src/interfaces/pricing.interface.ts create mode 100644 packages/pricing/src/internal.ts create mode 100644 packages/pricing/src/providers/coingecko.provider.ts create mode 100644 packages/pricing/src/providers/index.ts create mode 100644 packages/pricing/src/types/coingecko.types.ts create mode 100644 packages/pricing/src/types/index.ts create mode 100644 packages/pricing/src/types/pricing.types.ts create mode 100644 packages/pricing/tsconfig.build.json create mode 100644 packages/pricing/tsconfig.json create mode 100644 packages/pricing/vitest.config.ts create mode 100644 packages/shared/src/constants/address.ts create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/types/brand.ts diff --git a/.prettierrc b/.prettierrc index 2cb60b0..c08b953 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,6 +10,9 @@ "", "", "", + "^@grants-stack-indexer", + "^@grants-stack-indexer/(.*)$", + "", "^[.|..|~]", "^~/", "^[../]", diff --git a/packages/pricing/README.md b/packages/pricing/README.md new file mode 100644 index 0000000..673b67d --- /dev/null +++ b/packages/pricing/README.md @@ -0,0 +1,62 @@ +# Grants Stack Indexer v2: Pricing package + +This package provides different pricing providers that can be used to get +the price of a token at a specific timestamp, using chainId and token address. + +## Setup + +1. Install dependencies running `pnpm install` + +## Available Scripts + +Available scripts that can be run using `pnpm`: + +| Script | Description | +| ------------- | ------------------------------------------------------- | +| `build` | Build library using tsc | +| `check-types` | Check types issues using tsc | +| `clean` | Remove `dist` folder | +| `lint` | Run ESLint to check for coding standards | +| `lint:fix` | Run linter and automatically fix code formatting issues | +| `format` | Check code formatting and style using Prettier | +| `format:fix` | Run formatter and automatically fix issues | +| `test` | Run tests using vitest | +| `test:cov` | Run tests with coverage report | + +## Usage + +### Importing the Package + +You can import the package in your TypeScript or JavaScript files as follows: + +```typescript +import { CoingeckoProvider } from "@grants-stack-indexer/pricing"; +``` + +### Example + +```typescript +const coingecko = new CoingeckoProvider({ + apiKey: "your-api-key", + apiType: "demo", +}); + +const price = await coingecko.getTokenPrice( + 1, + "0x0d8775f5d29498461708d85e233a7b3331e6f5a0", + 1609459200000, + 1640908800000, +); +``` + +## API + +### [IPricingProvider](./src/interfaces/pricing.interface.ts) + +Available methods + +- `getTokenPrice(chainId: number, tokenAddress: Address, startTimestampMs: number, endTimestampMs: number): Promise` + +## References + +- [Coingecko API Historical Data](https://docs.coingecko.com/reference/coins-id-market-chart-range) diff --git a/packages/pricing/package.json b/packages/pricing/package.json new file mode 100644 index 0000000..39e1ff3 --- /dev/null +++ b/packages/pricing/package.json @@ -0,0 +1,34 @@ +{ + "name": "@grants-stack-indexer/pricing", + "version": "0.0.1", + "private": true, + "description": "", + "license": "MIT", + "author": "Wonderland", + "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "directories": { + "src": "src" + }, + "files": [ + "dist/*", + "package.json", + "!**/*.tsbuildinfo" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check-types": "tsc --noEmit -p ./tsconfig.json", + "clean": "rm -rf dist", + "format": "prettier --check \"{src,test}/**/*.{js,ts,json}\"", + "format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\"", + "lint": "eslint \"{src,test}/**/*.{js,ts,json}\"", + "lint:fix": "pnpm lint --fix", + "test": "vitest run --config vitest.config.ts --passWithNoTests", + "test:cov": "vitest run --config vitest.config.ts --coverage" + }, + "dependencies": { + "@grants-stack-indexer/shared": "workspace:0.0.1", + "axios": "1.7.7" + } +} diff --git a/packages/pricing/src/exceptions/index.ts b/packages/pricing/src/exceptions/index.ts new file mode 100644 index 0000000..63732e0 --- /dev/null +++ b/packages/pricing/src/exceptions/index.ts @@ -0,0 +1,3 @@ +export * from "./network.exception.js"; +export * from "./unsupportedChain.exception.js"; +export * from "./unknownPricing.exception.js"; diff --git a/packages/pricing/src/exceptions/network.exception.ts b/packages/pricing/src/exceptions/network.exception.ts new file mode 100644 index 0000000..b952e1a --- /dev/null +++ b/packages/pricing/src/exceptions/network.exception.ts @@ -0,0 +1,8 @@ +export class NetworkException extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + } +} diff --git a/packages/pricing/src/exceptions/unknownPricing.exception.ts b/packages/pricing/src/exceptions/unknownPricing.exception.ts new file mode 100644 index 0000000..5c77b95 --- /dev/null +++ b/packages/pricing/src/exceptions/unknownPricing.exception.ts @@ -0,0 +1,6 @@ +export class UnknownPricingException extends Error { + constructor(message: string, stack?: string) { + super(message); + this.stack = stack; + } +} diff --git a/packages/pricing/src/exceptions/unsupportedChain.exception.ts b/packages/pricing/src/exceptions/unsupportedChain.exception.ts new file mode 100644 index 0000000..d593c33 --- /dev/null +++ b/packages/pricing/src/exceptions/unsupportedChain.exception.ts @@ -0,0 +1,5 @@ +export class UnsupportedChainException extends Error { + constructor(chainId: number) { + super(`Unsupported chain ID: ${chainId}`); + } +} diff --git a/packages/pricing/src/external.ts b/packages/pricing/src/external.ts new file mode 100644 index 0000000..cdc716a --- /dev/null +++ b/packages/pricing/src/external.ts @@ -0,0 +1,8 @@ +export type { TokenPrice } from "./internal.js"; + +export { CoingeckoProvider } from "./internal.js"; +export { + UnsupportedChainException, + NetworkException, + UnknownPricingException, +} from "./internal.js"; diff --git a/packages/pricing/src/index.ts b/packages/pricing/src/index.ts new file mode 100644 index 0000000..a5a2748 --- /dev/null +++ b/packages/pricing/src/index.ts @@ -0,0 +1 @@ +export * from "./external.js"; diff --git a/packages/pricing/src/interfaces/index.ts b/packages/pricing/src/interfaces/index.ts new file mode 100644 index 0000000..13271cd --- /dev/null +++ b/packages/pricing/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from "./pricing.interface.js"; diff --git a/packages/pricing/src/interfaces/pricing.interface.ts b/packages/pricing/src/interfaces/pricing.interface.ts new file mode 100644 index 0000000..ab3e214 --- /dev/null +++ b/packages/pricing/src/interfaces/pricing.interface.ts @@ -0,0 +1,28 @@ +import { Address } from "@grants-stack-indexer/shared"; + +import { TokenPrice } from "../internal.js"; + +/** + * Represents a pricing service that retrieves token prices. + * @dev is service responsibility to map address to their internal ID + * @dev for native token (eg. ETH), use the one address + */ +export interface IPricingProvider { + /** + * Retrieves the price of a token at a timestamp range. + * @param chainId - The ID of the blockchain network. + * @param tokenAddress - The address of the token. + * @param startTimestampMs - The start timestamp for which to retrieve the price. + * @param endTimestampMs - The end timestamp for which to retrieve the price. + * @returns A promise that resolves to the price of the token at the specified timestamp or undefined if no price is found. + * @throws {UnsupportedChainException} if the chain ID is not supported by the pricing provider. + * @throws {NetworkException} if the network is not reachable. + * @throws {UnknownFetchException} if the pricing provider returns an unknown error. + */ + getTokenPrice( + chainId: number, + tokenAddress: Address, + startTimestampMs: number, + endTimestampMs: number, + ): Promise; +} diff --git a/packages/pricing/src/internal.ts b/packages/pricing/src/internal.ts new file mode 100644 index 0000000..74118ac --- /dev/null +++ b/packages/pricing/src/internal.ts @@ -0,0 +1,4 @@ +export * from "./types/index.js"; +export * from "./interfaces/index.js"; +export * from "./providers/index.js"; +export * from "./exceptions/index.js"; diff --git a/packages/pricing/src/providers/coingecko.provider.ts b/packages/pricing/src/providers/coingecko.provider.ts new file mode 100644 index 0000000..a7e2beb --- /dev/null +++ b/packages/pricing/src/providers/coingecko.provider.ts @@ -0,0 +1,150 @@ +import { isNativeError } from "util/types"; +import axios, { AxiosInstance, isAxiosError } from "axios"; + +import { Address, isNativeToken } from "@grants-stack-indexer/shared"; + +import { IPricingProvider } from "../interfaces/index.js"; +import { + CoingeckoPlatformId, + CoingeckoPriceChartData, + CoingeckoSupportedChainId, + CoingeckoTokenId, + NetworkException, + TokenPrice, + UnknownPricingException, + UnsupportedChainException, +} from "../internal.js"; + +type CoingeckoOptions = { + apiKey: string; + apiType: "demo" | "pro"; +}; + +const getApiTypeConfig = (apiType: "demo" | "pro"): { baseURL: string; authHeader: string } => + apiType === "demo" + ? { baseURL: "https://api.coingecko.com/api/v3", authHeader: "x-cg-demo-api-key" } + : { baseURL: "https://pro-api.coingecko.com/api/v3/", authHeader: "x-cg-pro-api-key" }; + +const platforms: { [key in CoingeckoSupportedChainId]: CoingeckoPlatformId } = { + 1: "ethereum" as CoingeckoPlatformId, + 10: "optimistic-ethereum" as CoingeckoPlatformId, + 100: "xdai" as CoingeckoPlatformId, + 250: "fantom" as CoingeckoPlatformId, + 42161: "arbitrum-one" as CoingeckoPlatformId, + 43114: "avalanche" as CoingeckoPlatformId, + 713715: "sei-network" as CoingeckoPlatformId, + 1329: "sei-network" as CoingeckoPlatformId, + 42: "lukso" as CoingeckoPlatformId, + 42220: "celo" as CoingeckoPlatformId, + 1088: "metis" as CoingeckoPlatformId, +}; + +const nativeTokens: { [key in CoingeckoSupportedChainId]: CoingeckoTokenId } = { + 1: "ethereum" as CoingeckoTokenId, + 10: "ethereum" as CoingeckoTokenId, + 100: "xdai" as CoingeckoTokenId, + 250: "fantom" as CoingeckoTokenId, + 42161: "ethereum" as CoingeckoTokenId, + 43114: "avalanche-2" as CoingeckoTokenId, + 713715: "sei-network" as CoingeckoTokenId, + 1329: "sei-network" as CoingeckoTokenId, + 42: "lukso-token" as CoingeckoTokenId, + 42220: "celo" as CoingeckoTokenId, + 1088: "metis-token" as CoingeckoTokenId, +}; + +export class CoingeckoProvider implements IPricingProvider { + private readonly axios: AxiosInstance; + + /** + * @param options.apiKey - Coingecko API key. + * @param options.apiType - Coingecko API type (demo or pro). + */ + constructor(options: CoingeckoOptions) { + const { apiKey, apiType } = options; + const { baseURL, authHeader } = getApiTypeConfig(apiType); + + this.axios = axios.create({ + baseURL, + headers: { + common: { + [authHeader]: apiKey, + Accept: "application/json", + }, + }, + }); + } + + /* @inheritdoc */ + async getTokenPrice( + chainId: number, + tokenAddress: Address, + startTimestampMs: number, + endTimestampMs: number, + ): Promise { + if (!this.isSupportedChainId(chainId)) { + throw new UnsupportedChainException(chainId); + } + + const startTimestampSecs = Math.floor(startTimestampMs / 1000); + const endTimestampSecs = Math.floor(endTimestampMs / 1000); + + const path = this.getApiPath(chainId, tokenAddress, startTimestampSecs, endTimestampSecs); + + //TODO: handle retries + try { + const { data } = await this.axios.get(path); + + const closestEntry = data.prices.at(0); + if (!closestEntry) { + return undefined; + } + + return { + timestampMs: closestEntry[0], + priceUsd: closestEntry[1], + }; + } catch (error: unknown) { + //TODO: notify + if (isAxiosError(error)) { + if (error.status! >= 400 && error.status! < 500) { + console.error(`Coingecko API error: ${error.message}. Stack: ${error.stack}`); + return undefined; + } + + if (error.status! >= 500) { + throw new NetworkException(error.message, error.status!); + } + } + console.error(error); + throw new UnknownPricingException( + JSON.stringify(error), + isNativeError(error) ? error.stack : undefined, + ); + } + } + + /* + * @returns Whether the given chain ID is supported by the Coingecko API. + */ + private isSupportedChainId(chainId: number): chainId is CoingeckoSupportedChainId { + return chainId in platforms; + } + + /* + * @returns The API endpoint path for the given parameters. + */ + private getApiPath( + chainId: CoingeckoSupportedChainId, + tokenAddress: Address, + startTimestampSecs: number, + endTimestampSecs: number, + ): string { + const platform = platforms[chainId]; + const nativeTokenId = nativeTokens[chainId]; + + return isNativeToken(tokenAddress) + ? `/coins/${nativeTokenId}/market_chart/range?vs_currency=usd&from=${startTimestampSecs}&to=${endTimestampSecs}&precision=full` + : `/coins/${platform}/contract/${tokenAddress.toLowerCase()}/market_chart/range?vs_currency=usd&from=${startTimestampSecs}&to=${endTimestampSecs}&precision=full`; + } +} diff --git a/packages/pricing/src/providers/index.ts b/packages/pricing/src/providers/index.ts new file mode 100644 index 0000000..7172e08 --- /dev/null +++ b/packages/pricing/src/providers/index.ts @@ -0,0 +1 @@ +export * from "./coingecko.provider.js"; diff --git a/packages/pricing/src/types/coingecko.types.ts b/packages/pricing/src/types/coingecko.types.ts new file mode 100644 index 0000000..c678e5e --- /dev/null +++ b/packages/pricing/src/types/coingecko.types.ts @@ -0,0 +1,23 @@ +import { Branded } from "@grants-stack-indexer/shared"; + +export type CoingeckoSupportedChainId = + | 1 + | 10 + | 100 + | 250 + | 42161 + | 43114 + | 713715 + | 1329 + | 42 + | 42220 + | 1088; + +export type CoingeckoTokenId = Branded; +export type CoingeckoPlatformId = Branded; + +export type CoingeckoPriceChartData = { + prices: [number, number][]; + market_caps: [number, number][]; + total_volumes: [number, number][]; +}; diff --git a/packages/pricing/src/types/index.ts b/packages/pricing/src/types/index.ts new file mode 100644 index 0000000..b018325 --- /dev/null +++ b/packages/pricing/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./coingecko.types.js"; +export * from "./pricing.types.js"; diff --git a/packages/pricing/src/types/pricing.types.ts b/packages/pricing/src/types/pricing.types.ts new file mode 100644 index 0000000..a2388fb --- /dev/null +++ b/packages/pricing/src/types/pricing.types.ts @@ -0,0 +1,8 @@ +/** + * @timestampMs - The timestamp in milliseconds + * @priceUsd - The price in USD + */ +export type TokenPrice = { + timestampMs: number; + priceUsd: number; +}; diff --git a/packages/pricing/tsconfig.build.json b/packages/pricing/tsconfig.build.json new file mode 100644 index 0000000..a9bfa3d --- /dev/null +++ b/packages/pricing/tsconfig.build.json @@ -0,0 +1,13 @@ +/* Based on total-typescript no-dom library config */ +/* https://github.com/total-typescript/tsconfig */ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "composite": true, + "declarationMap": true, + "declaration": true, + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/pricing/tsconfig.json b/packages/pricing/tsconfig.json new file mode 100644 index 0000000..66bb87a --- /dev/null +++ b/packages/pricing/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/packages/pricing/vitest.config.ts b/packages/pricing/vitest.config.ts new file mode 100644 index 0000000..8e1bbf4 --- /dev/null +++ b/packages/pricing/vitest.config.ts @@ -0,0 +1,22 @@ +import path from "path"; +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, // Use Vitest's global API without importing it in each file + environment: "node", // Use the Node.js environment + include: ["test/**/*.spec.ts"], // Include test files + exclude: ["node_modules", "dist"], // Exclude certain directories + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], // Coverage reporters + exclude: ["node_modules", "dist", "src/index.ts", ...configDefaults.exclude], // Files to exclude from coverage + }, + }, + resolve: { + alias: { + // Setup path alias based on tsconfig paths + "@": path.resolve(__dirname, "src"), + }, + }, +}); diff --git a/packages/shared/src/constants/address.ts b/packages/shared/src/constants/address.ts new file mode 100644 index 0000000..e1884d2 --- /dev/null +++ b/packages/shared/src/constants/address.ts @@ -0,0 +1,7 @@ +import { Address } from "viem"; + +export const NATIVE_TOKEN_ADDRESS: Address = "0x0000000000000000000000000000000000000001"; + +export const isNativeToken = (address: Address): boolean => { + return address === NATIVE_TOKEN_ADDRESS; +}; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts new file mode 100644 index 0000000..c32d150 --- /dev/null +++ b/packages/shared/src/constants/index.ts @@ -0,0 +1 @@ +export * from "./address.js"; diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index 10fab87..ca87de6 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -1 +1,2 @@ -export type { AnyProtocolEvent, Address } from "./internal.js"; +export type { AnyProtocolEvent, Address, Branded } from "./internal.js"; +export { NATIVE_TOKEN_ADDRESS, isNativeToken } from "./constants/index.js"; diff --git a/packages/shared/src/internal.ts b/packages/shared/src/internal.ts index 2844bb7..7dc65a3 100644 --- a/packages/shared/src/internal.ts +++ b/packages/shared/src/internal.ts @@ -1,2 +1,3 @@ export type { Address } from "viem"; export * from "./types/index.js"; +export * from "./constants/index.js"; diff --git a/packages/shared/src/types/brand.ts b/packages/shared/src/types/brand.ts new file mode 100644 index 0000000..baca363 --- /dev/null +++ b/packages/shared/src/types/brand.ts @@ -0,0 +1,5 @@ +declare const __brand: unique symbol; + +type Brand = { [__brand]: B }; + +export type Branded = T & Brand; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0e54746..157826b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1 +1,2 @@ export * from "./events/index.js"; +export * from "./brand.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c099c36..359271f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,20 +96,14 @@ importers: specifier: 5.2.2 version: 5.2.2 - packages/data-flow: - dependencies: - viem: - specifier: 2.21.19 - version: 2.21.19(typescript@5.5.4)(zod@3.23.8) - - packages/indexer-client: + packages/pricing: dependencies: "@grants-stack-indexer/shared": - specifier: workspace:* + specifier: workspace:0.0.1 version: link:../shared - graphql-request: - specifier: 7.1.0 - version: 7.1.0(graphql@16.9.0) + axios: + specifier: 1.7.7 + version: 1.7.7 packages/shared: dependencies: @@ -629,14 +623,6 @@ packages: } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - "@graphql-typed-document-node/core@3.2.0": - resolution: - { - integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==, - } - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - "@humanwhocodes/config-array@0.11.14": resolution: { @@ -724,18 +710,6 @@ packages: integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, } - "@molt/command@0.9.0": - resolution: - { - integrity: sha512-1JI8dAlpqlZoXyKWVQggX7geFNPxBpocHIXQCsnxDjKy+3WX4SGyZVJXuLlqRRrX7FmQCuuMAfx642ovXmPA9g==, - } - - "@molt/types@0.2.0": - resolution: - { - integrity: sha512-p6ChnEZDGjg9PYPec9BK6Yp5/DdSrYQvXTBAtgrnqX6N36cZy37ql1c8Tc5LclfIYBNG7EZp8NBcRTYJwyi84g==, - } - "@noble/curves@1.2.0": resolution: { @@ -1210,12 +1184,6 @@ packages: integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==, } - alge@0.8.1: - resolution: - { - integrity: sha512-kiV9nTt+XIauAXsowVygDxMZLplZxDWt0W8plE/nB32/V2ziM/P/TxDbSVK7FYIUt2Xo16h3/htDh199LNPCKQ==, - } - ansi-colors@4.1.1: resolution: { @@ -1317,6 +1285,18 @@ packages: } engines: { node: ">=12" } + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, + } + + axios@1.7.7: + resolution: + { + integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==, + } + balanced-match@1.0.2: resolution: { @@ -1509,6 +1489,13 @@ packages: integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==, } + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, + } + engines: { node: ">= 0.8" } + commander@12.1.0: resolution: { @@ -1658,6 +1645,13 @@ packages: integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + delayed-stream@1.0.0: + resolution: + { + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, + } + engines: { node: ">=0.4.0" } + detect-indent@7.0.1: resolution: { @@ -2032,6 +2026,18 @@ packages: integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==, } + follow-redirects@1.15.9: + resolution: + { + integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==, + } + engines: { node: ">=4.0" } + peerDependencies: + debug: "*" + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.0: resolution: { @@ -2039,6 +2045,13 @@ packages: } engines: { node: ">=14" } + form-data@4.0.0: + resolution: + { + integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==, + } + engines: { node: ">= 6" } + fs.realpath@1.0.0: resolution: { @@ -2184,32 +2197,6 @@ packages: integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, } - graphql-request@7.1.0: - resolution: - { - integrity: sha512-Ouu/lYVFhARS1aXeZoVJWnGT6grFJXTLwXJuK4mUGGRo0EUk1JkyYp43mdGmRgUVezpRm6V5Sq3t8jBDQcajng==, - } - hasBin: true - peerDependencies: - "@dprint/formatter": ^0.3.0 - "@dprint/typescript": ^0.91.1 - dprint: ^0.46.2 - graphql: 14 - 16 - peerDependenciesMeta: - "@dprint/formatter": - optional: true - "@dprint/typescript": - optional: true - dprint: - optional: true - - graphql@16.9.0: - resolution: - { - integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==, - } - engines: { node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0 } - has-flag@3.0.0: resolution: { @@ -2592,12 +2579,6 @@ packages: integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==, } - lodash.ismatch@4.4.0: - resolution: - { - integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==, - } - lodash.isplainobject@4.0.6: resolution: { @@ -2736,6 +2717,20 @@ packages: } engines: { node: ">=8.6" } + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + } + engines: { node: ">= 0.6" } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + } + engines: { node: ">= 0.6" } + mimic-fn@4.0.0: resolution: { @@ -3045,6 +3040,12 @@ packages: engines: { node: ">=14" } hasBin: true + proxy-from-env@1.1.0: + resolution: + { + integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + } + punycode@2.3.1: resolution: { @@ -3071,19 +3072,6 @@ packages: } engines: { node: ">=8.10.0" } - readline-sync@1.4.10: - resolution: - { - integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==, - } - engines: { node: ">= 0.8.0" } - - remeda@1.61.0: - resolution: - { - integrity: sha512-caKfSz9rDeSKBQQnlJnVW3mbVdFgxgGWQKq1XlFokqjf+hQD5gxutLGTTY2A/x24UxVyJe9gH5fAkFI63ULw4A==, - } - require-directory@2.1.1: resolution: { @@ -3295,13 +3283,6 @@ packages: } engines: { node: ">=0.6.19" } - string-length@6.0.0: - resolution: - { - integrity: sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==, - } - engines: { node: ">=16" } - string-width@4.2.3: resolution: { @@ -3497,12 +3478,6 @@ packages: engines: { node: ">=4.2.0" } hasBin: true - ts-toolbelt@9.6.0: - resolution: - { - integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==, - } - tsconfig-paths@3.15.0: resolution: { @@ -3597,13 +3572,6 @@ packages: } engines: { node: ">=10" } - type-fest@4.26.1: - resolution: - { - integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==, - } - engines: { node: ">=16" } - typescript@5.2.2: resolution: { @@ -4248,10 +4216,6 @@ snapshots: "@eslint/js@8.56.0": {} - "@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)": - dependencies: - graphql: 16.9.0 - "@humanwhocodes/config-array@0.11.14": dependencies: "@humanwhocodes/object-schema": 2.0.3 @@ -4309,24 +4273,6 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.0 - "@molt/command@0.9.0": - dependencies: - "@molt/types": 0.2.0 - alge: 0.8.1 - chalk: 5.3.0 - lodash.camelcase: 4.3.0 - lodash.snakecase: 4.1.1 - readline-sync: 1.4.10 - string-length: 6.0.0 - strip-ansi: 7.1.0 - ts-toolbelt: 9.6.0 - type-fest: 4.26.1 - zod: 3.23.8 - - "@molt/types@0.2.0": - dependencies: - ts-toolbelt: 9.6.0 - "@noble/curves@1.2.0": dependencies: "@noble/hashes": 1.3.2 @@ -4616,13 +4562,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alge@0.8.1: - dependencies: - lodash.ismatch: 4.4.0 - remeda: 1.61.0 - ts-toolbelt: 9.6.0 - zod: 3.23.8 - ansi-colors@4.1.1: {} ansi-escapes@7.0.0: @@ -4662,6 +4601,16 @@ snapshots: assertion-error@2.0.1: {} + asynckit@0.4.0: {} + + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} binary-extensions@2.3.0: {} @@ -4782,6 +4731,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} commitlint@19.4.1(@types/node@20.3.1)(typescript@5.5.4): @@ -4862,6 +4815,8 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + detect-indent@7.0.1: {} detect-newline@4.0.1: {} @@ -5116,11 +5071,19 @@ snapshots: flatted@3.3.1: {} + follow-redirects@1.15.9: {} + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -5210,15 +5173,6 @@ snapshots: graphemer@1.4.0: {} - graphql-request@7.1.0(graphql@16.9.0): - dependencies: - "@graphql-typed-document-node/core": 3.2.0(graphql@16.9.0) - "@molt/command": 0.9.0 - graphql: 16.9.0 - zod: 3.23.8 - - graphql@16.9.0: {} - has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -5398,8 +5352,6 @@ snapshots: lodash.camelcase@4.3.0: {} - lodash.ismatch@4.4.0: {} - lodash.isplainobject@4.0.6: {} lodash.kebabcase@4.1.1: {} @@ -5470,6 +5422,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -5629,6 +5587,8 @@ snapshots: prettier@3.3.3: {} + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -5641,10 +5601,6 @@ snapshots: dependencies: picomatch: 2.3.1 - readline-sync@1.4.10: {} - - remeda@1.61.0: {} - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5756,10 +5712,6 @@ snapshots: string-argv@0.3.2: {} - string-length@6.0.0: - dependencies: - strip-ansi: 7.1.0 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5876,8 +5828,6 @@ snapshots: source-map-support: 0.5.21 yn: 2.0.0 - ts-toolbelt@9.6.0: {} - tsconfig-paths@3.15.0: dependencies: "@types/json5": 0.0.29 @@ -5925,8 +5875,6 @@ snapshots: type-fest@0.20.2: {} - type-fest@4.26.1: {} - typescript@5.2.2: {} typescript@5.5.4: {} @@ -6112,4 +6060,5 @@ snapshots: yocto-queue@1.1.1: {} - zod@3.23.8: {} + zod@3.23.8: + optional: true From 98324ea0b331dc0c571d953df8cc9f0096f47773 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:31:57 -0300 Subject: [PATCH 2/3] test: add unit tests --- packages/pricing/package.json | 3 + packages/pricing/src/external.ts | 2 +- .../src/providers/coingecko.provider.ts | 7 +- .../test/providers/coingecko.provider.spec.ts | 144 ++++++++++++++++++ pnpm-lock.yaml | 27 ++++ 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 packages/pricing/test/providers/coingecko.provider.spec.ts diff --git a/packages/pricing/package.json b/packages/pricing/package.json index 39e1ff3..e8cd5b6 100644 --- a/packages/pricing/package.json +++ b/packages/pricing/package.json @@ -30,5 +30,8 @@ "dependencies": { "@grants-stack-indexer/shared": "workspace:0.0.1", "axios": "1.7.7" + }, + "devDependencies": { + "axios-mock-adapter": "2.0.0" } } diff --git a/packages/pricing/src/external.ts b/packages/pricing/src/external.ts index cdc716a..0585585 100644 --- a/packages/pricing/src/external.ts +++ b/packages/pricing/src/external.ts @@ -1,4 +1,4 @@ -export type { TokenPrice } from "./internal.js"; +export type { TokenPrice, IPricingProvider } from "./internal.js"; export { CoingeckoProvider } from "./internal.js"; export { diff --git a/packages/pricing/src/providers/coingecko.provider.ts b/packages/pricing/src/providers/coingecko.provider.ts index a7e2beb..087ed06 100644 --- a/packages/pricing/src/providers/coingecko.provider.ts +++ b/packages/pricing/src/providers/coingecko.provider.ts @@ -53,6 +53,9 @@ const nativeTokens: { [key in CoingeckoSupportedChainId]: CoingeckoTokenId } = { 1088: "metis-token" as CoingeckoTokenId, }; +/** + * The Coingecko provider is a pricing provider that uses the Coingecko API to get the price of a token. + */ export class CoingeckoProvider implements IPricingProvider { private readonly axios: AxiosInstance; @@ -112,13 +115,13 @@ export class CoingeckoProvider implements IPricingProvider { return undefined; } - if (error.status! >= 500) { + if (error.status! >= 500 || error.message === "Network Error") { throw new NetworkException(error.message, error.status!); } } console.error(error); throw new UnknownPricingException( - JSON.stringify(error), + isNativeError(error) ? error.message : JSON.stringify(error), isNativeError(error) ? error.stack : undefined, ); } diff --git a/packages/pricing/test/providers/coingecko.provider.spec.ts b/packages/pricing/test/providers/coingecko.provider.spec.ts new file mode 100644 index 0000000..b660467 --- /dev/null +++ b/packages/pricing/test/providers/coingecko.provider.spec.ts @@ -0,0 +1,144 @@ +import MockAdapter from "axios-mock-adapter"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { Address, NATIVE_TOKEN_ADDRESS } from "@grants-stack-indexer/shared"; + +import type { TokenPrice } from "../../src/external.js"; +import { + CoingeckoProvider, + NetworkException, + UnsupportedChainException, +} from "../../src/external.js"; + +describe("CoingeckoProvider", () => { + let provider: CoingeckoProvider; + let mock: MockAdapter; + + beforeEach(() => { + provider = new CoingeckoProvider({ + apiKey: "test-api-key", + apiType: "demo", + }); + mock = new MockAdapter(provider["axios"]); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("getTokenPrice", () => { + it("return token price for a supported chain and valid token", async () => { + const mockResponse = { + prices: [[1609459200000, 100]], + }; + mock.onGet().reply(200, mockResponse); + + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ); + + const expectedPrice: TokenPrice = { + timestampMs: 1609459200000, + priceUsd: 100, + }; + + expect(result).toEqual(expectedPrice); + expect(mock.history.get[0].url).toContain( + "/coins/ethereum/contract/0x1234567890123456789012345678901234567890/market_chart/range?vs_currency=usd&from=1609459200&to=1609545600&precision=full", + ); + }); + + it("return token price for a supported chain and native token", async () => { + const mockResponse = { + prices: [[1609459200000, 100]], + }; + mock.onGet().reply(200, mockResponse); + + const result = await provider.getTokenPrice( + 10, + NATIVE_TOKEN_ADDRESS, + 1609459200000, + 1609545600000, + ); + + const expectedPrice: TokenPrice = { + timestampMs: 1609459200000, + priceUsd: 100, + }; + + expect(result).toEqual(expectedPrice); + expect(mock.history.get[0].url).toContain( + "/coins/ethereum/market_chart/range?vs_currency=usd&from=1609459200&to=1609545600&precision=full", + ); + }); + + it("return undefined if no price data is available for timerange", async () => { + const mockResponse = { + prices: [], + }; + mock.onGet().reply(200, mockResponse); + + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ); + + expect(result).toBeUndefined(); + }); + + it("return undefined if 400 family error", async () => { + mock.onGet().replyOnce(400, "Bad Request"); + + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ); + + expect(result).toBeUndefined(); + }); + + it("throw UnsupportedChainException for unsupported chain", async () => { + await expect(() => + provider.getTokenPrice( + 999999, // Unsupported chain ID + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ), + ).rejects.toThrow(UnsupportedChainException); + }); + + it("should throw NetworkException for 500 family errors", async () => { + mock.onGet().reply(500, "Internal Server Error"); + + await expect( + provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ), + ).rejects.toThrow(NetworkException); + }); + + it("throw NetworkException for network errors", async () => { + mock.onGet().networkErrorOnce(); + + await expect( + provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ), + ).rejects.toThrow(NetworkException); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 359271f..194e1d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,10 @@ importers: axios: specifier: 1.7.7 version: 1.7.7 + devDependencies: + axios-mock-adapter: + specifier: 2.0.0 + version: 2.0.0(axios@1.7.7) packages/shared: dependencies: @@ -1291,6 +1295,14 @@ packages: integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, } + axios-mock-adapter@2.0.0: + resolution: + { + integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==, + } + peerDependencies: + axios: ">= 0.17.0" + axios@1.7.7: resolution: { @@ -2299,6 +2311,13 @@ packages: } engines: { node: ">=8" } + is-buffer@2.0.5: + resolution: + { + integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==, + } + engines: { node: ">=4" } + is-extglob@2.1.1: resolution: { @@ -4603,6 +4622,12 @@ snapshots: asynckit@0.4.0: {} + axios-mock-adapter@2.0.0(axios@1.7.7): + dependencies: + axios: 1.7.7 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + axios@1.7.7: dependencies: follow-redirects: 1.15.9 @@ -5211,6 +5236,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@2.0.5: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} From 4bf5c337182ac3f2397fbc2ff234a2c2468ca8e8 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:13:59 -0300 Subject: [PATCH 3/3] fix: pr comments --- packages/pricing/package.json | 2 +- packages/pricing/src/providers/coingecko.provider.ts | 4 ++++ .../pricing/test/providers/coingecko.provider.spec.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/pricing/package.json b/packages/pricing/package.json index e8cd5b6..e4368c9 100644 --- a/packages/pricing/package.json +++ b/packages/pricing/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "check-types": "tsc --noEmit -p ./tsconfig.json", - "clean": "rm -rf dist", + "clean": "rm -rf dist/", "format": "prettier --check \"{src,test}/**/*.{js,ts,json}\"", "format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\"", "lint": "eslint \"{src,test}/**/*.{js,ts,json}\"", diff --git a/packages/pricing/src/providers/coingecko.provider.ts b/packages/pricing/src/providers/coingecko.provider.ts index 087ed06..2221740 100644 --- a/packages/pricing/src/providers/coingecko.provider.ts +++ b/packages/pricing/src/providers/coingecko.provider.ts @@ -89,6 +89,10 @@ export class CoingeckoProvider implements IPricingProvider { throw new UnsupportedChainException(chainId); } + if (startTimestampMs > endTimestampMs) { + return undefined; + } + const startTimestampSecs = Math.floor(startTimestampMs / 1000); const endTimestampSecs = Math.floor(endTimestampMs / 1000); diff --git a/packages/pricing/test/providers/coingecko.provider.spec.ts b/packages/pricing/test/providers/coingecko.provider.spec.ts index b660467..7210197 100644 --- a/packages/pricing/test/providers/coingecko.provider.spec.ts +++ b/packages/pricing/test/providers/coingecko.provider.spec.ts @@ -91,6 +91,17 @@ describe("CoingeckoProvider", () => { expect(result).toBeUndefined(); }); + it("return undefined when endTimestamp is greater than startTimestamp", async () => { + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609545600000, // startTimestamp + 1609459200000, // endTimestamp + ); + + expect(result).toBeUndefined(); + }); + it("return undefined if 400 family error", async () => { mock.onGet().replyOnce(400, "Bad Request");