@pioneer-platform/pioneer-cache 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1,2 @@
1
- $ tsc
1
+
2
+ $ tsc
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @pioneer-platform/pioneer-cache
2
2
 
3
+ ## 1.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - chore: chore: chore: 🔒 CRITICAL FIX: XRP destination tag validation
8
+
9
+ ## 1.1.5
10
+
11
+ ### Patch Changes
12
+
13
+ - chore: chore: 🔒 CRITICAL FIX: XRP destination tag validation
14
+
3
15
  ## 1.1.4
4
16
 
5
17
  ### Patch Changes
@@ -2,6 +2,7 @@ import { BalanceCache } from '../stores/balance-cache';
2
2
  import { PriceCache } from '../stores/price-cache';
3
3
  import { PortfolioCache } from '../stores/portfolio-cache';
4
4
  import { TransactionCache } from '../stores/transaction-cache';
5
+ import { StakingCache } from '../stores/staking-cache';
5
6
  import type { HealthCheckResult } from '../types';
6
7
  /**
7
8
  * Configuration for CacheManager
@@ -11,10 +12,12 @@ export interface CacheManagerConfig {
11
12
  redisQueue?: any;
12
13
  balanceModule?: any;
13
14
  markets?: any;
15
+ networkModules?: Map<string, any>;
14
16
  enableBalanceCache?: boolean;
15
17
  enablePriceCache?: boolean;
16
18
  enablePortfolioCache?: boolean;
17
19
  enableTransactionCache?: boolean;
20
+ enableStakingCache?: boolean;
18
21
  startWorkers?: boolean;
19
22
  }
20
23
  /**
@@ -27,6 +30,7 @@ export declare class CacheManager {
27
30
  private priceCache?;
28
31
  private portfolioCache?;
29
32
  private transactionCache?;
33
+ private stakingCache?;
30
34
  private workers;
31
35
  constructor(config: CacheManagerConfig);
32
36
  /**
@@ -52,11 +56,12 @@ export declare class CacheManager {
52
56
  price: PriceCache | undefined;
53
57
  portfolio: PortfolioCache | undefined;
54
58
  transaction: TransactionCache | undefined;
59
+ staking: StakingCache | undefined;
55
60
  };
56
61
  /**
57
62
  * Get specific cache by name
58
63
  */
59
- getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | undefined;
64
+ getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | StakingCache | undefined;
60
65
  /**
61
66
  * Clear all caches (use with caution!)
62
67
  */
@@ -65,5 +70,6 @@ export declare class CacheManager {
65
70
  price?: number;
66
71
  portfolio?: number;
67
72
  transaction?: number;
73
+ staking?: number;
68
74
  }>;
69
75
  }
@@ -11,6 +11,7 @@ const balance_cache_1 = require("../stores/balance-cache");
11
11
  const price_cache_1 = require("../stores/price-cache");
12
12
  const portfolio_cache_1 = require("../stores/portfolio-cache");
13
13
  const transaction_cache_1 = require("../stores/transaction-cache");
14
+ const staking_cache_1 = require("../stores/staking-cache");
14
15
  const refresh_worker_1 = require("../workers/refresh-worker");
15
16
  const log = require('@pioneer-platform/loggerdog')();
16
17
  const TAG = ' | CacheManager | ';
@@ -42,6 +43,11 @@ class CacheManager {
42
43
  this.transactionCache = new transaction_cache_1.TransactionCache(this.redis);
43
44
  log.info(TAG, '✅ Transaction cache initialized');
44
45
  }
46
+ // Initialize Staking Cache (with markets module for price enrichment)
47
+ if (config.enableStakingCache !== false && config.networkModules && config.networkModules.size > 0) {
48
+ this.stakingCache = new staking_cache_1.StakingCache(this.redis, config.networkModules, config.markets);
49
+ log.info(TAG, '✅ Staking cache initialized');
50
+ }
45
51
  // Auto-start workers if requested
