@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.
- package/CHANGELOG.md +7 -0
- package/DAPP-INVESTIGATION.md +459 -0
- package/IMPLEMENTATION-PLAN.md +296 -0
- package/PRICE-DISCOVERY.md +319 -0
- package/README.md +10 -4
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +79 -5
- package/dist/agent/index.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/workers/dapp-investigator.worker.d.ts +110 -0
- package/dist/workers/dapp-investigator.worker.d.ts.map +1 -0
- package/dist/workers/dapp-investigator.worker.js +277 -0
- package/dist/workers/dapp-investigator.worker.js.map +1 -0
- package/dist/workers/price-discovery.worker.d.ts +57 -0
- package/dist/workers/price-discovery.worker.d.ts.map +1 -0
- package/dist/workers/price-discovery.worker.js +372 -0
- package/dist/workers/price-discovery.worker.js.map +1 -0
- package/package.json +1 -1
- package/src/agent/index.ts +95 -5
- package/src/types/index.ts +1 -1
- package/src/workers/dapp-investigator.worker.ts +379 -0
- package/src/workers/price-discovery.worker.ts +397 -0
|
@@ -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
|
+
|