@pioneer-platform/pioneer-cache 1.1.24 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @pioneer-platform/pioneer-cache
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - chore: feat(pioneer): implement end-to-end Solana transaction signing
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - feat(pioneer): implement end-to-end Solana transaction signing
14
+
3
15
  ## 1.1.24
4
16
 
5
17
  ### Patch Changes
@@ -97,7 +97,12 @@ class BalanceCache extends base_cache_1.BaseCache {
97
97
  async fetchFromSource(params) {
98
98
  const tag = this.TAG + 'fetchFromSource | ';
99
99
  try {
100
- const { caip, pubkey } = params;
100
+ // PHASE 2: Normalize CAIP to lowercase at entry point for consistency
101
+ const normalizedParams = {
102
+ caip: params.caip.toLowerCase(),
103
+ pubkey: params.pubkey
104
+ };
105
+ const { caip, pubkey } = normalizedParams;
101
106
  // Log sanitized pubkey for debugging
102
107
  log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
103
108
  // Fetch balance using balance module (still uses real pubkey)
@@ -55,11 +55,17 @@ export declare class PortfolioCache extends BaseCache<PortfolioData> {
55
55
  * Not cryptographic - just needs to be stable and collision-resistant
56
56
  */
57
57
  private simpleHash;
58
+ /**
59
+ * Fetch stable coin balances for EVM addresses
60
+ * INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
61
+ */
62
+ private fetchStableCoins;
58
63
  /**
59
64
  * Fetch portfolio from blockchain APIs
60
65
  *
61
66
  * This is the SLOW operation that happens in the background
62
67
  * It fetches balances for all pubkeys and enriches with pricing
68
+ * INTEGRATED: Now includes stable coin balances automatically
63
69
  */
64
70
  protected fetchFromSource(params: Record<string, any>): Promise<PortfolioData>;
65
71
  /**
@@ -85,11 +85,120 @@ class PortfolioCache extends base_cache_1.BaseCache {
85
85
  }
86
86
  return Math.abs(hash).toString(36);
87
87
  }
88
+ /**
89
+ * Fetch stable coin balances for EVM addresses
90
+ * INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
91
+ */
92
+ async fetchStableCoins(primaryAddress, pubkeys) {
93
+ const tag = this.TAG + 'fetchStableCoins | ';
94
+ const stableCharts = [];
95
+ try {
96
+ // Stable coins configuration (same as GetStableCoins endpoint)
97
+ const STABLE_COINS = {
98
+ 'eip155:1': [
99
+ { symbol: 'USDC', name: 'USD Coin', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
100
+ { symbol: 'USDT', name: 'Tether USD', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
101
+ { symbol: 'LINK', name: 'Chainlink', address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
102
+ ],
103
+ 'eip155:137': [
104
+ { symbol: 'USDC', name: 'USD Coin (Polygon)', address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', decimals: 6, coingeckoId: 'usd-coin' },
105
+ { symbol: 'USDT', name: 'Tether USD (Polygon)', address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', decimals: 6, coingeckoId: 'tether' },
106
+ ],
107
+ 'eip155:8453': [
108
+ { symbol: 'USDC', name: 'USD Coin (Base)', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, coingeckoId: 'usd-coin' },
109
+ ],
110
+ 'eip155:56': [
111
+ { symbol: 'USDT', name: 'Tether USD (BSC)', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
112
+ { symbol: 'USDC', name: 'USD Coin (BSC)', address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
113
+ ],
114
+ };
115
+ // Extract EVM networks from pubkeys
116
+ const evmNetworks = [...new Set(pubkeys
117
+ .filter(p => p.caip && p.caip.startsWith('eip155:'))
118
+ .map(p => p.caip.split('/')[0]))];
119
+ if (evmNetworks.length === 0) {
120
+ log.debug(tag, 'No EVM networks found, skipping stable coin fetch');
121
+ return stableCharts;
122
+ }
123
+ log.info(tag, `Fetching stable coins for ${evmNetworks.length} EVM networks`);
124
+ // Initialize eth-network module for token balance queries
125
+ let ethNetwork;
126
+ try {
127
+ ethNetwork = require('@pioneer-platform/eth-network');
128
+ await ethNetwork.init({});
129
+ }
130
+ catch (error) {
131
+ log.error(tag, 'Failed to initialize eth-network:', error);
132
+ return stableCharts;
133
+ }
134
+ // Fetch stable coins for each network
135
+ for (const networkId of evmNetworks) {
136
+ const stableCoins = STABLE_COINS[networkId];
137
+ if (!stableCoins || stableCoins.length === 0) {
138
+ continue;
139
+ }
140
+ log.debug(tag, `Checking ${stableCoins.length} stable coins on ${networkId}`);
141
+ // Fetch all token balances for this network in parallel
142
+ const tokenPromises = stableCoins.map(async (tokenConfig) => {
143
+ try {
144
+ const balance = await ethNetwork.getBalanceTokenByNetwork(networkId, primaryAddress, tokenConfig.address);
145
+ const balanceNum = parseFloat(balance);
146
+ // Skip zero balances
147
+ if (isNaN(balanceNum) || balanceNum === 0) {
148
+ return null;
149
+ }
150
+ // Get price from markets module
151
+ let priceUsd = 0;
152
+ try {
153
+ const tokenCaip = `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`;
154
+ priceUsd = await this.marketsModule.getAssetPriceByCaip(tokenCaip);
155
+ if (isNaN(priceUsd) || priceUsd < 0) {
156
+ priceUsd = 0;
157
+ }
158
+ }
159
+ catch (priceError) {
160
+ log.warn(tag, `Error fetching price for ${tokenConfig.symbol}:`, priceError);
161
+ }
162
+ const valueUsd = balanceNum * priceUsd;
163
+ const chartData = {
164
+ caip: `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`,
165
+ pubkey: primaryAddress,
166
+ networkId,
167
+ symbol: tokenConfig.symbol,
168
+ name: tokenConfig.name,
169
+ balance: balance,
170
+ priceUsd,
171
+ valueUsd,
172
+ icon: '',
173
+ type: 'token',
174
+ decimal: tokenConfig.decimals
175
+ };
176
+ log.info(tag, `✅ ${tokenConfig.symbol}: ${balance} ($${valueUsd.toFixed(2)})`);
177
+ return chartData;
178
+ }
179
+ catch (error) {
180
+ log.error(tag, `Error fetching ${tokenConfig.symbol}:`, error);
181
+ return null;
182
+ }
183
+ });
184
+ const results = await Promise.all(tokenPromises);
185
+ const validTokens = results.filter((t) => t !== null);
186
+ stableCharts.push(...validTokens);
187
+ }
188
+ log.info(tag, `Fetched ${stableCharts.length} stable coin balances`);
189
+ return stableCharts;
190
+ }
191
+ catch (error) {
192
+ log.error(tag, 'Error fetching stable coins:', error);
193
+ return stableCharts;
194
+ }
195
+ }
88
196
  /**
89
197
  * Fetch portfolio from blockchain APIs
90
198
  *
91
199
  * This is the SLOW operation that happens in the background
92
200
  * It fetches balances for all pubkeys and enriches with pricing
201
+ * INTEGRATED: Now includes stable coin balances automatically
93
202
  */
94
203
  async fetchFromSource(params) {
95
204
  const tag = this.TAG + 'fetchFromSource | ';
@@ -165,14 +274,32 @@ class PortfolioCache extends base_cache_1.BaseCache {
165
274
  }
166
275
  });
167
276
  const results = await Promise.all(balancePromises);
168
- // Filter out nulls and calculate total
277
+ // Filter out nulls
169
278
  const validCharts = results.filter((c) => c !== null);
170
- const totalValueUsd = validCharts.reduce((sum, c) => sum + c.valueUsd, 0);
279
+ charts.push(...validCharts);
280
+ // INTEGRATED: Fetch stable coins for EVM addresses (background operation)
281
+ // This ensures stable coins are ALWAYS in the cache and available instantly
282
+ const evmPubkey = pubkeys.find((p) => p.caip && p.caip.startsWith('eip155:'));
283
+ if (evmPubkey) {
284
+ const primaryAddress = evmPubkey.pubkey;
285
+ log.info(tag, `Fetching stable coins for EVM address: ${primaryAddress.substring(0, 10)}...`);
286
+ const stableCoins = await this.fetchStableCoins(primaryAddress, pubkeys);
287
+ // Add stable coins to charts (deduplicate by CAIP+pubkey)
288
+ for (const stableCoin of stableCoins) {
289
+ const isDuplicate = charts.some(c => c.caip === stableCoin.caip && c.pubkey === stableCoin.pubkey);
290
+ if (!isDuplicate) {
291
+ charts.push(stableCoin);
292
+ }
293
+ }
294
+ log.info(tag, `Added ${stableCoins.length} stable coins to portfolio`);
295
+ }
296
+ // Calculate total value
297
+ const totalValueUsd = charts.reduce((sum, c) => sum + c.valueUsd, 0);
171
298
  const fetchTime = Date.now() - startTime;
172
- log.info(tag, `✅ Fetched portfolio: ${validCharts.length} assets, $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
299
+ log.info(tag, `✅ Fetched portfolio: ${charts.length} assets (including stable coins), $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
173
300
  return {
174
301
  pubkeys,
175
- charts: validCharts,
302
+ charts,
176
303
  totalValueUsd,
177
304
  timestamp: Date.now()
178
305
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/pioneer-cache",
3
- "version": "1.1.24",
3
+ "version": "1.3.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",
@@ -129,7 +129,12 @@ export class BalanceCache extends BaseCache<BalanceData> {
129
129
  const tag = this.TAG + 'fetchFromSource | ';
130
130
 
131
131
  try {
132
- const { caip, pubkey } = params;
132
+ // PHASE 2: Normalize CAIP to lowercase at entry point for consistency
133
+ const normalizedParams = {
134
+ caip: params.caip.toLowerCase(),
135
+ pubkey: params.pubkey
136
+ };
137
+ const { caip, pubkey } = normalizedParams;
133
138
 
134
139
  // Log sanitized pubkey for debugging
135
140
  log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
@@ -124,11 +124,150 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
124
124
  return Math.abs(hash).toString(36);
125
125
  }
126
126
 
127
+ /**
128
+ * Fetch stable coin balances for EVM addresses
129
+ * INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
130
+ */
131
+ private async fetchStableCoins(
132
+ primaryAddress: string,
133
+ pubkeys: Array<{ pubkey: string; caip: string }>
134
+ ): Promise<ChartData[]> {
135
+ const tag = this.TAG + 'fetchStableCoins | ';
136
+ const stableCharts: ChartData[] = [];
137
+
138
+ try {
139
+ // Stable coins configuration (same as GetStableCoins endpoint)
140
+ const STABLE_COINS: Record<string, Array<{
141
+ symbol: string;
142
+ name: string;
143
+ address: string;
144
+ decimals: number;
145
+ coingeckoId: string;
146
+ }>> = {
147
+ 'eip155:1': [
148
+ { symbol: 'USDC', name: 'USD Coin', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
149
+ { symbol: 'USDT', name: 'Tether USD', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
150
+ { symbol: 'LINK', name: 'Chainlink', address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
151
+ ],
152
+ 'eip155:137': [
153
+ { symbol: 'USDC', name: 'USD Coin (Polygon)', address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', decimals: 6, coingeckoId: 'usd-coin' },
154
+ { symbol: 'USDT', name: 'Tether USD (Polygon)', address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', decimals: 6, coingeckoId: 'tether' },
155
+ ],
156
+ 'eip155:8453': [
157
+ { symbol: 'USDC', name: 'USD Coin (Base)', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, coingeckoId: 'usd-coin' },
158
+ ],
159
+ 'eip155:56': [
160
+ { symbol: 'USDT', name: 'Tether USD (BSC)', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
161
+ { symbol: 'USDC', name: 'USD Coin (BSC)', address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
162
+ ],
163
+ };
164
+
165
+ // Extract EVM networks from pubkeys
166
+ const evmNetworks = [...new Set(
167
+ pubkeys
168
+ .filter(p => p.caip && p.caip.startsWith('eip155:'))
169
+ .map(p => p.caip.split('/')[0])
170
+ )];
171
+
172
+ if (evmNetworks.length === 0) {
173
+ log.debug(tag, 'No EVM networks found, skipping stable coin fetch');
174
+ return stableCharts;
175
+ }
176
+
177
+ log.info(tag, `Fetching stable coins for ${evmNetworks.length} EVM networks`);
178
+
179
+ // Initialize eth-network module for token balance queries
180
+ let ethNetwork: any;
181
+ try {
182
+ ethNetwork = require('@pioneer-platform/eth-network');
183
+ await ethNetwork.init({});
184
+ } catch (error) {
185
+ log.error(tag, 'Failed to initialize eth-network:', error);
186
+ return stableCharts;
187
+ }
188
+
189
+ // Fetch stable coins for each network
190
+ for (const networkId of evmNetworks) {
191
+ const stableCoins = STABLE_COINS[networkId];
192
+ if (!stableCoins || stableCoins.length === 0) {
193
+ continue;
194
+ }
195
+
196
+ log.debug(tag, `Checking ${stableCoins.length} stable coins on ${networkId}`);
197
+
198
+ // Fetch all token balances for this network in parallel
199
+ const tokenPromises = stableCoins.map(async (tokenConfig) => {
200
+ try {
201
+ const balance = await ethNetwork.getBalanceTokenByNetwork(
202
+ networkId,
203
+ primaryAddress,
204
+ tokenConfig.address
205
+ );
206
+
207
+ const balanceNum = parseFloat(balance);
208
+
209
+ // Skip zero balances
210
+ if (isNaN(balanceNum) || balanceNum === 0) {
211
+ return null;
212
+ }
213
+
214
+ // Get price from markets module
215
+ let priceUsd = 0;
216
+ try {
217
+ const tokenCaip = `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`;
218
+ priceUsd = await this.marketsModule.getAssetPriceByCaip(tokenCaip);
219
+ if (isNaN(priceUsd) || priceUsd < 0) {
220
+ priceUsd = 0;
221
+ }
222
+ } catch (priceError) {
223
+ log.warn(tag, `Error fetching price for ${tokenConfig.symbol}:`, priceError);
224
+ }
225
+
226
+ const valueUsd = balanceNum * priceUsd;
227
+
228
+ const chartData: ChartData = {
229
+ caip: `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`,
230
+ pubkey: primaryAddress,
231
+ networkId,
232
+ symbol: tokenConfig.symbol,
233
+ name: tokenConfig.name,
234
+ balance: balance,
235
+ priceUsd,
236
+ valueUsd,
237
+ icon: '',
238
+ type: 'token',
239
+ decimal: tokenConfig.decimals
240
+ };
241
+
242
+ log.info(tag, `✅ ${tokenConfig.symbol}: ${balance} ($${valueUsd.toFixed(2)})`);
243
+ return chartData;
244
+
245
+ } catch (error) {
246
+ log.error(tag, `Error fetching ${tokenConfig.symbol}:`, error);
247
+ return null;
248
+ }
249
+ });
250
+
251
+ const results = await Promise.all(tokenPromises);
252
+ const validTokens = results.filter((t): t is ChartData => t !== null);
253
+ stableCharts.push(...validTokens);
254
+ }
255
+
256
+ log.info(tag, `Fetched ${stableCharts.length} stable coin balances`);
257
+ return stableCharts;
258
+
259
+ } catch (error) {
260
+ log.error(tag, 'Error fetching stable coins:', error);
261
+ return stableCharts;
262
+ }
263
+ }
264
+
127
265
  /**
128
266
  * Fetch portfolio from blockchain APIs
129
- *
267
+ *
130
268
  * This is the SLOW operation that happens in the background
131
269
  * It fetches balances for all pubkeys and enriches with pricing
270
+ * INTEGRATED: Now includes stable coin balances automatically
132
271
  */
133
272
  protected async fetchFromSource(params: Record<string, any>): Promise<PortfolioData> {
134
273
  const tag = this.TAG + 'fetchFromSource | ';
@@ -216,17 +355,42 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
216
355
  });
217
356
 
218
357
  const results = await Promise.all(balancePromises);
219
-
220
- // Filter out nulls and calculate total
358
+
359
+ // Filter out nulls
221
360
  const validCharts = results.filter((c): c is ChartData => c !== null);
222
- const totalValueUsd = validCharts.reduce((sum, c) => sum + c.valueUsd, 0);
361
+ charts.push(...validCharts);
362
+
363
+ // INTEGRATED: Fetch stable coins for EVM addresses (background operation)
364
+ // This ensures stable coins are ALWAYS in the cache and available instantly
365
+ const evmPubkey = pubkeys.find(
366
+ (p: any) => p.caip && p.caip.startsWith('eip155:')
367
+ );
368
+ if (evmPubkey) {
369
+ const primaryAddress = evmPubkey.pubkey;
370
+ log.info(tag, `Fetching stable coins for EVM address: ${primaryAddress.substring(0, 10)}...`);
371
+ const stableCoins = await this.fetchStableCoins(primaryAddress, pubkeys);
372
+
373
+ // Add stable coins to charts (deduplicate by CAIP+pubkey)
374
+ for (const stableCoin of stableCoins) {
375
+ const isDuplicate = charts.some(c =>
376
+ c.caip === stableCoin.caip && c.pubkey === stableCoin.pubkey
377
+ );
378
+ if (!isDuplicate) {
379
+ charts.push(stableCoin);
380
+ }
381
+ }
382
+ log.info(tag, `Added ${stableCoins.length} stable coins to portfolio`);
383
+ }
384
+
385
+ // Calculate total value
386
+ const totalValueUsd = charts.reduce((sum, c) => sum + c.valueUsd, 0);
223
387
 
224
388
  const fetchTime = Date.now() - startTime;
225
- log.info(tag, `✅ Fetched portfolio: ${validCharts.length} assets, $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
389
+ log.info(tag, `✅ Fetched portfolio: ${charts.length} assets (including stable coins), $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
226
390
 
227
391
  return {
228
392
  pubkeys,
229
- charts: validCharts,
393
+ charts,
230
394
  totalValueUsd,
231
395
  timestamp: Date.now()
232
396
  };
@@ -1,345 +0,0 @@
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
- }