Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: coingecko pricing provider #3

Merged
merged 3 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just as a bonus, if you add a trailing slash you are forcing rm to remove a dist folder (if it's a dist file it's not removed):

Suggested change
"clean": "rm -rf dist",
"clean": "rm -rf dist/",

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can add this to the boilerplate

"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";
153 changes: 153 additions & 0 deletions packages/pricing/src/providers/coingecko.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just in case asking, does the apiKey expire?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noup, it's long lived

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);
}

const startTimestampSecs = Math.floor(startTimestampMs / 1000);
const endTimestampSecs = Math.floor(endTimestampMs / 1000);
Comment on lines +96 to +97
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if start > end? No results? API failure?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aaa good catch, will add that validation


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