Skip to content

Commit

Permalink
PP-778: implement the exchange cache (#87)
Browse files Browse the repository at this point in the history
* feat: implement an exchange cache

* feat(CachedExchangeApi): cached value retrieved from coingecko for 60 seconds

* fix: change the pricer to use always the same exchange api instead of creating one for each request

* style: apply format rules

* chore: rename builders to exchanges
  • Loading branch information
antomor authored Jun 12, 2023
1 parent b2a1c8b commit 21465da
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 39 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rsksmart/rif-relay-client",
"version": "2.0.0-beta.7",
"version": "2.0.0",
"private": false,
"description": "This project contains all the client code for the rif relay system.",
"license": "MIT",
Expand Down
9 changes: 8 additions & 1 deletion src/api/pricer/BaseExchangeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ export type BaseCurrency = 'TRIF' | 'RIF' | 'RDOC' | 'RBTC' | 'TKN';

export type CurrencyMapping = Partial<Record<BaseCurrency, string>>;

export default abstract class BaseExchangeApi {
export interface ExchangeApi {
queryExchangeRate: (
sourceCurrency: string,
targetCurrency: string
) => Promise<BigNumberJs>;
}

export default abstract class BaseExchangeApi implements ExchangeApi {
constructor(
protected readonly api: string,
private currencyMapping: CurrencyMapping
Expand Down
57 changes: 57 additions & 0 deletions src/api/pricer/ExchangeApiCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import log from 'loglevel';
import type { ExchangeApi } from './BaseExchangeApi';
import type { BigNumber as BigNumberJs } from 'bignumber.js';

export type RateWithExpiration = {
rate: BigNumberJs;
expirationTime: number;
};

export class ExchangeApiCache implements ExchangeApi {
constructor(
private exchangeApi: ExchangeApi,
private expirationTimeInMillisec: number,
private cache: Map<string, RateWithExpiration> = new Map<
string,
RateWithExpiration
>()
) {}

public async queryExchangeRate(
sourceCurrency: string,
targetCurrency: string
): Promise<BigNumberJs> {
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
const now = Date.now();
const cachedRate = this.cache.get(key);
if (!cachedRate || cachedRate.expirationTime <= now) {
log.debug(
'CachedExchangeApi: value not available or expired',
cachedRate
);
const rate = await this.exchangeApi.queryExchangeRate(
sourceCurrency,
targetCurrency
);
const expirationTime = now + this.expirationTimeInMillisec;
this.cache.set(key, { expirationTime, rate });
log.debug('ExchangeApiCache: storing a new value', key, {
expirationTime,
rate,
});

return rate;
}
log.debug(
'ExchangeApiCache: value available in cache, API not called',
cachedRate
);

return cachedRate.rate;
}
}

export const getKeyFromArgs = (
sourceCurrency: string,
targetCurrency: string
) => `${sourceCurrency}->${targetCurrency}`;
4 changes: 2 additions & 2 deletions src/pricer/pricer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ExchangeApiName,
INTERMEDIATE_CURRENCY,
tokenToApi,
apiBuilder,
exchanges,
} from './utils';

const NOTIFIER = 'Notifier |';
Expand Down Expand Up @@ -69,7 +69,7 @@ const queryExchangeApis = async (
): Promise<BigNumberJs> => {
for (const api of exchangeApis) {
try {
const exchangeApi = apiBuilder.get(api)?.();
const exchangeApi = exchanges.get(api);

const exchangeRate = await exchangeApi?.queryExchangeRate(
sourceCurrency,
Expand Down
37 changes: 14 additions & 23 deletions src/pricer/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { BaseExchangeApi } from '../api';
import { ExchangeApiCache } from '../api/pricer/ExchangeApiCache';
import {
CoinBase,
CoinCodex,
CoinGecko,
RdocExchange,
TestExchange,
} from '../api';
import type { ExchangeApi } from 'src/api/pricer/BaseExchangeApi';

type ExchangeApiName =
| 'coinBase'
Expand All @@ -22,35 +23,25 @@ const tokenToApi: Record<string, ExchangeApiName[]> = {
TKN: ['testExchange'],
};

const CACHE_EXPIRATION_TIME = 60_000;
const INTERMEDIATE_CURRENCY = 'USD';

type CoinBaseConstructorArgs = ConstructorParameters<typeof CoinBase>;
type CoinCodexConstructorArgs = ConstructorParameters<typeof CoinCodex>;
type CoinGeckoConstructorArgs = ConstructorParameters<typeof CoinGecko>;
type ConstructorArgs =
| CoinBaseConstructorArgs
| CoinCodexConstructorArgs
| CoinGeckoConstructorArgs;

const builders = new Map<
ExchangeApiName,
(args?: ConstructorArgs) => BaseExchangeApi
>();
builders.set(
const exchanges = new Map<ExchangeApiName, ExchangeApi>();
exchanges.set(
'coinBase',
(args: CoinBaseConstructorArgs = []) => new CoinBase(...args)
new ExchangeApiCache(new CoinBase(), CACHE_EXPIRATION_TIME)
);
builders.set(
exchanges.set(
'coinCodex',
(args: CoinCodexConstructorArgs = []) => new CoinCodex(...args)
new ExchangeApiCache(new CoinCodex(), CACHE_EXPIRATION_TIME)
);
builders.set(
exchanges.set(
'coinGecko',
(args: CoinGeckoConstructorArgs = []) => new CoinGecko(...args)
new ExchangeApiCache(new CoinGecko(), CACHE_EXPIRATION_TIME)
);
builders.set('rdocExchange', () => new RdocExchange());
builders.set('testExchange', () => new TestExchange());
exchanges.set('rdocExchange', new RdocExchange());
exchanges.set('testExchange', new TestExchange());

export { tokenToApi, INTERMEDIATE_CURRENCY, builders as apiBuilder };
export { tokenToApi, INTERMEDIATE_CURRENCY, exchanges };

export type { ExchangeApiName, ConstructorArgs };
export type { ExchangeApiName };
96 changes: 96 additions & 0 deletions test/api/pricer/ExchangeApiCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import BigNumber from 'bignumber.js';
import { expect } from 'chai';
import {
RateWithExpiration,
ExchangeApiCache,
getKeyFromArgs,
} from '../../../src/api/pricer/ExchangeApiCache';
import type { ExchangeApi } from 'src/api/pricer/BaseExchangeApi';
import sinon from 'sinon';

describe('ExchangeApiCache', function () {
const sourceCurrency = 'source';
const targetCurrency = 'target';
const valueFromExchange = '1';
let fakeExchangeAPI: ExchangeApi;

beforeEach(function () {
fakeExchangeAPI = {
queryExchangeRate: sinon.fake.resolves(new BigNumber(valueFromExchange)),
};
});

it('should perform a call to the inner exchange if the value is not cached', async function () {
const cache = new Map<string, RateWithExpiration>();
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
const rate = await cachedExchangeApi.queryExchangeRate(
sourceCurrency,
targetCurrency
);

expect(rate.toString()).to.be.eq(valueFromExchange);
expect(fakeExchangeAPI.queryExchangeRate).to.have.been.calledWithExactly(
sourceCurrency,
targetCurrency
);
});

it('should store the value if it is not cached', async function () {
const cache = new Map<string, RateWithExpiration>();
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
const rate = await cachedExchangeApi.queryExchangeRate(
sourceCurrency,
targetCurrency
);
const key = getKeyFromArgs(sourceCurrency, targetCurrency);

expect(rate.toString()).to.be.eq(valueFromExchange);
expect(cache.get(key)?.rate.toString()).to.be.eq(valueFromExchange);
});

it('should perform a call to the inner exchange if the cached value is expired', async function () {
const previouslyStoredValue = 100;
const cache: Map<string, RateWithExpiration> = new Map();
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
// 1 second ago
const expirationTime = Date.now() - 1_000;
cache.set(key, {
rate: new BigNumber(previouslyStoredValue),
expirationTime,
});
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
const rate = await cachedExchangeApi.queryExchangeRate(
sourceCurrency,
targetCurrency
);

expect(rate.toString()).to.be.eq(valueFromExchange);

expect(fakeExchangeAPI.queryExchangeRate).to.have.been.calledWithExactly(
sourceCurrency,
targetCurrency
);
expect(cache.get(key)?.rate.toString()).to.be.eq(valueFromExchange);
});

it('should not perform a call to the inner exchange if the cached value is not expired', async function () {
const cache: Map<string, RateWithExpiration> = new Map();
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
// in 10 seconds
const expirationTime = Date.now() + 10_000;
const cachedRate = new BigNumber(100);
cache.set(key, {
rate: cachedRate,
expirationTime,
});
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
const rate = await cachedExchangeApi.queryExchangeRate(
sourceCurrency,
targetCurrency
);

expect(rate.toString()).to.be.eq(cachedRate.toString());

expect(fakeExchangeAPI.queryExchangeRate).not.to.have.been.called;
});
});
21 changes: 9 additions & 12 deletions test/pricer/pricer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import {
import { BigNumber as BigNumberJs } from 'bignumber.js';
import { getExchangeRate } from '../../src/pricer/pricer';
import {
BaseExchangeApi,
CoinCodex,
CoinGecko,
RdocExchange,
TestExchange,
} from '../../src/api';
import * as pricerUtils from '../../src/pricer/utils';
import type { ConstructorArgs, ExchangeApiName } from '../../src/pricer/utils';
import type { ExchangeApiName } from '../../src/pricer/utils';
import type { ExchangeApi } from 'src/api/pricer/BaseExchangeApi';

describe('pricer', function () {
describe('getExchangeRate', function () {
Expand All @@ -27,10 +27,7 @@ describe('pricer', function () {
let fakeRifRbtc: BigNumberJs;
const RIF_SYMBOL = 'RIF';
const RBTC_SYMBOL = 'RBTC';
let fakeBuilder: Map<
ExchangeApiName,
(args?: ConstructorArgs) => BaseExchangeApi
>;
let fakeBuilder: Map<ExchangeApiName, ExchangeApi>;

beforeEach(function () {
coinGeckoStub = createStubInstance(CoinGecko);
Expand All @@ -39,8 +36,8 @@ describe('pricer', function () {
fakeRbtcRif = fakeRbtcUsd.dividedBy(fakeRifUsd);
fakeRifRbtc = fakeRifUsd.dividedBy(fakeRbtcUsd);
fakeBuilder = new Map();
fakeBuilder.set('coinGecko', () => coinGeckoStub);
replace(pricerUtils, 'apiBuilder', fakeBuilder);
fakeBuilder.set('coinGecko', coinGeckoStub);
replace(pricerUtils, 'exchanges', fakeBuilder);
coinGeckoStub.queryExchangeRate
.withArgs(RIF_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
.resolves(fakeRifUsd);
Expand Down Expand Up @@ -94,7 +91,7 @@ describe('pricer', function () {
coinCodexStub.queryExchangeRate
.withArgs(RBTC_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
.resolves(fakeRbtcUsd);
fakeBuilder.set('coinCodex', () => coinCodexStub);
fakeBuilder.set('coinCodex', coinCodexStub);
coinGeckoStub.queryExchangeRate
.withArgs(RBTC_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
.rejects();
Expand All @@ -120,7 +117,7 @@ describe('pricer', function () {

it("should fail if all the mapped API's fail", async function () {
const coinCodexStub = createStubInstance(CoinCodex);
fakeBuilder.set('coinCodex', () => coinCodexStub);
fakeBuilder.set('coinCodex', coinCodexStub);
coinGeckoStub.queryExchangeRate
.withArgs(RBTC_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
.rejects();
Expand All @@ -140,8 +137,8 @@ describe('pricer', function () {
const testExchangeStub = createStubInstance(TestExchange, {
queryExchangeRate: Promise.reject(),
});
fakeBuilder.set('rdocExchange', () => rDocExchangeStub);
fakeBuilder.set('testExchange', () => testExchangeStub);
fakeBuilder.set('rdocExchange', rDocExchangeStub);
fakeBuilder.set('testExchange', testExchangeStub);

await expect(getExchangeRate('RDOC', 'TKN', 'NA')).to.be.rejectedWith(
`Currency conversion for pair RDOC:TKN not found in current exchange api`
Expand Down

0 comments on commit 21465da

Please sign in to comment.