Skip to content

Commit

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

Closes GIT-136 GIT-144

## Description
- DummyPricingProvider for testing, dev environments to avoid rate
limiting
- write a Factory to instantiate a pricing provider from Env config
- makes `timestampEnd` optional for `IPricingProvider`

## 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 Nov 14, 2024
1 parent bd5be45 commit 0d4872b
Show file tree
Hide file tree
Showing 24 changed files with 273 additions and 42 deletions.
2 changes: 2 additions & 0 deletions apps/processing/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ 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 | dummy

COINGECKO_API_KEY={{YOUR_KEY}}
COINGECKO_API_TYPE=demo
2 changes: 2 additions & 0 deletions apps/processing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Available options:
| `INDEXER_GRAPHQL_URL` | GraphQL endpoint for the indexer | N/A | Yes | |
| `INDEXER_ADMIN_SECRET` | Admin secret for indexer authentication | N/A | Yes | |
| `IPFS_GATEWAYS_URL` | Array of IPFS gateway URLs | N/A | Yes | Multiple gateways for redundancy |
| `PRICING_SOURCE` | Pricing source (coingecko or dummy) | coingecko | No | |
| `DUMMY_PRICE` | Dummy price | 1 | No | Only if PRICING_SOURCE is dummy |
| `COINGECKO_API_KEY` | API key for CoinGecko service | N/A | Yes | |
| `COINGECKO_API_TYPE` | CoinGecko API tier (demo or pro) | pro | No | |

Expand Down
31 changes: 28 additions & 3 deletions apps/processing/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const stringToJSONSchema = z.string().transform((str, ctx): z.infer<ReturnType<t
}
});

