@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.
- package/.turbo/turbo-build.log +1 -0
- package/CHANGELOG.md +6 -0
- package/dist/core/base-cache.d.ts +1 -0
- package/dist/core/base-cache.js +56 -2
- package/dist/stores/balance-cache.d.ts +6 -0
- package/dist/stores/balance-cache.js +80 -5
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/core/base-cache.ts +63 -2
- package/src/stores/balance-cache.ts +82 -5
- package/src/stores/balance-cache.ts.bak +345 -0
- package/src/types/index.ts +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc
|
package/CHANGELOG.md
CHANGED
|
@@ -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
|
/**
|
package/dist/core/base-cache.js
CHANGED
|
@@ -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.
|
|
168
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/dist/types/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/core/base-cache.ts
CHANGED
|
@@ -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.
|
|
205
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/types/index.ts
CHANGED