Skip to content

Commit b4efee3

Browse files
authored
feat: coingecko pricing provider (#3)
# 🤖 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.
1 parent e7544bc commit b4efee3

28 files changed

+699
-156
lines changed

.prettierrc

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
"<TYPES>",
1111
"<THIRD_PARTY_MODULES>",
1212
"",
13+
"<TYPES>^@grants-stack-indexer",
14+
"^@grants-stack-indexer/(.*)$",
15+
"",
1316
"<TYPES>^[.|..|~]",
1417
"^~/",
1518
"^[../]",

packages/pricing/README.md

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Grants Stack Indexer v2: Pricing package
2+
3+
This package provides different pricing providers that can be used to get
4+
the price of a token at a specific timestamp, using chainId and token address.
5+
6+
## Setup
7+
8+
1. Install dependencies running `pnpm install`
9+
10+
## Available Scripts
11+
12+
Available scripts that can be run using `pnpm`:
13+
14+
| Script | Description |
15+
| ------------- | ------------------------------------------------------- |
16+
| `build` | Build library using tsc |
17+
| `check-types` | Check types issues using tsc |
18+
| `clean` | Remove `dist` folder |
19+
| `lint` | Run ESLint to check for coding standards |
20+
| `lint:fix` | Run linter and automatically fix code formatting issues |
21+
| `format` | Check code formatting and style using Prettier |
22+
| `format:fix` | Run formatter and automatically fix issues |
23+
| `test` | Run tests using vitest |
24+
| `test:cov` | Run tests with coverage report |
25+
26+
## Usage
27+
28+
### Importing the Package
29+
30+
You can import the package in your TypeScript or JavaScript files as follows:
31+
32+
```typescript
33+
import { CoingeckoProvider } from "@grants-stack-indexer/pricing";
34+
```
35+
36+
### Example
37+
38+
```typescript
39+
const coingecko = new CoingeckoProvider({
40+
apiKey: "your-api-key",
41+
apiType: "demo",
42+
});
43+
44+
const price = await coingecko.getTokenPrice(
45+
1,
46+
"0x0d8775f5d29498461708d85e233a7b3331e6f5a0",
47+
1609459200000,
48+
1640908800000,
49+
);
50+
```
51+
52+
## API
53+
54+
### [IPricingProvider](./src/interfaces/pricing.interface.ts)
55+
56+
Available methods
57+
58+
- `getTokenPrice(chainId: number, tokenAddress: Address, startTimestampMs: number, endTimestampMs: number): Promise<TokenPrice | undefined>`
59+
60+
## References
61+
62+
- [Coingecko API Historical Data](https://docs.coingecko.com/reference/coins-id-market-chart-range)

packages/pricing/package.json

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@grants-stack-indexer/pricing",
3+
"version": "0.0.1",
4+
"private": true,
5+
"description": "",
6+
"license": "MIT",
7+
"author": "Wonderland",
8+
"type": "module",
9+
"main": "./dist/src/index.js",
10+
"types": "./dist/src/index.d.ts",
11+
"directories": {
12+
"src": "src"
13+
},
14+
"files": [
15+
"dist/*",
16+
"package.json",
17+
"!**/*.tsbuildinfo"
18+
],
19+
"scripts": {
20+
"build": "tsc -p tsconfig.build.json",
21+
"check-types": "tsc --noEmit -p ./tsconfig.json",
22+
"clean": "rm -rf dist/",
23+
"format": "prettier --check \"{src,test}/**/*.{js,ts,json}\"",
24+
"format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\"",
25+
"lint": "eslint \"{src,test}/**/*.{js,ts,json}\"",
26+
"lint:fix": "pnpm lint --fix",
27+
"test": "vitest run --config vitest.config.ts --passWithNoTests",
28+
"test:cov": "vitest run --config vitest.config.ts --coverage"
29+
},
30+
"dependencies": {
31+
"@grants-stack-indexer/shared": "workspace:0.0.1",
32+
"axios": "1.7.7"
33+
},
34+
"devDependencies": {
35+
"axios-mock-adapter": "2.0.0"
36+
}
37+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./network.exception.js";
2+
export * from "./unsupportedChain.exception.js";
3+
export * from "./unknownPricing.exception.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export class NetworkException extends Error {
2+
constructor(
3+
message: string,
4+
public readonly status: number,
5+
) {
6+
super(message);
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class UnknownPricingException extends Error {
2+
constructor(message: string, stack?: string) {
3+
super(message);
4+
this.stack = stack;
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class UnsupportedChainException extends Error {
2+
constructor(chainId: number) {
3+
super(`Unsupported chain ID: ${chainId}`);
4+
}
5+
}

packages/pricing/src/external.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type { TokenPrice, IPricingProvider } from "./internal.js";
2+
3+
export { CoingeckoProvider } from "./internal.js";
4+
export {
5+
UnsupportedChainException,
6+
NetworkException,
7+
UnknownPricingException,
8+
} from "./internal.js";

packages/pricing/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./external.js";
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./pricing.interface.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Address } from "@grants-stack-indexer/shared";
2+
3+
import { TokenPrice } from "../internal.js";
4+
5+
/**
6+
* Represents a pricing service that retrieves token prices.
7+
* @dev is service responsibility to map address to their internal ID
8+
* @dev for native token (eg. ETH), use the one address
9+
*/
10+
export interface IPricingProvider {
11+
/**
12+
* Retrieves the price of a token at a timestamp range.
13+
* @param chainId - The ID of the blockchain network.
14+
* @param tokenAddress - The address of the token.
15+
* @param startTimestampMs - The start timestamp for which to retrieve the price.
16+
* @param endTimestampMs - The end timestamp for which to retrieve the price.
17+
* @returns A promise that resolves to the price of the token at the specified timestamp or undefined if no price is found.
18+
* @throws {UnsupportedChainException} if the chain ID is not supported by the pricing provider.
19+
* @throws {NetworkException} if the network is not reachable.
20+
* @throws {UnknownFetchException} if the pricing provider returns an unknown error.
21+
*/
22+
getTokenPrice(
23+
chainId: number,
24+
tokenAddress: Address,
25+
startTimestampMs: number,
26+
endTimestampMs: number,
27+
): Promise<TokenPrice | undefined>;
28+
}

packages/pricing/src/internal.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from "./types/index.js";
2+
export * from "./interfaces/index.js";
3+
export * from "./providers/index.js";
4+
export * from "./exceptions/index.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { isNativeError } from "util/types";
2+
import axios, { AxiosInstance, isAxiosError } from "axios";
3+
4+
import { Address, isNativeToken } from "@grants-stack-indexer/shared";
5+
6+
import { IPricingProvider } from "../interfaces/index.js";
7+
import {
8+
CoingeckoPlatformId,
9+
CoingeckoPriceChartData,
10+
CoingeckoSupportedChainId,
11+
CoingeckoTokenId,
12+
NetworkException,
13+
TokenPrice,
14+
UnknownPricingException,
15+
UnsupportedChainException,
16+
} from "../internal.js";
17+
18+
type CoingeckoOptions = {
19+
apiKey: string;
20+
apiType: "demo" | "pro";
21+
};
22+
23+
const getApiTypeConfig = (apiType: "demo" | "pro"): { baseURL: string; authHeader: string } =>
24+
apiType === "demo"
25+
? { baseURL: "https://api.coingecko.com/api/v3", authHeader: "x-cg-demo-api-key" }
26+
: { baseURL: "https://pro-api.coingecko.com/api/v3/", authHeader: "x-cg-pro-api-key" };
27+
28+
const platforms: { [key in CoingeckoSupportedChainId]: CoingeckoPlatformId } = {
29+
1: "ethereum" as CoingeckoPlatformId,
30+
10: "optimistic-ethereum" as CoingeckoPlatformId,
31+
100: "xdai" as CoingeckoPlatformId,
32+
250: "fantom" as CoingeckoPlatformId,
33+
42161: "arbitrum-one" as CoingeckoPlatformId,
34+
43114: "avalanche" as CoingeckoPlatformId,
35+
713715: "sei-network" as CoingeckoPlatformId,
36+
1329: "sei-network" as CoingeckoPlatformId,
37+
42: "lukso" as CoingeckoPlatformId,
38+
42220: "celo" as CoingeckoPlatformId,
39+
1088: "metis" as CoingeckoPlatformId,
40+
};
41+
42+
const nativeTokens: { [key in CoingeckoSupportedChainId]: CoingeckoTokenId } = {
43+
1: "ethereum" as CoingeckoTokenId,
44+
10: "ethereum" as CoingeckoTokenId,
45+
100: "xdai" as CoingeckoTokenId,
46+
250: "fantom" as CoingeckoTokenId,
47+
42161: "ethereum" as CoingeckoTokenId,
48+
43114: "avalanche-2" as CoingeckoTokenId,
49+
713715: "sei-network" as CoingeckoTokenId,
50+
1329: "sei-network" as CoingeckoTokenId,
51+
42: "lukso-token" as CoingeckoTokenId,
52+
42220: "celo" as CoingeckoTokenId,
53+
1088: "metis-token" as CoingeckoTokenId,
54+
};
55+
56+
/**
57+
* The Coingecko provider is a pricing provider that uses the Coingecko API to get the price of a token.
58+
*/
59+
export class CoingeckoProvider implements IPricingProvider {
60+
private readonly axios: AxiosInstance;
61+
62+
/**
63+
* @param options.apiKey - Coingecko API key.
64+
* @param options.apiType - Coingecko API type (demo or pro).
65+
*/
66+
constructor(options: CoingeckoOptions) {
67+
const { apiKey, apiType } = options;
68+
const { baseURL, authHeader } = getApiTypeConfig(apiType);
69+
70+
this.axios = axios.create({
71+
baseURL,
72+
headers: {
73+
common: {
74+
[authHeader]: apiKey,
75+
Accept: "application/json",
76+
},
77+
},
78+
});
79+
}
80+
81+
/* @inheritdoc */
82+
async getTokenPrice(
83+
chainId: number,
84+
tokenAddress: Address,
85+
startTimestampMs: number,
86+
endTimestampMs: number,
87+
): Promise<TokenPrice | undefined> {
88+
if (!this.isSupportedChainId(chainId)) {
89+
throw new UnsupportedChainException(chainId);
90+
}
91+
92+
if (startTimestampMs > endTimestampMs) {
93+
return undefined;
94+
}
95+
96+
const startTimestampSecs = Math.floor(startTimestampMs / 1000);
97+
const endTimestampSecs = Math.floor(endTimestampMs / 1000);
98+
99+
const path = this.getApiPath(chainId, tokenAddress, startTimestampSecs, endTimestampSecs);
100+
101+
//TODO: handle retries
102+
try {
103+
const { data } = await this.axios.get<CoingeckoPriceChartData>(path);
104+
105+
const closestEntry = data.prices.at(0);
106+
if (!closestEntry) {
107+
return undefined;
108+
}
109+
110+
return {
111+
timestampMs: closestEntry[0],
112+
priceUsd: closestEntry[1],
113+
};
114+
} catch (error: unknown) {
115+
//TODO: notify
116+
if (isAxiosError(error)) {
117+
if (error.status! >= 400 && error.status! < 500) {
118+
console.error(`Coingecko API error: ${error.message}. Stack: ${error.stack}`);
119+
return undefined;
120+
}
121+
122+
if (error.status! >= 500 || error.message === "Network Error") {
123+
throw new NetworkException(error.message, error.status!);
124+
}
125+
}
126+
console.error(error);
127+
throw new UnknownPricingException(
128+
isNativeError(error) ? error.message : JSON.stringify(error),
129+
isNativeError(error) ? error.stack : undefined,
130+
);
131+
}
132+
}
133+
134+
/*
135+
* @returns Whether the given chain ID is supported by the Coingecko API.
136+
*/
137+
private isSupportedChainId(chainId: number): chainId is CoingeckoSupportedChainId {
138+
return chainId in platforms;
139+
}
140+
141+
/*
142+
* @returns The API endpoint path for the given parameters.
143+
*/
144+
private getApiPath(
145+
chainId: CoingeckoSupportedChainId,
146+
tokenAddress: Address,
147+
startTimestampSecs: number,
148+
endTimestampSecs: number,
149+
): string {
150+
const platform = platforms[chainId];
151+
const nativeTokenId = nativeTokens[chainId];
152+
153+
return isNativeToken(tokenAddress)
154+
? `/coins/${nativeTokenId}/market_chart/range?vs_currency=usd&from=${startTimestampSecs}&to=${endTimestampSecs}&precision=full`
155+
: `/coins/${platform}/contract/${tokenAddress.toLowerCase()}/market_chart/range?vs_currency=usd&from=${startTimestampSecs}&to=${endTimestampSecs}&precision=full`;
156+
}
157+
}
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./coingecko.provider.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Branded } from "@grants-stack-indexer/shared";
2+
3+
export type CoingeckoSupportedChainId =
4+
| 1
5+
| 10
6+
| 100
7+
| 250
8+
| 42161
9+
| 43114
10+
| 713715
11+
| 1329
12+
| 42
13+
| 42220
14+
| 1088;
15+
16+
export type CoingeckoTokenId = Branded<string, "CoingeckoTokenId">;
17+
export type CoingeckoPlatformId = Branded<string, "CoingeckoPlatformId">;
18+
19+
export type CoingeckoPriceChartData = {
20+
prices: [number, number][];
21+
market_caps: [number, number][];
22+
total_volumes: [number, number][];
23+
};

packages/pricing/src/types/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./coingecko.types.js";
2+
export * from "./pricing.types.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @timestampMs - The timestamp in milliseconds
3+
* @priceUsd - The price in USD
4+
*/
5+
export type TokenPrice = {
6+
timestampMs: number;
7+
priceUsd: number;
8+
};

0 commit comments

Comments
 (0)