@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.
@@ -1,5 +1,5 @@
1
1
 
2
2
  
3
- > @pioneer-platform/pioneer-cache@1.21.0 build /Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer/modules/pioneer/pioneer-cache
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
  */
@@ -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) {
@@ -12,6 +12,7 @@ import type { CacheAlertHandler } from '../types';
12
12
  export interface CacheManagerConfig {
13
13
  redis: any;
14
14
  redisQueue?: any;
15
+ redisPublisher?: any;
15
16
  balanceModule?: any;
16
17
  markets?: any;
17
18
  networkModules?: Map<string, any>;
@@ -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
- // Skip zero balances
239
- const balanceNum = parseFloat(balanceInfo.balance);
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: balanceInfo.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.22.0",
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/redis-queue": "8.12.17"
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",
@@ -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
- // Skip zero balances
319
- const balanceNum = parseFloat(balanceInfo.balance);
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: balanceInfo.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
  */