@pioneer-platform/pioneer-discovery-service 0.2.1 → 0.2.2

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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Price Discovery Worker
3
+ *
4
+ * Background worker that:
5
+ * 1. Discovers alternative free price data sources
6
+ * 2. Monitors primary asset prices for empty/missing data
7
+ * 3. Sends Discord alerts for critical price issues
8
+ * 4. Maintains a database of working price sources
9
+ *
10
+ * This runs as part of the discovery service cron cycle
11
+ */
12
+
13
+ import axios from 'axios';
14
+ import { discoveryDB } from '../db';
15
+
16
+ const log = require('@pioneer-platform/loggerdog')();
17
+ const TAG = ' | price-discovery | ';
18
+
19
+ // CRITICAL: Major cryptocurrencies that should NEVER have empty prices
20
+ const PRIMARY_ASSETS = [
21
+ { caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', symbol: 'bitcoin', name: 'Bitcoin (BTC)' },
22
+ { caip: 'eip155:1/slip44:60', symbol: 'ethereum', name: 'Ethereum (ETH)' },
23
+ { caip: 'eip155:56/slip44:60', symbol: 'binancecoin', name: 'BNB Chain (BNB)' },
24
+ { caip: 'eip155:137/slip44:60', symbol: 'matic-network', name: 'Polygon (MATIC)' },
25
+ { caip: 'cosmos:cosmoshub-4/slip44:118', symbol: 'cosmos', name: 'Cosmos (ATOM)' },
26
+ { caip: 'cosmos:thorchain-mainnet-v1/slip44:931', symbol: 'thorchain', name: 'Thorchain (RUNE)' },
27
+ { caip: 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', symbol: 'litecoin', name: 'Litecoin (LTC)' },
28
+ { caip: 'bip122:00000000001a91e3dace36e2be3bf030/slip44:3', symbol: 'dogecoin', name: 'Dogecoin (DOGE)' },
29
+ ];
30
+
31
+ // Free price API sources (no API key required)
32
+ const FREE_PRICE_SOURCES = [
33
+ {
34
+ name: 'CoinGecko (Free)',
35
+ url: 'https://api.coingecko.com/api/v3/simple/price',
36
+ rateLimit: { requests: 10, perMinutes: 1 },
37
+ priority: 1,
38
+ },
39
+ {
40
+ name: 'Blockchain.com',
41
+ url: 'https://blockchain.info/ticker',
42
+ rateLimit: { requests: 600, perHour: 1 },
43
+ priority: 2,
44
+ },
45
+ {
46
+ name: 'CoinPaprika',
47
+ url: 'https://api.coinpaprika.com/v1/tickers',
48
+ rateLimit: { requests: 20, perMinutes: 1 },
49
+ priority: 3,
50
+ },
51
+ {
52
+ name: 'CryptoCompare',
53
+ url: 'https://min-api.cryptocompare.com/data/price',
54
+ rateLimit: { requests: 100, perHour: 1 },
55
+ priority: 4,
56
+ },
57
+ {
58
+ name: 'Binance Public',
59
+ url: 'https://api.binance.com/api/v3/ticker/price',
60
+ rateLimit: { requests: 1200, perMinutes: 1 },
61
+ priority: 5,
62
+ },
63
+ {
64
+ name: 'Kraken Public',
65
+ url: 'https://api.kraken.com/0/public/Ticker',
66
+ rateLimit: { requests: 1, perSecond: 1 },
67
+ priority: 6,
68
+ },
69
+ {
70
+ name: 'Coinbase Public',
71
+ url: 'https://api.coinbase.com/v2/exchange-rates',
72
+ rateLimit: { requests: 10, perSecond: 1 },
73
+ priority: 7,
74
+ },
75
+ ];
76
+
77
+ interface PriceSourceStatus {
78
+ name: string;
79
+ url: string;
80
+ working: boolean;
81
+ lastChecked: number;
82
+ lastPrice?: number;
83
+ responseTime?: number;
84
+ error?: string;
85
+ }
86
+
87
+ interface PriceCheckResult {
88
+ asset: string;
89
+ hasPrice: boolean;
90
+ price?: number;
91
+ source?: string;
92
+ checked: number;
93
+ }
94
+
95
+ export class PriceDiscoveryWorker {
96
+ private priceSourceCache: Map<string, PriceSourceStatus> = new Map();
97
+ private discordNotifier: any;
98
+
99
+ constructor() {
100
+ // Initialize Discord notifier if available
101
+ this.initDiscordNotifier();
102
+ }
103
+
104
+ private async initDiscordNotifier() {
105
+ try {
106
+ // Try to import discord notifier from pioneer-server if available
107
+ // This will fail gracefully if running standalone
108
+ const notifierPath = '../../../pioneer-server/src/services/discord-notifier.service';
109
+ const { discordNotifier } = await import(notifierPath);
110
+ if (discordNotifier && typeof discordNotifier.isEnabled === 'function' && discordNotifier.isEnabled()) {
111
+ this.discordNotifier = discordNotifier;
112
+ log.info(TAG, 'āœ… Discord notifier connected for price alerts');
113
+ }
114
+ } catch (error) {
115
+ log.debug(TAG, 'Discord notifier not available (standalone mode) - this is OK');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Main discovery run - called by the discovery agent
121
+ */
122
+ async run(): Promise<void> {
123
+ const tag = TAG + 'run | ';
124
+ log.info(tag, 'šŸ” Starting price discovery worker...');
125
+
126
+ try {
127
+ // Phase 1: Test all free price sources
128
+ await this.discoverPriceSources();
129
+
130
+ // Phase 2: Check primary assets for empty prices
131
+ await this.monitorPrimaryAssets();
132
+
133
+ // Phase 3: Report findings
134
+ await this.reportFindings();
135
+
136
+ log.info(tag, 'āœ… Price discovery worker completed');
137
+ } catch (error) {
138
+ log.error(tag, 'āŒ Price discovery worker failed:', error);
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Phase 1: Discover and test free price sources
145
+ */
146
+ private async discoverPriceSources(): Promise<void> {
147
+ const tag = TAG + 'discoverPriceSources | ';
148
+ log.info(tag, `Testing ${FREE_PRICE_SOURCES.length} free price sources...`);
149
+
150
+ for (const source of FREE_PRICE_SOURCES) {
151
+ try {
152
+ const status = await this.testPriceSource(source);
153
+ this.priceSourceCache.set(source.name, status);
154
+
155
+ if (status.working) {
156
+ log.info(tag, `āœ… ${source.name} - Working (${status.responseTime}ms)`);
157
+ } else {
158
+ log.warn(tag, `āŒ ${source.name} - Failed: ${status.error}`);
159
+ }
160
+
161
+ // Small delay to respect rate limits
162
+ await new Promise(resolve => setTimeout(resolve, 500));
163
+ } catch (error) {
164
+ log.error(tag, `Error testing ${source.name}:`, error);
165
+ }
166
+ }
167
+
168
+ const workingCount = Array.from(this.priceSourceCache.values()).filter(s => s.working).length;
169
+ log.info(tag, `Price source discovery complete: ${workingCount}/${FREE_PRICE_SOURCES.length} working`);
170
+ }
171
+
172
+ /**
173
+ * Test a single price source
174
+ */
175
+ private async testPriceSource(source: typeof FREE_PRICE_SOURCES[0]): Promise<PriceSourceStatus> {
176
+ const tag = TAG + 'testPriceSource | ';
177
+ const startTime = Date.now();
178
+
179
+ const status: PriceSourceStatus = {
180
+ name: source.name,
181
+ url: source.url,
182
+ working: false,
183
+ lastChecked: Date.now(),
184
+ };
185
+
186
+ try {
187
+ let price: number | undefined;
188
+
189
+ // Test with Bitcoin as the reference asset
190
+ switch (source.name) {
191
+ case 'CoinGecko (Free)':
192
+ const cgResponse = await axios.get(`${source.url}?ids=bitcoin&vs_currencies=usd`, {
193
+ timeout: 5000,
194
+ });
195
+ price = cgResponse.data?.bitcoin?.usd;
196
+ break;
197
+
198
+ case 'Blockchain.com':
199
+ const bcResponse = await axios.get(source.url, { timeout: 5000 });
200
+ price = bcResponse.data?.USD?.last;
201
+ break;
202
+
203
+ case 'CoinPaprika':
204
+ const cpResponse = await axios.get(`${source.url}/btc-bitcoin`, { timeout: 5000 });
205
+ price = cpResponse.data?.quotes?.USD?.price;
206
+ break;
207
+
208
+ case 'CryptoCompare':
209
+ const ccResponse = await axios.get(`${source.url}?fsym=BTC&tsyms=USD`, {
210
+ timeout: 5000,
211
+ });
212
+ price = ccResponse.data?.USD;
213
+ break;
214
+
215
+ case 'Binance Public':
216
+ const binanceResponse = await axios.get(`${source.url}?symbol=BTCUSDT`, {
217
+ timeout: 5000,
218
+ });
219
+ price = parseFloat(binanceResponse.data?.price);
220
+ break;
221
+
222
+ case 'Kraken Public':
223
+ const krakenResponse = await axios.get(`${source.url}?pair=XBTUSD`, {
224
+ timeout: 5000,
225
+ });
226
+ price = parseFloat(krakenResponse.data?.result?.XXBTZUSD?.c?.[0]);
227
+ break;
228
+
229
+ case 'Coinbase Public':
230
+ const coinbaseResponse = await axios.get(`${source.url}?currency=BTC`, {
231
+ timeout: 5000,
232
+ });
233
+ price = parseFloat(coinbaseResponse.data?.data?.rates?.USD);
234
+ break;
235
+ }
236
+
237
+ const responseTime = Date.now() - startTime;
238
+
239
+ if (price && price > 0) {
240
+ status.working = true;
241
+ status.lastPrice = price;
242
+ status.responseTime = responseTime;
243
+ log.debug(tag, `${source.name}: $${price} (${responseTime}ms)`);
244
+ } else {
245
+ status.error = 'No price data returned';
246
+ }
247
+ } catch (error: any) {
248
+ status.error = error.message || 'Unknown error';
249
+ status.responseTime = Date.now() - startTime;
250
+ log.debug(tag, `${source.name} failed:`, status.error);
251
+ }
252
+
253
+ return status;
254
+ }
255
+
256
+ /**
257
+ * Phase 2: Monitor primary assets for empty prices
258
+ */
259
+ private async monitorPrimaryAssets(): Promise<void> {
260
+ const tag = TAG + 'monitorPrimaryAssets | ';
261
+ log.info(tag, `Checking prices for ${PRIMARY_ASSETS.length} primary assets...`);
262
+
263
+ const results: PriceCheckResult[] = [];
264
+
265
+ for (const asset of PRIMARY_ASSETS) {
266
+ try {
267
+ const result = await this.checkAssetPrice(asset);
268
+ results.push(result);
269
+
270
+ if (!result.hasPrice) {
271
+ log.error(tag, `🚨 EMPTY PRICE: ${asset.name} (${asset.caip})`);
272
+
273
+ // Send Discord alert (rate limited to 1 per 24h per asset)
274
+ if (this.discordNotifier) {
275
+ await this.discordNotifier.sendEmptyPriceAlert(asset.caip, asset.name);
276
+ }
277
+ } else {
278
+ log.debug(tag, `āœ… ${asset.name}: $${result.price} (from ${result.source})`);
279
+ }
280
+
281
+ // Small delay between checks
282
+ await new Promise(resolve => setTimeout(resolve, 300));
283
+ } catch (error) {
284
+ log.error(tag, `Error checking ${asset.name}:`, error);
285
+ }
286
+ }
287
+
288
+ const emptyCount = results.filter(r => !r.hasPrice).length;
289
+ if (emptyCount > 0) {
290
+ log.warn(tag, `āš ļø Found ${emptyCount} primary assets with empty prices!`);
291
+ } else {
292
+ log.info(tag, 'āœ… All primary assets have valid prices');
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Check if an asset has a valid price using working sources
298
+ */
299
+ private async checkAssetPrice(asset: typeof PRIMARY_ASSETS[0]): Promise<PriceCheckResult> {
300
+ const tag = TAG + 'checkAssetPrice | ';
301
+
302
+ // Try working sources in priority order
303
+ const workingSources = Array.from(this.priceSourceCache.values())
304
+ .filter(s => s.working)
305
+ .sort((a, b) => {
306
+ const aPriority = FREE_PRICE_SOURCES.find(f => f.name === a.name)?.priority || 999;
307
+ const bPriority = FREE_PRICE_SOURCES.find(f => f.name === b.name)?.priority || 999;
308
+ return aPriority - bPriority;
309
+ });
310
+
311
+ if (workingSources.length === 0) {
312
+ log.warn(tag, 'No working price sources available');
313
+ return {
314
+ asset: asset.name,
315
+ hasPrice: false,
316
+ checked: Date.now(),
317
+ };
318
+ }
319
+
320
+ // Try first working source
321
+ const source = workingSources[0];
322
+
323
+ try {
324
+ // For simplicity, we'll use CoinGecko format for all (could be extended)
325
+ if (source.name === 'CoinGecko (Free)') {
326
+ const response = await axios.get(
327
+ `${source.url}?ids=${asset.symbol}&vs_currencies=usd`,
328
+ { timeout: 5000 }
329
+ );
330
+ const price = response.data?.[asset.symbol]?.usd;
331
+
332
+ return {
333
+ asset: asset.name,
334
+ hasPrice: !!(price && price > 0),
335
+ price,
336
+ source: source.name,
337
+ checked: Date.now(),
338
+ };
339
+ }
340
+
341
+ // Fallback: assume no price
342
+ return {
343
+ asset: asset.name,
344
+ hasPrice: false,
345
+ checked: Date.now(),
346
+ };
347
+ } catch (error) {
348
+ log.debug(tag, `Failed to check price for ${asset.name}:`, error);
349
+ return {
350
+ asset: asset.name,
351
+ hasPrice: false,
352
+ checked: Date.now(),
353
+ };
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Phase 3: Report findings
359
+ */
360
+ private async reportFindings(): Promise<void> {
361
+ const tag = TAG + 'reportFindings | ';
362
+
363
+ const workingSources = Array.from(this.priceSourceCache.values()).filter(s => s.working);
364
+ const failedSources = Array.from(this.priceSourceCache.values()).filter(s => !s.working);
365
+
366
+ log.info(tag, '\nšŸ“Š Price Discovery Report:');
367
+ log.info(tag, ` Working Sources: ${workingSources.length}`);
368
+ log.info(tag, ` Failed Sources: ${failedSources.length}`);
369
+
370
+ if (workingSources.length > 0) {
371
+ log.info(tag, '\nāœ… Working Price Sources:');
372
+ for (const source of workingSources) {
373
+ log.info(tag, ` - ${source.name}: $${source.lastPrice} (${source.responseTime}ms)`);
374
+ }
375
+ }
376
+
377
+ if (failedSources.length > 0) {
378
+ log.warn(tag, '\nāŒ Failed Price Sources:');
379
+ for (const source of failedSources) {
380
+ log.warn(tag, ` - ${source.name}: ${source.error}`);
381
+ }
382
+ }
383
+
384
+ // TODO: Save findings to MongoDB for historical tracking
385
+ }
386
+
387
+ /**
388
+ * Get current status of all price sources
389
+ */
390
+ getSourceStatus(): PriceSourceStatus[] {
391
+ return Array.from(this.priceSourceCache.values());
392
+ }
393
+ }
394
+
395
+ // Export singleton instance
396
+ export const priceDiscoveryWorker = new PriceDiscoveryWorker();
397
+