@pioneer-platform/pioneer-cache 1.1.1 → 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 -2
- 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 +8 -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 +84 -5
- package/src/stores/balance-cache.ts.bak +345 -0
- package/src/types/index.ts +1 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
[0m[2m[35m$[0m [2m[1mtsc[0m
|
|
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(),
|
|
@@ -12,6 +12,11 @@ export interface BalanceData {
|
|
|
12
12
|
symbol?: string;
|
|
13
13
|
name?: string;
|
|
14
14
|
networkId?: string;
|
|
15
|
+
type?: string;
|
|
16
|
+
isNative?: boolean;
|
|
17
|
+
fetchedAt?: number;
|
|
18
|
+
fetchedAtISO?: string;
|
|
19
|
+
dataSource?: string;
|
|
15
20
|
}
|
|
16
21
|
/**
|
|
17
22
|
* BalanceCache - Caches blockchain balance data
|
|
@@ -45,6 +50,9 @@ export declare class BalanceCache extends BaseCache<BalanceData> {
|
|
|
45
50
|
/**
|
|
46
51
|
* Get balances for multiple assets (batch operation)
|
|
47
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
|
|
48
56
|
*/
|
|
49
57
|
getBatchBalances(items: Array<{
|
|
50
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(),
|
|
@@ -55,6 +55,11 @@ export interface BalanceData {
|
|
|
55
55
|
symbol?: string;
|
|
56
56
|
name?: string;
|
|
57
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 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")
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
/**
|
|
@@ -134,10 +139,13 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
134
139
|
|
|
135
140
|
if (!balanceInfo || !balanceInfo.balance) {
|
|
136
141
|
log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
|
|
142
|
+
const now = Date.now();
|
|
137
143
|
return {
|
|
138
144
|
caip,
|
|
139
145
|
pubkey,
|
|
140
|
-
balance: '0'
|
|
146
|
+
balance: '0',
|
|
147
|
+
fetchedAt: now,
|
|
148
|
+
fetchedAtISO: new Date(now).toISOString()
|
|
141
149
|
};
|
|
142
150
|
}
|
|
143
151
|
|
|
@@ -148,13 +156,41 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
148
156
|
const assetInfo = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()] || {};
|
|
149
157
|
const networkId = caipToNetworkId(caip);
|
|
150
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
|
+
|
|
151
184
|
return {
|
|
152
185
|
caip,
|
|
153
186
|
pubkey,
|
|
154
187
|
balance: balanceInfo.balance,
|
|
155
188
|
symbol: assetInfo.symbol,
|
|
156
189
|
name: assetInfo.name,
|
|
157
|
-
networkId
|
|
190
|
+
networkId,
|
|
191
|
+
fetchedAt: now,
|
|
192
|
+
fetchedAtISO: new Date(now).toISOString(),
|
|
193
|
+
dataSource
|
|
158
194
|
};
|
|
159
195
|
|
|
160
196
|
} catch (error) {
|
|
@@ -209,12 +245,45 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
209
245
|
/**
|
|
210
246
|
* Get balances for multiple assets (batch operation)
|
|
211
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
|
|
212
251
|
*/
|
|
213
252
|
async getBatchBalances(items: Array<{ caip: string; pubkey: string }>, waitForFresh?: boolean): Promise<BalanceData[]> {
|
|
214
253
|
const tag = this.TAG + 'getBatchBalances | ';
|
|
215
254
|
const startTime = Date.now();
|
|
216
255
|
|
|
217
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
|
|
218
287
|
log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
|
|
219
288
|
|
|
220
289
|
// Build all Redis keys
|
|
@@ -254,7 +323,7 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
254
323
|
|
|
255
324
|
// If we have cache misses and blocking is enabled, fetch them
|
|
256
325
|
if (missedItems.length > 0) {
|
|
257
|
-
const shouldBlock =
|
|
326
|
+
const shouldBlock = this.config.blockOnMiss;
|
|
258
327
|
|
|
259
328
|
if (shouldBlock) {
|
|
260
329
|
log.info(tag, `Fetching ${missedItems.length} cache misses...`);
|
|
@@ -268,7 +337,14 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
268
337
|
results[item.index] = freshData;
|
|
269
338
|
} catch (error) {
|
|
270
339
|
log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
|
|
271
|
-
|
|
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
|
+
};
|
|
272
348
|
}
|
|
273
349
|
});
|
|
274
350
|
|
|
@@ -287,10 +363,13 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
287
363
|
} catch (error) {
|
|
288
364
|
log.error(tag, 'Error in batch balance request:', error);
|
|
289
365
|
// Return defaults for all items
|
|
366
|
+
const now = Date.now();
|
|
290
367
|
return items.map(item => ({
|
|
291
368
|
caip: item.caip,
|
|
292
369
|
pubkey: item.pubkey,
|
|
293
|
-
balance: '0'
|
|
370
|
+
balance: '0',
|
|
371
|
+
fetchedAt: now,
|
|
372
|
+
fetchedAtISO: new Date(now).toISOString()
|
|
294
373
|
}));
|
|
295
374
|
}
|
|
296
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