Skip to content

Commit

Permalink
feat: add whitelist tokens (#13)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes GIT-111 GIT-66

## Description

- copies Tokens whitelist per chain to Shared package
- modifies `PricingProvider` interface to use `TokenCode` in place of
(addreess, chainId)
- updates internal CoingeckoProvider to map `TokenCode` to `CoingeckoID`
- completes TODO in `PoolCreatedHandler` to fetch Token given the Chain
and TokenAddress from Event

## Checklist before requesting a review

-   [x] I have conducted a self-review of my code.
-   [x] I have conducted a QA.
-   [ ] If it is a core feature, I have included comprehensive tests.
  • Loading branch information
0xnigir1 authored Oct 22, 2024
1 parent f585a86 commit 22900ac
Show file tree
Hide file tree
Showing 11 changed files with 760 additions and 144 deletions.
1 change: 1 addition & 0 deletions packages/pricing/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./network.exception.js";
export * from "./unsupportedChain.exception.js";
export * from "./unknownPricing.exception.js";
export * from "./unsupportedToken.exception.js";
7 changes: 7 additions & 0 deletions packages/pricing/src/exceptions/unsupportedToken.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenCode } from "@grants-stack-indexer/shared";

export class UnsupportedToken extends Error {
constructor(tokenCode: TokenCode) {
super(`Unsupported token: ${tokenCode}`);
}
}
1 change: 1 addition & 0 deletions packages/pricing/src/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export {
UnsupportedChainException,
NetworkException,
UnknownPricingException,
UnsupportedToken,
} from "./internal.js";
13 changes: 5 additions & 8 deletions packages/pricing/src/interfaces/pricing.interface.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import { Address } from "@grants-stack-indexer/shared";
import { TokenCode } 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
* @dev is service responsibility to map token code to their internal platform ID
*/
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 tokenCode - The code 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 {UnsupportedToken} if the token 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,
tokenCode: TokenCode,
startTimestampMs: number,
endTimestampMs: number,
): Promise<TokenPrice | undefined>;
Expand Down
93 changes: 34 additions & 59 deletions packages/pricing/src/providers/coingecko.provider.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { isNativeError } from "util/types";
import axios, { AxiosInstance, isAxiosError } from "axios";

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

import { IPricingProvider } from "../interfaces/index.js";
import {
CoingeckoPlatformId,
CoingeckoPriceChartData,
CoingeckoSupportedChainId,
CoingeckoTokenId,
NetworkException,
TokenPrice,
UnknownPricingException,
UnsupportedChainException,
UnsupportedToken,
} from "../internal.js";

