@pioneer-platform/pioneer-cache 1.22.0 → 1.24.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/core/base-cache.d.ts +2 -1
- package/dist/core/base-cache.js +2 -1
- package/dist/core/cache-manager.d.ts +1 -0
- package/dist/core/cache-manager.js +1 -1
- package/dist/stores/balance-cache.d.ts +1 -1
- package/dist/stores/balance-cache.js +17 -2
- package/dist/stores/portfolio-cache.d.ts +9 -0
- package/dist/stores/portfolio-cache.js +166 -3
- package/package.json +4 -4
- package/src/core/base-cache.ts +3 -1
- package/src/core/cache-manager.ts +2 -1
- package/src/stores/balance-cache.ts +18 -2
- package/src/stores/portfolio-cache.ts +199 -3
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @pioneer-platform/pioneer-cache@1.
|
|
3
|
+
> @pioneer-platform/pioneer-cache@1.24.0 build /Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer/modules/pioneer/pioneer-cache
|
|
4
4
|
> tsc
|
|
5
5
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @pioneer-platform/pioneer-cache
|
|
2
2
|
|
|
3
|
+
## 1.24.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- chore: feat(e2e): add --blue and --production flags to pioneer-sdk test
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @pioneer-platform/pioneer-discovery@8.47.0
|
|
13
|
+
- @pioneer-platform/pioneer-caip@9.23.0
|
|
14
|
+
|
|
15
|
+
## 1.23.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- feat(e2e): add --blue and --production flags to pioneer-sdk test
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies
|
|
24
|
+
- @pioneer-platform/pioneer-caip@9.22.0
|
|
25
|
+
- @pioneer-platform/pioneer-discovery@8.46.0
|
|
26
|
+
|
|
27
|
+
## 1.22.1
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- fix(cache-worker): fix balance module import and initialization
|
|
32
|
+
- Updated dependencies
|
|
33
|
+
- @pioneer-platform/pioneer-discovery@8.45.1
|
|
34
|
+
|
|
3
35
|
## 1.22.0
|
|
4
36
|
|
|
5
37
|
### Minor Changes
|
|
@@ -5,11 +5,12 @@ export declare abstract class BaseCache<T> {
|
|
|
5
5
|
protected config: CacheConfig;
|
|
6
6
|
protected queueInitialized: boolean;
|
|
7
7
|
protected TAG: string;
|
|
8
|
+
protected redisPublisher?: any;
|
|
8
9
|
private cachedStats;
|
|
9
10
|
private cachedStatsTimestamp;
|
|
10
11
|
private readonly STATS_CACHE_TTL;
|
|
11
12
|
private pendingFetches;
|
|
12
|
-
constructor(redis: any, config: CacheConfig);
|
|
13
|
+
constructor(redis: any, config: CacheConfig, redisPublisher?: any);
|
|
13
14
|
/**
|
|
14
15
|
* Initialize Redis queue for background refresh
|
|
15
16
|
*/
|
package/dist/core/base-cache.js
CHANGED
|
@@ -29,7 +29,7 @@ const MAJOR_CRYPTO_WHITELIST = new Set([
|
|
|
29
29
|
'bip122:000000000000000000651ef99cb9fcbe/slip44:145', // Bitcoin Cash
|
|
30
30
|
]);
|
|
31
31
|
class BaseCache {
|
|
32
|
-
constructor(redis, config) {
|
|
32
|
+
constructor(redis, config, redisPublisher) {
|
|
33
33
|
this.queueInitialized = false;
|
|
34
34
|
// Cache for stats (to avoid expensive SCAN operations on every health check)
|
|
35
35
|
this.cachedStats = null;
|
|
@@ -40,6 +40,7 @@ class BaseCache {
|
|
|
40
40
|
this.pendingFetches = new Map();
|
|
41
41
|
this.redis = redis;
|
|
42
42
|
this.config = config;
|
|
43
|
+
this.redisPublisher = redisPublisher;
|
|
43
44
|
this.TAG = ` | ${config.name}Cache | `;
|
|
44
45
|
// Initialize queue if enabled
|
|
45
46
|
if (config.enableQueue) {
|
|
@@ -29,7 +29,7 @@ class CacheManager {
|
|
|
29
29
|
if (config.enableBalanceCache !== false && config.balanceModule) {
|
|
30
30
|
this.balanceCache = new balance_cache_1.BalanceCache(this.redis, config.balanceModule, {
|
|
31
31
|
alertHandler: config.alertHandler
|
|
32
|
-
});
|
|
32
|
+
}, config.redisPublisher);
|
|
33
33
|
log.info(TAG, '✅ Balance cache initialized');
|
|
34
34
|
}
|
|
35
35
|
// Initialize Price Cache
|
|
@@ -28,7 +28,7 @@ export interface BalanceData {
|
|
|
28
28
|
*/
|
|
29
29
|
export declare class BalanceCache extends BaseCache<BalanceData> {
|
|
30
30
|
private balanceModule;
|
|
31
|
-
constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig
|
|
31
|
+
constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>, redisPublisher?: any);
|
|
32
32
|
/**
|
|
33
33
|
* Build Redis key for balance data
|
|
34
34
|
* Format: balance_v2:caip:hashedPubkey
|
|
@@ -47,7 +47,7 @@ function sanitizePubkey(pubkey) {
|
|
|
47
47
|
* BalanceCache - Caches blockchain balance data
|
|
48
48
|
*/
|
|
49
49
|
class BalanceCache extends base_cache_1.BaseCache {
|
|
50
|
-
constructor(redis, balanceModule, config) {
|
|
50
|
+
constructor(redis, balanceModule, config, redisPublisher) {
|
|
51
51
|
const defaultConfig = {
|
|
52
52
|
name: 'balance',
|
|
53
53
|
keyPrefix: 'balance_v2:',
|
|
@@ -71,7 +71,7 @@ class BalanceCache extends base_cache_1.BaseCache {
|
|
|
71
71
|
logCacheMisses: true,
|
|
72
72
|
logRefreshJobs: true
|
|
73
73
|
};
|
|
74
|
-
super(redis, { ...defaultConfig, ...config });
|
|
74
|
+
super(redis, { ...defaultConfig, ...config }, redisPublisher);
|
|
75
75
|
this.balanceModule = balanceModule;
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
@@ -298,6 +298,21 @@ class BalanceCache extends base_cache_1.BaseCache {
|
|
|
298
298
|
const parsed = JSON.parse(cached);
|
|
299
299
|
if (parsed.value && parsed.value.caip && parsed.value.pubkey) {
|
|
300
300
|
results[i] = parsed.value;
|
|
301
|
+
// CRITICAL FIX: Check staleness and trigger background refresh for stale cache hits
|
|
302
|
+
if (this.config.staleThreshold && parsed.timestamp) {
|
|
303
|
+
const age = Date.now() - parsed.timestamp;
|
|
304
|
+
if (age > this.config.staleThreshold) {
|
|
305
|
+
// Stale! Trigger background refresh (stale-while-revalidate pattern)
|
|
306
|
+
log.info(tag, `🔄 [STALE HIT] ${item.caip} is ${Math.floor(age / 1000 / 60)}m old (threshold: ${this.config.staleThreshold / 1000 / 60}m) - queueing refresh`);
|
|
307
|
+
if (!skipAsyncRefresh) {
|
|
308
|
+
log.info(tag, `[DEBUG] Calling triggerAsyncRefresh for ${item.caip}, queueInit=${this.queueInitialized}, enableQueue=${this.config.enableQueue}`);
|
|
309
|
+
this.triggerAsyncRefresh({ caip: item.caip, pubkey: item.pubkey }, 'normal');
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
log.info(tag, `[DEBUG] skipAsyncRefresh=true, NOT calling triggerAsyncRefresh for ${item.caip}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
301
316
|
continue;
|
|
302
317
|
}
|
|
303
318
|
}
|
|
@@ -61,6 +61,11 @@ export declare class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
61
61
|
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
62
62
|
*/
|
|
63
63
|
private fetchStableCoins;
|
|
64
|
+
/**
|
|
65
|
+
* Fetch custom tokens for a user address
|
|
66
|
+
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
67
|
+
*/
|
|
68
|
+
private fetchCustomTokens;
|
|
64
69
|
/**
|
|
65
70
|
* Fetch portfolio from blockchain APIs
|
|
66
71
|
*
|
|
@@ -69,6 +74,10 @@ export declare class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
69
74
|
* INTEGRATED: Now includes stable coin balances automatically
|
|
70
75
|
*/
|
|
71
76
|
protected fetchFromSource(params: Record<string, any>): Promise<PortfolioData>;
|
|
77
|
+
/**
|
|
78
|
+
* Apply Redis overlays to adjust balances for pending transactions
|
|
79
|
+
*/
|
|
80
|
+
private applyRedisOverlays;
|
|
72
81
|
/**
|
|
73
82
|
* No legacy cache format for portfolios
|
|
74
83
|
*/
|
|
@@ -198,6 +198,103 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
198
198
|
return stableCharts;
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Fetch custom tokens for a user address
|
|
203
|
+
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
204
|
+
*/
|
|
205
|
+
async fetchCustomTokens(primaryAddress, pubkeys) {
|
|
206
|
+
const tag = this.TAG + 'fetchCustomTokens | ';
|
|
207
|
+
const customCharts = [];
|
|
208
|
+
try {
|
|
209
|
+
log.info(tag, `Fetching custom tokens for address: ${primaryAddress.substring(0, 10)}...`);
|
|
210
|
+
// Get custom tokens from MongoDB
|
|
211
|
+
// Note: This creates a soft dependency on the server service layer
|
|
212
|
+
// In production, this would be better implemented as a shared service
|
|
213
|
+
const { CustomTokenService } = require('../../../../../services/pioneer-server/src/services/custom-token.service');
|
|
214
|
+
// Get custom tokens for all EVM networks the user might have
|
|
215
|
+
const evmNetworks = ['eip155:1', 'eip155:137', 'eip155:56', 'eip155:43114', 'eip155:42161', 'eip155:8453'];
|
|
216
|
+
let allCustomTokens = [];
|
|
217
|
+
for (const networkId of evmNetworks) {
|
|
218
|
+
try {
|
|
219
|
+
const networkTokens = await CustomTokenService.getTokens(primaryAddress, networkId);
|
|
220
|
+
allCustomTokens.push(...networkTokens);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
log.debug(tag, `No custom tokens for ${networkId}: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (allCustomTokens.length === 0) {
|
|
227
|
+
log.info(tag, 'No custom tokens found for user');
|
|
228
|
+
return customCharts;
|
|
229
|
+
}
|
|
230
|
+
log.info(tag, `Found ${allCustomTokens.length} custom tokens, fetching balances...`);
|
|
231
|
+
// Initialize eth-network if needed
|
|
232
|
+
let ethNetwork = global.ethNetwork;
|
|
233
|
+
if (!ethNetwork) {
|
|
234
|
+
ethNetwork = require('@pioneer-platform/eth-network');
|
|
235
|
+
await ethNetwork.init({});
|
|
236
|
+
global.ethNetwork = ethNetwork;
|
|
237
|
+
}
|
|
238
|
+
// Fetch balances for each custom token
|
|
239
|
+
const tokenPromises = allCustomTokens.map(async (tokenConfig) => {
|
|
240
|
+
try {
|
|
241
|
+
const balance = await Promise.race([
|
|
242
|
+
ethNetwork.getBalanceTokenByNetwork(tokenConfig.networkId, primaryAddress, tokenConfig.address),
|
|
243
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
|
|
244
|
+
]);
|
|
245
|
+
const balanceNum = parseFloat(balance);
|
|
246
|
+
// Skip zero balances
|
|
247
|
+
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
// Get price from markets module
|
|
251
|
+
let priceUsd = 0;
|
|
252
|
+
try {
|
|
253
|
+
priceUsd = await this.marketsModule.getAssetPriceByCaip(tokenConfig.caip);
|
|
254
|
+
if (isNaN(priceUsd) || priceUsd < 0) {
|
|
255
|
+
priceUsd = 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (priceError) {
|
|
259
|
+
log.warn(tag, `Error fetching price for ${tokenConfig.symbol}:`, priceError);
|
|
260
|
+
}
|
|
261
|
+
const valueUsd = balanceNum * priceUsd;
|
|
262
|
+
const chartData = {
|
|
263
|
+
caip: tokenConfig.caip,
|
|
264
|
+
pubkey: primaryAddress,
|
|
265
|
+
networkId: tokenConfig.networkId,
|
|
266
|
+
symbol: tokenConfig.symbol,
|
|
267
|
+
name: tokenConfig.name,
|
|
268
|
+
balance: balance,
|
|
269
|
+
priceUsd,
|
|
270
|
+
valueUsd,
|
|
271
|
+
icon: tokenConfig.icon || '',
|
|
272
|
+
type: 'custom-token',
|
|
273
|
+
decimal: tokenConfig.decimals
|
|
274
|
+
};
|
|
275
|
+
log.info(tag, `✅ ${tokenConfig.symbol}: ${balance} ($${valueUsd.toFixed(2)})`);
|
|
276
|
+
return chartData;
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
const errorMsg = error.isAxiosError
|
|
280
|
+
? `${error.message} (${error.code}) - ${error.config?.url || 'unknown url'}`
|
|
281
|
+
: error.message || String(error);
|
|
282
|
+
log.error(tag, `Error fetching ${tokenConfig.symbol}: ${errorMsg}`);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
const results = await Promise.all(tokenPromises);
|
|
287
|
+
const validTokens = results.filter((t) => t !== null);
|
|
288
|
+
customCharts.push(...validTokens);
|
|
289
|
+
log.info(tag, `Fetched ${customCharts.length} custom token balances`);
|
|
290
|
+
return customCharts;
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
const errorMsg = error.message || String(error);
|
|
294
|
+
log.error(tag, `Error fetching custom tokens: ${errorMsg}`);
|
|
295
|
+
return customCharts;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
201
298
|
/**
|
|
202
299
|
* Fetch portfolio from blockchain APIs
|
|
203
300
|
*
|
|
@@ -235,8 +332,17 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
235
332
|
log.debug(tag, `No balance for ${item.caip}/${item.pubkey.substring(0, 10)}...`);
|
|
236
333
|
return null;
|
|
237
334
|
}
|
|
238
|
-
//
|
|
239
|
-
|
|
335
|
+
// Apply Redis overlays for server-side pending balance adjustments
|
|
336
|
+
let adjustedBalance = balanceInfo.balance;
|
|
337
|
+
try {
|
|
338
|
+
adjustedBalance = await this.applyRedisOverlays(item.caip, item.pubkey, balanceInfo.balance);
|
|
339
|
+
}
|
|
340
|
+
catch (overlayError) {
|
|
341
|
+
log.warn(tag, `Failed to apply overlays for ${item.caip}/${item.pubkey.substring(0, 10)}: ${overlayError.message}`);
|
|
342
|
+
// Continue with original balance if overlay application fails
|
|
343
|
+
}
|
|
344
|
+
// Skip zero balances (use adjusted balance for check)
|
|
345
|
+
const balanceNum = parseFloat(adjustedBalance);
|
|
240
346
|
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
241
347
|
return null;
|
|
242
348
|
}
|
|
@@ -274,7 +380,7 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
274
380
|
networkId,
|
|
275
381
|
symbol: assetInfo.symbol || 'UNKNOWN',
|
|
276
382
|
name: assetInfo.name || 'Unknown Asset',
|
|
277
|
-
balance:
|
|
383
|
+
balance: adjustedBalance,
|
|
278
384
|
priceUsd,
|
|
279
385
|
valueUsd,
|
|
280
386
|
icon: assetInfo.icon || '',
|
|
@@ -319,6 +425,17 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
319
425
|
}
|
|
320
426
|
}
|
|
321
427
|
log.info(tag, `Added ${stableCoins.length} stable coins to portfolio`);
|
|
428
|
+
// INTEGRATED: Fetch custom tokens for EVM addresses (background operation)
|
|
429
|
+
log.info(tag, `Fetching custom tokens for EVM address: ${primaryAddress.substring(0, 10)}...`);
|
|
430
|
+
const customTokens = await this.fetchCustomTokens(primaryAddress, pubkeys);
|
|
431
|
+
// Add custom tokens to charts (deduplicate by CAIP+pubkey)
|
|
432
|
+
for (const customToken of customTokens) {
|
|
433
|
+
const isDuplicate = charts.some(c => c.caip === customToken.caip && c.pubkey === customToken.pubkey);
|
|
434
|
+
if (!isDuplicate) {
|
|
435
|
+
charts.push(customToken);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
log.info(tag, `Added ${customTokens.length} custom tokens to portfolio`);
|
|
322
439
|
}
|
|
323
440
|
// Calculate total value
|
|
324
441
|
const totalValueUsd = charts.reduce((sum, c) => sum + c.valueUsd, 0);
|
|
@@ -337,6 +454,52 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
337
454
|
throw error;
|
|
338
455
|
}
|
|
339
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* Apply Redis overlays to adjust balances for pending transactions
|
|
459
|
+
*/
|
|
460
|
+
async applyRedisOverlays(caip, pubkey, originalBalance) {
|
|
461
|
+
const tag = this.TAG + 'applyRedisOverlays | ';
|
|
462
|
+
try {
|
|
463
|
+
// Normalize inputs for overlay lookup
|
|
464
|
+
const normalizedCaip = caip.toLowerCase();
|
|
465
|
+
const normalizedPubkey = pubkey.toLowerCase();
|
|
466
|
+
// Find all overlays for this asset/pubkey combination
|
|
467
|
+
const overlayPattern = `pending-overlay:*:${normalizedCaip}:${normalizedPubkey}:*`;
|
|
468
|
+
const overlayKeys = await this.redis.keys(overlayPattern);
|
|
469
|
+
if (overlayKeys.length === 0) {
|
|
470
|
+
return originalBalance; // No overlays to apply
|
|
471
|
+
}
|
|
472
|
+
let adjustedBalance = parseFloat(originalBalance);
|
|
473
|
+
log.debug(tag, `Applying ${overlayKeys.length} overlays to ${normalizedCaip}/${normalizedPubkey.substring(0, 10)} (base: ${originalBalance})`);
|
|
474
|
+
for (const overlayKey of overlayKeys) {
|
|
475
|
+
try {
|
|
476
|
+
const overlayData = await this.redis.get(overlayKey);
|
|
477
|
+
if (!overlayData)
|
|
478
|
+
continue;
|
|
479
|
+
const overlay = JSON.parse(overlayData);
|
|
480
|
+
const overlayAmount = parseFloat(overlay.amount || '0');
|
|
481
|
+
if (overlay.type === 'debit') {
|
|
482
|
+
adjustedBalance -= overlayAmount;
|
|
483
|
+
log.debug(tag, `Applied debit overlay: -${overlayAmount} (new balance: ${adjustedBalance})`);
|
|
484
|
+
}
|
|
485
|
+
else if (overlay.type === 'credit') {
|
|
486
|
+
adjustedBalance += overlayAmount;
|
|
487
|
+
log.debug(tag, `Applied credit overlay: +${overlayAmount} (new balance: ${adjustedBalance})`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (parseError) {
|
|
491
|
+
log.warn(tag, `Failed to parse overlay ${overlayKey}: ${parseError.message}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const finalBalance = Math.max(0, adjustedBalance).toString(); // Ensure non-negative
|
|
495
|
+
log.debug(tag, `Final adjusted balance: ${originalBalance} -> ${finalBalance} (${overlayKeys.length} overlays)`);
|
|
496
|
+
return finalBalance;
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
log.error(tag, `Failed to apply Redis overlays for ${caip}/${pubkey.substring(0, 10)}: ${error.message}`);
|
|
500
|
+
return originalBalance; // Return original on error
|
|
501
|
+
}
|
|
502
|
+
}
|
|
340
503
|
/**
|
|
341
504
|
* No legacy cache format for portfolios
|
|
342
505
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pioneer-platform/pioneer-cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Unified caching system for Pioneer platform with Redis backend",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"author": "Pioneer Platform",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"dependencies": {
|
|
17
|
+
"@pioneer-platform/redis-queue": "8.12.17",
|
|
17
18
|
"@pioneer-platform/loggerdog": "8.11.0",
|
|
18
|
-
"@pioneer-platform/pioneer-discovery": "8.44.0",
|
|
19
|
-
"@pioneer-platform/pioneer-caip": "9.21.0",
|
|
20
19
|
"@pioneer-platform/default-redis": "8.11.7",
|
|
21
|
-
"@pioneer-platform/
|
|
20
|
+
"@pioneer-platform/pioneer-discovery": "8.47.0",
|
|
21
|
+
"@pioneer-platform/pioneer-caip": "9.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/jest": "^29.5.0",
|
package/src/core/base-cache.ts
CHANGED
|
@@ -46,6 +46,7 @@ export abstract class BaseCache<T> {
|
|
|
46
46
|
protected config: CacheConfig;
|
|
47
47
|
protected queueInitialized: boolean = false;
|
|
48
48
|
protected TAG: string;
|
|
49
|
+
protected redisPublisher?: any; // Optional Redis publisher for WebSocket events
|
|
49
50
|
|
|
50
51
|
// Cache for stats (to avoid expensive SCAN operations on every health check)
|
|
51
52
|
private cachedStats: CacheStats | null = null;
|
|
@@ -56,9 +57,10 @@ export abstract class BaseCache<T> {
|
|
|
56
57
|
// Tracks in-flight network requests to prevent duplicate API calls
|
|
57
58
|
private pendingFetches: Map<string, Promise<T>> = new Map();
|
|
58
59
|
|
|
59
|
-
constructor(redis: any, config: CacheConfig) {
|
|
60
|
+
constructor(redis: any, config: CacheConfig, redisPublisher?: any) {
|
|
60
61
|
this.redis = redis;
|
|
61
62
|
this.config = config;
|
|
63
|
+
this.redisPublisher = redisPublisher;
|
|
62
64
|
this.TAG = ` | ${config.name}Cache | `;
|
|
63
65
|
|
|
64
66
|
// Initialize queue if enabled
|
|
@@ -27,6 +27,7 @@ import type { CacheAlertHandler } from '../types';
|
|
|
27
27
|
export interface CacheManagerConfig {
|
|
28
28
|
redis: any;
|
|
29
29
|
redisQueue?: any; // Dedicated Redis client for blocking queue operations (brpop, etc.)
|
|
30
|
+
redisPublisher?: any; // Optional: Redis publisher for WebSocket balance update events
|
|
30
31
|
balanceModule?: any; // Optional: if not provided, balance cache won't be initialized
|
|
31
32
|
markets?: any; // Optional: if not provided, price cache won't be initialized
|
|
32
33
|
networkModules?: Map<string, any>; // Optional: network modules for staking cache (keyed by networkId)
|
|
@@ -64,7 +65,7 @@ export class CacheManager {
|
|
|
64
65
|
if (config.enableBalanceCache !== false && config.balanceModule) {
|
|
65
66
|
this.balanceCache = new BalanceCache(this.redis, config.balanceModule, {
|
|
66
67
|
alertHandler: config.alertHandler
|
|
67
|
-
});
|
|
68
|
+
}, config.redisPublisher);
|
|
68
69
|
log.info(TAG, '✅ Balance cache initialized');
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -75,7 +75,7 @@ export interface BalanceData {
|
|
|
75
75
|
export class BalanceCache extends BaseCache<BalanceData> {
|
|
76
76
|
private balanceModule: any;
|
|
77
77
|
|
|
78
|
-
constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig
|
|
78
|
+
constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>, redisPublisher?: any) {
|
|
79
79
|
const defaultConfig: CacheConfig = {
|
|
80
80
|
name: 'balance',
|
|
81
81
|
keyPrefix: 'balance_v2:',
|
|
@@ -100,7 +100,7 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
100
100
|
logRefreshJobs: true
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
super(redis, { ...defaultConfig, ...config });
|
|
103
|
+
super(redis, { ...defaultConfig, ...config }, redisPublisher);
|
|
104
104
|
this.balanceModule = balanceModule;
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -361,6 +361,22 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
361
361
|
const parsed = JSON.parse(cached);
|
|
362
362
|
if (parsed.value && parsed.value.caip && parsed.value.pubkey) {
|
|
363
363
|
results[i] = parsed.value;
|
|
364
|
+
|
|
365
|
+
// CRITICAL FIX: Check staleness and trigger background refresh for stale cache hits
|
|
366
|
+
if (this.config.staleThreshold && parsed.timestamp) {
|
|
367
|
+
const age = Date.now() - parsed.timestamp;
|
|
368
|
+
if (age > this.config.staleThreshold) {
|
|
369
|
+
// Stale! Trigger background refresh (stale-while-revalidate pattern)
|
|
370
|
+
log.info(tag, `🔄 [STALE HIT] ${item.caip} is ${Math.floor(age / 1000 / 60)}m old (threshold: ${this.config.staleThreshold / 1000 / 60}m) - queueing refresh`);
|
|
371
|
+
if (!skipAsyncRefresh) {
|
|
372
|
+
log.info(tag, `[DEBUG] Calling triggerAsyncRefresh for ${item.caip}, queueInit=${this.queueInitialized}, enableQueue=${this.config.enableQueue}`);
|
|
373
|
+
this.triggerAsyncRefresh({ caip: item.caip, pubkey: item.pubkey }, 'normal');
|
|
374
|
+
} else {
|
|
375
|
+
log.info(tag, `[DEBUG] skipAsyncRefresh=true, NOT calling triggerAsyncRefresh for ${item.caip}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
364
380
|
continue;
|
|
365
381
|
}
|
|
366
382
|
} catch (e) {
|
|
@@ -268,6 +268,125 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Fetch custom tokens for a user address
|
|
273
|
+
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
274
|
+
*/
|
|
275
|
+
private async fetchCustomTokens(
|
|
276
|
+
primaryAddress: string,
|
|
277
|
+
pubkeys: Array<{ pubkey: string; caip: string }>
|
|
278
|
+
): Promise<ChartData[]> {
|
|
279
|
+
const tag = this.TAG + 'fetchCustomTokens | ';
|
|
280
|
+
const customCharts: ChartData[] = [];
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
log.info(tag, `Fetching custom tokens for address: ${primaryAddress.substring(0, 10)}...`);
|
|
284
|
+
|
|
285
|
+
// Get custom tokens from MongoDB
|
|
286
|
+
// Note: This creates a soft dependency on the server service layer
|
|
287
|
+
// In production, this would be better implemented as a shared service
|
|
288
|
+
const { CustomTokenService } = require('../../../../../services/pioneer-server/src/services/custom-token.service');
|
|
289
|
+
|
|
290
|
+
// Get custom tokens for all EVM networks the user might have
|
|
291
|
+
const evmNetworks = ['eip155:1', 'eip155:137', 'eip155:56', 'eip155:43114', 'eip155:42161', 'eip155:8453'];
|
|
292
|
+
|
|
293
|
+
let allCustomTokens: any[] = [];
|
|
294
|
+
for (const networkId of evmNetworks) {
|
|
295
|
+
try {
|
|
296
|
+
const networkTokens = await CustomTokenService.getTokens(primaryAddress, networkId);
|
|
297
|
+
allCustomTokens.push(...networkTokens);
|
|
298
|
+
} catch (error: any) {
|
|
299
|
+
log.debug(tag, `No custom tokens for ${networkId}: ${error.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (allCustomTokens.length === 0) {
|
|
304
|
+
log.info(tag, 'No custom tokens found for user');
|
|
305
|
+
return customCharts;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
log.info(tag, `Found ${allCustomTokens.length} custom tokens, fetching balances...`);
|
|
309
|
+
|
|
310
|
+
// Initialize eth-network if needed
|
|
311
|
+
let ethNetwork = (global as any).ethNetwork;
|
|
312
|
+
if (!ethNetwork) {
|
|
313
|
+
ethNetwork = require('@pioneer-platform/eth-network');
|
|
314
|
+
await ethNetwork.init({});
|
|
315
|
+
(global as any).ethNetwork = ethNetwork;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Fetch balances for each custom token
|
|
319
|
+
const tokenPromises = allCustomTokens.map(async (tokenConfig) => {
|
|
320
|
+
try {
|
|
321
|
+
const balance = await Promise.race([
|
|
322
|
+
ethNetwork.getBalanceTokenByNetwork(
|
|
323
|
+
tokenConfig.networkId,
|
|
324
|
+
primaryAddress,
|
|
325
|
+
tokenConfig.address
|
|
326
|
+
),
|
|
327
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
const balanceNum = parseFloat(balance);
|
|
331
|
+
|
|
332
|
+
// Skip zero balances
|
|
333
|
+
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Get price from markets module
|
|
338
|
+
let priceUsd = 0;
|
|
339
|
+
try {
|
|
340
|
+
priceUsd = await this.marketsModule.getAssetPriceByCaip(tokenConfig.caip);
|
|
341
|
+
if (isNaN(priceUsd) || priceUsd < 0) {
|
|
342
|
+
priceUsd = 0;
|
|
343
|
+
}
|
|
344
|
+
} catch (priceError) {
|
|
345
|
+
log.warn(tag, `Error fetching price for ${tokenConfig.symbol}:`, priceError);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const valueUsd = balanceNum * priceUsd;
|
|
349
|
+
|
|
350
|
+
const chartData: ChartData = {
|
|
351
|
+
caip: tokenConfig.caip,
|
|
352
|
+
pubkey: primaryAddress,
|
|
353
|
+
networkId: tokenConfig.networkId,
|
|
354
|
+
symbol: tokenConfig.symbol,
|
|
355
|
+
name: tokenConfig.name,
|
|
356
|
+
balance: balance,
|
|
357
|
+
priceUsd,
|
|
358
|
+
valueUsd,
|
|
359
|
+
icon: tokenConfig.icon || '',
|
|
360
|
+
type: 'custom-token',
|
|
361
|
+
decimal: tokenConfig.decimals
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
log.info(tag, `✅ ${tokenConfig.symbol}: ${balance} ($${valueUsd.toFixed(2)})`);
|
|
365
|
+
return chartData;
|
|
366
|
+
|
|
367
|
+
} catch (error: any) {
|
|
368
|
+
const errorMsg = error.isAxiosError
|
|
369
|
+
? `${error.message} (${error.code}) - ${error.config?.url || 'unknown url'}`
|
|
370
|
+
: error.message || String(error);
|
|
371
|
+
log.error(tag, `Error fetching ${tokenConfig.symbol}: ${errorMsg}`);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const results = await Promise.all(tokenPromises);
|
|
377
|
+
const validTokens = results.filter((t): t is ChartData => t !== null);
|
|
378
|
+
customCharts.push(...validTokens);
|
|
379
|
+
|
|
380
|
+
log.info(tag, `Fetched ${customCharts.length} custom token balances`);
|
|
381
|
+
return customCharts;
|
|
382
|
+
|
|
383
|
+
} catch (error: any) {
|
|
384
|
+
const errorMsg = error.message || String(error);
|
|
385
|
+
log.error(tag, `Error fetching custom tokens: ${errorMsg}`);
|
|
386
|
+
return customCharts;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
271
390
|
/**
|
|
272
391
|
* Fetch portfolio from blockchain APIs
|
|
273
392
|
*
|
|
@@ -315,8 +434,17 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
315
434
|
return null;
|
|
316
435
|
}
|
|
317
436
|
|
|
318
|
-
//
|
|
319
|
-
|
|
437
|
+
// Apply Redis overlays for server-side pending balance adjustments
|
|
438
|
+
let adjustedBalance = balanceInfo.balance;
|
|
439
|
+
try {
|
|
440
|
+
adjustedBalance = await this.applyRedisOverlays(item.caip, item.pubkey, balanceInfo.balance);
|
|
441
|
+
} catch (overlayError: any) {
|
|
442
|
+
log.warn(tag, `Failed to apply overlays for ${item.caip}/${item.pubkey.substring(0, 10)}: ${overlayError.message}`);
|
|
443
|
+
// Continue with original balance if overlay application fails
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Skip zero balances (use adjusted balance for check)
|
|
447
|
+
const balanceNum = parseFloat(adjustedBalance);
|
|
320
448
|
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
321
449
|
return null;
|
|
322
450
|
}
|
|
@@ -358,7 +486,7 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
358
486
|
networkId,
|
|
359
487
|
symbol: assetInfo.symbol || 'UNKNOWN',
|
|
360
488
|
name: assetInfo.name || 'Unknown Asset',
|
|
361
|
-
balance:
|
|
489
|
+
balance: adjustedBalance,
|
|
362
490
|
priceUsd,
|
|
363
491
|
valueUsd,
|
|
364
492
|
icon: assetInfo.icon || '',
|
|
@@ -415,6 +543,21 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
415
543
|
}
|
|
416
544
|
}
|
|
417
545
|
log.info(tag, `Added ${stableCoins.length} stable coins to portfolio`);
|
|
546
|
+
|
|
547
|
+
// INTEGRATED: Fetch custom tokens for EVM addresses (background operation)
|
|
548
|
+
log.info(tag, `Fetching custom tokens for EVM address: ${primaryAddress.substring(0, 10)}...`);
|
|
549
|
+
const customTokens = await this.fetchCustomTokens(primaryAddress, pubkeys);
|
|
550
|
+
|
|
551
|
+
// Add custom tokens to charts (deduplicate by CAIP+pubkey)
|
|
552
|
+
for (const customToken of customTokens) {
|
|
553
|
+
const isDuplicate = charts.some(c =>
|
|
554
|
+
c.caip === customToken.caip && c.pubkey === customToken.pubkey
|
|
555
|
+
);
|
|
556
|
+
if (!isDuplicate) {
|
|
557
|
+
charts.push(customToken);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
log.info(tag, `Added ${customTokens.length} custom tokens to portfolio`);
|
|
418
561
|
}
|
|
419
562
|
|
|
420
563
|
// Calculate total value
|
|
@@ -437,6 +580,59 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
437
580
|
}
|
|
438
581
|
}
|
|
439
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Apply Redis overlays to adjust balances for pending transactions
|
|
585
|
+
*/
|
|
586
|
+
private async applyRedisOverlays(caip: string, pubkey: string, originalBalance: string): Promise<string> {
|
|
587
|
+
const tag = this.TAG + 'applyRedisOverlays | ';
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
// Normalize inputs for overlay lookup
|
|
591
|
+
const normalizedCaip = caip.toLowerCase();
|
|
592
|
+
const normalizedPubkey = pubkey.toLowerCase();
|
|
593
|
+
|
|
594
|
+
// Find all overlays for this asset/pubkey combination
|
|
595
|
+
const overlayPattern = `pending-overlay:*:${normalizedCaip}:${normalizedPubkey}:*`;
|
|
596
|
+
const overlayKeys = await this.redis.keys(overlayPattern);
|
|
597
|
+
|
|
598
|
+
if (overlayKeys.length === 0) {
|
|
599
|
+
return originalBalance; // No overlays to apply
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let adjustedBalance = parseFloat(originalBalance);
|
|
603
|
+
log.debug(tag, `Applying ${overlayKeys.length} overlays to ${normalizedCaip}/${normalizedPubkey.substring(0, 10)} (base: ${originalBalance})`);
|
|
604
|
+
|
|
605
|
+
for (const overlayKey of overlayKeys) {
|
|
606
|
+
try {
|
|
607
|
+
const overlayData = await this.redis.get(overlayKey);
|
|
608
|
+
if (!overlayData) continue;
|
|
609
|
+
|
|
610
|
+
const overlay = JSON.parse(overlayData);
|
|
611
|
+
const overlayAmount = parseFloat(overlay.amount || '0');
|
|
612
|
+
|
|
613
|
+
if (overlay.type === 'debit') {
|
|
614
|
+
adjustedBalance -= overlayAmount;
|
|
615
|
+
log.debug(tag, `Applied debit overlay: -${overlayAmount} (new balance: ${adjustedBalance})`);
|
|
616
|
+
} else if (overlay.type === 'credit') {
|
|
617
|
+
adjustedBalance += overlayAmount;
|
|
618
|
+
log.debug(tag, `Applied credit overlay: +${overlayAmount} (new balance: ${adjustedBalance})`);
|
|
619
|
+
}
|
|
620
|
+
} catch (parseError: any) {
|
|
621
|
+
log.warn(tag, `Failed to parse overlay ${overlayKey}: ${parseError.message}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const finalBalance = Math.max(0, adjustedBalance).toString(); // Ensure non-negative
|
|
626
|
+
log.debug(tag, `Final adjusted balance: ${originalBalance} -> ${finalBalance} (${overlayKeys.length} overlays)`);
|
|
627
|
+
|
|
628
|
+
return finalBalance;
|
|
629
|
+
|
|
630
|
+
} catch (error: any) {
|
|
631
|
+
log.error(tag, `Failed to apply Redis overlays for ${caip}/${pubkey.substring(0, 10)}: ${error.message}`);
|
|
632
|
+
return originalBalance; // Return original on error
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
440
636
|
/**
|
|
441
637
|
* No legacy cache format for portfolios
|
|
442
638
|
*/
|