const validationSchema = z.object({
const baseSchema = z.object({
RPC_URLS: stringToJSONSchema.pipe(z.array(z.string().url())),
CHAIN_ID: z.coerce.number().int().positive(),
FETCH_LIMIT: z.coerce.number().int().positive().default(500),
Expand All @@ -23,13 +23,38 @@ const validationSchema = z.object({
DATABASE_SCHEMA: z.string().default("public"),
INDEXER_GRAPHQL_URL: z.string().url(),
INDEXER_ADMIN_SECRET: z.string(),
COINGECKO_API_KEY: z.string(),
COINGECKO_API_TYPE: z.enum(["demo", "pro"]).default("pro"),
PRICING_SOURCE: z.enum(["dummy", "coingecko"]).default("coingecko"),
IPFS_GATEWAYS_URL: stringToJSONSchema
.pipe(z.array(z.string().url()))
.default('["https://ipfs.io"]'),
});

const dummyPricingSchema = baseSchema.extend({
PRICING_SOURCE: z.literal("dummy"),
DUMMY_PRICE: z.coerce.number().optional().default(1),
});

const coingeckoPricingSchema = baseSchema.extend({
PRICING_SOURCE: z.literal("coingecko"),
COINGECKO_API_KEY: z.string().min(1),
COINGECKO_API_TYPE: z.enum(["demo", "pro"]).default("pro"),
});

const validationSchema = z
.discriminatedUnion("PRICING_SOURCE", [dummyPricingSchema, coingeckoPricingSchema])
.transform((val) => {
if (val.PRICING_SOURCE === "dummy") {
return { pricingSource: val.PRICING_SOURCE, dummyPrice: val.DUMMY_PRICE, ...val };
}

return {
pricingSource: val.PRICING_SOURCE,
apiKey: val.COINGECKO_API_KEY,
apiType: val.COINGECKO_API_TYPE,
...val,
};
});

const env = validationSchema.safeParse(process.env);

if (!env.success) {
Expand Down
10 changes: 2 additions & 8 deletions apps/processing/src/services/sharedDependencies.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from "@grants-stack-indexer/data-flow";
import { EnvioIndexerClient } from "@grants-stack-indexer/indexer-client";
import { IpfsProvider } from "@grants-stack-indexer/metadata";
import { CoingeckoProvider } from "@grants-stack-indexer/pricing";
import { PricingProviderFactory } from "@grants-stack-indexer/pricing";
import {
createKyselyDatabase,
KyselyApplicationRepository,
Expand Down Expand Up @@ -50,13 +50,7 @@ export class SharedDependenciesService {
kyselyDatabase,
env.DATABASE_SCHEMA,
);
const pricingProvider = new CoingeckoProvider(
{
apiKey: env.COINGECKO_API_KEY,
apiType: env.COINGECKO_API_TYPE,
},
logger,
);
const pricingProvider = PricingProviderFactory.create(env, { logger });

const metadataProvider = new IpfsProvider(env.IPFS_GATEWAYS_URL, logger);

Expand Down
2 changes: 2 additions & 0 deletions packages/pricing/src/exceptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from "./network.exception.js";
export * from "./unsupportedChain.exception.js";
export * from "./unknownPricing.exception.js";
export * from "./unsupportedToken.exception.js";
export * from "./invalidSource.exception.js";
export * from "./missingDependencies.exception.js";
5 changes: 5 additions & 0 deletions packages/pricing/src/exceptions/invalidSource.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class InvalidPricingSource extends Error {
constructor() {
super(`Invalid pricing source`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class MissingDependencies extends Error {
constructor() {
super(`Missing dependencies`);
}
}
13 changes: 12 additions & 1 deletion packages/pricing/src/external.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
export type { TokenPrice, IPricingProvider } from "./internal.js";

export { CoingeckoProvider } from "./internal.js";
export { CoingeckoProvider, DummyPricingProvider } from "./internal.js";

export { PricingProviderFactory } from "./internal.js";
export type {
PricingConfig,
PricingProvider,
DummyPricingConfig,
CoingeckoPricingConfig,
} from "./internal.js";

export {
UnsupportedChainException,
NetworkException,
UnknownPricingException,
UnsupportedToken,
InvalidPricingSource,
MissingDependencies,
} from "./internal.js";
56 changes: 56 additions & 0 deletions packages/pricing/src/factory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ILogger } from "@grants-stack-indexer/shared";

import {
CoingeckoProvider,
DummyPricingProvider,
InvalidPricingSource,
IPricingProvider,
MissingDependencies,
PricingConfig,
PricingProvider,
} from "../internal.js";

/**
* Factory class for creating pricing providers.
*/
export class PricingProviderFactory {
/**
* Creates a pricing provider based on the provided configuration.
* @param options - The pricing configuration.
* @param deps - dependencies to inject into the pricing provider.
* @returns The created pricing provider.
* @throws {InvalidPricingSource} if the pricing source is invalid.
* @throws {MissingDependencies} if the dependencies are missing.
*/
static create(
options: PricingConfig<PricingProvider>,
deps?: {
logger?: ILogger;
},
): IPricingProvider {
let pricingProvider: IPricingProvider;

switch (options.pricingSource) {
case "dummy":
pricingProvider = new DummyPricingProvider(options.dummyPrice);
break;
case "coingecko":
if (!deps?.logger) {
throw new MissingDependencies();
}

pricingProvider = new CoingeckoProvider(
{
apiKey: options.apiKey,
apiType: options.apiType,
},
deps.logger,
);
break;
default:
throw new InvalidPricingSource();
}

return pricingProvider;
}
}
1 change: 1 addition & 0 deletions packages/pricing/src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./pricing.interface.js";
export * from "./pricingConfig.interface.js";
5 changes: 4 additions & 1 deletion packages/pricing/src/interfaces/pricing.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { TokenCode } from "@grants-stack-indexer/shared";

import { TokenPrice } from "../internal.js";

// available pricing providers
export type PricingProvider = "coingecko" | "dummy";

/**
* Represents a pricing service that retrieves token prices.
* @dev is service responsibility to map token code to their internal platform ID
Expand All @@ -20,6 +23,6 @@ export interface IPricingProvider {
getTokenPrice(
tokenCode: TokenCode,
startTimestampMs: number,
endTimestampMs: number,
endTimestampMs?: number,
): Promise<TokenPrice | undefined>;
}
18 changes: 18 additions & 0 deletions packages/pricing/src/interfaces/pricingConfig.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PricingProvider } from "./index.js";

export type DummyPricingConfig = {
pricingSource: "dummy";
dummyPrice?: number;
};

export type CoingeckoPricingConfig = {
pricingSource: "coingecko";
apiKey: string;
apiType: "demo" | "pro";
};

export type PricingConfig<Source extends PricingProvider> = Source extends "dummy"
? DummyPricingConfig
: Source extends "coingecko"
? CoingeckoPricingConfig
: never;
1 change: 1 addition & 0 deletions packages/pricing/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./types/index.js";
export * from "./interfaces/index.js";
export * from "./providers/index.js";
export * from "./exceptions/index.js";
export * from "./factory/index.js";
9 changes: 8 additions & 1 deletion packages/pricing/src/providers/coingecko.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const TokenMapping: { [key: string]: CoingeckoTokenId | undefined } = {
WSEI: "wrapped-sei" as CoingeckoTokenId,
};

// sometimes coingecko returns no prices for 1 hour range, 2 hours works better
const TIME_DELTA = 2 * 60 * 60 * 1000;

/**
* The Coingecko provider is a pricing provider that uses the Coingecko API to get the price of a token.
*/
Expand Down Expand Up @@ -83,13 +86,17 @@ export class CoingeckoProvider implements IPricingProvider {
async getTokenPrice(
tokenCode: TokenCode,
startTimestampMs: number,
endTimestampMs: number,
endTimestampMs?: number,
): Promise<TokenPrice | undefined> {
const tokenId = TokenMapping[tokenCode];
if (!tokenId) {
throw new UnsupportedToken(tokenCode);
}

if (!endTimestampMs) {
endTimestampMs = startTimestampMs + TIME_DELTA;
}

if (startTimestampMs > endTimestampMs) {
return undefined;
}
Expand Down
24 changes: 24 additions & 0 deletions packages/pricing/src/providers/dummy.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { TokenCode } from "@grants-stack-indexer/shared";

import { IPricingProvider, TokenPrice } from "../internal.js";

/**
* DummyPricingProvider class that implements the IPricingProvider interface.
* This provider returns a configurable fixed price (defaults to 1) for any token code.
* Used primarily for testing purposes when actual token prices are not needed.
*/
export class DummyPricingProvider implements IPricingProvider {
constructor(private readonly dummyPrice: number = 1) {}

/* @inheritdoc */
async getTokenPrice(
_tokenCode: TokenCode,
startTimestampMs: number,
_endTimestampMs?: number,
): Promise<TokenPrice | undefined> {
return Promise.resolve({
priceUsd: this.dummyPrice,
timestampMs: startTimestampMs,
});
}
}
1 change: 1 addition & 0 deletions packages/pricing/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./coingecko.provider.js";
export * from "./dummy.provider.js";
56 changes: 56 additions & 0 deletions packages/pricing/test/factory/pricingFactory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";

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 { DummyPricingProvider } from "../../src/providers/dummy.provider.js";

describe("PricingProviderFactory", () => {
it("create a DummyPricingProvider", () => {
const options: PricingConfig<"dummy"> = {
pricingSource: "dummy",
dummyPrice: 1,
};

const pricingProvider = PricingProviderFactory.create(options);
expect(pricingProvider).toBeInstanceOf(DummyPricingProvider);
const dummyProvider = pricingProvider as DummyPricingProvider;
expect(dummyProvider["dummyPrice"]).toBe(1);
});

it("create a CoingeckoProvider", () => {
const options: PricingConfig<"coingecko"> = {
pricingSource: "coingecko",
apiKey: "some-api-key",
apiType: "pro",
};

const pricingProvider = PricingProviderFactory.create(options, {
logger: {} as unknown as ILogger,
});

expect(pricingProvider).toBeInstanceOf(CoingeckoProvider);
});

it("throws if logger instance is not provided for CoingeckoProvider", () => {
const options: PricingConfig<"coingecko"> = {
pricingSource: "coingecko",
apiKey: "some-api-key",
apiType: "demo",
};

expect(() => PricingProviderFactory.create(options)).toThrowError(MissingDependencies);
});

it("should throw an error for invalid pricing source", () => {
const options = {
source: "invalid",
};

expect(() => {
PricingProviderFactory.create(options as unknown as PricingConfig<"dummy">);
}).toThrowError(InvalidPricingSource);
});
});
19 changes: 19 additions & 0 deletions packages/pricing/test/providers/coingecko.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ describe("CoingeckoProvider", () => {
);
});

it("uses default endTimestampMs if not provided", async () => {
const mockResponse = {
prices: [[1609459200000, 100]],
};
mock.get.mockResolvedValueOnce({ status: 200, data: mockResponse });

const result = await provider.getTokenPrice("ETH" as TokenCode, 1609459200000);

const expectedPrice: TokenPrice = {
timestampMs: 1609459200000,
priceUsd: 100,
};

expect(result).toEqual(expectedPrice);
expect(mock.get).toHaveBeenCalledWith(
"/coins/ethereum/market_chart/range?vs_currency=usd&from=1609459200000&to=1609466400000&precision=full",
);
});

it("return undefined if no price data is available for timerange", async () => {
const mockResponse = {
prices: [],
Expand Down
18 changes: 18 additions & 0 deletions packages/pricing/test/providers/dummy.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";

import { TokenCode } from "@grants-stack-indexer/shared";

import { DummyPricingProvider } from "../../src/providers/dummy.provider.js";

describe("DummyPricingProvider", () => {
it("return 1 for all token prices", async () => {
const provider = new DummyPricingProvider();

const response = await provider.getTokenPrice("ETH" as TokenCode, 11111111);

expect(response).toEqual({
priceUsd: 1,
timestampMs: 11111111,
});
});
});
Loading

0 comments on commit 0d4872b

Please sign in to comment.