Skip to content

Commit

Permalink
feat: add coingecko token offchain data provider (#102)
Browse files Browse the repository at this point in the history
# What ❔

- add coingecko tokens off-chain data provider

## Why ❔

- to support both L1 and L2 zksync tokens

## Checklist

<!-- Check your PR fulfills the following items. -->
<!-- For draft PRs check the boxes as you complete them. -->

- [+ ] PR title corresponds to the body of PR (we generate changelog
entries from PRs).
- [ +] Tests for the changes have been added / updated.
  • Loading branch information
Romsters authored and pcheremu committed Feb 15, 2024
1 parent 60780c6 commit 8717940
Show file tree
Hide file tree
Showing 22 changed files with 775 additions and 135 deletions.
1 change: 1 addition & 0 deletions packages/api/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface IPaginationFilterOptions {
blockNumber?: number;
address?: string;
l1BatchNumber?: number;
minLiquidity?: number;
}

export interface IPaginationOptions<CustomMetaType = IPaginationMeta> extends NestIPaginationOptions<CustomMetaType> {
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/token/token.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ describe("TokenController", () => {
it("queries tokens with the specified options", async () => {
await controller.getTokens(pagingOptions, 1000);
expect(serviceMock.findAll).toHaveBeenCalledTimes(1);
expect(serviceMock.findAll).toHaveBeenCalledWith({ minLiquidity: 1000 }, { ...pagingOptions, route: "tokens" });
expect(serviceMock.findAll).toHaveBeenCalledWith(
{ minLiquidity: 1000 },
{ ...pagingOptions, filterOptions: { minLiquidity: 1000 }, route: "tokens" }
);
});

it("returns the tokens", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/token/token.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class TokenController {
minLiquidity,
},
{
filterOptions: { minLiquidity },
...pagingOptions,
route: entityName,
}
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/token/token.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, FindOptionsSelect, MoreThanOrEqual } from "typeorm";
import { Pagination, IPaginationOptions } from "nestjs-typeorm-paginate";
import { Pagination } from "nestjs-typeorm-paginate";
import { IPaginationOptions } from "../common/types";
import { paginate } from "../common/utils";
import { Token, ETH_TOKEN } from "./token.entity";

Expand Down
18 changes: 14 additions & 4 deletions packages/app/src/composables/useTokenLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ import useContext, { type Context } from "@/composables/useContext";

const retrieveTokens = useMemoize(
async (context: Context): Promise<Api.Response.Token[]> => {
const tokensResponse = await $fetch<Api.Response.Collection<Api.Response.Token>>(
`${context.currentNetwork.value.apiUrl}/tokens?minLiquidity=0&limit=100`
);
return tokensResponse.items;
const tokens = [];
let page = 1;
let hasMore = true;

while (hasMore) {
const tokensResponse = await $fetch<Api.Response.Collection<Api.Response.Token>>(
`${context.currentNetwork.value.apiUrl}/tokens?minLiquidity=0&limit=100&page=${page}`
);
tokens.push(...tokensResponse.items);
page++;
hasMore = tokensResponse.meta.totalPages > tokensResponse.meta.currentPage;
}

return tokens;
},
{
getKey(context: Context) {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@
"tokenListView": {
"title": "Token List",
"heading": "Tokens",
"offChainDataPoweredBy": "Off-chain data powered by",
"table": {
"tokenName": "Token Name",
"price": "Price",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/locales/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@
"tokenListView": {
"title": "Список Токенів",
"heading": "Токени",
"offChainDataPoweredBy": "Off-chain дані взяті з",
"table": {
"tokenName": "Назва Токена",
"price": "Ціна",
Expand Down
20 changes: 19 additions & 1 deletion packages/app/src/views/TokensView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
<Breadcrumbs :items="breadcrumbItems" />
<SearchForm class="search-form" />
</div>
<h1>{{ t("tokenListView.heading") }}</h1>
<div class="tokens-header">
<h1>{{ t("tokenListView.heading") }}</h1>
<div v-if="tokens[0]?.iconURL" class="coingecko-attribution">
<span>{{ t("tokenListView.offChainDataPoweredBy") }}{{ " " }}</span>
<a href="https://www.coingecko.com/en/api" target="_blank">CoinGecko API</a>
</div>
</div>
<div class="tokens-container">
<span v-if="isTokensFailed" class="error-message">
{{ t("failedRequest") }}
Expand Down Expand Up @@ -51,4 +57,16 @@ getTokens();
.tokens-container {
@apply mt-8;
}
.tokens-header {
@apply flex justify-between items-end gap-4;
.coingecko-attribution {
@apply mr-1 text-gray-300;
a {
@apply text-blue-100;
}
}
}
</style>
48 changes: 37 additions & 11 deletions packages/app/tests/composables/useTokenLibrary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,40 @@ vi.mock("ohmyfetch", async () => {
const mod = await vi.importActual<typeof import("ohmyfetch")>("ohmyfetch");
return {
...mod,
$fetch: vi.fn().mockResolvedValue([
{
decimals: 18,
iconURL: "https://icon.url",
l1Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
l2Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
name: "Ether",
symbol: "ETH",
} as Api.Response.Token,
]),
$fetch: vi
.fn()
.mockResolvedValueOnce({
items: [
{
decimals: 18,
iconURL: "https://icon.url",
l1Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
l2Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
name: "Ether",
symbol: "ETH",
} as Api.Response.Token,
],
meta: {
totalPages: 2,
currentPage: 1,
},
})
.mockResolvedValueOnce({
items: [
{
decimals: 18,
iconURL: "https://icon2.url",
l1Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef",
l2Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef",
name: "Ether2",
symbol: "ETH2",
} as Api.Response.Token,
],
meta: {
totalPages: 2,
currentPage: 2,
},
}),
};
});

Expand All @@ -31,7 +55,9 @@ describe("useTokenLibrary:", () => {
const { getTokens } = useTokenLibrary();
await getTokens();
await getTokens();
expect($fetch).toHaveBeenCalledTimes(1);
await getTokens();
await getTokens();
expect($fetch).toHaveBeenCalledTimes(2);
});
it("sets isRequestPending to true when request is pending", async () => {
const { isRequestPending, getTokens } = useTokenLibrary();
Expand Down
5 changes: 4 additions & 1 deletion packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ DISABLE_BLOCKS_REVERT=false

ENABLE_TOKEN_OFFCHAIN_DATA_SAVER=false
UPDATE_TOKEN_OFFCHAIN_DATA_INTERVAL=86400000
TOKEN_OFFCHAIN_DATA_MIN_LIQUIDITY_FILTER=0
SELECTED_TOKEN_OFFCHAIN_DATA_PROVIDER=coingecko

FROM_BLOCK=0
TO_BLOCK=

COINGECKO_IS_PRO_PLAN=false
COINGECKO_API_KEY=
18 changes: 14 additions & 4 deletions packages/worker/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Module, Logger } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ConfigModule } from "@nestjs/config";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { HttpModule } from "@nestjs/axios";
import { HttpModule, HttpService } from "@nestjs/axios";
import { PrometheusModule } from "@willsoto/nestjs-prometheus";
import config from "./config";
import { HealthModule } from "./health/health.module";
Expand All @@ -18,7 +18,8 @@ import { BalanceService, BalancesCleanerService } from "./balance";
import { TransferService } from "./transfer/transfer.service";
import { TokenService } from "./token/token.service";
import { TokenOffChainDataProvider } from "./token/tokenOffChainData/tokenOffChainDataProvider.abstract";
import { PortalsFiTokenOffChainDataProvider } from "./token/tokenOffChainData/providers/portalsFiTokenOffChainDataProvider";
import { CoingeckoTokenOffChainDataProvider } from "./token/tokenOffChainData/providers/coingecko/coingeckoTokenOffChainDataProvider";
import { PortalsFiTokenOffChainDataProvider } from "./token/tokenOffChainData/providers/portalsFi/portalsFiTokenOffChainDataProvider";
import { TokenOffChainDataSaverService } from "./token/tokenOffChainData/tokenOffChainDataSaver.service";
import { CounterModule } from "./counter/counter.module";
import {
Expand Down Expand Up @@ -100,7 +101,16 @@ import { UnitOfWorkModule } from "./unitOfWork";
TokenService,
{
provide: TokenOffChainDataProvider,
useClass: PortalsFiTokenOffChainDataProvider,
useFactory: (configService: ConfigService, httpService: HttpService) => {
const selectedProvider = configService.get<string>("tokens.selectedTokenOffChainDataProvider");
switch (selectedProvider) {
case "portalsFi":
return new PortalsFiTokenOffChainDataProvider(httpService);
default:
return new CoingeckoTokenOffChainDataProvider(configService, httpService);
}
},
inject: [ConfigService, HttpService],
},
TokenOffChainDataSaverService,
BatchRepository,
Expand Down
6 changes: 5 additions & 1 deletion packages/worker/src/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ describe("config", () => {
tokens: {
enableTokenOffChainDataSaver: false,
updateTokenOffChainDataInterval: 86_400_000,
tokenOffChainDataMinLiquidityFilter: 0,
tokenOffChainDataProviders: ["coingecko", "portalsFi"],
selectedTokenOffChainDataProvider: "coingecko",
coingecko: {
isProPlan: false,
},
},
metrics: {
collectDbConnectionPoolMetricsInterval: 10000,
Expand Down
11 changes: 9 additions & 2 deletions packages/worker/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ export default () => {
DISABLE_BLOCKS_REVERT,
ENABLE_TOKEN_OFFCHAIN_DATA_SAVER,
UPDATE_TOKEN_OFFCHAIN_DATA_INTERVAL,
TOKEN_OFFCHAIN_DATA_MIN_LIQUIDITY_FILTER,
SELECTED_TOKEN_OFFCHAIN_DATA_PROVIDER,
FROM_BLOCK,
TO_BLOCK,
COINGECKO_IS_PRO_PLAN,
COINGECKO_API_KEY,
} = process.env;

return {
Expand Down Expand Up @@ -59,7 +61,12 @@ export default () => {
tokens: {
enableTokenOffChainDataSaver: ENABLE_TOKEN_OFFCHAIN_DATA_SAVER === "true",
updateTokenOffChainDataInterval: parseInt(UPDATE_TOKEN_OFFCHAIN_DATA_INTERVAL, 10) || 86_400_000,
tokenOffChainDataMinLiquidityFilter: parseInt(TOKEN_OFFCHAIN_DATA_MIN_LIQUIDITY_FILTER, 10) || 0,
tokenOffChainDataProviders: ["coingecko", "portalsFi"],
selectedTokenOffChainDataProvider: SELECTED_TOKEN_OFFCHAIN_DATA_PROVIDER || "coingecko",
coingecko: {
isProPlan: COINGECKO_IS_PRO_PLAN === "true",
apiKey: COINGECKO_API_KEY,
},
},
metrics: {
collectDbConnectionPoolMetricsInterval: parseInt(COLLECT_DB_CONNECTION_POOL_METRICS_INTERVAL, 10) || 10000,
Expand Down
55 changes: 55 additions & 0 deletions packages/worker/src/repositories/token.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,61 @@ describe("TokenRepository", () => {
});

describe("updateTokenOffChainData", () => {
it("throws error when no l1Address or l2Address provided", async () => {
const updatedAt = new Date();
await expect(
repository.updateTokenOffChainData({
liquidity: 1000000,
usdPrice: 55.89037747,
updatedAt,
})
).rejects.toThrowError("l1Address or l2Address must be provided");
});

it("updates token offchain data using l1Address when provided", async () => {
const updatedAt = new Date();
await repository.updateTokenOffChainData({
l1Address: "0xD754fF5e8A6f257E162F72578A4Bb0493C0681d1",
liquidity: 1000000,
usdPrice: 55.89037747,
updatedAt,
});

expect(entityManagerMock.update).toBeCalledWith(
Token,
{
l1Address: "0xD754fF5e8A6f257E162F72578A4Bb0493C0681d1",
},
{
liquidity: 1000000,
usdPrice: 55.89037747,
offChainDataUpdatedAt: updatedAt,
}
);
});

it("updates token offchain data using l2Address when provided", async () => {
const updatedAt = new Date();
await repository.updateTokenOffChainData({
l2Address: "0xD754fF5e8A6f257E162F72578A4Bb0493C0681d1",
liquidity: 1000000,
usdPrice: 55.89037747,
updatedAt,
});

expect(entityManagerMock.update).toBeCalledWith(
Token,
{
l2Address: "0xD754fF5e8A6f257E162F72578A4Bb0493C0681d1",
},
{
liquidity: 1000000,
usdPrice: 55.89037747,
offChainDataUpdatedAt: updatedAt,
}
);
});

it("updates token offchain data when iconURL is not provided", async () => {
const updatedAt = new Date();
await repository.updateTokenOffChainData({
Expand Down
15 changes: 10 additions & 5 deletions packages/worker/src/repositories/token.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,27 @@ export class TokenRepository extends BaseRepository<Token> {

public async updateTokenOffChainData({
l1Address,
l2Address,
liquidity,
usdPrice,
updatedAt,
iconURL,
}: {
l1Address: string;
liquidity: number;
usdPrice: number;
updatedAt: Date;
l1Address?: string;
l2Address?: string;
liquidity?: number;
usdPrice?: number;
updatedAt?: Date;
iconURL?: string;
}): Promise<void> {
if (!l1Address && !l2Address) {
throw new Error("l1Address or l2Address must be provided");
}
const transactionManager = this.unitOfWork.getTransactionManager();
await transactionManager.update(
this.entityTarget,
{
l1Address,
...(l1Address ? { l1Address } : { l2Address }),
},
{
liquidity,
Expand Down
Loading

0 comments on commit 8717940

Please sign in to comment.