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: Fetch fiat price #26

Merged
merged 10 commits into from
May 29, 2024
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ DOC_TOKEN2=ct_JDp175ruWd7mQggeHewSLS1PFXt9AzThCDaFedxon8mF8xTRF
#DOC_PAIR=ct_NNmdSt3Ws4r87pESGKrhGb7VJmC8zpZymXNJKHY8bTLaFttsi
#DOC_TOKEN1=ct_2dE7Xd7XCg3cwpKWP18VPDwfhz5Miji9FoKMTZN7TYvGt64Kc
#DOC_TOKEN2=ct_7ur9ypT3a4tjxxv5iG6zEQDQhysNtCKr6tyc7PkqhtRmEw6yY

COIN_MARKET_CAP_API_KEY=
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@prisma/client": "^5.10.2",
"dex-contracts-v2": "github:aeternity/dex-contracts-v2",
"dotenv": "^16.4.3",
"limiter": "^2.1.0",
"lodash": "^4.17.21",
"reflect-metadata": "^0.2.1",
"rimraf": "^5.0.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CREATE TABLE "PairLiquidityInfoHistoryV2" (
"reserve1" DECIMAL(100,0) NOT NULL,
"deltaReserve0" DECIMAL(100,0) NOT NULL,
"deltaReserve1" DECIMAL(100,0) NOT NULL,
"fiatPrice" DECIMAL(100,0) NOT NULL,
"aeUsdPrice" DECIMAL(100,6) NOT NULL,
"height" INTEGER NOT NULL,
"microBlockHash" TEXT NOT NULL,
"microBlockTime" BIGINT NOT NULL,
Expand Down
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ model PairLiquidityInfoHistoryV2 {
reserve1 Decimal @db.Decimal(100, 0)
deltaReserve0 Decimal @db.Decimal(100, 0)
deltaReserve1 Decimal @db.Decimal(100, 0)
fiatPrice Decimal @db.Decimal(100, 0)
aeUsdPrice Decimal @db.Decimal(100, 6)
height Int
microBlockHash String
microBlockTime BigInt
Expand Down
18 changes: 16 additions & 2 deletions src/clients/clients.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { Module } from '@nestjs/common';

import { CoinmarketcapClientService } from '@/clients/coinmarketcap-client.service';
import { HttpService } from '@/clients/http.service';
import { MdwHttpClientService } from '@/clients/mdw-http-client.service';
import { MdwWsClientService } from '@/clients/mdw-ws-client.service';
import { SdkClientService } from '@/clients/sdk-client.service';

@Module({
providers: [MdwHttpClientService, MdwWsClientService, SdkClientService],
exports: [MdwHttpClientService, MdwWsClientService, SdkClientService],
providers: [
CoinmarketcapClientService,
HttpService,
MdwHttpClientService,
MdwWsClientService,
SdkClientService,
],
exports: [
CoinmarketcapClientService,
HttpService,
MdwHttpClientService,
MdwWsClientService,
SdkClientService,
],
})
export class ClientsModule {}
42 changes: 42 additions & 0 deletions src/clients/coinmarketcap-client.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type AeUsdQuoteData = {
1700: {
id: number;
name: string;
symbol: string;
is_active: 0 | 1;
is_fiat: 0 | 1;
quotes: [
{
timestamp: Date;
quote: {
USD: {
percent_change_1h: number;
percent_change_24h: number;
percent_change_7d: number;
percent_change_30d: number;
price: number;
volume_24h: number;
market_cap: number;
total_supply: number;
circulating_supply: number;
timestamp: Date;
};
};
},
];
};
};

export type CoinmarketcapResponse<T> = {
status: CoinmarketcapStatus;
data: T;
};

export type CoinmarketcapStatus = {
timestamp: Date;
error_code: number;
error_message: string;
elapsed: number;
credit_count: number;
notice: string;
};
73 changes: 73 additions & 0 deletions src/clients/coinmarketcap-client.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Test, TestingModule } from '@nestjs/testing';

import { CoinmarketcapClientService } from '@/clients/coinmarketcap-client.service';
import { HttpService } from '@/clients/http.service';
import resetAllMocks = jest.resetAllMocks;
import { RateLimiter } from 'limiter';

import { coinmarketcapResponseAeUsdQuoteData } from '@/test/mock-data/pair-liquidity-info-history-mock-data';

const mockHttpService = {
get: jest.fn(),
};

describe('CoinmarketcapClientService', () => {
let service: CoinmarketcapClientService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CoinmarketcapClientService,
{ provide: HttpService, useValue: mockHttpService },
],
}).compile();
service = module.get<CoinmarketcapClientService>(
CoinmarketcapClientService,
);
resetAllMocks();
});

