@pioneer-platform/pioneer-cache 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ $ tsc
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @pioneer-platform/pioneer-cache
2
2
 
3
+ ## 1.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - bump
8
+
3
9
  ## 1.1.1
4
10
 
5
11
  ### Patch Changes
@@ -26,6 +26,7 @@ export declare abstract class BaseCache<T> {
26
26
  /**
27
27
  * Update cache with new value
28
28
  * FIX #2: Always includes TTL
29
+ * CRITICAL FIX: Never overwrite existing valid price with $0 (API failure protection)
29
30
  */
30
31
  updateCache(key: string, value: T, metadata?: Record<string, any>): Promise<void>;
31
32
  /**
@@ -8,6 +8,26 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.BaseCache = void 0;
10
10
  const log = require('@pioneer-platform/loggerdog')();
11
+ // CRITICAL: Major cryptocurrencies that should NEVER have $0 prices cached as "fresh"
12
+ // If cache has $0 for these, it's an API failure and we should immediately retry
13
+ const MAJOR_CRYPTO_WHITELIST = new Set([
14
+ 'bip122:000000000019d6689c085ae165831e93/slip44:0', // Bitcoin
15
+ 'eip155:1/slip44:60', // Ethereum
16
+ 'eip155:56/slip44:60', // BNB Chain
17
+ 'eip155:137/slip44:60', // Polygon
18
+ 'eip155:42161/slip44:60', // Arbitrum
19
+ 'eip155:10/slip44:60', // Optimism
20
+ 'eip155:8453/slip44:60', // Base
21
+ 'cosmos:cosmoshub-4/slip44:118', // Cosmos
22
+ 'cosmos:osmosis-1/slip44:118', // Osmosis
23
+ 'cosmos:thorchain-mainnet-v1/slip44:931', // Thorchain
24
+ 'cosmos:mayachain-mainnet-v1/slip44:931', // Mayachain
25
+ 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144', // XRP
26
+ 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', // Litecoin
27
+ 'bip122:00000000001a91e3dace36e2be3bf030/slip44:3', // Dogecoin
28
+ 'bip122:000007d91d1254d60e2dd1ae58038307/slip44:5', // Dash
29
+ 'bip122:000000000000000000651ef99cb9fcbe/slip44:145', // Bitcoin Cash
30
+ ]);
11
31
  class BaseCache {
12
32
  constructor(redis, config) {
13
33
  this.queueInitialized = false;
@@ -70,6 +90,27 @@ class BaseCache {
70
90
  if (this.config.logCacheHits) {
71
91
  log.debug(tag, `Cache hit: ${key} (${responseTime}ms, age: ${age}ms)`);
72
92
  }
93
+ // CRITICAL FIX: Treat zero prices for major cryptocurrencies as ALWAYS STALE
94
+ // Zero price for BTC/ETH/etc means API failure, not actual price
95
+ // Must immediately retry to get real price
96
+ const normalizedCaip = params.caip?.toLowerCase();
97
+ const isInvalidMajorCryptoPrice = (this.config.name === 'price' &&
98
+ cachedValue.value.price === 0 &&
99
+ normalizedCaip &&
100
+ Array.from(MAJOR_CRYPTO_WHITELIST).some(wl => wl.toLowerCase() === normalizedCaip));
101
+ if (isInvalidMajorCryptoPrice) {
102
+ log.warn(tag, `🚨 Zero price cached for major cryptocurrency: ${params.caip} - triggering HIGH PRIORITY refresh`);
103
+ this.triggerAsyncRefresh(params, 'high');
104
+ // Still return the cached zero, but mark as not fresh
105
+ return {
106
+ success: true,
107
+ value: cachedValue.value,
108
+ cached: true,
109
+ fresh: false, // NOT FRESH - force UI to show loading state
110
+ age,
111
+ invalidPrice: true
112
+ };
113
+ }
73
114
  // Check staleness async (don't wait)
74
115
  if (this.config.staleThreshold && age > this.config.staleThreshold) {
75
116
  this.triggerAsyncRefresh(params, 'normal');
@@ -164,8 +205,10 @@ class BaseCache {
164
205
  // Validate structure - Check for undefined/null, NOT falsy values!
165
206
  // CRITICAL: Balance "0", empty arrays [], and empty objects {} are VALID!
166
207
  if (parsed.value === undefined || parsed.value === null || typeof parsed.timestamp !== 'number') {
167
- log.warn(tag, `Invalid cache structure for ${key}, removing`);
168
- await this.redis.del(key);
208
+ log.error(tag, `Invalid cache structure for ${key} - KEEPING key and triggering refresh`);
209
+ log.error(tag, `Corrupted data: ${JSON.stringify(parsed).substring(0, 200)}`);
210
+ // NEVER DELETE: Keep the key, return null to trigger async refresh
211
+ // The refresh will overwrite the corrupted data
169
212
  return null;
170
213
  }
171
214
  log.debug(tag, `Cache hit: ${key}`);
@@ -179,10 +222,21 @@ class BaseCache {
179
222
  /**
180
223
  * Update cache with new value
181
224
  * FIX #2: Always includes TTL
225
+ * CRITICAL FIX: Never overwrite existing valid price with $0 (API failure protection)
182
226
  */
183
227
  async updateCache(key, value, metadata) {
184
228
  const tag = this.TAG + 'updateCache | ';
185
229
  try {
230
+ // CRITICAL: Never overwrite existing non-zero price with $0
231
+ // This protects against API failures/rate limits overwriting good cached prices
232
+ if (this.config.name === 'price' && value.price === 0) {
233
+ const existingCache = await this.getCached(key);
234
+ if (existingCache && existingCache.value.price > 0) {
235
+ log.warn(tag, `🛡️ Refusing to overwrite $${existingCache.value.price} with $0 for ${key}`);
236
+ log.warn(tag, ` Keeping existing price - likely API failure/rate limit`);
237
+ return; // Do NOT overwrite - keep the old good price
238
+ }
239
+ }
186
240
  const cachedValue = {
187
241
  value,
188
242
  timestamp: Date.now(),
@@ -14,6 +14,9 @@ export interface BalanceData {
14
14
  networkId?: string;
15
15
  type?: string;
16
16
  isNative?: boolean;
17
+ fetchedAt?: number;
18
+ fetchedAtISO?: string;
19
+ dataSource?: string;
17
20
  }
18
21
  /**
19
22
  * BalanceCache - Caches blockchain balance data
@@ -47,6 +50,9 @@ export declare class BalanceCache extends BaseCache<BalanceData> {
47
50
  /**
48
51
  * Get balances for multiple assets (batch operation)
49
52
  * OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
53
+ *
54
+ * CRITICAL FIX: When waitForFresh=true (forceRefresh), bypass cache entirely
55
+ * and fetch fresh blockchain data for ALL items, not just cache misses
50
56
  */
51
57
  getBatchBalances(items: Array<{
52
58
  caip: string;
@@ -106,10 +106,13 @@ class BalanceCache extends base_cache_1.BaseCache {
106
106
  const balanceInfo = await this.balanceModule.getBalance(asset, owner);
107
107
  if (!balanceInfo || !balanceInfo.balance) {
108
108
  log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
109
+ const now = Date.now();
109
110
  return {
110
111
  caip,
111
112
  pubkey,
112
- balance: '0'
113
+ balance: '0',
114
+ fetchedAt: now,
115
+ fetchedAtISO: new Date(now).toISOString()
113
116
  };
114
117
  }
115
118
  // Get asset metadata
@@ -117,13 +120,44 @@ class BalanceCache extends base_cache_1.BaseCache {
117
120
  const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
118
121
  const assetInfo = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()] || {};
119
122
  const networkId = caipToNetworkId(caip);
123
+ // Use actual node URL if available, otherwise construct generic description
124
+ let dataSource = 'Unknown';
125
+ if (balanceInfo.nodeUrl) {
126
+ // Actual node URL returned from network module
127
+ dataSource = balanceInfo.nodeUrl;
128
+ }
129
+ else {
130
+ // Fallback to generic description if nodeUrl not available
131
+ const chainName = assetInfo.name || 'Unknown Chain';
132
+ if (caip.startsWith('bip122:')) {
133
+ dataSource = `${chainName} (Blockbook API)`;
134
+ }
135
+ else if (caip.startsWith('eip155:')) {
136
+ const isToken = caip.includes('/erc20:');
137
+ dataSource = isToken ? `${chainName} (RPC - ERC20)` : `${chainName} (RPC)`;
138
+ }
139
+ else if (caip.startsWith('cosmos:')) {
140
+ dataSource = `${chainName} (LCD API)`;
141
+ }
142
+ else if (caip.startsWith('ripple:')) {
143
+ dataSource = `${chainName} (JSON-RPC)`;
144
+ }
145
+ else {
146
+ dataSource = `${chainName} (API)`;
147
+ }
148
+ }
149
+ // Capture fetch timestamp for freshness tracking
150
+ const now = Date.now();
120
151
  return {
121
152
  caip,
122
153
  pubkey,
123
154
  balance: balanceInfo.balance,
124
155
  symbol: assetInfo.symbol,
125
156
  name: assetInfo.name,
126
- networkId
157
+ networkId,
158
+ fetchedAt: now,
159
+ fetchedAtISO: new Date(now).toISOString(),
160
+ dataSource
127
161
  };
128
162
  }
129
163
  catch (error) {
@@ -171,11 +205,42 @@ class BalanceCache extends base_cache_1.BaseCache {
171
205
  /**
172
206
  * Get balances for multiple assets (batch operation)
173
207
  * OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
208
+ *
209
+ * CRITICAL FIX: When waitForFresh=true (forceRefresh), bypass cache entirely
210
+ * and fetch fresh blockchain data for ALL items, not just cache misses
174
211
  */
175
212
  async getBatchBalances(items, waitForFresh) {
176
213
  const tag = this.TAG + 'getBatchBalances | ';
177
214
  const startTime = Date.now();
178
215
  try {
216
+ // CRITICAL FIX: If waitForFresh=true, skip cache entirely and fetch fresh data
217
+ if (waitForFresh) {
218
+ log.info(tag, `🔄 FORCE REFRESH: Bypassing cache for ${items.length} balances - fetching fresh blockchain data`);
219
+ const fetchStart = Date.now();
220
+ // Fetch all items fresh in parallel
221
+ const fetchPromises = items.map(async (item) => {
222
+ try {
223
+ // Use fetchFresh to get blockchain data and update cache
224
+ const freshData = await this.fetchFresh({ caip: item.caip, pubkey: item.pubkey });
225
+ return freshData;
226
+ }
227
+ catch (error) {
228
+ log.error(tag, `Failed to fetch fresh ${item.caip}/${item.pubkey}:`, error);
229
+ const now = Date.now();
230
+ return {
231
+ caip: item.caip,
232
+ pubkey: item.pubkey,
233
+ balance: '0',
234
+ fetchedAt: now,
235
+ fetchedAtISO: new Date(now).toISOString()
236
+ };
237
+ }
238
+ });
239
+ const results = await Promise.all(fetchPromises);
240
+ log.info(tag, `✅ Force refresh completed: fetched ${items.length} fresh balances in ${Date.now() - fetchStart}ms`);
241
+ return results;
242
+ }
243
+ // Normal flow: Check cache first
179
244
  log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
180
245
  // Build all Redis keys
181
246
  const keys = items.map(item => this.buildKey({ caip: item.caip, pubkey: item.pubkey }));
@@ -208,7 +273,7 @@ class BalanceCache extends base_cache_1.BaseCache {
208
273
  log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
209
274
  // If we have cache misses and blocking is enabled, fetch them
210
275
  if (missedItems.length > 0) {
211
- const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
276
+ const shouldBlock = this.config.blockOnMiss;
212
277
  if (shouldBlock) {
213
278
  log.info(tag, `Fetching ${missedItems.length} cache misses...`);
214
279
  const fetchStart = Date.now();
@@ -221,7 +286,14 @@ class BalanceCache extends base_cache_1.BaseCache {
221
286
  }
222
287
  catch (error) {
223
288
  log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
224
- results[item.index] = { caip: item.caip, pubkey: item.pubkey, balance: '0' };
289
+ const now = Date.now();
290
+ results[item.index] = {
291
+ caip: item.caip,
292
+ pubkey: item.pubkey,
293
+ balance: '0',
294
+ fetchedAt: now,
295
+ fetchedAtISO: new Date(now).toISOString()
296
+ };
225
297
  }
226
298
  });
227
299
  await Promise.all(fetchPromises);
@@ -239,10 +311,13 @@ class BalanceCache extends base_cache_1.BaseCache {
239
311
  catch (error) {
240
312
  log.error(tag, 'Error in batch balance request:', error);
241
313
  // Return defaults for all items
314
+ const now = Date.now();
242
315
  return items.map(item => ({
243
316
  caip: item.caip,
244
317
  pubkey: item.pubkey,
245
- balance: '0'
318
+ balance: '0',
319
+ fetchedAt: now,
320
+ fetchedAtISO: new Date(now).toISOString()
246
321
  }));
247
322
  }
248
323
  }
@@ -41,6 +41,7 @@ export interface CacheResult<T> {
41
41
  fresh: boolean;
42
42
  age?: number;
43
43
  error?: string;
44
+ invalidPrice?: boolean;
44
45
  }
45
46
  /**
46
47
  * Health check result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/pioneer-cache",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
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",
@@ -19,6 +19,27 @@ import type {
19
19
 
20
20
  const log = require('@pioneer-platform/loggerdog')();
21
21
 
22
+ // CRITICAL: Major cryptocurrencies that should NEVER have $0 prices cached as "fresh"
23
+ // If cache has $0 for these, it's an API failure and we should immediately retry
24
+ const MAJOR_CRYPTO_WHITELIST = new Set([
25
+ 'bip122:000000000019d6689c085ae165831e93/slip44:0', // Bitcoin
26
+ 'eip155:1/slip44:60', // Ethereum
27
+ 'eip155:56/slip44:60', // BNB Chain
28
+ 'eip155:137/slip44:60', // Polygon
29
+ 'eip155:42161/slip44:60', // Arbitrum
30
+ 'eip155:10/slip44:60', // Optimism
31
+ 'eip155:8453/slip44:60', // Base
32
+ 'cosmos:cosmoshub-4/slip44:118', // Cosmos
33
+ 'cosmos:osmosis-1/slip44:118', // Osmosis
34
+ 'cosmos:thorchain-mainnet-v1/slip44:931', // Thorchain
35
+ 'cosmos:mayachain-mainnet-v1/slip44:931', // Mayachain
36
+ 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144', // XRP
37
+ 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', // Litecoin
38
+ 'bip122:00000000001a91e3dace36e2be3bf030/slip44:3', // Dogecoin
39
+ 'bip122:000007d91d1254d60e2dd1ae58038307/slip44:5', // Dash
40
+ 'bip122:000000000000000000651ef99cb9fcbe/slip44:145', // Bitcoin Cash
41
+ ]);
42
+
22
43
  export abstract class BaseCache<T> {
23
44
  protected redis: any;
24
45
  protected redisQueue: any;
@@ -94,6 +115,32 @@ export abstract class BaseCache<T> {
94
115
  log.debug(tag, `Cache hit: ${key} (${responseTime}ms, age: ${age}ms)`);
95
116
  }
96
117
 
118
+ // CRITICAL FIX: Treat zero prices for major cryptocurrencies as ALWAYS STALE
119
+ // Zero price for BTC/ETH/etc means API failure, not actual price
120
+ // Must immediately retry to get real price
121
+ const normalizedCaip = params.caip?.toLowerCase();
122
+ const isInvalidMajorCryptoPrice = (
123
+ this.config.name === 'price' &&
124
+ (cachedValue.value as any).price === 0 &&
125
+ normalizedCaip &&
126
+ Array.from(MAJOR_CRYPTO_WHITELIST).some(wl => wl.toLowerCase() === normalizedCaip)
127
+ );
128
+
129
+ if (isInvalidMajorCryptoPrice) {
130
+ log.warn(tag, `🚨 Zero price cached for major cryptocurrency: ${params.caip} - triggering HIGH PRIORITY refresh`);
131
+ this.triggerAsyncRefresh(params, 'high');
132
+
133
+ // Still return the cached zero, but mark as not fresh
134
+ return {
135
+ success: true,
136
+ value: cachedValue.value,
137
+ cached: true,
138
+ fresh: false, // NOT FRESH - force UI to show loading state
139
+ age,
140
+ invalidPrice: true
141
+ };
142
+ }
143
+
97
144
  // Check staleness async (don't wait)
98
145
  if (this.config.staleThreshold && age > this.config.staleThreshold) {
99
146
  this.triggerAsyncRefresh(params, 'normal');
@@ -201,8 +248,10 @@ export abstract class BaseCache<T> {
201
248
  // Validate structure - Check for undefined/null, NOT falsy values!
202
249
  // CRITICAL: Balance "0", empty arrays [], and empty objects {} are VALID!
203
250
  if (parsed.value === undefined || parsed.value === null || typeof parsed.timestamp !== 'number') {
204
- log.warn(tag, `Invalid cache structure for ${key}, removing`);
205
- await this.redis.del(key);
251
+ log.error(tag, `Invalid cache structure for ${key} - KEEPING key and triggering refresh`);
252
+ log.error(tag, `Corrupted data: ${JSON.stringify(parsed).substring(0, 200)}`);
253
+ // NEVER DELETE: Keep the key, return null to trigger async refresh
254
+ // The refresh will overwrite the corrupted data
206
255
  return null;
207
256
  }
208
257
 
@@ -218,11 +267,23 @@ export abstract class BaseCache<T> {
218
267
  /**
219
268
  * Update cache with new value
220
269
  * FIX #2: Always includes TTL
270
+ * CRITICAL FIX: Never overwrite existing valid price with $0 (API failure protection)
221
271
  */
222
272
  async updateCache(key: string, value: T, metadata?: Record<string, any>): Promise<void> {
223
273
  const tag = this.TAG + 'updateCache | ';
224
274
 
225
275
  try {
276
+ // CRITICAL: Never overwrite existing non-zero price with $0
277
+ // This protects against API failures/rate limits overwriting good cached prices
278
+ if (this.config.name === 'price' && (value as any).price === 0) {
279
+ const existingCache = await this.getCached(key);
280
+ if (existingCache && (existingCache.value as any).price > 0) {
281
+ log.warn(tag, `🛡️ Refusing to overwrite $${(existingCache.value as any).price} with $0 for ${key}`);
282
+ log.warn(tag, ` Keeping existing price - likely API failure/rate limit`);
283
+ return; // Do NOT overwrite - keep the old good price
284
+ }
285
+ }
286
+
226
287
  const cachedValue: CachedValue<T> = {
227
288
  value,
228
289
  timestamp: Date.now(),
@@ -57,6 +57,9 @@ export interface BalanceData {
57
57
  networkId?: string;
58
58
  type?: string; // ✅ PHASE 1: Asset type (native, token, unknown)
59
59
  isNative?: boolean; // ✅ PHASE 1: Whether asset is native to chain (backward compat)
60
+ fetchedAt?: number; // Unix timestamp when balance was fetched from blockchain
61
+ fetchedAtISO?: string; // ISO 8601 string for display (e.g., "2025-01-11T12:34:56.789Z")
62
+ dataSource?: string; // Data source description (e.g., "Bitcoin Blockbook", "Ethereum RPC")
60
63
  }
61
64
 
62
65
  /**
@@ -136,10 +139,13 @@ export class BalanceCache extends BaseCache<BalanceData> {
136
139
 
137
140
  if (!balanceInfo || !balanceInfo.balance) {
138
141
  log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
142
+ const now = Date.now();
139
143
  return {
140
144
  caip,
141
145
  pubkey,
142
- balance: '0'
146
+ balance: '0',
147
+ fetchedAt: now,
148
+ fetchedAtISO: new Date(now).toISOString()
143
149
  };
144
150
  }
145
151
 
@@ -150,13 +156,41 @@ export class BalanceCache extends BaseCache<BalanceData> {
150
156
  const assetInfo = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()] || {};
151
157
  const networkId = caipToNetworkId(caip);
152
158
 
159
+ // Use actual node URL if available, otherwise construct generic description
160
+ let dataSource = 'Unknown';
161
+ if (balanceInfo.nodeUrl) {
162
+ // Actual node URL returned from network module
163
+ dataSource = balanceInfo.nodeUrl;
164
+ } else {
165
+ // Fallback to generic description if nodeUrl not available
166
+ const chainName = assetInfo.name || 'Unknown Chain';
167
+ if (caip.startsWith('bip122:')) {
168
+ dataSource = `${chainName} (Blockbook API)`;
169
+ } else if (caip.startsWith('eip155:')) {
170
+ const isToken = caip.includes('/erc20:');
171
+ dataSource = isToken ? `${chainName} (RPC - ERC20)` : `${chainName} (RPC)`;
172
+ } else if (caip.startsWith('cosmos:')) {
173
+ dataSource = `${chainName} (LCD API)`;
174
+ } else if (caip.startsWith('ripple:')) {
175
+ dataSource = `${chainName} (JSON-RPC)`;
176
+ } else {
177
+ dataSource = `${chainName} (API)`;
178
+ }
179
+ }
180
+
181
+ // Capture fetch timestamp for freshness tracking
182
+ const now = Date.now();
183
+
153
184
  return {
154
185
  caip,
155
186
  pubkey,
156
187
  balance: balanceInfo.balance,
157
188
  symbol: assetInfo.symbol,
158
189
  name: assetInfo.name,
159
- networkId
190
+ networkId,
191
+ fetchedAt: now,
192
+ fetchedAtISO: new Date(now).toISOString(),
193
+ dataSource
160
194
  };
161
195
 
162
196
  } catch (error) {
@@ -211,12 +245,45 @@ export class BalanceCache extends BaseCache<BalanceData> {
211
245
  /**
212
246
  * Get balances for multiple assets (batch operation)
213
247
  * OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
248
+ *
249
+ * CRITICAL FIX: When waitForFresh=true (forceRefresh), bypass cache entirely
250
+ * and fetch fresh blockchain data for ALL items, not just cache misses
214
251
  */
215
252
  async getBatchBalances(items: Array<{ caip: string; pubkey: string }>, waitForFresh?: boolean): Promise<BalanceData[]> {
216
253
  const tag = this.TAG + 'getBatchBalances | ';
217
254
  const startTime = Date.now();
218
255
 
219
256
  try {
257
+ // CRITICAL FIX: If waitForFresh=true, skip cache entirely and fetch fresh data
258
+ if (waitForFresh) {
259
+ log.info(tag, `🔄 FORCE REFRESH: Bypassing cache for ${items.length} balances - fetching fresh blockchain data`);
260
+ const fetchStart = Date.now();
261
+
262
+ // Fetch all items fresh in parallel
263
+ const fetchPromises = items.map(async (item) => {
264
+ try {
265
+ // Use fetchFresh to get blockchain data and update cache
266
+ const freshData = await this.fetchFresh({ caip: item.caip, pubkey: item.pubkey });
267
+ return freshData;
268
+ } catch (error) {
269
+ log.error(tag, `Failed to fetch fresh ${item.caip}/${item.pubkey}:`, error);
270
+ const now = Date.now();
271
+ return {
272
+ caip: item.caip,
273
+ pubkey: item.pubkey,
274
+ balance: '0',
275
+ fetchedAt: now,
276
+ fetchedAtISO: new Date(now).toISOString()
277
+ };
278
+ }
279
+ });
280
+
281
+ const results = await Promise.all(fetchPromises);
282
+ log.info(tag, `✅ Force refresh completed: fetched ${items.length} fresh balances in ${Date.now() - fetchStart}ms`);
283
+ return results;
284
+ }
285
+
286
+ // Normal flow: Check cache first
220
287
  log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
221
288
 
222
289
  // Build all Redis keys
@@ -256,7 +323,7 @@ export class BalanceCache extends BaseCache<BalanceData> {
256
323
 
257
324
  // If we have cache misses and blocking is enabled, fetch them
258
325
  if (missedItems.length > 0) {
259
- const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
326
+ const shouldBlock = this.config.blockOnMiss;
260
327
 
261
328
  if (shouldBlock) {
262
329
  log.info(tag, `Fetching ${missedItems.length} cache misses...`);
@@ -270,7 +337,14 @@ export class BalanceCache extends BaseCache<BalanceData> {
270
337
  results[item.index] = freshData;
271
338
  } catch (error) {
272
339
  log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
273
- results[item.index] = { caip: item.caip, pubkey: item.pubkey, balance: '0' };
340
+ const now = Date.now();
341
+ results[item.index] = {
342
+ caip: item.caip,
343
+ pubkey: item.pubkey,
344
+ balance: '0',
345
+ fetchedAt: now,
346
+ fetchedAtISO: new Date(now).toISOString()
347
+ };
274
348
  }
275
349
  });
276
350
 
@@ -289,10 +363,13 @@ export class BalanceCache extends BaseCache<BalanceData> {
289
363
  } catch (error) {
290
364
  log.error(tag, 'Error in batch balance request:', error);
291
365
  // Return defaults for all items
366
+ const now = Date.now();
292
367
  return items.map(item => ({
293
368
  caip: item.caip,
294
369
  pubkey: item.pubkey,
295
- balance: '0'
370
+ balance: '0',
371
+ fetchedAt: now,
372
+ fetchedAtISO: new Date(now).toISOString()
296
373
  }));
297
374
  }
298
375
  }
@@ -0,0 +1,345 @@
1
+ /*
2
+ BalanceCache - Balance-specific cache implementation
3
+
4
+ Extends BaseCache with balance-specific logic.
5
+ All common logic is inherited from BaseCache.
6
+ */
7
+
8
+ import { BaseCache } from '../core/base-cache';
9
+ import type { CacheConfig } from '../types';
10
+ import crypto from 'crypto';
11
+
12
+ const log = require('@pioneer-platform/loggerdog')();
13
+
14
+ /**
15
+ * Hash pubkey/xpub for privacy-protecting cache keys
16
+ * Uses SHA-256 for fast, deterministic, one-way hashing
17
+ */
18
+ function hashPubkey(pubkey: string, salt: string = ''): string {
19
+ // Detect if this is an xpub (don't lowercase) or regular address (lowercase)
20
+ const isXpub = pubkey.match(/^[xyz]pub[1-9A-HJ-NP-Za-km-z]{100,}/);
21
+ const normalized = isXpub ? pubkey.trim() : pubkey.trim().toLowerCase();
22
+
23
+ const hash = crypto.createHash('sha256');
24
+ hash.update(normalized);
25
+ if (salt) hash.update(salt);
26
+
27
+ // Return first 128 bits (32 hex chars) for shorter Redis keys
28
+ return hash.digest('hex').substring(0, 32);
29
+ }
30
+
31
+ /**
32
+ * Sanitize pubkey for safe logging
33
+ */
34
+ function sanitizePubkey(pubkey: string): string {
35
+ if (!pubkey || pubkey.length < 12) return '[invalid]';
36
+
37
+ // Check if xpub (show first 8, last 8)
38
+ if (pubkey.match(/^[xyz]pub/)) {
39
+ return `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`;
40
+ }
41
+
42
+ // Regular address (show first 6, last 4)
43
+ return `${pubkey.substring(0, 6)}...${pubkey.substring(pubkey.length - 4)}`;
44
+ }
45
+
46
+ /**
47
+ * Balance data structure
48
+ */
49
+ export interface BalanceData {
50
+ caip: string;
51
+ pubkey: string;
52
+ balance: string;
53
+ priceUsd?: string;
54
+ valueUsd?: string;
55
+ symbol?: string;
56
+ name?: string;
57
+ networkId?: string;
58
+ type?: string; // ✅ PHASE 1: Asset type (native, token, unknown)
59
+ isNative?: boolean; // ✅ PHASE 1: Whether asset is native to chain (backward compat)
60
+ fetchedAt?: number; // Unix timestamp when balance was fetched
61
+ fetchedAtISO?: string; // ISO 8601 timestamp
62
+ fetchedAt?: number; // Unix timestamp when balance was fetched from blockchain
63
+ fetchedAtISO?: string; // ISO 8601 string for display (e.g., "2025-01-11T12:34:56.789Z")
64
+ dataSource?: string; // Data source description (e.g., "Bitcoin Blockbook", "Ethereum RPC")
65
+ }
66
+
67
+ /**
68
+ * BalanceCache - Caches blockchain balance data
69
+ */
70
+ export class BalanceCache extends BaseCache<BalanceData> {
71
+ private balanceModule: any;
72
+
73
+ constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>) {
74
+ const defaultConfig: CacheConfig = {
75
+ name: 'balance',
76
+ keyPrefix: 'balance_v2:',
77
+ ttl: 0, // Ignored when enableTTL: false
78
+ staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
79
+ enableTTL: false, // NEVER EXPIRE - data persists forever
80
+ queueName: 'cache-refresh',
81
+ enableQueue: true,
82
+ maxRetries: 3,
83
+ retryDelay: 10000,
84
+ blockOnMiss: true, // Wait for fresh data on first request - users need real balances!
85
+ enableLegacyFallback: true,
86
+ defaultValue: {
87
+ caip: '',
88
+ pubkey: '',
89
+ balance: '0',
90
+ },
91
+ maxConcurrentJobs: 10,
92
+ apiTimeout: 15000,
93
+ logCacheHits: false,
94
+ logCacheMisses: true,
95
+ logRefreshJobs: true
96
+ };
97
+
98
+ super(redis, { ...defaultConfig, ...config });
99
+ this.balanceModule = balanceModule;
100
+ }
101
+
102
+ /**
103
+ * Build Redis key for balance data
104
+ * Format: balance_v2:caip:hashedPubkey
105
+ *
106
+ * PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
107
+ * - One-way: cannot recover pubkey from hash
108
+ * - Deterministic: same pubkey always produces same key
109
+ * - Fast: ~0.02ms per hash
110
+ */
111
+ protected buildKey(params: Record<string, any>): string {
112
+ const { caip, pubkey } = params;
113
+ if (!caip || !pubkey) {
114
+ throw new Error('BalanceCache.buildKey: caip and pubkey required');
115
+ }
116
+
117
+ const normalizedCaip = caip.toLowerCase();
118
+
119
+ // Hash pubkey for privacy protection
120
+ const hashedPubkey = hashPubkey(pubkey, caip);
121
+
122
+ return `${this.config.keyPrefix}${normalizedCaip}:${hashedPubkey}`;
123
+ }
124
+
125
+ /**
126
+ * Fetch balance from blockchain via balance module
127
+ */
128
+ protected async fetchFromSource(params: Record<string, any>): Promise<BalanceData> {
129
+ const tag = this.TAG + 'fetchFromSource | ';
130
+
131
+ try {
132
+ const { caip, pubkey } = params;
133
+
134
+ // Log sanitized pubkey for debugging
135
+ log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
136
+
137
+ // Fetch balance using balance module (still uses real pubkey)
138
+ const asset = { caip };
139
+ const owner = { pubkey };
140
+ const balanceInfo = await this.balanceModule.getBalance(asset, owner);
141
+
142
+ if (!balanceInfo || !balanceInfo.balance) {
143
+ log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
144
+ const now = Date.now();
145
+ return {
146
+ caip,
147
+ pubkey,
148
+ balance: '0',
149
+ fetchedAt: now,
150
+ fetchedAtISO: new Date(now).toISOString()
151
+ };
152
+ }
153
+
154
+ // Get asset metadata
155
+ const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
156
+ const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
157
+
158
+ const assetInfo = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()] || {};
159
+ const networkId = caipToNetworkId(caip);
160
+
161
+ // Use actual node URL if available, otherwise construct generic description
162
+ let dataSource = 'Unknown';
163
+ if (balanceInfo.nodeUrl) {
164
+ // Actual node URL returned from network module
165
+ dataSource = balanceInfo.nodeUrl;
166
+ } else {
167
+ // Fallback to generic description if nodeUrl not available
168
+ const chainName = assetInfo.name || 'Unknown Chain';
169
+ if (caip.startsWith('bip122:')) {
170
+ dataSource = `${chainName} (Blockbook API)`;
171
+ } else if (caip.startsWith('eip155:')) {
172
+ const isToken = caip.includes('/erc20:');
173
+ dataSource = isToken ? `${chainName} (RPC - ERC20)` : `${chainName} (RPC)`;
174
+ } else if (caip.startsWith('cosmos:')) {
175
+ dataSource = `${chainName} (LCD API)`;
176
+ } else if (caip.startsWith('ripple:')) {
177
+ dataSource = `${chainName} (JSON-RPC)`;
178
+ } else {
179
+ dataSource = `${chainName} (API)`;
180
+ }
181
+ }
182
+
183
+ // Capture fetch timestamp for freshness tracking
184
+ const now = Date.now();
185
+
186
+ return {
187
+ caip,
188
+ pubkey,
189
+ balance: balanceInfo.balance,
190
+ symbol: assetInfo.symbol,
191
+ name: assetInfo.name,
192
+ networkId,
193
+ fetchedAt: now,
194
+ fetchedAtISO: new Date(now).toISOString(),
195
+ dataSource
196
+ };
197
+
198
+ } catch (error) {
199
+ log.error(tag, 'Error fetching balance:', error);
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Try to get balance from legacy cache format
206
+ */
207
+ protected async getLegacyCached(params: Record<string, any>): Promise<BalanceData | null> {
208
+ const tag = this.TAG + 'getLegacyCached | ';
209
+
210
+ try {
211
+ const { caip, pubkey } = params;
212
+ const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
213
+ const networkId = caipToNetworkId(caip);
214
+
215
+ // Try legacy format: cache:balance:pubkey:asset
216
+ const legacyKey = `cache:balance:${pubkey}:${networkId}`;
217
+ const legacyData = await this.redis.get(legacyKey);
218
+
219
+ if (legacyData) {
220
+ const parsed = JSON.parse(legacyData);
221
+ if (parsed.balance !== undefined) {
222
+ return {
223
+ caip,
224
+ pubkey,
225
+ balance: typeof parsed.balance === 'string' ? parsed.balance : String(parsed.balance)
226
+ };
227
+ }
228
+ }
229
+
230
+ return null;
231
+
232
+ } catch (error) {
233
+ log.error(tag, 'Error getting legacy cached balance:', error);
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Get balance for a specific asset and pubkey
240
+ * Convenience method that wraps base get()
241
+ */
242
+ async getBalance(caip: string, pubkey: string, waitForFresh?: boolean): Promise<BalanceData> {
243
+ const result = await this.get({ caip, pubkey }, waitForFresh);
244
+ return result.value || this.config.defaultValue;
245
+ }
246
+
247
+ /**
248
+ * Get balances for multiple assets (batch operation)
249
+ * OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
250
+ */
251
+ async getBatchBalances(items: Array<{ caip: string; pubkey: string }>, waitForFresh?: boolean): Promise<BalanceData[]> {
252
+ const tag = this.TAG + 'getBatchBalances | ';
253
+ const startTime = Date.now();
254
+
255
+ try {
256
+ log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
257
+
258
+ // Build all Redis keys
259
+ const keys = items.map(item => this.buildKey({ caip: item.caip, pubkey: item.pubkey }));
260
+
261
+ // PERF: Use MGET to fetch all keys in ONE Redis round-trip
262
+ const cachedValues = await this.redis.mget(...keys);
263
+
264
+ // Process results
265
+ const results: BalanceData[] = [];
266
+ const missedItems: Array<{ caip: string; pubkey: string; index: number }> = [];
267
+
268
+ for (let i = 0; i < items.length; i++) {
269
+ const item = items[i];
270
+ const cached = cachedValues[i];
271
+
272
+ if (cached) {
273
+ try {
274
+ const parsed = JSON.parse(cached);
275
+ if (parsed.value && parsed.value.caip && parsed.value.pubkey) {
276
+ results[i] = parsed.value;
277
+ continue;
278
+ }
279
+ } catch (e) {
280
+ log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
281
+ }
282
+ }
283
+
284
+ // Cache miss - record for fetching
285
+ missedItems.push({ ...item, index: i });
286
+ results[i] = this.config.defaultValue; // Placeholder
287
+ }
288
+
289
+ const responseTime = Date.now() - startTime;
290
+ const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
291
+ log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
292
+
293
+ // If we have cache misses and blocking is enabled, fetch them
294
+ if (missedItems.length > 0) {
295
+ const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
296
+
297
+ if (shouldBlock) {
298
+ log.info(tag, `Fetching ${missedItems.length} cache misses...`);
299
+ const fetchStart = Date.now();
300
+
301
+ // Fetch all misses in parallel
302
+ const fetchPromises = missedItems.map(async (item) => {
303
+ try {
304
+ // Use fetchFresh to ensure Redis is updated and requests are deduplicated
305
+ const freshData = await this.fetchFresh({ caip: item.caip, pubkey: item.pubkey });
306
+ results[item.index] = freshData;
307
+ } catch (error) {
308
+ log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
309
+ const now = Date.now();
310
+ results[item.index] = {
311
+ caip: item.caip,
312
+ pubkey: item.pubkey,
313
+ balance: '0',
314
+ fetchedAt: now,
315
+ fetchedAtISO: new Date(now).toISOString()
316
+ };
317
+ }
318
+ });
319
+
320
+ await Promise.all(fetchPromises);
321
+ log.info(tag, `Fetched ${missedItems.length} misses in ${Date.now() - fetchStart}ms`);
322
+ } else {
323
+ // Non-blocking: trigger background refresh for misses
324
+ missedItems.forEach(item => {
325
+ this.triggerAsyncRefresh({ caip: item.caip, pubkey: item.pubkey }, 'high');
326
+ });
327
+ }
328
+ }
329
+
330
+ return results;
331
+
332
+ } catch (error) {
333
+ log.error(tag, 'Error in batch balance request:', error);
334
+ // Return defaults for all items
335
+ const now = Date.now();
336
+ return items.map(item => ({
337
+ caip: item.caip,
338
+ pubkey: item.pubkey,
339
+ balance: '0',
340
+ fetchedAt: now,
341
+ fetchedAtISO: new Date(now).toISOString()
342
+ }));
343
+ }
344
+ }
345
+ }
@@ -58,6 +58,7 @@ export interface CacheResult<T> {
58
58
  fresh: boolean; // Is value fresh (not stale)?
59
59
  age?: number; // Age of cached value in ms
60
60
  error?: string;
61
+ invalidPrice?: boolean; // For price cache: indicates $0 for major crypto (API failure)
61
62
  }
62
63
 
63
64
  /**