Skip to content

Commit

Permalink
feat: coingecko pricing provider (#3)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes GIT-67

## Description
- add `pricing` package with `CoingeckoProvider`
- generic interface `IPricingProvider` for future extensibility

## Checklist before requesting a review

- [x] I have conducted a self-review of my code.
- [x] I have conducted a QA.
- [x] If it is a core feature, I have included comprehensive tests.
  • Loading branch information
0xnigir1 authored Oct 8, 2024
1 parent e7544bc commit b4efee3
Show file tree
Hide file tree
Showing 28 changed files with 699 additions and 156 deletions.
3 changes: 3 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"<TYPES>",
"<THIRD_PARTY_MODULES>",
"",
"<TYPES>^@grants-stack-indexer",
"^@grants-stack-indexer/(.*)$",
"",
"<TYPES>^[.|..|~]",
"^~/",
"^[../]",
Expand Down
62 changes: 62 additions & 0 deletions packages/pricing/README.md
Original file line number Diff line number Diff line change
@@ -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<TokenPrice | undefined>`

## References

- [Coingecko API Historical Data](https://docs.coingecko.com/reference/coins-id-market-chart-range)
37 changes: 37 additions & 0 deletions packages/pricing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"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"
},
"devDependencies": {
"axios-mock-adapter": "2.0.0"
}
}
3 changes: 3 additions & 0 deletions packages/pricing/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./network.exception.js";
export * from "./unsupportedChain.exception.js";
export * from "./unknownPricing.exception.js";
8 changes: 8 additions & 0 deletions packages/pricing/src/exceptions/network.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class NetworkException extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
}
}
6 changes: 6 additions & 0 deletions packages/pricing/src/exceptions/unknownPricing.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class UnknownPricingException extends Error {
constructor(message: string, stack?: string) {
super(message);
this.stack = stack;
}
}
5 changes: 5 additions & 0 deletions packages/pricing/src/exceptions/unsupportedChain.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class UnsupportedChainException extends Error {
constructor(chainId: number) {
super(`Unsupported chain ID: ${chainId}`);
}
}
8 changes: 8 additions & 0 deletions packages/pricing/src/external.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type { TokenPrice, IPricingProvider } from "./internal.js";

export { CoingeckoProvider } from "./internal.js";
export {
UnsupportedChainException,
NetworkException,
UnknownPricingException,
} from "./internal.js";
1 change: 1 addition & 0 deletions packages/pricing/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./external.js";
1 change: 1 addition & 0 deletions packages/pricing/src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./pricing.interface.js";
28 changes: 28 additions & 0 deletions packages/pricing/src/interfaces/pricing.interface.ts
Original file line number Diff line number Diff line change
@@ -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<TokenPrice | undefined>;
}
4 changes: 4 additions & 0 deletions packages/pricing/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./types/index.js";
export * from "./interfaces/index.js";
export * from "./providers/index.js";
export * from "./exceptions/index.js";
157 changes: 157 additions & 0 deletions packages/pricing/src/providers/coingecko.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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,
};

/**
* 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;

/**
* @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<TokenPrice | undefined> {
if (!this.isSupportedChainId(chainId)) {
throw new UnsupportedChainException(chainId);
}

if (startTimestampMs > endTimestampMs) {
return undefined;
}

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<CoingeckoPriceChartData>(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 || error.message === "Network Error") {
throw new NetworkException(error.message, error.status!);
}
}
console.error(error);
throw new UnknownPricingException(
isNativeError(error) ? error.message : 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`;
}
}
1 change: 1 addition & 0 deletions packages/pricing/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./coingecko.provider.js";
23 changes: 23 additions & 0 deletions packages/pricing/src/types/coingecko.types.ts
Original file line number Diff line number Diff line change
@@ -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<string, "CoingeckoTokenId">;
export type CoingeckoPlatformId = Branded<string, "CoingeckoPlatformId">;

export type CoingeckoPriceChartData = {
prices: [number, number][];
market_caps: [number, number][];
total_volumes: [number, number][];
};
2 changes: 2 additions & 0 deletions packages/pricing/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./coingecko.types.js";
export * from "./pricing.types.js";
8 changes: 8 additions & 0 deletions packages/pricing/src/types/pricing.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @timestampMs - The timestamp in milliseconds
* @priceUsd - The price in USD
*/
export type TokenPrice = {
timestampMs: number;
priceUsd: number;
};
Loading

0 comments on commit b4efee3

Please sign in to comment.