describe('getHistoricalPriceDataThrottled', () => {
it('should correctly calculate and fetch the latest 5 min interval for a given timestamp', async () => {
// Mock functions
mockHttpService.get.mockResolvedValue(
coinmarketcapResponseAeUsdQuoteData,
);
// Call function
await service.getHistoricalPriceDataThrottled(1704203935123);

// Assertions
expect(mockHttpService.get).toHaveBeenCalledWith(
'https://pro-api.coinmarketcap.com/v3/cryptocurrency/quotes/historical?id=1700&interval=24h&count=1&time_end=2024-01-02T13:55:00.000Z',
expect.anything(),
);

await service.getHistoricalPriceDataThrottled(1704203614123);

expect(mockHttpService.get).toHaveBeenCalledWith(
'https://pro-api.coinmarketcap.com/v3/cryptocurrency/quotes/historical?id=1700&interval=24h&count=1&time_end=2024-01-02T13:50:00.000Z',
expect.anything(),
);
});

Choose a reason for hiding this comment

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

should we test what happens if we run in the rate limit error?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good, I'd add it to the importer spec however, as in the CoinmarketcapClieht nothing much happens besides returning the error (we are using a mocked version of the API).

it('should be throtteled to defined rate limit', async () => {
service['rateLimiter'] = new RateLimiter({
tokensPerInterval: 2,
interval: 'minute',
});

// Mock
mockHttpService.get.mockResolvedValue({});

// Call function
service.getHistoricalPriceDataThrottled(1704203935123);
service.getHistoricalPriceDataThrottled(1704203935123);
service.getHistoricalPriceDataThrottled(1704203935123);

await new Promise((res) => setTimeout(res, 200));

Choose a reason for hiding this comment

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

seems a bit flaky, could we await the first two calls and then for the third call use the timeout?


// Assertion
expect(mockHttpService.get).toHaveBeenCalledTimes(2);
});
});
});
47 changes: 47 additions & 0 deletions src/clients/coinmarketcap-client.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { RateLimiter } from 'limiter';

import {
AeUsdQuoteData,
CoinmarketcapResponse,
} from '@/clients/coinmarketcap-client.model';
import { HttpService } from '@/clients/http.service';

@Injectable()
export class CoinmarketcapClientService {
constructor(private httpService: HttpService) {}

private readonly AUTH_HEADER = {
'X-CMC_PRO_API_KEY': process.env.COIN_MARKET_CAP_API_KEY || '',
};
private readonly AE_CURRENCY_ID = 1700;
private readonly COUNT = 1;
private readonly INTERVAL = '24h';
private readonly CALLS_LIMIT = 28;
private readonly CALL_INTERVAL = 'minute';
private rateLimiter = new RateLimiter({
tokensPerInterval: this.CALLS_LIMIT,
interval: this.CALL_INTERVAL,
});

async getHistoricalPriceDataThrottled(microBlockTime: number) {
await this.rateLimiter.removeTokens(1);
const timeEnd = this.roundMicroBlockTimeDownTo5MinInterval(microBlockTime);
const url = `https://pro-api.coinmarketcap.com/v3/cryptocurrency/quotes/historical?id=${this.AE_CURRENCY_ID}&interval=${this.INTERVAL}&count=${this.COUNT}&time_end=${timeEnd}`;
return this.get<CoinmarketcapResponse<AeUsdQuoteData>>(url);
}

private async get<T>(url: string): Promise<T> {
return this.httpService.get<T>(url, new Headers(this.AUTH_HEADER));
}

private roundMicroBlockTimeDownTo5MinInterval(
microBlockTime: number,
): string {
const date = new Date(microBlockTime);
date.setMinutes(Math.floor(date.getMinutes() / 5) * 5);
date.setSeconds(0);
date.setMilliseconds(0);
return date.toISOString();
}
}
16 changes: 16 additions & 0 deletions src/clients/http.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class HttpService {
async get<T>(url: string, headers?: Headers): Promise<T> {
return fetch(url, { method: 'GET', headers: headers }).then(async (res) => {
if (res.ok) {
return (await res.json()) as Promise<T>;
} else {
throw new Error(
`GET ${url} failed with status ${res.status}. Response body: ${await res.text()}`,
);
}
});
}
}
17 changes: 7 additions & 10 deletions src/clients/mdw-http-client.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';

import { HttpService } from '@/clients/http.service';
import {
AccountBalance,
Contract,
Expand All @@ -19,6 +20,8 @@ import { nonNullable } from '@/lib/utils';

@Injectable()
export class MdwHttpClientService {
constructor(private httpService: HttpService) {}

private readonly LIMIT = 100;
private readonly DIRECTION = 'forward';
private readonly INT_AS_STRING = true;
Expand Down Expand Up @@ -81,23 +84,17 @@ export class MdwHttpClientService {

private async get<T>(url: string): Promise<T> {
const fullUrl = `${NETWORKS[nonNullable(process.env.NETWORK_NAME)].middlewareHttpUrl}${url}`;
return fetch(fullUrl).then(async (res) => {
if (res.ok) {
return (await res.json()) as Promise<T>;
} else {
throw new Error(
`GET ${url} failed with status ${res.status}. Response body: ${await res.text()}`,
);
}
});
return this.httpService.get<T>(fullUrl);
}

// Fetches pages from middleware until the page contains at least one entry that meets the condition
private async getPagesUntilCondition<T>(
condition: (data: T) => boolean,
next: string,
): Promise<T[]> {
const result = await this.get<MdwPaginatedResponse<T>>(next);
const result = await this.get<MdwPaginatedResponse<T>>(
next + `&int-as-string=${this.INT_AS_STRING}`,
);
Comment on lines +95 to +97

Choose a reason for hiding this comment

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

minor notes: should have been its own PR


if (result.data.filter(condition).length === 0 && result.next) {
return result.data.concat(
Expand Down
3 changes: 3 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const pluralize = (count: number, noun: string, suffix = 's') =>
const parseEnv = (x) => x && JSON.parse(x);
export const presentInvalidTokens = parseEnv(process.env.SHOW_INVALID_TOKENS);

export const numberToDecimal = (number: number): Decimal =>
new Decimal(number.toString());

export const bigIntToDecimal = (bigInt: bigint): Decimal =>
new Decimal(bigInt.toString());

Expand Down
Loading