Skip to content

Commit 21465da

Browse files
authored
PP-778: implement the exchange cache (#87)
* 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
1 parent b2a1c8b commit 21465da

File tree

7 files changed

+187
-39
lines changed

7 files changed

+187
-39
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rsksmart/rif-relay-client",
3-
"version": "2.0.0-beta.7",
3+
"version": "2.0.0",
44
"private": false,
55
"description": "This project contains all the client code for the rif relay system.",
66
"license": "MIT",

src/api/pricer/BaseExchangeApi.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ export type BaseCurrency = 'TRIF' | 'RIF' | 'RDOC' | 'RBTC' | 'TKN';
44

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

7-
export default abstract class BaseExchangeApi {
7+
export interface ExchangeApi {
8+
queryExchangeRate: (
9+
sourceCurrency: string,
10+
targetCurrency: string
11+
) => Promise<BigNumberJs>;
12+
}
13+
14+
export default abstract class BaseExchangeApi implements ExchangeApi {
815
constructor(
916
protected readonly api: string,
1017
private currencyMapping: CurrencyMapping

src/api/pricer/ExchangeApiCache.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import log from 'loglevel';
2+
import type { ExchangeApi } from './BaseExchangeApi';
3+
import type { BigNumber as BigNumberJs } from 'bignumber.js';
4+
5+
export type RateWithExpiration = {
6+
rate: BigNumberJs;
7+
expirationTime: number;
8+
};
9+
10+
export class ExchangeApiCache implements ExchangeApi {
11+
constructor(
12+
private exchangeApi: ExchangeApi,
13+
private expirationTimeInMillisec: number,
14+
private cache: Map<string, RateWithExpiration> = new Map<
15+
string,
16+
RateWithExpiration
17+
>()
18+
) {}
19+
20+
public async queryExchangeRate(
21+
sourceCurrency: string,
22+
targetCurrency: string
23+
): Promise<BigNumberJs> {
24+
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
25+
const now = Date.now();
26+
const cachedRate = this.cache.get(key);
27+
if (!cachedRate || cachedRate.expirationTime <= now) {
28+
log.debug(
29+
'CachedExchangeApi: value not available or expired',
30+
cachedRate
31+
);
32+
const rate = await this.exchangeApi.queryExchangeRate(
33+
sourceCurrency,
34+
targetCurrency
35+
);
36+
const expirationTime = now + this.expirationTimeInMillisec;
37+
this.cache.set(key, { expirationTime, rate });
38+
log.debug('ExchangeApiCache: storing a new value', key, {
39+
expirationTime,
40+
rate,
41+
});
42+
43+
return rate;
44+
}
45+
log.debug(
46+
'ExchangeApiCache: value available in cache, API not called',
47+
cachedRate
48+
);
49+
50+
return cachedRate.rate;
51+
}
52+
}
53+
54+
export const getKeyFromArgs = (
55+
sourceCurrency: string,
56+
targetCurrency: string
57+
) => `${sourceCurrency}->${targetCurrency}`;

src/pricer/pricer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
ExchangeApiName,
55
INTERMEDIATE_CURRENCY,
66
tokenToApi,
7-
apiBuilder,
7+
exchanges,
88
} from './utils';
99

1010
const NOTIFIER = 'Notifier |';
@@ -69,7 +69,7 @@ const queryExchangeApis = async (
6969
): Promise<BigNumberJs> => {
7070
for (const api of exchangeApis) {
7171
try {
72-
const exchangeApi = apiBuilder.get(api)?.();
72+
const exchangeApi = exchanges.get(api);
7373

7474
const exchangeRate = await exchangeApi?.queryExchangeRate(
7575
sourceCurrency,

src/pricer/utils.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import type { BaseExchangeApi } from '../api';
1+
import { ExchangeApiCache } from '../api/pricer/ExchangeApiCache';
22
import {
33
CoinBase,
44
CoinCodex,
55
CoinGecko,
66
RdocExchange,
77
TestExchange,
88
} from '../api';
9+
import type { ExchangeApi } from 'src/api/pricer/BaseExchangeApi';
910

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

26+
const CACHE_EXPIRATION_TIME = 60_000;
2527
const INTERMEDIATE_CURRENCY = 'USD';
2628

27-
type CoinBaseConstructorArgs = ConstructorParameters<typeof CoinBase>;
28-
type CoinCodexConstructorArgs = ConstructorParameters<typeof CoinCodex>;
29-
type CoinGeckoConstructorArgs = ConstructorParameters<typeof CoinGecko>;
30-
type ConstructorArgs =
31-
| CoinBaseConstructorArgs
32-
| CoinCodexConstructorArgs
33-
| CoinGeckoConstructorArgs;
34-
35-
const builders = new Map<
36-
ExchangeApiName,
37-
(args?: ConstructorArgs) => BaseExchangeApi
38-
>();
39-
builders.set(
29+
const exchanges = new Map<ExchangeApiName, ExchangeApi>();
30+
exchanges.set(
4031
'coinBase',
41-
(args: CoinBaseConstructorArgs = []) => new CoinBase(...args)
32+
new ExchangeApiCache(new CoinBase(), CACHE_EXPIRATION_TIME)
4233
);
43-
builders.set(
34+
exchanges.set(
4435
'coinCodex',
45-
(args: CoinCodexConstructorArgs = []) => new CoinCodex(...args)
36+
new ExchangeApiCache(new CoinCodex(), CACHE_EXPIRATION_TIME)
4637
);
47-
builders.set(
38+
exchanges.set(
4839
'coinGecko',
49-
(args: CoinGeckoConstructorArgs = []) => new CoinGecko(...args)
40+
new ExchangeApiCache(new CoinGecko(), CACHE_EXPIRATION_TIME)
5041
);
51-
builders.set('rdocExchange', () => new RdocExchange());
52-
builders.set('testExchange', () => new TestExchange());
42+
exchanges.set('rdocExchange', new RdocExchange());
43+
exchanges.set('testExchange', new TestExchange());
5344

54-
export { tokenToApi, INTERMEDIATE_CURRENCY, builders as apiBuilder };
45+
export { tokenToApi, INTERMEDIATE_CURRENCY, exchanges };
5546

56-
export type { ExchangeApiName, ConstructorArgs };
47+
export type { ExchangeApiName };
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import BigNumber from 'bignumber.js';
2+
import { expect } from 'chai';
3+
import {
4+
RateWithExpiration,
5+
ExchangeApiCache,
6+
getKeyFromArgs,
7+
} from '../../../src/api/pricer/ExchangeApiCache';
8+
import type { ExchangeApi } from 'src/api/pricer/BaseExchangeApi';
9+
import sinon from 'sinon';
10+
11+
describe('ExchangeApiCache', function () {
12+
const sourceCurrency = 'source';
13+
const targetCurrency = 'target';
14+
const valueFromExchange = '1';
15+
let fakeExchangeAPI: ExchangeApi;
16+
17+
beforeEach(function () {
18+
fakeExchangeAPI = {
19+
queryExchangeRate: sinon.fake.resolves(new BigNumber(valueFromExchange)),
20+
};
21+
});
22+
23+
it('should perform a call to the inner exchange if the value is not cached', async function () {
24+
const cache = new Map<string, RateWithExpiration>();
25+
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
26+
const rate = await cachedExchangeApi.queryExchangeRate(
27+
sourceCurrency,
28+
targetCurrency
29+
);
30+
31+
expect(rate.toString()).to.be.eq(valueFromExchange);
32+
expect(fakeExchangeAPI.queryExchangeRate).to.have.been.calledWithExactly(
33+
sourceCurrency,
34+
targetCurrency
35+
);
36+
});
37+
38+
it('should store the value if it is not cached', async function () {
39+
const cache = new Map<string, RateWithExpiration>();
40+
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
41+
const rate = await cachedExchangeApi.queryExchangeRate(
42+
sourceCurrency,
43+
targetCurrency
44+
);
45+
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
46+
47+
expect(rate.toString()).to.be.eq(valueFromExchange);
48+
expect(cache.get(key)?.rate.toString()).to.be.eq(valueFromExchange);
49+
});
50+
51+
it('should perform a call to the inner exchange if the cached value is expired', async function () {
52+
const previouslyStoredValue = 100;
53+
const cache: Map<string, RateWithExpiration> = new Map();
54+
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
55+
// 1 second ago
56+
const expirationTime = Date.now() - 1_000;
57+
cache.set(key, {
58+
rate: new BigNumber(previouslyStoredValue),
59+
expirationTime,
60+
});
61+
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
62+
const rate = await cachedExchangeApi.queryExchangeRate(
63+
sourceCurrency,
64+
targetCurrency
65+
);
66+
67+
expect(rate.toString()).to.be.eq(valueFromExchange);
68+
69+
expect(fakeExchangeAPI.queryExchangeRate).to.have.been.calledWithExactly(
70+
sourceCurrency,
71+
targetCurrency
72+
);
73+
expect(cache.get(key)?.rate.toString()).to.be.eq(valueFromExchange);
74+
});
75+
76+
it('should not perform a call to the inner exchange if the cached value is not expired', async function () {
77+
const cache: Map<string, RateWithExpiration> = new Map();
78+
const key = getKeyFromArgs(sourceCurrency, targetCurrency);
79+
// in 10 seconds
80+
const expirationTime = Date.now() + 10_000;
81+
const cachedRate = new BigNumber(100);
82+
cache.set(key, {
83+
rate: cachedRate,
84+
expirationTime,
85+
});
86+
const cachedExchangeApi = new ExchangeApiCache(fakeExchangeAPI, 100, cache);
87+
const rate = await cachedExchangeApi.queryExchangeRate(
88+
sourceCurrency,
89+
targetCurrency
90+
);
91+
92+
expect(rate.toString()).to.be.eq(cachedRate.toString());
93+
94+
expect(fakeExchangeAPI.queryExchangeRate).not.to.have.been.called;
95+
});
96+
});

test/pricer/pricer.test.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import {
99
import { BigNumber as BigNumberJs } from 'bignumber.js';
1010
import { getExchangeRate } from '../../src/pricer/pricer';
1111
import {
12-
BaseExchangeApi,
1312
CoinCodex,
1413
CoinGecko,
1514
RdocExchange,
1615
TestExchange,
1716
} from '../../src/api';
1817
import * as pricerUtils from '../../src/pricer/utils';
19-
import type { ConstructorArgs, ExchangeApiName } from '../../src/pricer/utils';
18+
import type { ExchangeApiName } from '../../src/pricer/utils';
19+
import type { ExchangeApi } from 'src/api/pricer/BaseExchangeApi';
2020

2121
describe('pricer', function () {
2222
describe('getExchangeRate', function () {
@@ -27,10 +27,7 @@ describe('pricer', function () {
2727
let fakeRifRbtc: BigNumberJs;
2828
const RIF_SYMBOL = 'RIF';
2929
const RBTC_SYMBOL = 'RBTC';
30-
let fakeBuilder: Map<
31-
ExchangeApiName,
32-
(args?: ConstructorArgs) => BaseExchangeApi
33-
>;
30+
let fakeBuilder: Map<ExchangeApiName, ExchangeApi>;
3431

3532
beforeEach(function () {
3633
coinGeckoStub = createStubInstance(CoinGecko);
@@ -39,8 +36,8 @@ describe('pricer', function () {
3936
fakeRbtcRif = fakeRbtcUsd.dividedBy(fakeRifUsd);
4037
fakeRifRbtc = fakeRifUsd.dividedBy(fakeRbtcUsd);
4138
fakeBuilder = new Map();
42-
fakeBuilder.set('coinGecko', () => coinGeckoStub);
43-
replace(pricerUtils, 'apiBuilder', fakeBuilder);
39+
fakeBuilder.set('coinGecko', coinGeckoStub);
40+
replace(pricerUtils, 'exchanges', fakeBuilder);
4441
coinGeckoStub.queryExchangeRate
4542
.withArgs(RIF_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
4643
.resolves(fakeRifUsd);
@@ -94,7 +91,7 @@ describe('pricer', function () {
9491
coinCodexStub.queryExchangeRate
9592
.withArgs(RBTC_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
9693
.resolves(fakeRbtcUsd);
97-
fakeBuilder.set('coinCodex', () => coinCodexStub);
94+
fakeBuilder.set('coinCodex', coinCodexStub);
9895
coinGeckoStub.queryExchangeRate
9996
.withArgs(RBTC_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
10097
.rejects();
@@ -120,7 +117,7 @@ describe('pricer', function () {
120117

121118
it("should fail if all the mapped API's fail", async function () {
122119
const coinCodexStub = createStubInstance(CoinCodex);
123-
fakeBuilder.set('coinCodex', () => coinCodexStub);
120+
fakeBuilder.set('coinCodex', coinCodexStub);
124121
coinGeckoStub.queryExchangeRate
125122
.withArgs(RBTC_SYMBOL, pricerUtils.INTERMEDIATE_CURRENCY)
126123
.rejects();
@@ -140,8 +137,8 @@ describe('pricer', function () {
140137
const testExchangeStub = createStubInstance(TestExchange, {
141138
queryExchangeRate: Promise.reject(),
142139
});
143-
fakeBuilder.set('rdocExchange', () => rDocExchangeStub);
144-
fakeBuilder.set('testExchange', () => testExchangeStub);
140+
fakeBuilder.set('rdocExchange', rDocExchangeStub);
141+
fakeBuilder.set('testExchange', testExchangeStub);
145142

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

0 commit comments

Comments
 (0)