type CoingeckoOptions = {
Expand All @@ -25,32 +23,33 @@ const getApiTypeConfig = (apiType: "demo" | "pro"): { baseURL: string; authHeade
? { 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,
const TokenMapping: { [key: string]: CoingeckoTokenId | undefined } = {
USDC: "usd-coin" as CoingeckoTokenId,
DAI: "dai" as CoingeckoTokenId,
ETH: "ethereum" as CoingeckoTokenId,
eBTC: "ebtc" as CoingeckoTokenId,
USDGLO: "glo-dollar" as CoingeckoTokenId,
GIST: "dai" as CoingeckoTokenId,
OP: "optimism" as CoingeckoTokenId,
LYX: "lukso-token-2" as CoingeckoTokenId,
WLYX: "wrapped-lyx-universalswaps" as CoingeckoTokenId,
XDAI: "xdai" as CoingeckoTokenId,
MATIC: "polygon-ecosystem-token" as CoingeckoTokenId,
DATA: "streamr" as CoingeckoTokenId,
FTM: "fantom" as CoingeckoTokenId,
GcV: undefined,
USDT: "tether" as CoingeckoTokenId,
LUSD: "liquity-usd" as CoingeckoTokenId,
MUTE: "mute" as CoingeckoTokenId,
GTC: "gitcoin" as CoingeckoTokenId,
METIS: "metis" as CoingeckoTokenId,
SEI: "sei-network" as CoingeckoTokenId,
ARB: "arbitrum" as CoingeckoTokenId,
CELO: "celo" as CoingeckoTokenId,
CUSD: "celo-dollar" as CoingeckoTokenId,
AVAX: "avalanche-2" as CoingeckoTokenId,
MTK: undefined,
WSEI: "wrapped-sei" as CoingeckoTokenId,
};

/**
Expand Down Expand Up @@ -80,13 +79,13 @@ export class CoingeckoProvider implements IPricingProvider {

/* @inheritdoc */
async getTokenPrice(
chainId: number,
tokenAddress: Address,
tokenCode: TokenCode,
startTimestampMs: number,
endTimestampMs: number,
): Promise<TokenPrice | undefined> {
if (!this.isSupportedChainId(chainId)) {
throw new UnsupportedChainException(chainId);
const tokenId = TokenMapping[tokenCode];
if (!tokenId) {
throw new UnsupportedToken(tokenCode);
}

if (startTimestampMs > endTimestampMs) {
Expand All @@ -96,7 +95,7 @@ export class CoingeckoProvider implements IPricingProvider {
const startTimestampSecs = Math.floor(startTimestampMs / 1000);
const endTimestampSecs = Math.floor(endTimestampMs / 1000);

const path = this.getApiPath(chainId, tokenAddress, startTimestampSecs, endTimestampSecs);
const path = `/coins/${tokenId}/market_chart/range?vs_currency=usd&from=${startTimestampSecs}&to=${endTimestampSecs}&precision=full`;

//TODO: handle retries
try {
Expand Down Expand Up @@ -130,28 +129,4 @@ export class CoingeckoProvider implements IPricingProvider {
);
}
}

/*
* @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`;
}
}
69 changes: 11 additions & 58 deletions packages/pricing/test/providers/coingecko.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

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

import type { TokenPrice } from "../../src/external.js";
import {
CoingeckoProvider,
NetworkException,
UnsupportedChainException,
} from "../../src/external.js";
import { CoingeckoProvider, NetworkException, UnsupportedToken } from "../../src/external.js";

const mock = vi.hoisted(() => ({
get: vi.fn(),
Expand Down Expand Up @@ -53,32 +49,7 @@ describe("CoingeckoProvider", () => {
mock.get.mockResolvedValueOnce({ status: 200, data: mockResponse });

const result = await provider.getTokenPrice(
1,
"0x1234567890123456789012345678901234567890" as Address,
1609459200000,
1609545600000,
);

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

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

it("return token price for a supported chain and native token", async () => {
const mockResponse = {
prices: [[1609459200000, 100]],
};
mock.get.mockResolvedValueOnce({ status: 200, data: mockResponse });

const result = await provider.getTokenPrice(
10,
NATIVE_TOKEN_ADDRESS,
"ETH" as TokenCode,
1609459200000,
1609545600000,
);
Expand All @@ -101,8 +72,7 @@ describe("CoingeckoProvider", () => {
mock.get.mockResolvedValueOnce({ status: 200, data: mockResponse });

const result = await provider.getTokenPrice(
1,
"0x1234567890123456789012345678901234567890" as Address,
"ETH" as TokenCode,
1609459200000,
1609545600000,
);
Expand All @@ -112,8 +82,7 @@ describe("CoingeckoProvider", () => {

it("return undefined when endTimestamp is greater than startTimestamp", async () => {
const result = await provider.getTokenPrice(
1,
"0x1234567890123456789012345678901234567890" as Address,
"ETH" as TokenCode,
1609545600000, // startTimestamp
1609459200000, // endTimestamp
);
Expand All @@ -129,24 +98,18 @@ describe("CoingeckoProvider", () => {
});

const result = await provider.getTokenPrice(
1,
"0x1234567890123456789012345678901234567890" as Address,
"ETH" as TokenCode,
1609459200000,
1609545600000,
);

expect(result).toBeUndefined();
});

it("throw UnsupportedChainException for unsupported chain", async () => {
it("throw UnsupportedTokenException for unsupported token", async () => {
await expect(() =>
provider.getTokenPrice(
999999, // Unsupported chain ID
"0x1234567890123456789012345678901234567890" as Address,
1609459200000,
1609545600000,
),
).rejects.toThrow(UnsupportedChainException);
provider.getTokenPrice("UNSUPPORTED" as TokenCode, 1609459200000, 1609545600000),
).rejects.toThrow(UnsupportedToken);
});

it("throws NetworkException for 500 family errors", async () => {
Expand All @@ -156,12 +119,7 @@ describe("CoingeckoProvider", () => {
isAxiosError: true,
});
await expect(
provider.getTokenPrice(
1,
"0x1234567890123456789012345678901234567890" as Address,
1609459200000,
1609545600000,
),
provider.getTokenPrice("ETH" as TokenCode, 1609459200000, 1609545600000),
).rejects.toThrow(NetworkException);
});

Expand All @@ -173,12 +131,7 @@ describe("CoingeckoProvider", () => {
});

await expect(
provider.getTokenPrice(
1,
"0x1234567890123456789012345678901234567890" as Address,
1609459200000,
1609545600000,
),
provider.getTokenPrice("ETH" as TokenCode, 1609459200000, 1609545600000),
).rejects.toThrow(NetworkException);
});
});
Expand Down
24 changes: 9 additions & 15 deletions packages/processors/src/allo/handlers/poolCreated.handler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Address, getAddress, parseUnits, zeroAddress } from "viem";
import { getAddress, parseUnits, zeroAddress } from "viem";

import type { Changeset, NewRound, PendingRoundRole } from "@grants-stack-indexer/repository";
import type { ChainId, ProtocolEvent } from "@grants-stack-indexer/shared";
import type { ChainId, ProtocolEvent, Token } from "@grants-stack-indexer/shared";
import { isAlloNativeToken } from "@grants-stack-indexer/shared";
import { getToken } from "@grants-stack-indexer/shared/dist/src/internal.js";

import type { IEventHandler, ProcessorDependencies, StrategyTimings } from "../../internal.js";
import { getRoundRoles } from "../../helpers/roles.js";
Expand All @@ -17,7 +18,7 @@ type Dependencies = Pick<
>;

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

/**
/**
Expand Down Expand Up @@ -62,13 +63,7 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated">

const strategy = extractStrategyFromId(strategyId);

// TODO: get token for the chain
const token = {
address: matchTokenAddress,
decimals: 18, //TODO: get decimals from token
symbol: "USDC", //TODO: get symbol from token
name: "USDC", //TODO: get name from token
};
const token = getToken(this.chainId, matchTokenAddress);

let strategyTimings: StrategyTimings = {
applicationsStartTime: null,
Expand All @@ -87,7 +82,7 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated">
if (
strategy.name === "allov2.DonationVotingMerkleDistributionDirectTransferStrategy" &&
parsedRoundMetadata.success &&
token !== null
token
) {
matchAmount = parseUnits(
parsedRoundMetadata.data.quadraticFundingConfig.matchingFundsAvailable.toString(),
Expand All @@ -104,7 +99,7 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated">

let fundedAmountInUsd = "0";

if (token !== null && fundedAmount > 0n) {
if (token && fundedAmount > 0n) {
fundedAmountInUsd = await this.getTokenAmountInUsd(
token,
fundedAmount,
Expand Down Expand Up @@ -206,14 +201,13 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated">
}

private async getTokenAmountInUsd(
token: { address: Address; decimals: number },
token: Token,
amount: bigint,
timestamp: number,
): Promise<string> {
const { pricingProvider } = this.dependencies;
const tokenPrice = await pricingProvider.getTokenPrice(
this.chainId,
token.address,
token.priceSourceCode,
timestamp,
timestamp + TIMESTAMP_DELTA_RANGE,
);
Expand Down
Loading

0 comments on commit 22900ac

Please sign in to comment.