46
52
  if (config.startWorkers) {
47
53
  setImmediate(() => {
@@ -68,6 +74,9 @@ class CacheManager {
68
74
  if (this.portfolioCache) {
69
75
  cacheRegistry.set('portfolio', this.portfolioCache);
70
76
  }
77
+ if (this.stakingCache) {
78
+ cacheRegistry.set('staking', this.stakingCache);
79
+ }
71
80
  // Start unified worker if we have any caches with queues
72
81
  if (cacheRegistry.size > 0) {
73
82
  const worker = await (0, refresh_worker_1.startUnifiedWorker)(this.redisQueue, // Use dedicated queue client for blocking operations
@@ -155,6 +164,17 @@ class CacheManager {
155
164
  stats: txStats
156
165
  };
157
166
  }
167
+ // Check staking cache
168
+ if (this.stakingCache) {
169
+ const stakingHealth = await this.stakingCache.getHealth(forceRefresh);
170
+ checks.staking = stakingHealth;
171
+ if (stakingHealth.status === 'unhealthy') {
172
+ issues.push(...stakingHealth.issues.map(i => `Staking: ${i}`));
173
+ }
174
+ else if (stakingHealth.status === 'degraded') {
175
+ warnings.push(...stakingHealth.warnings.map(w => `Staking: ${w}`));
176
+ }
177
+ }
158
178
  // Check workers
159
179
  for (const worker of this.workers) {
160
180
  const workerStats = await worker.getStats();
@@ -215,7 +235,8 @@ class CacheManager {
215
235
  balance: this.balanceCache,
216
236
  price: this.priceCache,
217
237
  portfolio: this.portfolioCache,
218
- transaction: this.transactionCache
238
+ transaction: this.transactionCache,
239
+ staking: this.stakingCache
219
240
  };
220
241
  }
221
242
  /**
@@ -231,6 +252,8 @@ class CacheManager {
231
252
  return this.portfolioCache;
232
253
  case 'transaction':
233
254
  return this.transactionCache;
255
+ case 'staking':
256
+ return this.stakingCache;
234
257
  default:
235
258
  return undefined;
236
259
  }
@@ -254,6 +277,9 @@ class CacheManager {
254
277
  if (this.transactionCache) {
255
278
  result.transaction = await this.transactionCache.clearAll();
256
279
  }
280
+ if (this.stakingCache) {
281
+ result.staking = await this.stakingCache.clearAll();
282
+ }
257
283
  log.info(tag, 'Cleared all caches:', result);
258
284
  return result;
259
285
  }
package/dist/index.d.ts CHANGED
@@ -4,10 +4,12 @@ export { BalanceCache } from './stores/balance-cache';
4
4
  export { PriceCache } from './stores/price-cache';
5
5
  export { PortfolioCache } from './stores/portfolio-cache';
6
6
  export { TransactionCache } from './stores/transaction-cache';
7
+ export { StakingCache } from './stores/staking-cache';
7
8
  export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
8
9
  export type { CacheConfig, CachedValue, CacheResult, RefreshJob, HealthCheckResult, CacheStats } from './types';
9
10
  export type { BalanceData } from './stores/balance-cache';
10
11
  export type { PriceData } from './stores/price-cache';
11
12
  export type { PortfolioData, ChartData } from './stores/portfolio-cache';
13
+ export type { StakingPosition } from './stores/staking-cache';
12
14
  export type { CacheManagerConfig } from './core/cache-manager';
13
15
  export type { WorkerConfig } from './workers/refresh-worker';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  Provides stale-while-revalidate caching with TTL, background refresh, and health monitoring.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.startUnifiedWorker = exports.RefreshWorker = exports.TransactionCache = exports.PortfolioCache = exports.PriceCache = exports.BalanceCache = exports.CacheManager = exports.BaseCache = void 0;
9
+ exports.startUnifiedWorker = exports.RefreshWorker = exports.StakingCache = exports.TransactionCache = exports.PortfolioCache = exports.PriceCache = exports.BalanceCache = exports.CacheManager = exports.BaseCache = void 0;
10
10
  // Core exports
11
11
  var base_cache_1 = require("./core/base-cache");
12
12
  Object.defineProperty(exports, "BaseCache", { enumerable: true, get: function () { return base_cache_1.BaseCache; } });
@@ -21,6 +21,8 @@ var portfolio_cache_1 = require("./stores/portfolio-cache");
21
21
  Object.defineProperty(exports, "PortfolioCache", { enumerable: true, get: function () { return portfolio_cache_1.PortfolioCache; } });
22
22
  var transaction_cache_1 = require("./stores/transaction-cache");
23
23
  Object.defineProperty(exports, "TransactionCache", { enumerable: true, get: function () { return transaction_cache_1.TransactionCache; } });
24
+ var staking_cache_1 = require("./stores/staking-cache");
25
+ Object.defineProperty(exports, "StakingCache", { enumerable: true, get: function () { return staking_cache_1.StakingCache; } });
24
26
  // Worker exports
25
27
  var refresh_worker_1 = require("./workers/refresh-worker");
26
28
  Object.defineProperty(exports, "RefreshWorker", { enumerable: true, get: function () { return refresh_worker_1.RefreshWorker; } });
@@ -0,0 +1,75 @@
1
+ import { BaseCache } from '../core/base-cache';
2
+ import type { CacheConfig } from '../types';
3
+ /**
4
+ * Staking position data structure
5
+ */
6
+ export interface StakingPosition {
7
+ type: 'delegation' | 'reward' | 'unbonding';
8
+ chart: 'staking';
9
+ context: string;
10
+ contextType: string;
11
+ caip: string;
12
+ networkId: string;
13
+ pubkey: string;
14
+ validatorAddress?: string;
15
+ validator?: string;
16
+ balance: number;
17
+ denom: string;
18
+ ticker: string;
19
+ symbol: string;
20
+ name: string;
21
+ icon: string;
22
+ status: 'active' | 'claimable' | 'unbonding';
23
+ completionTime?: string;
24
+ shares?: string;
25
+ priceUsd?: number;
26
+ valueUsd?: number;
27
+ updated: number;
28
+ }
29
+ /**
30
+ * Network module interface for staking
31
+ */
32
+ interface NetworkModule {
33
+ getStakingPositions(address: string): Promise<StakingPosition[]>;
34
+ }
35
+ /**
36
+ * StakingCache - Caches Cosmos staking positions (delegations, rewards, unbonding)
37
+ */
38
+ export declare class StakingCache extends BaseCache<StakingPosition[]> {
39
+ private networkModules;
40
+ private markets;
41
+ constructor(redis: any, networkModules: Map<string, NetworkModule>, markets?: any, config?: Partial<CacheConfig>);
42
+ /**
43
+ * Build Redis key for staking data
44
+ * Format: staking_v1:networkId:address
45
+ */
46
+ protected buildKey(params: Record<string, any>): string;
47
+ /**
48
+ * Fetch staking positions from blockchain via network module
49
+ * and enrich with pricing data from markets module
50
+ */
51
+ protected fetchFromSource(params: Record<string, any>): Promise<StakingPosition[]>;
52
+ /**
53
+ * Enrich staking positions with USD pricing
54
+ * Uses the markets module to fetch prices for the native tokens
55
+ */
56
+ private enrichPositionsWithPricing;
57
+ /**
58
+ * No legacy cache format for staking (new feature)
59
+ */
60
+ protected getLegacyCached(params: Record<string, any>): Promise<StakingPosition[] | null>;
61
+ /**
62
+ * Get staking positions for a specific network and address
63
+ * Convenience method that wraps base get()
64
+ */
65
+ getStakingPositions(networkId: string, address: string, waitForFresh?: boolean): Promise<StakingPosition[]>;
66
+ /**
67
+ * Get staking positions for multiple addresses (batch operation)
68
+ * OPTIMIZED: Uses Redis MGET for single round-trip
69
+ */
70
+ getBatchStakingPositions(items: Array<{
71
+ networkId: string;
72
+ address: string;
73
+ }>, waitForFresh?: boolean): Promise<Map<string, StakingPosition[]>>;
74
+ }
75
+ export {};
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ /*
3
+ StakingCache - Staking position cache implementation
4
+
5
+ Extends BaseCache with staking-specific logic for Cosmos-based chains.
6
+ Caches delegation, reward, and unbonding positions.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.StakingCache = void 0;
10
+ const base_cache_1 = require("../core/base-cache");
11
+ const log = require('@pioneer-platform/loggerdog')();
12
+ const TAG = ' | StakingCache | ';
13
+ /**
14
+ * StakingCache - Caches Cosmos staking positions (delegations, rewards, unbonding)
15
+ */
16
+ class StakingCache extends base_cache_1.BaseCache {
17
+ constructor(redis, networkModules, markets, config) {
18
+ const defaultConfig = {
19
+ name: 'staking',
20
+ keyPrefix: 'staking_v1:',
21
+ ttl: 5 * 60 * 1000, // 5 minutes - staking changes slowly
22
+ staleThreshold: 2 * 60 * 1000, // Refresh after 2 minutes
23
+ enableTTL: true, // Enable expiration
24
+ queueName: 'cache-refresh',
25
+ enableQueue: true,
26
+ maxRetries: 3,
27
+ retryDelay: 10000,
28
+ blockOnMiss: false, // Return [] immediately, fetch async
29
+ enableLegacyFallback: false, // No legacy format
30
+ defaultValue: [], // Empty array for no positions
31
+ maxConcurrentJobs: 10,
32
+ apiTimeout: 30000, // 30s timeout for blockchain API
33
+ logCacheHits: false,
34
+ logCacheMisses: true,
35
+ logRefreshJobs: true
36
+ };
37
+ super(redis, { ...defaultConfig, ...config });
38
+ this.networkModules = networkModules;
39
+ this.markets = markets;
40
+ }
41
+ /**
42
+ * Build Redis key for staking data
43
+ * Format: staking_v1:networkId:address
44
+ */
45
+ buildKey(params) {
46
+ const { networkId, address } = params;
47
+ if (!networkId || !address) {
48
+ throw new Error('StakingCache.buildKey: networkId and address required');
49
+ }
50
+ const normalizedNetworkId = networkId.toLowerCase();
51
+ const normalizedAddress = address.toLowerCase();
52
+ return `${this.config.keyPrefix}${normalizedNetworkId}:${normalizedAddress}`;
53
+ }
54
+ /**
55
+ * Fetch staking positions from blockchain via network module
56
+ * and enrich with pricing data from markets module
57
+ */
58
+ async fetchFromSource(params) {
59
+ const tag = this.TAG + 'fetchFromSource | ';
60
+ try {
61
+ const { networkId, address } = params;
62
+ log.debug(tag, `Fetching staking positions for ${address} on ${networkId}`);
63
+ // Get the appropriate network module
64
+ const networkModule = this.networkModules.get(networkId);
65
+ if (!networkModule) {
66
+ log.warn(tag, `No network module found for ${networkId}`);
67
+ return [];
68
+ }
69
+ // Fetch staking positions from network module (raw blockchain data)
70
+ const positions = await networkModule.getStakingPositions(address);
71
+ if (!positions || !Array.isArray(positions)) {
72
+ log.warn(tag, `Invalid positions returned for ${networkId}/${address}`);
73
+ return [];
74
+ }
75
+ log.info(tag, `Found ${positions.length} staking positions for ${networkId}/${address}`);
76
+ // Enrich with pricing data if markets module is available
77
+ if (this.markets && positions.length > 0) {
78
+ await this.enrichPositionsWithPricing(positions);
79
+ }
80
+ else if (!this.markets) {
81
+ log.warn(tag, 'Markets module not available, positions will have no pricing');
82
+ }
83
+ return positions;
84
+ }
85
+ catch (error) {
86
+ log.error(tag, 'Error fetching staking positions:', error);
87
+ // Return empty array instead of throwing - staking is optional
88
+ return [];
89
+ }
90
+ }
91
+ /**
92
+ * Enrich staking positions with USD pricing
93
+ * Uses the markets module to fetch prices for the native tokens
94
+ */
95
+ async enrichPositionsWithPricing(positions) {
96
+ const tag = TAG + 'enrichPositionsWithPricing | ';
97
+ try {
98
+ // Collect unique CAIPs from all positions
99
+ const uniqueCAIPs = [...new Set(positions.map(p => p.caip))];
100
+ log.debug(tag, `Fetching prices for ${uniqueCAIPs.length} unique assets:`, uniqueCAIPs);
101
+ // Batch fetch prices from markets module
102
+ const prices = {};
103
+ for (const caip of uniqueCAIPs) {
104
+ try {
105
+ const price = await this.markets.getAssetPriceByCaip(caip);
106
+ prices[caip] = price || 0;
107
+ log.debug(tag, `Price for ${caip}: $${price}`);
108
+ }
109
+ catch (priceError) {
110
+ log.error(tag, `Error fetching price for ${caip}:`, priceError);
111
+ prices[caip] = 0;
112
+ }
113
+ }
114
+ // Enrich each position with its price
115
+ for (const position of positions) {
116
+ const priceUsd = prices[position.caip] || 0;
117
+ position.priceUsd = priceUsd;
118
+ position.valueUsd = position.balance * priceUsd;
119
+ log.debug(tag, `Enriched ${position.type} position:`, {
120
+ caip: position.caip,
121
+ balance: position.balance,
122
+ priceUsd,
123
+ valueUsd: position.valueUsd
124
+ });
125
+ }
126
+ log.info(tag, `✅ Enriched ${positions.length} positions with pricing data`);
127
+ }
128
+ catch (error) {
129
+ log.error(tag, 'Error enriching positions with pricing:', error);
130
+ // Don't throw - positions are still valid without pricing
131
+ }
132
+ }
133
+ /**
134
+ * No legacy cache format for staking (new feature)
135
+ */
136
+ async getLegacyCached(params) {
137
+ return null;
138
+ }
139
+ /**
140
+ * Get staking positions for a specific network and address
141
+ * Convenience method that wraps base get()
142
+ */
143
+ async getStakingPositions(networkId, address, waitForFresh) {
144
+ const result = await this.get({ networkId, address }, waitForFresh);
145
+ return result.value || this.config.defaultValue;
146
+ }
147
+ /**
148
+ * Get staking positions for multiple addresses (batch operation)
149
+ * OPTIMIZED: Uses Redis MGET for single round-trip
150
+ */
151
+ async getBatchStakingPositions(items, waitForFresh) {
152
+ const tag = this.TAG + 'getBatchStakingPositions | ';
153
+ const startTime = Date.now();
154
+ try {
155
+ // If waitForFresh=true, skip cache and fetch fresh data
156
+ if (waitForFresh) {
157
+ log.info(tag, `FORCE REFRESH: Bypassing cache for ${items.length} addresses`);
158
+ const fetchStart = Date.now();
159
+ const results = new Map();
160
+ const fetchPromises = items.map(async (item) => {
161
+ try {
162
+ const freshData = await this.fetchFresh({ networkId: item.networkId, address: item.address });
163
+ const key = `${item.networkId}:${item.address}`;
164
+ results.set(key, freshData);
165
+ }
166
+ catch (error) {
167
+ log.error(tag, `Failed to fetch fresh ${item.networkId}/${item.address}:`, error);
168
+ const key = `${item.networkId}:${item.address}`;
169
+ results.set(key, []);
170
+ }
171
+ });
172
+ await Promise.all(fetchPromises);
173
+ log.info(tag, `Force refresh completed: fetched ${items.length} addresses in ${Date.now() - fetchStart}ms`);
174
+ return results;
175
+ }
176
+ // Normal flow: Check cache first
177
+ log.info(tag, `Batch request for ${items.length} addresses using Redis MGET`);
178
+ // Build all Redis keys
179
+ const keys = items.map(item => this.buildKey({ networkId: item.networkId, address: item.address }));
180
+ // PERF: Use MGET to fetch all keys in ONE Redis round-trip
181
+ const cachedValues = await this.redis.mget(...keys);
182
+ // Process results
183
+ const results = new Map();
184
+ const missedItems = [];
185
+ for (let i = 0; i < items.length; i++) {
186
+ const item = items[i];
187
+ const cached = cachedValues[i];
188
+ const itemKey = `${item.networkId}:${item.address}`;
189
+ if (cached) {
190
+ try {
191
+ const parsed = JSON.parse(cached);
192
+ if (parsed.value && Array.isArray(parsed.value)) {
193
+ results.set(itemKey, parsed.value);
194
+ continue;
195
+ }
196
+ }
197
+ catch (e) {
198
+ log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
199
+ }
200
+ }
201
+ // Cache miss - record for fetching
202
+ missedItems.push({ ...item, index: i });
203
+ results.set(itemKey, []); // Placeholder
204
+ }
205
+ const responseTime = Date.now() - startTime;
206
+ const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
207
+ log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
208
+ // If we have cache misses, trigger background refresh (non-blocking)
209
+ if (missedItems.length > 0) {
210
+ log.info(tag, `Triggering background refresh for ${missedItems.length} cache misses`);
211
+ missedItems.forEach(item => {
212
+ this.triggerAsyncRefresh({ networkId: item.networkId, address: item.address }, 'high');
213
+ });
214
+ }
215
+ return results;
216
+ }
217
+ catch (error) {
218
+ log.error(tag, 'Error in batch staking request:', error);
219
+ // Return empty arrays for all items
220
+ const results = new Map();
221
+ items.forEach(item => {
222
+ const key = `${item.networkId}:${item.address}`;
223
+ results.set(key, []);
224
+ });
225
+ return results;
226
+ }
227
+ }
228
+ }
229
+ exports.StakingCache = StakingCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/pioneer-cache",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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",
@@ -9,6 +9,7 @@ import { BalanceCache } from '../stores/balance-cache';
9
9
  import { PriceCache } from '../stores/price-cache';
10
10
  import { PortfolioCache } from '../stores/portfolio-cache';
11
11
  import { TransactionCache } from '../stores/transaction-cache';
12
+ import { StakingCache } from '../stores/staking-cache';
12
13
  import { RefreshWorker, startUnifiedWorker } from '../workers/refresh-worker';
13
14
  import type { BaseCache } from './base-cache';
14
15
  import type { HealthCheckResult } from '../types';
@@ -24,10 +25,12 @@ export interface CacheManagerConfig {
24
25
  redisQueue?: any; // Dedicated Redis client for blocking queue operations (brpop, etc.)
25
26
  balanceModule?: any; // Optional: if not provided, balance cache won't be initialized
26
27
  markets?: any; // Optional: if not provided, price cache won't be initialized
28
+ networkModules?: Map<string, any>; // Optional: network modules for staking cache (keyed by networkId)
27
29
  enableBalanceCache?: boolean;
28
30
  enablePriceCache?: boolean;
29
31
  enablePortfolioCache?: boolean;
30
32
  enableTransactionCache?: boolean;
33
+ enableStakingCache?: boolean;
31
34
  startWorkers?: boolean; // Auto-start workers on initialization
32
35
  }
33
36
 
@@ -41,6 +44,7 @@ export class CacheManager {
41
44
  private priceCache?: PriceCache;
42
45
  private portfolioCache?: PortfolioCache;
43
46
  private transactionCache?: TransactionCache;
47
+ private stakingCache?: StakingCache;
44
48
  private workers: RefreshWorker[] = [];
45
49
 
46
50
  constructor(config: CacheManagerConfig) {
@@ -71,6 +75,12 @@ export class CacheManager {
71
75
  log.info(TAG, '✅ Transaction cache initialized');
72
76
  }
73
77
 
78
+ // Initialize Staking Cache (with markets module for price enrichment)
79
+ if (config.enableStakingCache !== false && config.networkModules && config.networkModules.size > 0) {
80
+ this.stakingCache = new StakingCache(this.redis, config.networkModules, config.markets);
81
+ log.info(TAG, '✅ Staking cache initialized');
82
+ }
83
+
74
84
  // Auto-start workers if requested
75
85
  if (config.startWorkers) {
76
86
  setImmediate(() => {
@@ -105,6 +115,10 @@ export class CacheManager {
105
115
  cacheRegistry.set('portfolio', this.portfolioCache);
106
116
  }
107
117
 
118
+ if (this.stakingCache) {
119
+ cacheRegistry.set('staking', this.stakingCache);
120
+ }
121
+
108
122
  // Start unified worker if we have any caches with queues
109
123
  if (cacheRegistry.size > 0) {
110
124
  const worker = await startUnifiedWorker(
@@ -207,6 +221,18 @@ export class CacheManager {
207
221
  };
208
222
  }
209
223
 
224
+ // Check staking cache
225
+ if (this.stakingCache) {
226
+ const stakingHealth = await this.stakingCache.getHealth(forceRefresh);
227
+ checks.staking = stakingHealth;
228
+
229
+ if (stakingHealth.status === 'unhealthy') {
230
+ issues.push(...stakingHealth.issues.map(i => `Staking: ${i}`));
231
+ } else if (stakingHealth.status === 'degraded') {
232
+ warnings.push(...stakingHealth.warnings.map(w => `Staking: ${w}`));
233
+ }
234
+ }
235
+
210
236
  // Check workers
211
237
  for (const worker of this.workers) {
212
238
  const workerStats = await worker.getStats();
@@ -270,14 +296,15 @@ export class CacheManager {
270
296
  balance: this.balanceCache,
271
297
  price: this.priceCache,
272
298
  portfolio: this.portfolioCache,
273
- transaction: this.transactionCache
299
+ transaction: this.transactionCache,
300
+ staking: this.stakingCache
274
301
  };
275
302
  }
276
303
 
277
304
  /**
278
305
  * Get specific cache by name
279
306
  */
280
- getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction') {
307
+ getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking') {
281
308
  switch (name) {
282
309
  case 'balance':
283
310
  return this.balanceCache;
@@ -287,6 +314,8 @@ export class CacheManager {
287
314
  return this.portfolioCache;
288
315
  case 'transaction':
289
316
  return this.transactionCache;
317
+ case 'staking':
318
+ return this.stakingCache;
290
319
  default:
291
320
  return undefined;
292
321
  }
@@ -295,7 +324,7 @@ export class CacheManager {
295
324
  /**
296
325
  * Clear all caches (use with caution!)
297
326
  */
298
- async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number }> {
327
+ async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number; staking?: number }> {
299
328
  const tag = TAG + 'clearAll | ';
300
329
 
301
330
  try {
@@ -317,6 +346,10 @@ export class CacheManager {
317
346
  result.transaction = await this.transactionCache.clearAll();
318
347
  }
319
348
 
349
+ if (this.stakingCache) {
350
+ result.staking = await this.stakingCache.clearAll();
351
+ }
352
+
320
353
  log.info(tag, 'Cleared all caches:', result);
321
354
  return result;
322
355
 
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { BalanceCache } from './stores/balance-cache';
14
14
  export { PriceCache } from './stores/price-cache';
15
15
  export { PortfolioCache } from './stores/portfolio-cache';
16
16
  export { TransactionCache } from './stores/transaction-cache';
17
+ export { StakingCache } from './stores/staking-cache';
17
18
 
18
19
  // Worker exports
19
20
  export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
@@ -32,6 +33,7 @@ export type {
32
33
  export type { BalanceData } from './stores/balance-cache';
33
34
  export type { PriceData } from './stores/price-cache';
34
35
  export type { PortfolioData, ChartData } from './stores/portfolio-cache';
36
+ export type { StakingPosition } from './stores/staking-cache';
35
37
 
36
38
  // Config type export
37
39
  export type { CacheManagerConfig } from './core/cache-manager';
@@ -0,0 +1,302 @@
1
+ /*
2
+ StakingCache - Staking position cache implementation
3
+
4
+ Extends BaseCache with staking-specific logic for Cosmos-based chains.
5
+ Caches delegation, reward, and unbonding positions.
6
+ */
7
+
8
+ import { BaseCache } from '../core/base-cache';
9
+ import type { CacheConfig } from '../types';
10
+
11
+ const log = require('@pioneer-platform/loggerdog')();
12
+ const TAG = ' | StakingCache | ';
13
+
14
+ /**
15
+ * Staking position data structure
16
+ */
17
+ export interface StakingPosition {
18
+ type: 'delegation' | 'reward' | 'unbonding';
19
+ chart: 'staking';
20
+ context: string;
21
+ contextType: string;
22
+ caip: string;
23
+ networkId: string;
24
+ pubkey: string;
25
+ validatorAddress?: string;
26
+ validator?: string;
27
+ balance: number;
28
+ denom: string;
29
+ ticker: string;
30
+ symbol: string;
31
+ name: string;
32
+ icon: string;
33
+ status: 'active' | 'claimable' | 'unbonding';
34
+ completionTime?: string; // For unbonding
35
+ shares?: string; // For delegations
36
+ priceUsd?: number;
37
+ valueUsd?: number;
38
+ updated: number;
39
+ }
40
+
41
+ /**
42
+ * Network module interface for staking
43
+ */
44
+ interface NetworkModule {
45
+ getStakingPositions(address: string): Promise<StakingPosition[]>;
46
+ }
47
+
48
+ /**
49
+ * StakingCache - Caches Cosmos staking positions (delegations, rewards, unbonding)
50
+ */
51
+ export class StakingCache extends BaseCache<StakingPosition[]> {
52
+ private networkModules: Map<string, NetworkModule>;
53
+ private markets: any;
54
+
55
+ constructor(redis: any, networkModules: Map<string, NetworkModule>, markets?: any, config?: Partial<CacheConfig>) {
56
+ const defaultConfig: CacheConfig = {
57
+ name: 'staking',
58
+ keyPrefix: 'staking_v1:',
59
+ ttl: 5 * 60 * 1000, // 5 minutes - staking changes slowly
60
+ staleThreshold: 2 * 60 * 1000, // Refresh after 2 minutes
61
+ enableTTL: true, // Enable expiration
62
+ queueName: 'cache-refresh',
63
+ enableQueue: true,
64
+ maxRetries: 3,
65
+ retryDelay: 10000,
66
+ blockOnMiss: false, // Return [] immediately, fetch async
67
+ enableLegacyFallback: false, // No legacy format
68
+ defaultValue: [], // Empty array for no positions
69
+ maxConcurrentJobs: 10,
70
+ apiTimeout: 30000, // 30s timeout for blockchain API
71
+ logCacheHits: false,
72
+ logCacheMisses: true,
73
+ logRefreshJobs: true
74
+ };
75
+
76
+ super(redis, { ...defaultConfig, ...config });
77
+ this.networkModules = networkModules;
78
+ this.markets = markets;
79
+ }
80
+
81
+ /**
82
+ * Build Redis key for staking data
83
+ * Format: staking_v1:networkId:address
84
+ */
85
+ protected buildKey(params: Record<string, any>): string {
86
+ const { networkId, address } = params;
87
+ if (!networkId || !address) {
88
+ throw new Error('StakingCache.buildKey: networkId and address required');
89
+ }
90
+
91
+ const normalizedNetworkId = networkId.toLowerCase();
92
+ const normalizedAddress = address.toLowerCase();
93
+
94
+ return `${this.config.keyPrefix}${normalizedNetworkId}:${normalizedAddress}`;
95
+ }
96
+
97
+ /**
98
+ * Fetch staking positions from blockchain via network module
99
+ * and enrich with pricing data from markets module
100
+ */
101
+ protected async fetchFromSource(params: Record<string, any>): Promise<StakingPosition[]> {
102
+ const tag = this.TAG + 'fetchFromSource | ';
103
+
104
+ try {
105
+ const { networkId, address } = params;
106
+
107
+ log.debug(tag, `Fetching staking positions for ${address} on ${networkId}`);
108
+
109
+ // Get the appropriate network module
110
+ const networkModule = this.networkModules.get(networkId);
111
+ if (!networkModule) {
112
+ log.warn(tag, `No network module found for ${networkId}`);
113
+ return [];
114
+ }
115
+
116
+ // Fetch staking positions from network module (raw blockchain data)
117
+ const positions = await networkModule.getStakingPositions(address);
118
+
119
+ if (!positions || !Array.isArray(positions)) {
120
+ log.warn(tag, `Invalid positions returned for ${networkId}/${address}`);
121
+ return [];
122
+ }
123
+
124
+ log.info(tag, `Found ${positions.length} staking positions for ${networkId}/${address}`);
125
+
126
+ // Enrich with pricing data if markets module is available
127
+ if (this.markets && positions.length > 0) {
128
+ await this.enrichPositionsWithPricing(positions);
129
+ } else if (!this.markets) {
130
+ log.warn(tag, 'Markets module not available, positions will have no pricing');
131
+ }
132
+
133
+ return positions;
134
+
135
+ } catch (error) {
136
+ log.error(tag, 'Error fetching staking positions:', error);
137
+ // Return empty array instead of throwing - staking is optional
138
+ return [];
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Enrich staking positions with USD pricing
144
+ * Uses the markets module to fetch prices for the native tokens
145
+ */
146
+ private async enrichPositionsWithPricing(positions: StakingPosition[]): Promise<void> {
147
+ const tag = TAG + 'enrichPositionsWithPricing | ';
148
+
149
+ try {
150
+ // Collect unique CAIPs from all positions
151
+ const uniqueCAIPs = [...new Set(positions.map(p => p.caip))];
152
+ log.debug(tag, `Fetching prices for ${uniqueCAIPs.length} unique assets:`, uniqueCAIPs);
153
+
154
+ // Batch fetch prices from markets module
155
+ const prices: Record<string, number> = {};
156
+
157
+ for (const caip of uniqueCAIPs) {
158
+ try {
159
+ const price = await this.markets.getAssetPriceByCaip(caip);
160
+ prices[caip] = price || 0;
161
+ log.debug(tag, `Price for ${caip}: $${price}`);
162
+ } catch (priceError) {
163
+ log.error(tag, `Error fetching price for ${caip}:`, priceError);
164
+ prices[caip] = 0;
165
+ }
166
+ }
167
+
168
+ // Enrich each position with its price
169
+ for (const position of positions) {
170
+ const priceUsd = prices[position.caip] || 0;
171
+ position.priceUsd = priceUsd;
172
+ position.valueUsd = position.balance * priceUsd;
173
+
174
+ log.debug(tag, `Enriched ${position.type} position:`, {
175
+ caip: position.caip,
176
+ balance: position.balance,
177
+ priceUsd,
178
+ valueUsd: position.valueUsd
179
+ });
180
+ }
181
+
182
+ log.info(tag, `✅ Enriched ${positions.length} positions with pricing data`);
183
+
184
+ } catch (error) {
185
+ log.error(tag, 'Error enriching positions with pricing:', error);
186
+ // Don't throw - positions are still valid without pricing
187
+ }
188
+ }
189
+
190
+ /**
191
+ * No legacy cache format for staking (new feature)
192
+ */
193
+ protected async getLegacyCached(params: Record<string, any>): Promise<StakingPosition[] | null> {
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Get staking positions for a specific network and address
199
+ * Convenience method that wraps base get()
200
+ */
201
+ async getStakingPositions(networkId: string, address: string, waitForFresh?: boolean): Promise<StakingPosition[]> {
202
+ const result = await this.get({ networkId, address }, waitForFresh);
203
+ return result.value || this.config.defaultValue;
204
+ }
205
+
206
+ /**
207
+ * Get staking positions for multiple addresses (batch operation)
208
+ * OPTIMIZED: Uses Redis MGET for single round-trip
209
+ */
210
+ async getBatchStakingPositions(
211
+ items: Array<{ networkId: string; address: string }>,
212
+ waitForFresh?: boolean
213
+ ): Promise<Map<string, StakingPosition[]>> {
214
+ const tag = this.TAG + 'getBatchStakingPositions | ';
215
+ const startTime = Date.now();
216
+
217
+ try {
218
+ // If waitForFresh=true, skip cache and fetch fresh data
219
+ if (waitForFresh) {
220
+ log.info(tag, `FORCE REFRESH: Bypassing cache for ${items.length} addresses`);
221
+ const fetchStart = Date.now();
222
+
223
+ const results = new Map<string, StakingPosition[]>();
224
+ const fetchPromises = items.map(async (item) => {
225
+ try {
226
+ const freshData = await this.fetchFresh({ networkId: item.networkId, address: item.address });
227
+ const key = `${item.networkId}:${item.address}`;
228
+ results.set(key, freshData);
229
+ } catch (error) {
230
+ log.error(tag, `Failed to fetch fresh ${item.networkId}/${item.address}:`, error);
231
+ const key = `${item.networkId}:${item.address}`;
232
+ results.set(key, []);
233
+ }
234
+ });
235
+
236
+ await Promise.all(fetchPromises);
237
+ log.info(tag, `Force refresh completed: fetched ${items.length} addresses in ${Date.now() - fetchStart}ms`);
238
+ return results;
239
+ }
240
+
241
+ // Normal flow: Check cache first
242
+ log.info(tag, `Batch request for ${items.length} addresses using Redis MGET`);
243
+
244
+ // Build all Redis keys
245
+ const keys = items.map(item => this.buildKey({ networkId: item.networkId, address: item.address }));
246
+
247
+ // PERF: Use MGET to fetch all keys in ONE Redis round-trip
248
+ const cachedValues = await this.redis.mget(...keys);
249
+
250
+ // Process results
251
+ const results = new Map<string, StakingPosition[]>();
252
+ const missedItems: Array<{ networkId: string; address: string; index: number }> = [];
253
+
254
+ for (let i = 0; i < items.length; i++) {
255
+ const item = items[i];
256
+ const cached = cachedValues[i];
257
+ const itemKey = `${item.networkId}:${item.address}`;
258
+
259
+ if (cached) {
260
+ try {
261
+ const parsed = JSON.parse(cached);
262
+ if (parsed.value && Array.isArray(parsed.value)) {
263
+ results.set(itemKey, parsed.value);
264
+ continue;
265
+ }
266
+ } catch (e) {
267
+ log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
268
+ }
269
+ }
270
+
271
+ // Cache miss - record for fetching
272
+ missedItems.push({ ...item, index: i });
273
+ results.set(itemKey, []); // Placeholder
274
+ }
275
+
276
+ const responseTime = Date.now() - startTime;
277
+ const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
278
+ log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
279
+
280
+ // If we have cache misses, trigger background refresh (non-blocking)
281
+ if (missedItems.length > 0) {
282
+ log.info(tag, `Triggering background refresh for ${missedItems.length} cache misses`);
283
+
284
+ missedItems.forEach(item => {
285
+ this.triggerAsyncRefresh({ networkId: item.networkId, address: item.address }, 'high');
286
+ });
287
+ }
288
+
289
+ return results;
290
+
291
+ } catch (error) {
292
+ log.error(tag, 'Error in batch staking request:', error);
293
+ // Return empty arrays for all items
294
+ const results = new Map<string, StakingPosition[]>();
295
+ items.forEach(item => {
296
+ const key = `${item.networkId}:${item.address}`;
297
+ results.set(key, []);
298
+ });
299
+ return results;
300
+ }
301
+ }
302
+ }