@bitpay-labs/bitcore-wallet-service 11.6.0 → 11.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/externalservices/coingecko.ts +448 -368
- package/src/lib/common/utils.ts +37 -32
- package/src/lib/pushnotificationsservice.ts +12 -12
- package/src/lib/tss.ts +18 -10
- package/test/integration/externalservices/coinGecko.test.ts +811 -156
- package/test/integration/pushNotifications.test.ts +6 -6
- package/test/utils.test.ts +5 -0
- package/ts_build/package.json +3 -3
- package/ts_build/src/externalservices/coingecko.d.ts +24 -20
- package/ts_build/src/externalservices/coingecko.d.ts.map +1 -1
- package/ts_build/src/externalservices/coingecko.js +356 -306
- package/ts_build/src/externalservices/coingecko.js.map +1 -1
- package/ts_build/src/lib/common/index.d.ts +2 -2
- package/ts_build/src/lib/common/utils.d.ts +2 -2
- package/ts_build/src/lib/common/utils.d.ts.map +1 -1
- package/ts_build/src/lib/common/utils.js +28 -29
- package/ts_build/src/lib/common/utils.js.map +1 -1
- package/ts_build/src/lib/tss.d.ts.map +1 -1
- package/ts_build/src/lib/tss.js +2 -10
- package/ts_build/src/lib/tss.js.map +1 -1
- package/ts_build/test/integration/externalservices/coinGecko.test.d.ts +1 -1
- package/ts_build/test/integration/externalservices/coinGecko.test.d.ts.map +1 -1
- package/ts_build/test/integration/externalservices/coinGecko.test.js +710 -149
- package/ts_build/test/integration/externalservices/coinGecko.test.js.map +1 -1
- package/ts_build/test/utils.test.js +4 -0
- package/ts_build/test/utils.test.js.map +1 -1
|
@@ -1,75 +1,244 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import * as chai from 'chai';
|
|
4
|
-
import '
|
|
4
|
+
import sinon from 'sinon';
|
|
5
5
|
import util from 'util';
|
|
6
6
|
import { WalletService } from '../../../src/lib/server';
|
|
7
7
|
import * as TestData from '../../testdata';
|
|
8
8
|
import helpers from '../helpers';
|
|
9
9
|
import config from '../../../src/config';
|
|
10
|
+
import logger from '../../../src/lib/logger';
|
|
10
11
|
|
|
11
12
|
const should = chai.should();
|
|
12
13
|
|
|
14
|
+
function toURL(url: string): URL {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(url);
|
|
17
|
+
} catch {
|
|
18
|
+
// Fallback for any accidental relative URLs in tests.
|
|
19
|
+
return new URL(url, 'https://example.com');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseRequestedIdsFromMarketsUrl(url: string): string[] {
|
|
24
|
+
const u = toURL(url);
|
|
25
|
+
const ids = u.searchParams.get('ids');
|
|
26
|
+
return ids ? ids.split(',').filter(Boolean) : [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function matchCoinIdFromMarketChartUrl(url: string): string | undefined {
|
|
30
|
+
const u = toURL(url);
|
|
31
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
32
|
+
const coinsIdx = parts.indexOf('coins');
|
|
33
|
+
if (coinsIdx === -1) return undefined;
|
|
34
|
+
const id = parts[coinsIdx + 1];
|
|
35
|
+
const next = parts[coinsIdx + 2];
|
|
36
|
+
if (id && next === 'market_chart') return decodeURIComponent(id);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchCoinIdFromInfoUrl(url: string): string | undefined {
|
|
41
|
+
const u = toURL(url);
|
|
42
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
43
|
+
const coinsIdx = parts.indexOf('coins');
|
|
44
|
+
if (coinsIdx === -1) return undefined;
|
|
45
|
+
|
|
46
|
+
const id = parts[coinsIdx + 1];
|
|
47
|
+
if (!id) return undefined;
|
|
48
|
+
|
|
49
|
+
// Exclude non-info endpoints.
|
|
50
|
+
if (id === 'list' || id === 'markets') return undefined;
|
|
51
|
+
|
|
52
|
+
const tail = parts.slice(coinsIdx + 2);
|
|
53
|
+
if (tail.length === 0) return decodeURIComponent(id);
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchCoinContract(url: string): { platformId: string; address: string } | undefined {
|
|
59
|
+
const u = toURL(url);
|
|
60
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
61
|
+
const coinsIdx = parts.indexOf('coins');
|
|
62
|
+
if (coinsIdx === -1) return undefined;
|
|
63
|
+
|
|
64
|
+
const platformId = parts[coinsIdx + 1];
|
|
65
|
+
const next = parts[coinsIdx + 2];
|
|
66
|
+
const address = parts[coinsIdx + 3];
|
|
67
|
+
|
|
68
|
+
if (platformId && next === 'contract' && address) {
|
|
69
|
+
return { platformId: decodeURIComponent(platformId), address: decodeURIComponent(address) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchTokenListPlatformId(url: string): string | undefined {
|
|
76
|
+
const u = toURL(url);
|
|
77
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
78
|
+
const idx = parts.indexOf('token_lists');
|
|
79
|
+
if (idx === -1) return undefined;
|
|
80
|
+
|
|
81
|
+
const platformId = parts[idx + 1];
|
|
82
|
+
const file = parts[idx + 2];
|
|
83
|
+
if (platformId && file === 'all.json') return decodeURIComponent(platformId);
|
|
84
|
+
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createCoinGeckoRequestStub(handlers: {
|
|
89
|
+
onMarkets?: (ids: string[]) => unknown;
|
|
90
|
+
onMarketChart?: (id: string) => unknown;
|
|
91
|
+
onCoinInfo?: (id: string) => unknown;
|
|
92
|
+
onCoinContract?: (platformId: string, address: string) => unknown;
|
|
93
|
+
onTokenList?: (platformId: string) => unknown;
|
|
94
|
+
}): { get: (url: string, opts: any, cb: (err?: any, data?: any) => void) => void } {
|
|
95
|
+
return {
|
|
96
|
+
get: (url, _opts, cb) => {
|
|
97
|
+
try {
|
|
98
|
+
const u = toURL(url);
|
|
99
|
+
const pathname = u.pathname;
|
|
100
|
+
|
|
101
|
+
if (pathname.endsWith('/v3/coins/markets')) {
|
|
102
|
+
const ids = parseRequestedIdsFromMarketsUrl(url);
|
|
103
|
+
return cb(null, { body: handlers.onMarkets ? handlers.onMarkets(ids) : [] });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const contract = matchCoinContract(url);
|
|
107
|
+
if (contract) {
|
|
108
|
+
return cb(null, {
|
|
109
|
+
body: handlers.onCoinContract ? handlers.onCoinContract(contract.platformId, contract.address) : {}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const marketChartId = matchCoinIdFromMarketChartUrl(url);
|
|
114
|
+
if (marketChartId) {
|
|
115
|
+
return cb(null, { body: handlers.onMarketChart ? handlers.onMarketChart(marketChartId) : { prices: [] } });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const coinInfoId = matchCoinIdFromInfoUrl(url);
|
|
119
|
+
if (coinInfoId) {
|
|
120
|
+
return cb(null, { body: handlers.onCoinInfo ? handlers.onCoinInfo(coinInfoId) : {} });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const tokenListPlatformId = matchTokenListPlatformId(url);
|
|
124
|
+
if (tokenListPlatformId) {
|
|
125
|
+
return cb(null, { body: handlers.onTokenList ? handlers.onTokenList(tokenListPlatformId) : { tokens: [] } });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return cb(new Error('unexpected url'));
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
return cb(e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
13
136
|
describe('CoinGecko integration', function() {
|
|
14
137
|
this.timeout(5000);
|
|
138
|
+
const sandbox = sinon.createSandbox();
|
|
15
139
|
let server;
|
|
16
140
|
let wallet;
|
|
17
141
|
let fakeRequest;
|
|
18
|
-
|
|
19
|
-
|
|
142
|
+
|
|
143
|
+
const cg = () => server.externalServices.coinGecko;
|
|
144
|
+
const getMarketStats = (query: any, code = 'USD') => cg().coinGeckoGetMarketStats({ params: { code }, query });
|
|
145
|
+
const getFiatRates = (query: any, code = 'USD') => cg().coinGeckoGetFiatRates({ params: { code }, query });
|
|
146
|
+
const getTokens = (req: any = {}) => cg().coinGeckoGetTokens(req);
|
|
147
|
+
|
|
148
|
+
function forceGlobalCacheMisses() {
|
|
149
|
+
sandbox.stub(cg().storage, 'checkAndUseGlobalCache').callsFake((_key, _duration, cb) => cb(null, null, null));
|
|
150
|
+
sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
|
|
151
|
+
}
|
|
152
|
+
|
|
20
153
|
before(async () => {
|
|
21
154
|
await helpers.before();
|
|
22
155
|
});
|
|
23
156
|
|
|
24
157
|
beforeEach(async () => {
|
|
25
158
|
config.coinGecko = {
|
|
26
|
-
api: '
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
fakeRequest = {
|
|
30
|
-
get: (_url, _opts, _cb) => { return _cb(null, { body: { tokens: [{
|
|
31
|
-
chainId: 1,
|
|
32
|
-
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
33
|
-
symbol: 'USDC',
|
|
34
|
-
name: 'USD Coin',
|
|
35
|
-
decimals: 6,
|
|
36
|
-
logoURI: 'xxxxxx'
|
|
37
|
-
}] } }); },
|
|
159
|
+
api: 'https://pro-api.coingecko.com/api'
|
|
38
160
|
};
|
|
39
161
|
|
|
40
162
|
await helpers.beforeEach();
|
|
41
163
|
({ wallet } = await helpers.createAndJoinWallet(1, 1));
|
|
42
164
|
const priv = TestData.copayers[0].privKey_1H_0;
|
|
43
165
|
const sig = helpers.signMessage('hello world', priv);
|
|
44
|
-
|
|
45
|
-
|
|
166
|
+
|
|
167
|
+
server = await util.promisify(WalletService.getInstanceWithAuth).call(WalletService, {
|
|
46
168
|
// test assumes wallet's copayer[0] is TestData's copayer[0]
|
|
47
169
|
copayerId: wallet.copayers[0].id,
|
|
48
170
|
message: 'hello world',
|
|
49
171
|
signature: sig,
|
|
50
172
|
clientVersion: 'bwc-2.0.0',
|
|
51
|
-
walletId: '123'
|
|
52
|
-
})
|
|
173
|
+
walletId: '123'
|
|
174
|
+
});
|
|
53
175
|
});
|
|
54
176
|
|
|
55
177
|
after(async () => {
|
|
56
178
|
await helpers.after();
|
|
57
179
|
});
|
|
58
180
|
|
|
181
|
+
afterEach(() => {
|
|
182
|
+
sandbox.restore();
|
|
183
|
+
});
|
|
184
|
+
|
|
59
185
|
describe('#coinGeckoGetTokenData', () => {
|
|
60
186
|
beforeEach(() => {
|
|
61
|
-
|
|
187
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
188
|
+
onTokenList: _platformId => ({
|
|
189
|
+
tokens: [
|
|
190
|
+
{
|
|
191
|
+
chainId: 1,
|
|
192
|
+
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
193
|
+
symbol: 'USDC',
|
|
194
|
+
name: 'USD Coin',
|
|
195
|
+
decimals: 6,
|
|
196
|
+
logoURI: 'xxxxxx'
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
})
|
|
200
|
+
});
|
|
201
|
+
cg().request = fakeRequest;
|
|
62
202
|
});
|
|
63
|
-
|
|
203
|
+
|
|
64
204
|
it('should get coinGecko list of tokens data', async () => {
|
|
65
|
-
const data = await
|
|
205
|
+
const data = await getTokens({});
|
|
206
|
+
should.exist(data);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should map chain aliases to the correct token list platform', async () => {
|
|
210
|
+
forceGlobalCacheMisses();
|
|
211
|
+
|
|
212
|
+
let platformSeen: string | undefined;
|
|
213
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
214
|
+
onTokenList: platformId => {
|
|
215
|
+
platformSeen = platformId;
|
|
216
|
+
return {
|
|
217
|
+
tokens: [
|
|
218
|
+
{
|
|
219
|
+
chainId: 137,
|
|
220
|
+
address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
|
|
221
|
+
symbol: 'USDC',
|
|
222
|
+
name: 'USD Coin',
|
|
223
|
+
decimals: 6,
|
|
224
|
+
logoURI: 'xxxxxx'
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
cg().request = fakeRequest;
|
|
232
|
+
|
|
233
|
+
const data = await getTokens({ params: { chain: 'pol' } });
|
|
66
234
|
should.exist(data);
|
|
235
|
+
platformSeen.should.equal('polygon-pos');
|
|
67
236
|
});
|
|
68
237
|
|
|
69
238
|
it('should return error if coinGecko is commented in config', async () => {
|
|
70
239
|
config.coinGecko = undefined;
|
|
71
240
|
try {
|
|
72
|
-
await
|
|
241
|
+
await getTokens({});
|
|
73
242
|
should.fail('should have thrown');
|
|
74
243
|
} catch (err) {
|
|
75
244
|
err.message.should.equal('coinGecko missing credentials');
|
|
@@ -79,112 +248,332 @@ describe('CoinGecko integration', function() {
|
|
|
79
248
|
|
|
80
249
|
describe('#coinGeckoGetMarketStats', () => {
|
|
81
250
|
beforeEach(() => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
'dogecoin',
|
|
94
|
-
'bitcoin-cash',
|
|
95
|
-
'shiba-inu',
|
|
96
|
-
'polygon-ecosystem-token',
|
|
97
|
-
'apecoin',
|
|
98
|
-
'litecoin',
|
|
99
|
-
'wrapped-bitcoin',
|
|
100
|
-
'weth'
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
const marketById: Record<string, any> = {
|
|
104
|
-
bitcoin: {
|
|
105
|
-
id: 'bitcoin',
|
|
106
|
-
symbol: 'btc',
|
|
107
|
-
name: 'Bitcoin',
|
|
108
|
-
image: 'btc.png',
|
|
109
|
-
current_price: 100,
|
|
110
|
-
total_volume: 200,
|
|
111
|
-
circulating_supply: 300,
|
|
112
|
-
market_cap: 400,
|
|
113
|
-
last_updated: '2020-01-01T00:00:00.000Z'
|
|
114
|
-
},
|
|
115
|
-
ethereum: {
|
|
116
|
-
id: 'ethereum',
|
|
117
|
-
symbol: 'eth',
|
|
118
|
-
name: 'Ethereum',
|
|
119
|
-
image: 'eth.png',
|
|
120
|
-
current_price: 10,
|
|
121
|
-
total_volume: 20,
|
|
122
|
-
circulating_supply: 30,
|
|
123
|
-
market_cap: 40,
|
|
124
|
-
last_updated: '2020-01-01T00:00:00.000Z'
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const aboutById: Record<string, string> = {
|
|
129
|
-
bitcoin: 'About BTC',
|
|
130
|
-
ethereum: 'About ETH'
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
if (url.includes('/v3/coins/markets')) {
|
|
134
|
-
return cb(null, {
|
|
135
|
-
body: [
|
|
136
|
-
...defaultIds.map(id =>
|
|
137
|
-
marketById[id]
|
|
138
|
-
? marketById[id]
|
|
139
|
-
: {
|
|
140
|
-
id,
|
|
141
|
-
symbol: id.replace(/[^a-z]/g, '').slice(0, 4),
|
|
142
|
-
name: id,
|
|
143
|
-
image: `${id}.png`,
|
|
144
|
-
current_price: 1,
|
|
145
|
-
total_volume: 1,
|
|
146
|
-
circulating_supply: 1,
|
|
147
|
-
market_cap: 1,
|
|
148
|
-
last_updated: '2020-01-01T00:00:00.000Z'
|
|
149
|
-
}
|
|
150
|
-
)
|
|
151
|
-
]
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const mChart = url.match(/\/v3\/coins\/([^/]+)\/market_chart\?/);
|
|
156
|
-
if (mChart?.[1]) {
|
|
157
|
-
const id = decodeURIComponent(mChart[1]);
|
|
158
|
-
if (id === 'bitcoin') return cb(null, { body: { prices: [[0, 90], [1, 110], [2, 95]] } });
|
|
159
|
-
if (id === 'ethereum') return cb(null, { body: { prices: [[0, 9], [1, 12], [2, 8]] } });
|
|
160
|
-
return cb(null, { body: { prices: [[0, 1], [1, 1], [2, 1]] } });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const mInfo = url.match(/\/v3\/coins\/([^?]+)\?/);
|
|
164
|
-
if (mInfo?.[1]) {
|
|
165
|
-
const id = decodeURIComponent(mInfo[1]);
|
|
166
|
-
return cb(null, { body: { description: { en: aboutById[id] || `About ${id}` } } });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return cb(new Error('unexpected url'));
|
|
251
|
+
const marketById: Record<string, any> = {
|
|
252
|
+
bitcoin: {
|
|
253
|
+
id: 'bitcoin',
|
|
254
|
+
symbol: 'btc',
|
|
255
|
+
name: 'Bitcoin',
|
|
256
|
+
image: 'btc.png',
|
|
257
|
+
current_price: 100,
|
|
258
|
+
total_volume: 200,
|
|
259
|
+
circulating_supply: 300,
|
|
260
|
+
market_cap: 400,
|
|
261
|
+
last_updated: '2020-01-01T00:00:00.000Z'
|
|
170
262
|
}
|
|
171
263
|
};
|
|
172
264
|
|
|
173
|
-
|
|
265
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
266
|
+
onMarkets: ids =>
|
|
267
|
+
ids.map(id =>
|
|
268
|
+
marketById[id]
|
|
269
|
+
? marketById[id]
|
|
270
|
+
: {
|
|
271
|
+
id,
|
|
272
|
+
symbol: id.replace(/[^a-z]/g, '').slice(0, 4),
|
|
273
|
+
name: id,
|
|
274
|
+
image: `${id}.png`,
|
|
275
|
+
current_price: 1,
|
|
276
|
+
total_volume: 1,
|
|
277
|
+
circulating_supply: 1,
|
|
278
|
+
market_cap: 1,
|
|
279
|
+
last_updated: '2020-01-01T00:00:00.000Z'
|
|
280
|
+
}
|
|
281
|
+
),
|
|
282
|
+
onMarketChart: id => {
|
|
283
|
+
if (id === 'bitcoin') return { prices: [[0, 90], [1, 110], [2, 95]] };
|
|
284
|
+
return { prices: [[0, 1], [1, 1], [2, 1]] };
|
|
285
|
+
},
|
|
286
|
+
onCoinInfo: id => ({ description: { en: id === 'bitcoin' ? 'About BTC' : `About ${id}` } })
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
cg().request = fakeRequest;
|
|
174
290
|
});
|
|
175
291
|
|
|
176
292
|
it('should get market stats for a single coin', async () => {
|
|
177
|
-
const data = await
|
|
293
|
+
const data = await getMarketStats({ coin: 'btc' }, 'ARS');
|
|
178
294
|
should.exist(data);
|
|
179
295
|
data.should.have.length(1);
|
|
180
296
|
data[0].about.should.equal('About BTC');
|
|
181
297
|
data[0].low52w.should.equal(90);
|
|
182
298
|
});
|
|
183
299
|
|
|
300
|
+
it('should reject non-default coin symbols without tokenAddress', async () => {
|
|
301
|
+
try {
|
|
302
|
+
await getMarketStats({ coin: 'WBTC' });
|
|
303
|
+
should.fail('should have thrown');
|
|
304
|
+
} catch (err) {
|
|
305
|
+
err.message.should.equal('Unsupported coin. For token symbols, pass `chain` and `tokenAddress`.');
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should require tokenAddress when chain is provided for market stats', async () => {
|
|
310
|
+
try {
|
|
311
|
+
await getMarketStats({ coin: ['btc'], chain: ['eth'] });
|
|
312
|
+
should.fail('should have thrown');
|
|
313
|
+
} catch (err) {
|
|
314
|
+
err.message.should.equal('chain is only supported for token lookups; provide tokenAddress or omit chain for native coins');
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should reject invalid chain values for default marketstats coins', async () => {
|
|
319
|
+
try {
|
|
320
|
+
await getMarketStats({ coin: 'BTC', chain: '!!!' });
|
|
321
|
+
should.fail('should have thrown');
|
|
322
|
+
} catch (err) {
|
|
323
|
+
err.message.should.equal('Unsupported chain');
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
it('should resolve a token unambiguously when tokenAddress is provided', async () => {
|
|
329
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
330
|
+
|
|
331
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
332
|
+
onCoinContract: (platformId, address) => {
|
|
333
|
+
platformId.should.equal('arbitrum-one');
|
|
334
|
+
address.should.equal(tokenAddress);
|
|
335
|
+
return {
|
|
336
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
337
|
+
symbol: 'usdc.e',
|
|
338
|
+
asset_platform_id: 'arbitrum-one'
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
onMarkets: ids =>
|
|
342
|
+
ids.map(id => ({
|
|
343
|
+
id,
|
|
344
|
+
symbol: 'usdc.e',
|
|
345
|
+
name: 'Arbitrum Bridged USDC (Arbitrum)',
|
|
346
|
+
image: `${id}.png`,
|
|
347
|
+
current_price: 1,
|
|
348
|
+
total_volume: 1,
|
|
349
|
+
circulating_supply: 1,
|
|
350
|
+
market_cap: 1,
|
|
351
|
+
last_updated: '2020-01-01T00:00:00.000Z'
|
|
352
|
+
})),
|
|
353
|
+
onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
|
|
354
|
+
onCoinInfo: _id => ({ description: { en: 'About token' }, asset_platform_id: 'arbitrum-one' })
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
cg().request = fakeRequest;
|
|
358
|
+
|
|
359
|
+
const data = await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
360
|
+
should.exist(data);
|
|
361
|
+
data.should.have.length(1);
|
|
362
|
+
data[0].name.should.equal('Arbitrum Bridged USDC (Arbitrum)');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should resolve market stats by chain + tokenAddress when coin is omitted', async () => {
|
|
366
|
+
const tokenAddress = '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8';
|
|
367
|
+
|
|
368
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
369
|
+
onCoinContract: (platformId, address) => {
|
|
370
|
+
platformId.should.equal('arbitrum-one');
|
|
371
|
+
address.should.equal(tokenAddress);
|
|
372
|
+
return {
|
|
373
|
+
id: 'bridged-usdc-arbitrum',
|
|
374
|
+
symbol: 'usdc',
|
|
375
|
+
asset_platform_id: 'arbitrum-one'
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
onMarkets: ids =>
|
|
379
|
+
ids.map(id => ({
|
|
380
|
+
id,
|
|
381
|
+
symbol: 'usdc',
|
|
382
|
+
name: 'Arbitrum Bridged USDC (Arbitrum)',
|
|
383
|
+
image: `${id}.png`,
|
|
384
|
+
current_price: 1,
|
|
385
|
+
total_volume: 1,
|
|
386
|
+
circulating_supply: 1,
|
|
387
|
+
market_cap: 1,
|
|
388
|
+
last_updated: '2020-01-01T00:00:00.000Z'
|
|
389
|
+
})),
|
|
390
|
+
onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
|
|
391
|
+
onCoinInfo: _id => ({ description: { en: 'About token' }, asset_platform_id: 'arbitrum-one' })
|
|
392
|
+
});
|
|
393
|
+
cg().request = fakeRequest;
|
|
394
|
+
|
|
395
|
+
const data = await getMarketStats({ chain: 'arb', tokenAddress });
|
|
396
|
+
should.exist(data);
|
|
397
|
+
data.should.have.length(1);
|
|
398
|
+
data[0].name.should.equal('Arbitrum Bridged USDC (Arbitrum)');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should map pol alias to polygon-pos for token marketstats lookup', async () => {
|
|
402
|
+
const tokenAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
|
|
403
|
+
|
|
404
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
405
|
+
onCoinContract: (platformId, address) => {
|
|
406
|
+
platformId.should.equal('polygon-pos');
|
|
407
|
+
address.should.equal(tokenAddress);
|
|
408
|
+
return {
|
|
409
|
+
id: 'usd-coin',
|
|
410
|
+
symbol: 'usdc',
|
|
411
|
+
asset_platform_id: 'polygon-pos'
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
onMarkets: ids =>
|
|
415
|
+
ids.map(id => ({
|
|
416
|
+
id,
|
|
417
|
+
symbol: 'usdc',
|
|
418
|
+
name: 'USD Coin (PoS)',
|
|
419
|
+
image: `${id}.png`,
|
|
420
|
+
current_price: 1,
|
|
421
|
+
total_volume: 1,
|
|
422
|
+
circulating_supply: 1,
|
|
423
|
+
market_cap: 1,
|
|
424
|
+
last_updated: '2020-01-01T00:00:00.000Z'
|
|
425
|
+
})),
|
|
426
|
+
onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
|
|
427
|
+
onCoinInfo: _id => ({ description: { en: 'About token' }, asset_platform_id: 'polygon-pos' })
|
|
428
|
+
});
|
|
429
|
+
cg().request = fakeRequest;
|
|
430
|
+
|
|
431
|
+
const data = await getMarketStats({ chain: 'pol', tokenAddress });
|
|
432
|
+
should.exist(data);
|
|
433
|
+
data.should.have.length(1);
|
|
434
|
+
data[0].name.should.equal('USD Coin (PoS)');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should reject tokenAddress when returned token chain does not match chain', async () => {
|
|
438
|
+
forceGlobalCacheMisses();
|
|
439
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
440
|
+
|
|
441
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
442
|
+
onCoinContract: () => ({
|
|
443
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
444
|
+
symbol: 'usdc.e',
|
|
445
|
+
asset_platform_id: 'ethereum'
|
|
446
|
+
})
|
|
447
|
+
});
|
|
448
|
+
cg().request = fakeRequest;
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
452
|
+
should.fail('should have thrown');
|
|
453
|
+
} catch (err) {
|
|
454
|
+
err.message.should.equal('tokenAddress does not match chain');
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should map tokenAddress contract 404s to Unsupported tokenAddress', async () => {
|
|
459
|
+
forceGlobalCacheMisses();
|
|
460
|
+
const tokenAddress = '0x000000000000000000000000000000000000dead';
|
|
461
|
+
|
|
462
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
463
|
+
onCoinContract: () => ({
|
|
464
|
+
status: { error_code: 404, error_message: 'not found' }
|
|
465
|
+
})
|
|
466
|
+
});
|
|
467
|
+
cg().request = fakeRequest;
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
471
|
+
should.fail('should have thrown');
|
|
472
|
+
} catch (err) {
|
|
473
|
+
err.message.should.equal('Unsupported tokenAddress');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should reject invalid EVM tokenAddress format before lookup', async () => {
|
|
478
|
+
const getSpy = sandbox.spy();
|
|
479
|
+
cg().request = { get: getSpy };
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress: 'invalid-evm-address' });
|
|
483
|
+
should.fail('should have thrown');
|
|
484
|
+
} catch (err) {
|
|
485
|
+
err.message.should.equal('Invalid tokenAddress');
|
|
486
|
+
err.statusCode.should.equal(400);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getSpy.callCount.should.equal(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('should reject tokenAddress when returned token symbol does not match coin', async () => {
|
|
493
|
+
forceGlobalCacheMisses();
|
|
494
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
495
|
+
|
|
496
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
497
|
+
onCoinContract: () => ({
|
|
498
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
499
|
+
symbol: 'usdc.e'
|
|
500
|
+
})
|
|
501
|
+
});
|
|
502
|
+
cg().request = fakeRequest;
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
await getMarketStats({ coin: 'WBTC', chain: 'arb', tokenAddress });
|
|
506
|
+
should.fail('should have thrown');
|
|
507
|
+
} catch (err) {
|
|
508
|
+
err.message.should.equal('tokenAddress does not match coin');
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
it('should use DB cache for default marketstats coin', async () => {
|
|
514
|
+
const checkCacheStub = sandbox
|
|
515
|
+
.stub(cg().storage, 'checkAndUseGlobalCache')
|
|
516
|
+
.callsFake((_key, _duration, cb) => cb(null, null, null));
|
|
517
|
+
const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
|
|
518
|
+
|
|
519
|
+
const data = await getMarketStats({ coin: 'btc' });
|
|
520
|
+
|
|
521
|
+
should.exist(data);
|
|
522
|
+
checkCacheStub.callCount.should.equal(1);
|
|
523
|
+
checkCacheStub.getCall(0).args[0].should.equal('cgMarketStats:usd:bitcoin');
|
|
524
|
+
storeCacheStub.callCount.should.equal(1);
|
|
525
|
+
storeCacheStub.getCall(0).args[0].should.equal('cgMarketStats:usd:bitcoin');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('should bypass DB cache for token marketstats requests', async () => {
|
|
529
|
+
const checkCacheStub = sandbox
|
|
530
|
+
.stub(cg().storage, 'checkAndUseGlobalCache')
|
|
531
|
+
.callsFake((_key, _duration, cb) => cb(null, null, null));
|
|
532
|
+
const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
|
|
533
|
+
|
|
534
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
535
|
+
|
|
536
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
537
|
+
onCoinContract: (platformId, address) => {
|
|
538
|
+
platformId.should.equal('arbitrum-one');
|
|
539
|
+
address.should.equal(tokenAddress);
|
|
540
|
+
return {
|
|
541
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
542
|
+
symbol: 'usdc.e',
|
|
543
|
+
asset_platform_id: 'arbitrum-one'
|
|
544
|
+
};
|
|
545
|
+
},
|
|
546
|
+
onMarkets: ids =>
|
|
547
|
+
ids.map(id => ({
|
|
548
|
+
id,
|
|
549
|
+
symbol: 'usdc.e',
|
|
550
|
+
name: 'Arbitrum Bridged USDC (Arbitrum)',
|
|
551
|
+
image: `${id}.png`,
|
|
552
|
+
current_price: 1,
|
|
553
|
+
total_volume: 1,
|
|
554
|
+
circulating_supply: 1,
|
|
555
|
+
market_cap: 1,
|
|
556
|
+
last_updated: '2020-01-01T00:00:00.000Z'
|
|
557
|
+
})),
|
|
558
|
+
onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
|
|
559
|
+
onCoinInfo: _id => ({ description: { en: 'About token' } })
|
|
560
|
+
});
|
|
561
|
+
cg().request = fakeRequest;
|
|
562
|
+
|
|
563
|
+
const data = await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
564
|
+
|
|
565
|
+
should.exist(data);
|
|
566
|
+
|
|
567
|
+
const checkedKeys = checkCacheStub.getCalls().map(c => c.args[0] as string);
|
|
568
|
+
const storedKeys = storeCacheStub.getCalls().map(c => c.args[0] as string);
|
|
569
|
+
checkedKeys.some(k => typeof k === 'string' && k.startsWith('cgMarketStats:')).should.equal(false);
|
|
570
|
+
storedKeys.some(k => typeof k === 'string' && k.startsWith('cgMarketStats:')).should.equal(false);
|
|
571
|
+
});
|
|
572
|
+
|
|
184
573
|
it('should return error if coinGecko is commented in config', async () => {
|
|
185
574
|
config.coinGecko = undefined;
|
|
186
575
|
try {
|
|
187
|
-
await
|
|
576
|
+
await getMarketStats({ coin: 'btc' });
|
|
188
577
|
should.fail('should have thrown');
|
|
189
578
|
} catch (err) {
|
|
190
579
|
err.message.should.equal('coinGecko missing credentials');
|
|
@@ -194,31 +583,20 @@ describe('CoinGecko integration', function() {
|
|
|
194
583
|
|
|
195
584
|
describe('#coinGeckoGetFiatRates', () => {
|
|
196
585
|
beforeEach(() => {
|
|
197
|
-
|
|
198
|
-
|
|
586
|
+
const pricesById: Record<string, Array<[number, number]>> = {
|
|
587
|
+
bitcoin: [[1, 100], [2, 110]],
|
|
588
|
+
'bitcoin-cash': [[1, 200], [2, 210]]
|
|
199
589
|
};
|
|
200
590
|
|
|
201
|
-
fakeRequest = {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (mChart?.[1]) {
|
|
205
|
-
const id = decodeURIComponent(mChart[1]);
|
|
206
|
-
if (id === 'bitcoin') return cb(null, { body: { prices: [[1, 100], [2, 110]] } });
|
|
207
|
-
if (id === 'bitcoin-cash') return cb(null, { body: { prices: [[1, 200], [2, 210]] } });
|
|
208
|
-
return cb(null, { body: { prices: [[1, 1], [2, 1]] } });
|
|
209
|
-
}
|
|
210
|
-
return cb(new Error('unexpected url'));
|
|
211
|
-
}
|
|
212
|
-
};
|
|
591
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
592
|
+
onMarketChart: id => ({ prices: pricesById[id] || [[1, 1], [2, 1]] }),
|
|
593
|
+
});
|
|
213
594
|
|
|
214
|
-
|
|
595
|
+
cg().request = fakeRequest;
|
|
215
596
|
});
|
|
216
597
|
|
|
217
598
|
it('should get fiat rates with default days', async () => {
|
|
218
|
-
const data = await
|
|
219
|
-
params: { code: 'USD' },
|
|
220
|
-
query: { coin: 'BTC' }
|
|
221
|
-
});
|
|
599
|
+
const data = await getFiatRates({ coin: 'BTC' });
|
|
222
600
|
should.exist(data);
|
|
223
601
|
data.should.deep.equal([
|
|
224
602
|
{ ts: 1, rate: 100 },
|
|
@@ -226,50 +604,297 @@ describe('CoinGecko integration', function() {
|
|
|
226
604
|
]);
|
|
227
605
|
});
|
|
228
606
|
|
|
607
|
+
it('should reject invalid chain values for default coins', async () => {
|
|
608
|
+
try {
|
|
609
|
+
await getFiatRates({ coin: 'BTC', chain: '!!!' });
|
|
610
|
+
should.fail('should have thrown');
|
|
611
|
+
} catch (err) {
|
|
612
|
+
err.message.should.equal('Unsupported chain');
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('should require tokenAddress when chain is provided for fiat rates', async () => {
|
|
617
|
+
try {
|
|
618
|
+
await getFiatRates({ coin: 'BTC', chain: 'eth' });
|
|
619
|
+
should.fail('should have thrown');
|
|
620
|
+
} catch (err) {
|
|
621
|
+
err.message.should.equal('chain is only supported for token lookups; provide tokenAddress or omit chain for native coins');
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
229
625
|
it('should get fiat rates with explicit days', async () => {
|
|
230
|
-
const data = await
|
|
231
|
-
params: { code: 'USD' },
|
|
232
|
-
query: { coin: 'BTC', days: 365 }
|
|
233
|
-
});
|
|
626
|
+
const data = await getFiatRates({ coin: 'BTC', days: 365 });
|
|
234
627
|
should.exist(data);
|
|
235
628
|
data.should.have.length(2);
|
|
236
629
|
data[0].rate.should.equal(100);
|
|
237
630
|
});
|
|
238
631
|
|
|
239
632
|
it('should support BCH coin mapping', async () => {
|
|
240
|
-
const data = await
|
|
241
|
-
params: { code: 'USD' },
|
|
242
|
-
query: { coin: 'BCH' }
|
|
243
|
-
});
|
|
633
|
+
const data = await getFiatRates({ coin: 'BCH' });
|
|
244
634
|
should.exist(data);
|
|
245
635
|
data[0].rate.should.equal(200);
|
|
246
636
|
});
|
|
247
637
|
|
|
248
|
-
it('should
|
|
638
|
+
it('should reject non-default coin symbols without tokenAddress', async () => {
|
|
639
|
+
try {
|
|
640
|
+
await getFiatRates({ coin: 'WBTC' });
|
|
641
|
+
should.fail('should have thrown');
|
|
642
|
+
} catch (err) {
|
|
643
|
+
err.message.should.equal('Unsupported coin. For token symbols, pass `chain` and `tokenAddress`.');
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should resolve fiat rates unambiguously when tokenAddress is provided', async () => {
|
|
648
|
+
forceGlobalCacheMisses();
|
|
649
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
650
|
+
|
|
651
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
652
|
+
onCoinContract: (platformId, address) => {
|
|
653
|
+
platformId.should.equal('arbitrum-one');
|
|
654
|
+
address.should.equal(tokenAddress);
|
|
655
|
+
return {
|
|
656
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
657
|
+
symbol: 'usdc.e',
|
|
658
|
+
asset_platform_id: 'arbitrum-one'
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
onMarketChart: id =>
|
|
662
|
+
id === 'arbitrum-bridged-usdc-arbitrum'
|
|
663
|
+
? { prices: [[1, 77], [2, 78]] }
|
|
664
|
+
: { prices: [[1, 1], [2, 1]] }
|
|
665
|
+
});
|
|
666
|
+
cg().request = fakeRequest;
|
|
667
|
+
|
|
668
|
+
const data = await getFiatRates({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
669
|
+
should.exist(data);
|
|
670
|
+
data.should.deep.equal([
|
|
671
|
+
{ ts: 1, rate: 77 },
|
|
672
|
+
{ ts: 2, rate: 78 }
|
|
673
|
+
]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should resolve fiat rates by chain + tokenAddress when coin is omitted', async () => {
|
|
677
|
+
forceGlobalCacheMisses();
|
|
678
|
+
const tokenAddress = '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8';
|
|
679
|
+
|
|
680
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
681
|
+
onCoinContract: (platformId, address) => {
|
|
682
|
+
platformId.should.equal('arbitrum-one');
|
|
683
|
+
address.should.equal(tokenAddress);
|
|
684
|
+
return {
|
|
685
|
+
id: 'bridged-usdc-arbitrum',
|
|
686
|
+
symbol: 'usdc',
|
|
687
|
+
asset_platform_id: 'arbitrum-one'
|
|
688
|
+
};
|
|
689
|
+
},
|
|
690
|
+
onMarketChart: id =>
|
|
691
|
+
id === 'bridged-usdc-arbitrum'
|
|
692
|
+
? { prices: [[1, 99], [2, 100]] }
|
|
693
|
+
: { prices: [[1, 1], [2, 1]] }
|
|
694
|
+
});
|
|
695
|
+
cg().request = fakeRequest;
|
|
696
|
+
|
|
697
|
+
const data = await getFiatRates({ chain: 'arb', tokenAddress });
|
|
698
|
+
should.exist(data);
|
|
699
|
+
data.should.deep.equal([
|
|
700
|
+
{ ts: 1, rate: 99 },
|
|
701
|
+
{ ts: 2, rate: 100 }
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('should map pol alias to polygon-pos for token fiatrates lookup', async () => {
|
|
706
|
+
forceGlobalCacheMisses();
|
|
707
|
+
const tokenAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
|
|
708
|
+
|
|
709
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
710
|
+
onCoinContract: (platformId, address) => {
|
|
711
|
+
platformId.should.equal('polygon-pos');
|
|
712
|
+
address.should.equal(tokenAddress);
|
|
713
|
+
return {
|
|
714
|
+
id: 'usd-coin',
|
|
715
|
+
symbol: 'usdc',
|
|
716
|
+
asset_platform_id: 'polygon-pos'
|
|
717
|
+
};
|
|
718
|
+
},
|
|
719
|
+
onMarketChart: id =>
|
|
720
|
+
id === 'usd-coin'
|
|
721
|
+
? { prices: [[1, 11], [2, 12]] }
|
|
722
|
+
: { prices: [[1, 1], [2, 1]] }
|
|
723
|
+
});
|
|
724
|
+
cg().request = fakeRequest;
|
|
725
|
+
|
|
726
|
+
const data = await getFiatRates({ chain: 'pol', tokenAddress });
|
|
727
|
+
should.exist(data);
|
|
728
|
+
data.should.deep.equal([
|
|
729
|
+
{ ts: 1, rate: 11 },
|
|
730
|
+
{ ts: 2, rate: 12 }
|
|
731
|
+
]);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('should require a valid chain when tokenAddress is provided', async () => {
|
|
735
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
736
|
+
try {
|
|
737
|
+
await getFiatRates({ coin: 'USDC.e', tokenAddress });
|
|
738
|
+
should.fail('should have thrown');
|
|
739
|
+
} catch (err) {
|
|
740
|
+
err.message.should.equal('tokenAddress requires valid chain');
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('should reject invalid Solana tokenAddress format before lookup', async () => {
|
|
745
|
+
const getSpy = sandbox.spy();
|
|
746
|
+
cg().request = { get: getSpy };
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
await getFiatRates({ chain: 'sol', tokenAddress: '0xdeadbeef' });
|
|
750
|
+
should.fail('should have thrown');
|
|
751
|
+
} catch (err) {
|
|
752
|
+
err.message.should.equal('Invalid tokenAddress');
|
|
753
|
+
err.statusCode.should.equal(400);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
getSpy.callCount.should.equal(0);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('should rethrow non-404 tokenAddress lookup errors', async () => {
|
|
760
|
+
forceGlobalCacheMisses();
|
|
761
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
762
|
+
|
|
763
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
764
|
+
onCoinContract: () => ({
|
|
765
|
+
status: { error_code: 429, error_message: 'rate limited' }
|
|
766
|
+
})
|
|
767
|
+
});
|
|
768
|
+
cg().request = fakeRequest;
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
await getFiatRates({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
772
|
+
should.fail('should have thrown');
|
|
773
|
+
} catch (err) {
|
|
774
|
+
err.message.should.equal('coinGecko rate limit');
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should reject fiat rates tokenAddress when returned token symbol does not match coin', async () => {
|
|
779
|
+
forceGlobalCacheMisses();
|
|
780
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
781
|
+
|
|
782
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
783
|
+
onCoinContract: () => ({
|
|
784
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
785
|
+
symbol: 'usdc.e'
|
|
786
|
+
})
|
|
787
|
+
});
|
|
788
|
+
cg().request = fakeRequest;
|
|
789
|
+
|
|
249
790
|
try {
|
|
250
|
-
await
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
791
|
+
await getFiatRates({ coin: 'WBTC', chain: 'arb', tokenAddress });
|
|
792
|
+
should.fail('should have thrown');
|
|
793
|
+
} catch (err) {
|
|
794
|
+
err.message.should.equal('tokenAddress does not match coin');
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it('should accept array query params for days', async () => {
|
|
799
|
+
const data = await getFiatRates({ coin: 'BTC', days: ['365'] });
|
|
800
|
+
should.exist(data);
|
|
801
|
+
data.should.deep.equal([
|
|
802
|
+
{ ts: 1, rate: 100 },
|
|
803
|
+
{ ts: 2, rate: 110 }
|
|
804
|
+
]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should treat whitespace coin as missing and return default rates', async () => {
|
|
808
|
+
const data: any = await getFiatRates({ coin: ' ' });
|
|
809
|
+
should.exist(data);
|
|
810
|
+
Object.keys(data)
|
|
811
|
+
.sort()
|
|
812
|
+
.should.deep.equal(['bch', 'btc', 'doge', 'eth', 'ltc', 'pol', 'sol', 'xrp']);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('should reject overly long coin symbols', async () => {
|
|
816
|
+
try {
|
|
817
|
+
await getFiatRates({ coin: 'x'.repeat(100) });
|
|
818
|
+
should.fail('should have thrown');
|
|
819
|
+
} catch (err) {
|
|
820
|
+
err.message.should.equal('Unsupported coin');
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('should reject overly long days values', async () => {
|
|
825
|
+
try {
|
|
826
|
+
await getFiatRates({ coin: 'BTC', days: 'x'.repeat(100) });
|
|
254
827
|
should.fail('should have thrown');
|
|
255
828
|
} catch (err) {
|
|
256
829
|
err.message.should.equal('Invalid days');
|
|
257
830
|
}
|
|
258
831
|
});
|
|
259
832
|
|
|
260
|
-
it('should
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
833
|
+
it('should use DB cache for default fiatrates coin', async () => {
|
|
834
|
+
const checkCacheStub = sandbox
|
|
835
|
+
.stub(cg().storage, 'checkAndUseGlobalCache')
|
|
836
|
+
.callsFake((_key, _duration, cb) => cb(null, null, null));
|
|
837
|
+
const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
|
|
838
|
+
|
|
839
|
+
const data = await getFiatRates({ coin: 'BTC' });
|
|
840
|
+
|
|
841
|
+
should.exist(data);
|
|
842
|
+
checkCacheStub.callCount.should.equal(1);
|
|
843
|
+
checkCacheStub.getCall(0).args[0].should.equal('cgFiatRates:bitcoin:usd:100000');
|
|
844
|
+
storeCacheStub.callCount.should.equal(1);
|
|
845
|
+
storeCacheStub.getCall(0).args[0].should.equal('cgFiatRates:bitcoin:usd:100000');
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('should bypass DB cache for token fiatrates requests', async () => {
|
|
849
|
+
const checkCacheStub = sandbox
|
|
850
|
+
.stub(cg().storage, 'checkAndUseGlobalCache')
|
|
851
|
+
.callsFake((_key, _duration, cb) => cb(null, null, null));
|
|
852
|
+
const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
|
|
853
|
+
|
|
854
|
+
const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
|
|
855
|
+
|
|
856
|
+
fakeRequest = createCoinGeckoRequestStub({
|
|
857
|
+
onCoinContract: (platformId, address) => {
|
|
858
|
+
platformId.should.equal('arbitrum-one');
|
|
859
|
+
address.should.equal(tokenAddress);
|
|
860
|
+
return {
|
|
861
|
+
id: 'arbitrum-bridged-usdc-arbitrum',
|
|
862
|
+
symbol: 'usdc.e',
|
|
863
|
+
asset_platform_id: 'arbitrum-one'
|
|
864
|
+
};
|
|
865
|
+
},
|
|
866
|
+
onMarketChart: id =>
|
|
867
|
+
id === 'arbitrum-bridged-usdc-arbitrum'
|
|
868
|
+
? { prices: [[1, 77], [2, 78]] }
|
|
869
|
+
: { prices: [[1, 1], [2, 1]] }
|
|
264
870
|
});
|
|
871
|
+
cg().request = fakeRequest;
|
|
872
|
+
|
|
873
|
+
const data = await getFiatRates({ coin: 'USDC.e', chain: 'arb', tokenAddress });
|
|
874
|
+
|
|
265
875
|
should.exist(data);
|
|
266
876
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
877
|
+
const checkedKeys = checkCacheStub.getCalls().map(c => c.args[0] as string);
|
|
878
|
+
const storedKeys = storeCacheStub.getCalls().map(c => c.args[0] as string);
|
|
879
|
+
checkedKeys.some(k => typeof k === 'string' && k.startsWith('cgFiatRates:')).should.equal(false);
|
|
880
|
+
storedKeys.some(k => typeof k === 'string' && k.startsWith('cgFiatRates:')).should.equal(false);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('should return error on invalid days', async () => {
|
|
884
|
+
try {
|
|
885
|
+
await getFiatRates({ coin: 'BTC', days: 0 });
|
|
886
|
+
should.fail('should have thrown');
|
|
887
|
+
} catch (err) {
|
|
888
|
+
err.message.should.equal('Invalid days');
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('should return rates for default coins if missing coin', async () => {
|
|
893
|
+
const data: any = await getFiatRates({});
|
|
894
|
+
should.exist(data);
|
|
895
|
+
Object.keys(data)
|
|
896
|
+
.sort()
|
|
897
|
+
.should.deep.equal(['bch', 'btc', 'doge', 'eth', 'ltc', 'pol', 'sol', 'xrp']);
|
|
273
898
|
|
|
274
899
|
data.btc.should.deep.equal([
|
|
275
900
|
{ ts: 1, rate: 100 },
|
|
@@ -280,5 +905,35 @@ describe('CoinGecko integration', function() {
|
|
|
280
905
|
{ ts: 2, rate: 210 }
|
|
281
906
|
]);
|
|
282
907
|
});
|
|
908
|
+
|
|
909
|
+
it('should omit a failed default coin and log a warning', async () => {
|
|
910
|
+
const warnSpy = sandbox.spy(logger, 'warn');
|
|
911
|
+
|
|
912
|
+
// Force global-cache misses so every default coin fetches via HTTP.
|
|
913
|
+
forceGlobalCacheMisses();
|
|
914
|
+
|
|
915
|
+
// Make one default coin fail its market_chart request.
|
|
916
|
+
const baseGet = fakeRequest.get;
|
|
917
|
+
fakeRequest.get = (url, opts, cb) => {
|
|
918
|
+
const mChart = url.match(/\/v3\/coins\/([^/]+)\/market_chart\?/);
|
|
919
|
+
if (mChart?.[1]) {
|
|
920
|
+
const id = decodeURIComponent(mChart[1]);
|
|
921
|
+
if (id === 'bitcoin-cash') return cb(new Error('boom'));
|
|
922
|
+
}
|
|
923
|
+
return baseGet(url, opts, cb);
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const data: any = await getFiatRates({});
|
|
927
|
+
|
|
928
|
+
should.exist(data);
|
|
929
|
+
Object.keys(data).should.not.include('bch');
|
|
930
|
+
Object.keys(data).should.include('btc');
|
|
931
|
+
|
|
932
|
+
const bchWarns = warnSpy
|
|
933
|
+
.getCalls()
|
|
934
|
+
.map(c => c.args)
|
|
935
|
+
.filter(args => args?.[0]?.toString().includes('CoinGecko fiat rates fetch failed') && args?.[1]?.coin === 'bch');
|
|
936
|
+
bchWarns.length.should.be.greaterThan(0);
|
|
937
|
+
});
|
|
283
938
|
});
|
|
284
939
|
});
|