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: dummy pricing provider #32

Merged
merged 4 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is that #coingecko ok?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

is # for comment, in next PR i'll add a whitespace


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

Comment on lines +32 to +57
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can move this into a different file in the future(not now), to have a more readable and modular code.

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();
}
Comment on lines +38 to +40
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm wondering if we should standardize the dependencies validation with some kind of generic pattern (with the help of zod maybe?) while initializing objects.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

you mean inside constructors or having a zod schema here instead of if (!deps?.logger)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Zod schema could be a solution yeah. I'm thinking in general, whenever a service needs dependencies and those dependencies might need to accomodate themselves to some particular schema (like optional dependencies, some dependencies need to be present based on some other dependency value, etc)

I guess that applying a zod schema could cover 99% of the cases, I'll apply it in my code and I'll let you know how happy/sad it is to use it lol


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(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Extremely minor detail but the async keyword here is not necessary given the Promise.resolve() usage

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ooo didn't know that

_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