@pioneer-platform/pioneer-cache 1.0.2 → 1.0.4

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,2 +1 @@
1
-
2
- $ tsc
1
+ $ tsc
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @pioneer-platform/pioneer-cache
2
2
 
3
+ ## 1.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ - cache work
8
+ - Updated dependencies
9
+ - @pioneer-platform/redis-queue@8.11.2
10
+
11
+ ## 1.0.3
12
+
13
+ ### Patch Changes
14
+
15
+ - cache work
16
+ - Updated dependencies
17
+ - @pioneer-platform/redis-queue@8.11.1
18
+
3
19
  ## 1.0.2
4
20
 
5
21
  ### Patch Changes
@@ -151,9 +151,11 @@ class BaseCache {
151
151
  const t0 = Date.now();
152
152
  try {
153
153
  // Redis timeout for cache reads
154
- // Increased from 100ms to 2000ms - 100ms was too aggressive and caused false cache misses
155
- // Increased from 2000ms to 10000ms - IPv4/IPv6 DNS resolution can cause delays
156
- const timeoutMs = 10000;
154
+ // PERFORMANCE: Generous timeout for connection pool under concurrent load
155
+ // - 1000ms accommodates worst-case scenarios with connection pool
156
+ // - Prevents false cache misses while still failing reasonably fast
157
+ // - Redis itself averages <1ms, but ioredis queuing can add latency
158
+ const timeoutMs = 1000;
157
159
  const cached = await Promise.race([
158
160
  this.redis.get(key),
159
161
  new Promise((resolve) => setTimeout(() => {
@@ -256,20 +258,34 @@ class BaseCache {
256
258
  }
257
259
  return;
258
260
  }
259
- const job = {
260
- type: `REFRESH_${this.config.name.toUpperCase()}`,
261
- key: this.buildKey(params),
262
- params,
263
- priority,
264
- retryCount: 0,
265
- timestamp: Date.now()
266
- };
267
- // Queue job async (don't wait)
261
+ const key = this.buildKey(params);
262
+ // RATE LIMITING: Prevent duplicate refresh jobs within time window
263
+ // Use Redis SET NX EX for atomic rate limiting
264
+ const rateLimitKey = `refresh_lock:${key}`;
265
+ const rateLimitWindow = 5000; // 5 seconds - don't queue same refresh more than once per 5s
266
+ // Queue job async (don't wait) with rate limiting
268
267
  setImmediate(async () => {
269
268
  try {
269
+ // Try to acquire rate limit lock (atomic operation)
270
+ const acquired = await this.redis.set(rateLimitKey, '1', 'PX', rateLimitWindow, // Expire in milliseconds
271
+ 'NX' // Only set if not exists
272
+ );
273
+ if (!acquired) {
274
+ // Rate limit hit - job already queued recently
275
+ log.debug(tag, `⏱️ Rate limit: Skip queuing ${key} (already queued in last ${rateLimitWindow}ms)`);
276
+ return;
277
+ }
278
+ const job = {
279
+ type: `REFRESH_${this.config.name.toUpperCase()}`,
280
+ key,
281
+ params,
282
+ priority,
283
+ retryCount: 0,
284
+ timestamp: Date.now()
285
+ };
270
286
  await this.redisQueue.createWork(this.config.queueName, job);
271
287
  if (this.config.logRefreshJobs) {
272
- log.debug(tag, `Queued refresh job: ${job.key} (priority: ${priority})`);
288
+ log.debug(tag, `✅ Queued refresh job: ${job.key} (priority: ${priority})`);
273
289
  }
274
290
  }
275
291
  catch (error) {
@@ -8,6 +8,7 @@ import type { HealthCheckResult } from '../types';
8
8
  */
9
9
  export interface CacheManagerConfig {
10
10
  redis: any;
11
+ redisQueue?: any;
11
12
  balanceModule?: any;
12
13
  markets?: any;
13
14
  enableBalanceCache?: boolean;
@@ -21,6 +22,7 @@ export interface CacheManagerConfig {
21
22
  */
22
23
  export declare class CacheManager {
23
24
  private redis;
25
+ private redisQueue;
24
26
  private balanceCache?;
25
27
  private priceCache?;
26
28
  private portfolioCache?;
@@ -21,6 +21,7 @@ class CacheManager {
21
21
  constructor(config) {
22
22
  this.workers = [];
23
23
  this.redis = config.redis;
24
+ this.redisQueue = config.redisQueue || config.redis; // Fallback to main redis if not provided
24
25
  // Initialize Balance Cache
25
26
  if (config.enableBalanceCache !== false && config.balanceModule) {
26
27
  this.balanceCache = new balance_cache_1.BalanceCache(this.redis, config.balanceModule);
@@ -69,7 +70,8 @@ class CacheManager {
69
70
  }
70
71
  // Start unified worker if we have any caches with queues
71
72
  if (cacheRegistry.size > 0) {
72
- const worker = await (0, refresh_worker_1.startUnifiedWorker)(this.redis, cacheRegistry, 'cache-refresh', // Unified queue name
73
+ const worker = await (0, refresh_worker_1.startUnifiedWorker)(this.redisQueue, // Use dedicated queue client for blocking operations
74
+ cacheRegistry, 'cache-refresh', // Unified queue name
73
75
  {
74
76
  maxRetries: 3,
75
77
  retryDelay: 5000,
@@ -21,7 +21,12 @@ export declare class BalanceCache extends BaseCache<BalanceData> {
21
21
  constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>);
22
22
  /**
23
23
  * Build Redis key for balance data
24
- * Format: balance_v2:caip:pubkey
24
+ * Format: balance_v2:caip:hashedPubkey
25
+ *
26
+ * PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
27
+ * - One-way: cannot recover pubkey from hash
28
+ * - Deterministic: same pubkey always produces same key
29
+ * - Fast: ~0.02ms per hash
25
30
  */
26
31
  protected buildKey(params: Record<string, any>): string;
27
32
  /**
@@ -5,10 +5,42 @@
5
5
  Extends BaseCache with balance-specific logic.
6
6
  All common logic is inherited from BaseCache.
7
7
  */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
8
11
  Object.defineProperty(exports, "__esModule", { value: true });
9
12
  exports.BalanceCache = void 0;
10
13
  const base_cache_1 = require("../core/base-cache");
14
+ const crypto_1 = __importDefault(require("crypto"));
11
15
  const log = require('@pioneer-platform/loggerdog')();
16
+ /**
17
+ * Hash pubkey/xpub for privacy-protecting cache keys
18
+ * Uses SHA-256 for fast, deterministic, one-way hashing
19
+ */
20
+ function hashPubkey(pubkey, salt = '') {
21
+ // Detect if this is an xpub (don't lowercase) or regular address (lowercase)
22
+ const isXpub = pubkey.match(/^[xyz]pub[1-9A-HJ-NP-Za-km-z]{100,}/);
23
+ const normalized = isXpub ? pubkey.trim() : pubkey.trim().toLowerCase();
24
+ const hash = crypto_1.default.createHash('sha256');
25
+ hash.update(normalized);
26
+ if (salt)
27
+ hash.update(salt);
28
+ // Return first 128 bits (32 hex chars) for shorter Redis keys
29
+ return hash.digest('hex').substring(0, 32);
30
+ }
31
+ /**
32
+ * Sanitize pubkey for safe logging
33
+ */
34
+ function sanitizePubkey(pubkey) {
35
+ if (!pubkey || pubkey.length < 12)
36
+ return '[invalid]';
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
+ // Regular address (show first 6, last 4)
42
+ return `${pubkey.substring(0, 6)}...${pubkey.substring(pubkey.length - 4)}`;
43
+ }
12
44
  /**
13
45
  * BalanceCache - Caches blockchain balance data
14
46
  */
@@ -42,7 +74,12 @@ class BalanceCache extends base_cache_1.BaseCache {
42
74
  }
43
75
  /**
44
76
  * Build Redis key for balance data
45
- * Format: balance_v2:caip:pubkey
77
+ * Format: balance_v2:caip:hashedPubkey
78
+ *
79
+ * PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
80
+ * - One-way: cannot recover pubkey from hash
81
+ * - Deterministic: same pubkey always produces same key
82
+ * - Fast: ~0.02ms per hash
46
83
  */
47
84
  buildKey(params) {
48
85
  const { caip, pubkey } = params;
@@ -50,9 +87,9 @@ class BalanceCache extends base_cache_1.BaseCache {
50
87
  throw new Error('BalanceCache.buildKey: caip and pubkey required');
51
88
  }
52
89
  const normalizedCaip = caip.toLowerCase();
53
- // Don't lowercase pubkeys - xpub/ypub/zpub are case-sensitive base58
54
- const normalizedPubkey = pubkey;
55
- return `${this.config.keyPrefix}${normalizedCaip}:${normalizedPubkey}`;
90
+ // Hash pubkey for privacy protection
91
+ const hashedPubkey = hashPubkey(pubkey, caip);
92
+ return `${this.config.keyPrefix}${normalizedCaip}:${hashedPubkey}`;
56
93
  }
57
94
  /**
58
95
  * Fetch balance from blockchain via balance module
@@ -61,12 +98,14 @@ class BalanceCache extends base_cache_1.BaseCache {
61
98
  const tag = this.TAG + 'fetchFromSource | ';
62
99
  try {
63
100
  const { caip, pubkey } = params;
64
- // Fetch balance using balance module
101
+ // Log sanitized pubkey for debugging
102
+ log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
103
+ // Fetch balance using balance module (still uses real pubkey)
65
104
  const asset = { caip };
66
105
  const owner = { pubkey };
67
106
  const balanceInfo = await this.balanceModule.getBalance(asset, owner);
68
107
  if (!balanceInfo || !balanceInfo.balance) {
69
- log.warn(tag, `No balance returned for ${caip}/${pubkey.substring(0, 10)}...`);
108
+ log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
70
109
  return {
71
110
  caip,
72
111
  pubkey,
@@ -22,6 +22,7 @@ export declare class PriceCache extends BaseCache<PriceData> {
22
22
  /**
23
23
  * Fetch price from markets API using CAIP-first approach
24
24
  * FIX #7: Graceful handling of zero prices to prevent cache disruption
25
+ * FIX #8: Cache zero prices for unpriceable tokens to prevent infinite retry loops
25
26
  */
26
27
  protected fetchFromSource(params: Record<string, any>): Promise<PriceData>;
27
28
  /**
@@ -55,6 +55,7 @@ class PriceCache extends base_cache_1.BaseCache {
55
55
  /**
56
56
  * Fetch price from markets API using CAIP-first approach
57
57
  * FIX #7: Graceful handling of zero prices to prevent cache disruption
58
+ * FIX #8: Cache zero prices for unpriceable tokens to prevent infinite retry loops
58
59
  */
59
60
  async fetchFromSource(params) {
60
61
  const tag = this.TAG + 'fetchFromSource | ';
@@ -63,32 +64,36 @@ class PriceCache extends base_cache_1.BaseCache {
63
64
  // Use CAIP-first API (no symbol conversion needed!)
64
65
  // This directly queries the markets module with CAIP identifiers
65
66
  const price = await this.markets.getAssetPriceByCaip(caip);
66
- // FIX #7: Gracefully handle zero prices without throwing
67
- // This prevents disrupting batch operations during API rate limits
68
- if (isNaN(price) || price <= 0) {
69
- log.warn(tag, `Price fetch returned $${price} for ${caip} (likely API timeout or rate limit) - returning stale cache if available`);
67
+ // FIX #8: Accept zero prices - they are VALID for unpriceable tokens
68
+ // Zero means "this token has no market value and we should stop trying to price it"
69
+ // Changed from: price <= 0 to: price < 0
70
+ if (isNaN(price) || price < 0) {
71
+ log.warn(tag, `Price fetch returned invalid price $${price} for ${caip} - returning stale cache if available`);
70
72
  // Try to get stale cached value instead of failing
71
73
  const key = this.buildKey(params);
72
74
  const cachedValue = await this.getCached(key);
73
- if (cachedValue && cachedValue.value.price > 0) {
75
+ if (cachedValue && cachedValue.value.price >= 0) {
74
76
  log.info(tag, `Returning stale cached price for ${caip}: $${cachedValue.value.price}`);
75
77
  return cachedValue.value;
76
78
  }
77
79
  // Try legacy cache as fallback
78
80
  const legacyValue = await this.getLegacyCached(params);
79
- if (legacyValue && legacyValue.price > 0) {
81
+ if (legacyValue && legacyValue.price >= 0) {
80
82
  log.info(tag, `Returning legacy cached price for ${caip}: $${legacyValue.price}`);
81
83
  return legacyValue;
82
84
  }
83
- // Last resort: return zero price but don't cache it
84
- log.warn(tag, `No cached price available for ${caip}, returning zero`);
85
+ // Last resort: throw error for truly invalid prices
86
+ log.warn(tag, `No cached price available for ${caip}, invalid price: $${price}`);
85
87
  throw new Error(`No valid price available for ${caip}`);
86
88
  }
87
89
  log.debug(tag, `Fetched price for ${caip}: $${price}`);
90
+ // FIX #8: Mark zero prices with special source to indicate they are unpriceable tokens
91
+ // This allows us to track and monitor unpriceable token caching
92
+ const source = price === 0 ? 'unpriceable' : 'markets-caip';
88
93
  return {
89
94
  caip,
90
95
  price,
91
- source: 'markets-caip'
96
+ source
92
97
  };
93
98
  }
94
99
  catch (error) {
@@ -35,6 +35,7 @@ export declare class RefreshWorker {
35
35
  stop(): Promise<void>;
36
36
  /**
37
37
  * Poll for next job from the queue
38
+ * FIX #3: Atomic flag check and job processing to prevent race conditions
38
39
  */
39
40
  private poll;
40
41
  /**
@@ -72,6 +72,7 @@ class RefreshWorker {
72
72
  }
73
73
  /**
74
74
  * Poll for next job from the queue
75
+ * FIX #3: Atomic flag check and job processing to prevent race conditions
75
76
  */
76
77
  async poll() {
77
78
  const tag = TAG + 'poll | ';
@@ -79,6 +80,7 @@ class RefreshWorker {
79
80
  return;
80
81
  }
81
82
  try {
83
+ // FIX #3: Atomic check-and-set to prevent race conditions
82
84
  // Don't poll if already processing
83
85
  if (this.isProcessing) {
84
86
  this.schedulePoll();
@@ -87,14 +89,20 @@ class RefreshWorker {
87
89
  // Get next job from queue
88
90
  const work = await this.redisQueue.getWork(this.config.queueName, 1);
89
91
  if (work) {
92
+ // Set processing flag BEFORE processing to prevent double execution
90
93
  this.isProcessing = true;
91
- await this.processJob(work);
92
- this.isProcessing = false;
94
+ try {
95
+ await this.processJob(work);
96
+ }
97
+ finally {
98
+ // Always clear flag, even if processJob throws
99
+ this.isProcessing = false;
100
+ }
93
101
  }
94
102
  }
95
103
  catch (error) {
96
104
  log.error(tag, 'Error in poll loop:', error.message);
97
- this.isProcessing = false;
105
+ // Flag already cleared in inner finally block
98
106
  }
99
107
  finally {
100
108
  // Schedule next poll
@@ -138,7 +146,18 @@ class RefreshWorker {
138
146
  catch (error) {
139
147
  const processingTime = Date.now() - startTime;
140
148
  log.error(tag, `❌ Failed to process ${job.type} after ${processingTime}ms:`, error);
141
- // Retry logic
149
+ // FIX #1: Detect permanent failures (don't retry unpriceable tokens)
150
+ const errorMsg = error instanceof Error ? error.message : String(error);
151
+ const isPermanentFailure = (errorMsg.includes('No valid price available') ||
152
+ errorMsg.includes('unpriceable') ||
153
+ errorMsg.includes('not found') ||
154
+ errorMsg.includes('404'));
155
+ if (isPermanentFailure) {
156
+ log.warn(tag, `Permanent failure detected for ${job.type}, will not retry: ${errorMsg}`);
157
+ // Don't retry - job complete (failed permanently)
158
+ return;
159
+ }
160
+ // Retry logic (only for transient failures)
142
161
  if ((job.retryCount || 0) < this.config.maxRetries) {
143
162
  const newRetryCount = (job.retryCount || 0) + 1;
144
163
  log.info(tag, `Retrying job (attempt ${newRetryCount}/${this.config.maxRetries})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/pioneer-cache",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",
@@ -20,9 +20,9 @@
20
20
  "author": "Pioneer Platform",
21
21
  "license": "MIT",
22
22
  "dependencies": {
23
- "@pioneer-platform/loggerdog": "^8.0.0",
24
- "@pioneer-platform/redis-queue": "^8.0.0",
25
- "@pioneer-platform/default-redis": "^8.0.0"
23
+ "@pioneer-platform/loggerdog": "^8.11.0",
24
+ "@pioneer-platform/redis-queue": "^8.11.2",
25
+ "@pioneer-platform/default-redis": "^8.11.7"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.0.0",
@@ -185,9 +185,11 @@ export abstract class BaseCache<T> {
185
185
 
186
186
  try {
187
187
  // Redis timeout for cache reads
188
- // Increased from 100ms to 2000ms - 100ms was too aggressive and caused false cache misses
189
- // Increased from 2000ms to 10000ms - IPv4/IPv6 DNS resolution can cause delays
190
- const timeoutMs = 10000;
188
+ // PERFORMANCE: Generous timeout for connection pool under concurrent load
189
+ // - 1000ms accommodates worst-case scenarios with connection pool
190
+ // - Prevents false cache misses while still failing reasonably fast
191
+ // - Redis itself averages <1ms, but ioredis queuing can add latency
192
+ const timeoutMs = 1000;
191
193
  const cached = await Promise.race([
192
194
  this.redis.get(key),
193
195
  new Promise<null>((resolve) => setTimeout(() => {
@@ -306,22 +308,43 @@ export abstract class BaseCache<T> {
306
308
  return;
307
309
  }
308
310
 
309
- const job: RefreshJob = {
310
- type: `REFRESH_${this.config.name.toUpperCase()}`,
311
- key: this.buildKey(params),
312
- params,
313
- priority,
314
- retryCount: 0,
315
- timestamp: Date.now()
316
- };
311
+ const key = this.buildKey(params);
317
312
 
318
- // Queue job async (don't wait)
313
+ // RATE LIMITING: Prevent duplicate refresh jobs within time window
314
+ // Use Redis SET NX EX for atomic rate limiting
315
+ const rateLimitKey = `refresh_lock:${key}`;
316
+ const rateLimitWindow = 5000; // 5 seconds - don't queue same refresh more than once per 5s
317
+
318
+ // Queue job async (don't wait) with rate limiting
319
319
  setImmediate(async () => {
320
320
  try {
321
+ // Try to acquire rate limit lock (atomic operation)
322
+ const acquired = await this.redis.set(
323
+ rateLimitKey,
324
+ '1',
325
+ 'PX', rateLimitWindow, // Expire in milliseconds
326
+ 'NX' // Only set if not exists
327
+ );
328
+
329
+ if (!acquired) {
330
+ // Rate limit hit - job already queued recently
331
+ log.debug(tag, `⏱️ Rate limit: Skip queuing ${key} (already queued in last ${rateLimitWindow}ms)`);
332
+ return;
333
+ }
334
+
335
+ const job: RefreshJob = {
336
+ type: `REFRESH_${this.config.name.toUpperCase()}`,
337
+ key,
338
+ params,
339
+ priority,
340
+ retryCount: 0,
341
+ timestamp: Date.now()
342
+ };
343
+
321
344
  await this.redisQueue.createWork(this.config.queueName, job);
322
345
 
323
346
  if (this.config.logRefreshJobs) {
324
- log.debug(tag, `Queued refresh job: ${job.key} (priority: ${priority})`);
347
+ log.debug(tag, `✅ Queued refresh job: ${job.key} (priority: ${priority})`);
325
348
  }
326
349
 
327
350
  } catch (error) {
@@ -21,6 +21,7 @@ const TAG = ' | CacheManager | ';
21
21
  */
22
22
  export interface CacheManagerConfig {
23
23
  redis: any;
24
+ redisQueue?: any; // Dedicated Redis client for blocking queue operations (brpop, etc.)
24
25
  balanceModule?: any; // Optional: if not provided, balance cache won't be initialized
25
26
  markets?: any; // Optional: if not provided, price cache won't be initialized
26
27
  enableBalanceCache?: boolean;
@@ -35,6 +36,7 @@ export interface CacheManagerConfig {
35
36
  */
36
37
  export class CacheManager {
37
38
  private redis: any;
39
+ private redisQueue: any; // Dedicated client for blocking operations
38
40
  private balanceCache?: BalanceCache;
39
41
  private priceCache?: PriceCache;
40
42
  private portfolioCache?: PortfolioCache;
@@ -43,6 +45,7 @@ export class CacheManager {
43
45
 
44
46
  constructor(config: CacheManagerConfig) {
45
47
  this.redis = config.redis;
48
+ this.redisQueue = config.redisQueue || config.redis; // Fallback to main redis if not provided
46
49
 
47
50
  // Initialize Balance Cache
48
51
  if (config.enableBalanceCache !== false && config.balanceModule) {
@@ -105,7 +108,7 @@ export class CacheManager {
105
108
  // Start unified worker if we have any caches with queues
106
109
  if (cacheRegistry.size > 0) {
107
110
  const worker = await startUnifiedWorker(
108
- this.redis,
111
+ this.redisQueue, // Use dedicated queue client for blocking operations
109
112
  cacheRegistry,
110
113
  'cache-refresh', // Unified queue name
111
114
  {
@@ -7,9 +7,42 @@
7
7
 
8
8
  import { BaseCache } from '../core/base-cache';
9
9
  import type { CacheConfig } from '../types';
10
+ import crypto from 'crypto';
10
11
 
11
12
  const log = require('@pioneer-platform/loggerdog')();
12
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
+
13
46
  /**
14
47
  * Balance data structure
15
48
  */
@@ -61,7 +94,12 @@ export class BalanceCache extends BaseCache<BalanceData> {
61
94
 
62
95
  /**
63
96
  * Build Redis key for balance data
64
- * Format: balance_v2:caip:pubkey
97
+ * Format: balance_v2:caip:hashedPubkey
98
+ *
99
+ * PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
100
+ * - One-way: cannot recover pubkey from hash
101
+ * - Deterministic: same pubkey always produces same key
102
+ * - Fast: ~0.02ms per hash
65
103
  */
66
104
  protected buildKey(params: Record<string, any>): string {
67
105
  const { caip, pubkey } = params;
@@ -70,10 +108,11 @@ export class BalanceCache extends BaseCache<BalanceData> {
70
108
  }
71
109
 
72
110
  const normalizedCaip = caip.toLowerCase();
73
- // Don't lowercase pubkeys - xpub/ypub/zpub are case-sensitive base58
74
- const normalizedPubkey = pubkey;
75
111
 
76
- return `${this.config.keyPrefix}${normalizedCaip}:${normalizedPubkey}`;
112
+ // Hash pubkey for privacy protection
113
+ const hashedPubkey = hashPubkey(pubkey, caip);
114
+
115
+ return `${this.config.keyPrefix}${normalizedCaip}:${hashedPubkey}`;
77
116
  }
78
117
 
79
118
  /**
@@ -85,13 +124,16 @@ export class BalanceCache extends BaseCache<BalanceData> {
85
124
  try {
86
125
  const { caip, pubkey } = params;
87
126
 
88
- // Fetch balance using balance module
127
+ // Log sanitized pubkey for debugging
128
+ log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
129
+
130
+ // Fetch balance using balance module (still uses real pubkey)
89
131
  const asset = { caip };
90
132
  const owner = { pubkey };
91
133
  const balanceInfo = await this.balanceModule.getBalance(asset, owner);
92
134
 
93
135
  if (!balanceInfo || !balanceInfo.balance) {
94
- log.warn(tag, `No balance returned for ${caip}/${pubkey.substring(0, 10)}...`);
136
+ log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
95
137
  return {
96
138
  caip,
97
139
  pubkey,
@@ -71,6 +71,7 @@ export class PriceCache extends BaseCache<PriceData> {
71
71
  /**
72
72
  * Fetch price from markets API using CAIP-first approach
73
73
  * FIX #7: Graceful handling of zero prices to prevent cache disruption
74
+ * FIX #8: Cache zero prices for unpriceable tokens to prevent infinite retry loops
74
75
  */
75
76
  protected async fetchFromSource(params: Record<string, any>): Promise<PriceData> {
76
77
  const tag = this.TAG + 'fetchFromSource | ';
@@ -82,38 +83,43 @@ export class PriceCache extends BaseCache<PriceData> {
82
83
  // This directly queries the markets module with CAIP identifiers
83
84
  const price = await this.markets.getAssetPriceByCaip(caip);
84
85
 
85
- // FIX #7: Gracefully handle zero prices without throwing
86
- // This prevents disrupting batch operations during API rate limits
87
- if (isNaN(price) || price <= 0) {
88
- log.warn(tag, `Price fetch returned $${price} for ${caip} (likely API timeout or rate limit) - returning stale cache if available`);
86
+ // FIX #8: Accept zero prices - they are VALID for unpriceable tokens
87
+ // Zero means "this token has no market value and we should stop trying to price it"
88
+ // Changed from: price <= 0 to: price < 0
89
+ if (isNaN(price) || price < 0) {
90
+ log.warn(tag, `Price fetch returned invalid price $${price} for ${caip} - returning stale cache if available`);
89
91
 
90
92
  // Try to get stale cached value instead of failing
91
93
  const key = this.buildKey(params);
92
94
  const cachedValue = await this.getCached(key);
93
95
 
94
- if (cachedValue && cachedValue.value.price > 0) {
96
+ if (cachedValue && cachedValue.value.price >= 0) {
95
97
  log.info(tag, `Returning stale cached price for ${caip}: $${cachedValue.value.price}`);
96
98
  return cachedValue.value;
97
99
  }
98
100
 
99
101
  // Try legacy cache as fallback
100
102
  const legacyValue = await this.getLegacyCached(params);
101
- if (legacyValue && legacyValue.price > 0) {
103
+ if (legacyValue && legacyValue.price >= 0) {
102
104
  log.info(tag, `Returning legacy cached price for ${caip}: $${legacyValue.price}`);
103
105
  return legacyValue;
104
106
  }
105
107
 
106
- // Last resort: return zero price but don't cache it
107
- log.warn(tag, `No cached price available for ${caip}, returning zero`);
108
+ // Last resort: throw error for truly invalid prices
109
+ log.warn(tag, `No cached price available for ${caip}, invalid price: $${price}`);
108
110
  throw new Error(`No valid price available for ${caip}`);
109
111
  }
110
112
 
111
113
  log.debug(tag, `Fetched price for ${caip}: $${price}`);
112
114
 
115
+ // FIX #8: Mark zero prices with special source to indicate they are unpriceable tokens
116
+ // This allows us to track and monitor unpriceable token caching
117
+ const source = price === 0 ? 'unpriceable' : 'markets-caip';
118
+
113
119
  return {
114
120
  caip,
115
121
  price,
116
- source: 'markets-caip'
122
+ source
117
123
  };
118
124
 
119
125
  } catch (error) {
@@ -100,6 +100,7 @@ export class RefreshWorker {
100
100
 
101
101
  /**
102
102
  * Poll for next job from the queue
103
+ * FIX #3: Atomic flag check and job processing to prevent race conditions
103
104
  */
104
105
  private async poll(): Promise<void> {
105
106
  const tag = TAG + 'poll | ';
@@ -109,6 +110,7 @@ export class RefreshWorker {
109
110
  }
110
111
 
111
112
  try {
113
+ // FIX #3: Atomic check-and-set to prevent race conditions
112
114
  // Don't poll if already processing
113
115
  if (this.isProcessing) {
114
116
  this.schedulePoll();
@@ -119,14 +121,19 @@ export class RefreshWorker {
119
121
  const work = await this.redisQueue.getWork(this.config.queueName, 1);
120
122
 
121
123
  if (work) {
124
+ // Set processing flag BEFORE processing to prevent double execution
122
125
  this.isProcessing = true;
123
- await this.processJob(work);
124
- this.isProcessing = false;
126
+ try {
127
+ await this.processJob(work);
128
+ } finally {
129
+ // Always clear flag, even if processJob throws
130
+ this.isProcessing = false;
131
+ }
125
132
  }
126
133
 
127
134
  } catch (error: any) {
128
135
  log.error(tag, 'Error in poll loop:', error.message);
129
- this.isProcessing = false;
136
+ // Flag already cleared in inner finally block
130
137
  } finally {
131
138
  // Schedule next poll
132
139
  this.schedulePoll();
@@ -180,7 +187,22 @@ export class RefreshWorker {
180
187
  const processingTime = Date.now() - startTime;
181
188
  log.error(tag, `❌ Failed to process ${job.type} after ${processingTime}ms:`, error);
182
189
 
183
- // Retry logic
190
+ // FIX #1: Detect permanent failures (don't retry unpriceable tokens)
191
+ const errorMsg = error instanceof Error ? error.message : String(error);
192
+ const isPermanentFailure = (
193
+ errorMsg.includes('No valid price available') ||
194
+ errorMsg.includes('unpriceable') ||
195
+ errorMsg.includes('not found') ||
196
+ errorMsg.includes('404')
197
+ );
198
+
199
+ if (isPermanentFailure) {
200
+ log.warn(tag, `Permanent failure detected for ${job.type}, will not retry: ${errorMsg}`);
201
+ // Don't retry - job complete (failed permanently)
202
+ return;
203
+ }
204
+
205
+ // Retry logic (only for transient failures)
184
206
  if ((job.retryCount || 0) < this.config.maxRetries) {
185
207
  const newRetryCount = (job.retryCount || 0) + 1;
186
208
  log.info(tag, `Retrying job (attempt ${newRetryCount}/${this.config.maxRetries})`);