@pioneer-platform/pioneer-cache 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @pioneer-platform/pioneer-cache
2
2
 
3
+ ## 1.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - chore: chore: chore: chore: chore: chore: feat(pioneer): implement end-to-end Solana transaction signing
8
+
3
9
  ## 1.7.0
4
10
 
5
11
  ### Minor Changes
@@ -3,6 +3,7 @@ import { PriceCache } from '../stores/price-cache';
3
3
  import { PortfolioCache } from '../stores/portfolio-cache';
4
4
  import { TransactionCache } from '../stores/transaction-cache';
5
5
  import { StakingCache } from '../stores/staking-cache';
6
+ import { ZapperCache } from '../stores/zapper-cache';
6
7
  import type { HealthCheckResult } from '../types';
7
8
  /**
8
9
  * Configuration for CacheManager
@@ -13,11 +14,13 @@ export interface CacheManagerConfig {
13
14
  balanceModule?: any;
14
15
  markets?: any;
15
16
  networkModules?: Map<string, any>;
17
+ zapperClient?: any;
16
18
  enableBalanceCache?: boolean;
17
19
  enablePriceCache?: boolean;
18
20
  enablePortfolioCache?: boolean;
19
21
  enableTransactionCache?: boolean;
20
22
  enableStakingCache?: boolean;
23
+ enableZapperCache?: boolean;
21
24
  startWorkers?: boolean;
22
25
  }
23
26
  /**
@@ -31,6 +34,7 @@ export declare class CacheManager {
31
34
  private portfolioCache?;
32
35
  private transactionCache?;
33
36
  private stakingCache?;
37
+ private zapperCache?;
34
38
  private workers;
35
39
  constructor(config: CacheManagerConfig);
36
40
  /**
@@ -57,11 +61,12 @@ export declare class CacheManager {
57
61
  portfolio: PortfolioCache | undefined;
58
62
  transaction: TransactionCache | undefined;
59
63
  staking: StakingCache | undefined;
64
+ zapper: ZapperCache | undefined;
60
65
  };
61
66
  /**
62
67
  * Get specific cache by name
63
68
  */
64
- getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | StakingCache | undefined;
69
+ getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking' | 'zapper'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | StakingCache | ZapperCache | undefined;
65
70
  /**
66
71
  * Clear all caches (use with caution!)
67
72
  */
@@ -71,5 +76,6 @@ export declare class CacheManager {
71
76
  portfolio?: number;
72
77
  transaction?: number;
73
78
  staking?: number;
79
+ zapper?: number;
74
80
  }>;
75
81
  }
@@ -12,6 +12,7 @@ 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
14
  const staking_cache_1 = require("../stores/staking-cache");
15
+ const zapper_cache_1 = require("../stores/zapper-cache");
15
16
  const refresh_worker_1 = require("../workers/refresh-worker");
16
17
  const log = require('@pioneer-platform/loggerdog')();
17
18
  const TAG = ' | CacheManager | ';
@@ -48,6 +49,11 @@ class CacheManager {
48
49
  this.stakingCache = new staking_cache_1.StakingCache(this.redis, config.networkModules, config.markets);
49
50
  log.info(TAG, '✅ Staking cache initialized');
50
51
  }
52
+ // Initialize Zapper Cache
53
+ if (config.enableZapperCache !== false && config.zapperClient) {
54
+ this.zapperCache = new zapper_cache_1.ZapperCache(this.redis, config.zapperClient);
55
+ log.info(TAG, '✅ Zapper cache initialized');
56
+ }
51
57
  // Auto-start workers if requested
52
58
  if (config.startWorkers) {
53
59
  setImmediate(() => {
@@ -77,6 +83,9 @@ class CacheManager {
77
83
  if (this.stakingCache) {
78
84
  cacheRegistry.set('staking', this.stakingCache);
79
85
  }
86
+ if (this.zapperCache) {
87
+ cacheRegistry.set('zapper', this.zapperCache);
88
+ }
80
89
  // Start unified worker if we have any caches with queues
81
90
  if (cacheRegistry.size > 0) {
82
91
  const worker = await (0, refresh_worker_1.startUnifiedWorker)(this.redisQueue, // Use dedicated queue client for blocking operations
@@ -175,6 +184,17 @@ class CacheManager {
175
184
  warnings.push(...stakingHealth.warnings.map(w => `Staking: ${w}`));
176
185
  }
177
186
  }
187
+ // Check zapper cache
188
+ if (this.zapperCache) {
189
+ const zapperHealth = await this.zapperCache.getHealth(forceRefresh);
190
+ checks.zapper = zapperHealth;
191
+ if (zapperHealth.status === 'unhealthy') {
192
+ issues.push(...zapperHealth.issues.map(i => `Zapper: ${i}`));
193
+ }
194
+ else if (zapperHealth.status === 'degraded') {
195
+ warnings.push(...zapperHealth.warnings.map(w => `Zapper: ${w}`));
196
+ }
197
+ }
178
198
  // Check workers
179
199
  for (const worker of this.workers) {
180
200
  const workerStats = await worker.getStats();
@@ -236,7 +256,8 @@ class CacheManager {
236
256
  price: this.priceCache,
237
257
  portfolio: this.portfolioCache,
238
258
  transaction: this.transactionCache,
239
- staking: this.stakingCache
259
+ staking: this.stakingCache,
260
+ zapper: this.zapperCache
240
261
  };
241
262
  }
242
263
  /**
@@ -254,6 +275,8 @@ class CacheManager {
254
275
  return this.transactionCache;
255
276
  case 'staking':
256
277
  return this.stakingCache;
278
+ case 'zapper':
279
+ return this.zapperCache;
257
280
  default:
258
281
  return undefined;
259
282
  }
@@ -280,6 +303,9 @@ class CacheManager {
280
303
  if (this.stakingCache) {
281
304
  result.staking = await this.stakingCache.clearAll();
282
305
  }
306
+ if (this.zapperCache) {
307
+ result.zapper = await this.zapperCache.clearAll();
308
+ }
283
309
  log.info(tag, 'Cleared all caches:', result);
284
310
  return result;
285
311
  }
package/dist/index.d.ts CHANGED
@@ -5,11 +5,13 @@ export { PriceCache } from './stores/price-cache';
5
5
  export { PortfolioCache } from './stores/portfolio-cache';
6
6
  export { TransactionCache } from './stores/transaction-cache';
7
7
  export { StakingCache } from './stores/staking-cache';
8
+ export { ZapperCache } from './stores/zapper-cache';
8
9
  export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
9
10
  export type { CacheConfig, CachedValue, CacheResult, RefreshJob, HealthCheckResult, CacheStats } from './types';
10
11
  export type { BalanceData } from './stores/balance-cache';
11
12
  export type { PriceData } from './stores/price-cache';
12
13
  export type { PortfolioData, ChartData } from './stores/portfolio-cache';
13
14
  export type { StakingPosition } from './stores/staking-cache';
15
+ export type { ZapperPortfolioData, ZapperNFTData, ZapperAppsData, ZapperTotalsData } from './stores/zapper-cache';
14
16
  export type { CacheManagerConfig } from './core/cache-manager';
15
17
  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.StakingCache = exports.TransactionCache = exports.PortfolioCache = exports.PriceCache = exports.BalanceCache = exports.CacheManager = exports.BaseCache = void 0;
9
+ exports.startUnifiedWorker = exports.RefreshWorker = exports.ZapperCache = 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; } });
@@ -23,6 +23,8 @@ var transaction_cache_1 = require("./stores/transaction-cache");
23
23
  Object.defineProperty(exports, "TransactionCache", { enumerable: true, get: function () { return transaction_cache_1.TransactionCache; } });
24
24
  var staking_cache_1 = require("./stores/staking-cache");
25
25
  Object.defineProperty(exports, "StakingCache", { enumerable: true, get: function () { return staking_cache_1.StakingCache; } });
26
+ var zapper_cache_1 = require("./stores/zapper-cache");
27
+ Object.defineProperty(exports, "ZapperCache", { enumerable: true, get: function () { return zapper_cache_1.ZapperCache; } });
26
28
  // Worker exports
27
29
  var refresh_worker_1 = require("./workers/refresh-worker");
28
30
  Object.defineProperty(exports, "RefreshWorker", { enumerable: true, get: function () { return refresh_worker_1.RefreshWorker; } });
@@ -0,0 +1,163 @@
1
+ import { BaseCache } from '../core/base-cache';
2
+ import type { CacheConfig } from '../types';
3
+ /**
4
+ * Zapper portfolio data structure
5
+ */
6
+ export interface ZapperPortfolioData {
7
+ address: string;
8
+ balances: Array<{
9
+ balance: string;
10
+ chain: string;
11
+ networkId: string;
12
+ symbol: string;
13
+ ticker: string;
14
+ name: string;
15
+ caip: string;
16
+ tokenAddress?: string;
17
+ tokenType: string;
18
+ priceUsd: string;
19
+ valueUsd: string;
20
+ icon?: string;
21
+ display?: string[];
22
+ decimals?: number;
23
+ appId?: string;
24
+ groupId?: string;
25
+ metaType?: string;
26
+ pubkey?: string;
27
+ }>;
28
+ tokens?: any[];
29
+ nfts: any[];
30
+ totalNetWorth: number;
31
+ totalBalanceUsdTokens: number;
32
+ totalBalanceUSDApp: number;
33
+ nftUsdNetWorth: {
34
+ [address: string]: string;
35
+ };
36
+ }
37
+ /**
38
+ * Zapper NFT data structure
39
+ */
40
+ export interface ZapperNFTData {
41
+ items: Array<{
42
+ tokenId: string;
43
+ name: string;
44
+ description?: string;
45
+ collection: {
46
+ address: string;
47
+ name: string;
48
+ network: string;
49
+ type: string;
50
+ };
51
+ estimatedValue: {
52
+ valueUsd: number;
53
+ };
54
+ mediasV3: {
55
+ images: {
56
+ edges: Array<{
57
+ node: {
58
+ originalUri: string;
59
+ thumbnail: string;
60
+ };
61
+ }>;
62
+ };
63
+ };
64
+ }>;
65
+ totalCount: number;
66
+ totalBalanceUSD: number;
67
+ }
68
+ /**
69
+ * Zapper apps data structure
70
+ */
71
+ export interface ZapperAppsData {
72
+ edges: Array<{
73
+ node: {
74
+ balanceUSD: number;
75
+ app: {
76
+ slug: string;
77
+ displayName: string;
78
+ };
79
+ network: {
80
+ name: string;
81
+ };
82
+ };
83
+ }>;
84
+ }
85
+ /**
86
+ * Zapper totals data structure
87
+ */
88
+ export interface ZapperTotalsData {
89
+ totalNetWorth: number;
90
+ fetchedAt: number;
91
+ }
92
+ /**
93
+ * ZapperCache - Caches Zapper API responses with 24-hour refresh cycle
94
+ *
95
+ * CRITICAL: Zapper API is EXPENSIVE - minimize calls!
96
+ * - First request: Blocks and waits for Zapper API
97
+ * - Cached data NEVER expires from Redis
98
+ * - Data stays fresh for 24 hours
99
+ * - After 24 hours: Returns cached data + triggers background refresh
100
+ * - Maximum 1 API call per address per day
101
+ */
102
+ export declare class ZapperCache extends BaseCache<ZapperPortfolioData | ZapperNFTData | ZapperAppsData | ZapperTotalsData> {
103
+ private zapperClient;
104
+ constructor(redis: any, zapperClient: any, config?: Partial<CacheConfig>);
105
+ /**
106
+ * Build Redis key for Zapper data
107
+ *
108
+ * Key strategy: zapper_v1:{dataType}:{address}
109
+ *
110
+ * Examples:
111
+ * - zapper_v1:portfolio:0x1234...
112
+ * - zapper_v1:nfts:0x1234...
113
+ * - zapper_v1:apps:0x1234...
114
+ * - zapper_v1:totals:0x1234...
115
+ */
116
+ protected buildKey(params: Record<string, any>): string;
117
+ /**
118
+ * Fetch data from Zapper API
119
+ *
120
+ * CRITICAL: This is an EXPENSIVE operation!
121
+ * Only called on:
122
+ * 1. First request (cache miss)
123
+ * 2. Background refresh after 24 hours
124
+ */
125
+ protected fetchFromSource(params: Record<string, any>): Promise<any>;
126
+ /**
127
+ * Legacy cache fallback (not applicable for Zapper)
128
+ */
129
+ protected getLegacyCached(params: Record<string, any>): Promise<any | null>;
130
+ /**
131
+ * Get portfolio data for an address
132
+ */
133
+ getPortfolio(address: string, forceRefresh?: boolean): Promise<ZapperPortfolioData>;
134
+ /**
135
+ * Get NFT data for an address
136
+ */
137
+ getNFTs(address: string, forceRefresh?: boolean): Promise<ZapperNFTData>;
138
+ /**
139
+ * Get DeFi app positions for an address
140
+ */
141
+ getApps(address: string, forceRefresh?: boolean): Promise<ZapperAppsData>;
142
+ /**
143
+ * Get portfolio totals for an address
144
+ */
145
+ getTotals(address: string, forceRefresh?: boolean): Promise<ZapperTotalsData>;
146
+ /**
147
+ * Clear cache for an address
148
+ * @param address - Ethereum address
149
+ * @param dataType - Optional: specific data type to clear, or all if omitted
150
+ */
151
+ clearAddress(address: string, dataType?: string): Promise<number>;
152
+ /**
153
+ * Get cache statistics for an address
154
+ */
155
+ getAddressStats(address: string): Promise<{
156
+ address: string;
157
+ cached: string[];
158
+ ages: {
159
+ [key: string]: number;
160
+ };
161
+ staleThreshold: number;
162
+ }>;
163
+ }
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ /*
3
+ ZapperCache - Zapper portfolio data cache implementation
4
+
5
+ Extends BaseCache with Zapper-specific logic.
6
+
7
+ CRITICAL: This is an EXPENSIVE API - minimize calls!
8
+ - Data NEVER expires (enableTTL: false)
9
+ - 24-hour stale threshold before background refresh
10
+ - Maximum 1 API call per address per day
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ZapperCache = void 0;
14
+ const base_cache_1 = require("../core/base-cache");
15
+ const log = require('@pioneer-platform/loggerdog')();
16
+ /**
17
+ * ZapperCache - Caches Zapper API responses with 24-hour refresh cycle
18
+ *
19
+ * CRITICAL: Zapper API is EXPENSIVE - minimize calls!
20
+ * - First request: Blocks and waits for Zapper API
21
+ * - Cached data NEVER expires from Redis
22
+ * - Data stays fresh for 24 hours
23
+ * - After 24 hours: Returns cached data + triggers background refresh
24
+ * - Maximum 1 API call per address per day
25
+ */
26
+ class ZapperCache extends base_cache_1.BaseCache {
27
+ constructor(redis, zapperClient, config) {
28
+ const defaultConfig = {
29
+ name: 'zapper',
30
+ keyPrefix: 'zapper_v1:',
31
+ ttl: 0, // Ignored when enableTTL: false
32
+ staleThreshold: 24 * 60 * 60 * 1000, // 24 hours - ONLY refresh after this
33
+ enableTTL: false, // NEVER EXPIRE - data persists forever
34
+ queueName: 'cache-refresh',
35
+ enableQueue: true,
36
+ maxRetries: 3,
37
+ retryDelay: 5000,
38
+ blockOnMiss: true, // BLOCK on first request (no cached data yet)
39
+ enableLegacyFallback: false, // No legacy Zapper cache format
40
+ defaultValue: {
41
+ balances: [],
42
+ tokens: [],
43
+ nfts: [],
44
+ totalNetWorth: 0,
45
+ totalBalanceUsdTokens: 0,
46
+ totalBalanceUSDApp: 0,
47
+ nftUsdNetWorth: {}
48
+ },
49
+ useSyncFallback: false,
50
+ maxConcurrentJobs: 2, // Limit concurrent Zapper API calls
51
+ apiTimeout: 30000, // 30s timeout for Zapper API
52
+ logCacheHits: true,
53
+ logCacheMisses: true,
54
+ logRefreshJobs: true
55
+ };
56
+ super(redis, { ...defaultConfig, ...config });
57
+ if (!zapperClient) {
58
+ throw new Error('ZapperCache requires zapperClient');
59
+ }
60
+ this.zapperClient = zapperClient;
61
+ }
62
+ /**
63
+ * Build Redis key for Zapper data
64
+ *
65
+ * Key strategy: zapper_v1:{dataType}:{address}
66
+ *
67
+ * Examples:
68
+ * - zapper_v1:portfolio:0x1234...
69
+ * - zapper_v1:nfts:0x1234...
70
+ * - zapper_v1:apps:0x1234...
71
+ * - zapper_v1:totals:0x1234...
72
+ */
73
+ buildKey(params) {
74
+ const { address, dataType } = params;
75
+ if (!address) {
76
+ throw new Error('ZapperCache.buildKey: address required');
77
+ }
78
+ if (!dataType) {
79
+ throw new Error('ZapperCache.buildKey: dataType required (portfolio, nfts, apps, totals)');
80
+ }
81
+ // Normalize address to lowercase
82
+ const normalizedAddress = address.toLowerCase();
83
+ // Validate Ethereum address format
84
+ if (!normalizedAddress.match(/^0x[a-f0-9]{40}$/)) {
85
+ throw new Error(`ZapperCache.buildKey: invalid Ethereum address format: ${address}`);
86
+ }
87
+ return `${this.config.keyPrefix}${dataType}:${normalizedAddress}`;
88
+ }
89
+ /**
90
+ * Fetch data from Zapper API
91
+ *
92
+ * CRITICAL: This is an EXPENSIVE operation!
93
+ * Only called on:
94
+ * 1. First request (cache miss)
95
+ * 2. Background refresh after 24 hours
96
+ */
97
+ async fetchFromSource(params) {
98
+ const tag = this.TAG + 'fetchFromSource | ';
99
+ const { address, dataType } = params;
100
+ try {
101
+ log.info(tag, `🌐 Fetching ${dataType} from Zapper API for ${address} (EXPENSIVE!)`);
102
+ let result;
103
+ switch (dataType) {
104
+ case 'portfolio':
105
+ result = await this.zapperClient.getPortfolio(address);
106
+ result.address = address;
107
+ break;
108
+ case 'nfts':
109
+ result = await this.zapperClient.getNFTs(address);
110
+ break;
111
+ case 'apps':
112
+ result = await this.zapperClient.getTokens(address);
113
+ break;
114
+ case 'totals':
115
+ const totalNetWorth = await this.zapperClient.getTotalNetworth(address);
116
+ result = {
117
+ totalNetWorth,
118
+ fetchedAt: Date.now()
119
+ };
120
+ break;
121
+ default:
122
+ throw new Error(`Unknown dataType: ${dataType}`);
123
+ }
124
+ log.info(tag, `✅ Successfully fetched ${dataType} for ${address} from Zapper`);
125
+ return result;
126
+ }
127
+ catch (error) {
128
+ log.error(tag, `❌ Failed to fetch ${dataType} from Zapper for ${address}:`, error);
129
+ throw error;
130
+ }
131
+ }
132
+ /**
133
+ * Legacy cache fallback (not applicable for Zapper)
134
+ */
135
+ async getLegacyCached(params) {
136
+ // No legacy Zapper cache format exists
137
+ return null;
138
+ }
139
+ /**
140
+ * Get portfolio data for an address
141
+ */
142
+ async getPortfolio(address, forceRefresh) {
143
+ const result = await this.get({ address, dataType: 'portfolio' }, forceRefresh);
144
+ return result.value;
145
+ }
146
+ /**
147
+ * Get NFT data for an address
148
+ */
149
+ async getNFTs(address, forceRefresh) {
150
+ const result = await this.get({ address, dataType: 'nfts' }, forceRefresh);
151
+ return result.value;
152
+ }
153
+ /**
154
+ * Get DeFi app positions for an address
155
+ */
156
+ async getApps(address, forceRefresh) {
157
+ const result = await this.get({ address, dataType: 'apps' }, forceRefresh);
158
+ return result.value;
159
+ }
160
+ /**
161
+ * Get portfolio totals for an address
162
+ */
163
+ async getTotals(address, forceRefresh) {
164
+ const result = await this.get({ address, dataType: 'totals' }, forceRefresh);
165
+ return result.value;
166
+ }
167
+ /**
168
+ * Clear cache for an address
169
+ * @param address - Ethereum address
170
+ * @param dataType - Optional: specific data type to clear, or all if omitted
171
+ */
172
+ async clearAddress(address, dataType) {
173
+ const tag = this.TAG + 'clearAddress | ';
174
+ try {
175
+ const normalizedAddress = address.toLowerCase();
176
+ let cleared = 0;
177
+ if (dataType) {
178
+ // Clear specific data type
179
+ const key = this.buildKey({ address: normalizedAddress, dataType });
180
+ cleared = await this.redis.del(key);
181
+ log.info(tag, `Cleared ${dataType} cache for ${address}`);
182
+ }
183
+ else {
184
+ // Clear all data types for this address
185
+ const pattern = `${this.config.keyPrefix}*:${normalizedAddress}`;
186
+ const keys = await this.redis.keys(pattern);
187
+ if (keys.length > 0) {
188
+ cleared = await this.redis.del(...keys);
189
+ }
190
+ log.info(tag, `Cleared ${cleared} cache entries for ${address}`);
191
+ }
192
+ return cleared;
193
+ }
194
+ catch (error) {
195
+ log.error(tag, `Error clearing cache for ${address}:`, error);
196
+ return 0;
197
+ }
198
+ }
199
+ /**
200
+ * Get cache statistics for an address
201
+ */
202
+ async getAddressStats(address) {
203
+ const tag = this.TAG + 'getAddressStats | ';
204
+ try {
205
+ const normalizedAddress = address.toLowerCase();
206
+ const pattern = `${this.config.keyPrefix}*:${normalizedAddress}`;
207
+ const keys = await this.redis.keys(pattern);
208
+ const cached = [];
209
+ const ages = {};
210
+ for (const key of keys) {
211
+ // Extract data type from key (e.g., "zapper_v1:portfolio:0x123..." -> "portfolio")
212
+ const parts = key.split(':');
213
+ const dataType = parts[1];
214
+ const data = await this.redis.get(key);
215
+ if (data) {
216
+ const parsed = JSON.parse(data);
217
+ cached.push(dataType);
218
+ ages[dataType] = Math.round((Date.now() - parsed.timestamp) / 1000);
219
+ }
220
+ }
221
+ return {
222
+ address: normalizedAddress,
223
+ cached,
224
+ ages,
225
+ staleThreshold: this.config.staleThreshold || 0
226
+ };
227
+ }
228
+ catch (error) {
229
+ log.error(tag, `Error getting stats for ${address}:`, error);
230
+ return {
231
+ address: address.toLowerCase(),
232
+ cached: [],
233
+ ages: {},
234
+ staleThreshold: this.config.staleThreshold || 0
235
+ };
236
+ }
237
+ }
238
+ }
239
+ exports.ZapperCache = ZapperCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/pioneer-cache",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Unified caching system for Pioneer platform with Redis backend",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -10,6 +10,7 @@ import { PriceCache } from '../stores/price-cache';
10
10
  import { PortfolioCache } from '../stores/portfolio-cache';
11
11
  import { TransactionCache } from '../stores/transaction-cache';
12
12
  import { StakingCache } from '../stores/staking-cache';
13
+ import { ZapperCache } from '../stores/zapper-cache';
13
14
  import { RefreshWorker, startUnifiedWorker } from '../workers/refresh-worker';
14
15
  import type { BaseCache } from './base-cache';
15
16
  import type { HealthCheckResult } from '../types';
@@ -26,11 +27,13 @@ export interface CacheManagerConfig {
26
27
  balanceModule?: any; // Optional: if not provided, balance cache won't be initialized
27
28
  markets?: any; // Optional: if not provided, price cache won't be initialized
28
29
  networkModules?: Map<string, any>; // Optional: network modules for staking cache (keyed by networkId)
30
+ zapperClient?: any; // Optional: Zapper API client for portfolio data
29
31
  enableBalanceCache?: boolean;
30
32
  enablePriceCache?: boolean;
31
33
  enablePortfolioCache?: boolean;
32
34
  enableTransactionCache?: boolean;
33
35
  enableStakingCache?: boolean;
36
+ enableZapperCache?: boolean;
34
37
  startWorkers?: boolean; // Auto-start workers on initialization
35
38
  }
36
39
 
@@ -45,6 +48,7 @@ export class CacheManager {
45
48
  private portfolioCache?: PortfolioCache;
46
49
  private transactionCache?: TransactionCache;
47
50
  private stakingCache?: StakingCache;
51
+ private zapperCache?: ZapperCache;
48
52
  private workers: RefreshWorker[] = [];
49
53
 
50
54
  constructor(config: CacheManagerConfig) {
@@ -81,6 +85,12 @@ export class CacheManager {
81
85
  log.info(TAG, '✅ Staking cache initialized');
82
86
  }
83
87
 
88
+ // Initialize Zapper Cache
89
+ if (config.enableZapperCache !== false && config.zapperClient) {
90
+ this.zapperCache = new ZapperCache(this.redis, config.zapperClient);
91
+ log.info(TAG, '✅ Zapper cache initialized');
92
+ }
93
+
84
94
  // Auto-start workers if requested
85
95
  if (config.startWorkers) {
86
96
  setImmediate(() => {
@@ -119,6 +129,10 @@ export class CacheManager {
119
129
  cacheRegistry.set('staking', this.stakingCache);
120
130
  }
121
131
 
132
+ if (this.zapperCache) {
133
+ cacheRegistry.set('zapper', this.zapperCache);
134
+ }
135
+
122
136
  // Start unified worker if we have any caches with queues
123
137
  if (cacheRegistry.size > 0) {
124
138
  const worker = await startUnifiedWorker(
@@ -233,6 +247,18 @@ export class CacheManager {
233
247
  }
234
248
  }
235
249
 
250
+ // Check zapper cache
251
+ if (this.zapperCache) {
252
+ const zapperHealth = await this.zapperCache.getHealth(forceRefresh);
253
+ checks.zapper = zapperHealth;
254
+
255
+ if (zapperHealth.status === 'unhealthy') {
256
+ issues.push(...zapperHealth.issues.map(i => `Zapper: ${i}`));
257
+ } else if (zapperHealth.status === 'degraded') {
258
+ warnings.push(...zapperHealth.warnings.map(w => `Zapper: ${w}`));
259
+ }
260
+ }
261
+
236
262
  // Check workers
237
263
  for (const worker of this.workers) {
238
264
  const workerStats = await worker.getStats();
@@ -297,14 +323,15 @@ export class CacheManager {
297
323
  price: this.priceCache,
298
324
  portfolio: this.portfolioCache,
299
325
  transaction: this.transactionCache,
300
- staking: this.stakingCache
326
+ staking: this.stakingCache,
327
+ zapper: this.zapperCache
301
328
  };
302
329
  }
303
330
 
304
331
  /**
305
332
  * Get specific cache by name
306
333
  */
307
- getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking') {
334
+ getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking' | 'zapper') {
308
335
  switch (name) {
309
336
  case 'balance':
310
337
  return this.balanceCache;
@@ -316,6 +343,8 @@ export class CacheManager {
316
343
  return this.transactionCache;
317
344
  case 'staking':
318
345
  return this.stakingCache;
346
+ case 'zapper':
347
+ return this.zapperCache;
319
348
  default:
320
349
  return undefined;
321
350
  }
@@ -324,7 +353,7 @@ export class CacheManager {
324
353
  /**
325
354
  * Clear all caches (use with caution!)
326
355
  */
327
- async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number; staking?: number }> {
356
+ async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number; staking?: number; zapper?: number }> {
328
357
  const tag = TAG + 'clearAll | ';
329
358
 
330
359
  try {
@@ -350,6 +379,10 @@ export class CacheManager {
350
379
  result.staking = await this.stakingCache.clearAll();
351
380
  }
352
381
 
382
+ if (this.zapperCache) {
383
+ result.zapper = await this.zapperCache.clearAll();
384
+ }
385
+
353
386
  log.info(tag, 'Cleared all caches:', result);
354
387
  return result;
355
388
 
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { PriceCache } from './stores/price-cache';
15
15
  export { PortfolioCache } from './stores/portfolio-cache';
16
16
  export { TransactionCache } from './stores/transaction-cache';
17
17
  export { StakingCache } from './stores/staking-cache';
18
+ export { ZapperCache } from './stores/zapper-cache';
18
19
 
19
20
  // Worker exports
20
21
  export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
@@ -34,6 +35,7 @@ export type { BalanceData } from './stores/balance-cache';
34
35
  export type { PriceData } from './stores/price-cache';
35
36
  export type { PortfolioData, ChartData } from './stores/portfolio-cache';
36
37
  export type { StakingPosition } from './stores/staking-cache';
38
+ export type { ZapperPortfolioData, ZapperNFTData, ZapperAppsData, ZapperTotalsData } from './stores/zapper-cache';
37
39
 
38
40
  // Config type export
39
41
  export type { CacheManagerConfig } from './core/cache-manager';
@@ -0,0 +1,368 @@
1
+ /*
2
+ ZapperCache - Zapper portfolio data cache implementation
3
+
4
+ Extends BaseCache with Zapper-specific logic.
5
+
6
+ CRITICAL: This is an EXPENSIVE API - minimize calls!
7
+ - Data NEVER expires (enableTTL: false)
8
+ - 24-hour stale threshold before background refresh
9
+ - Maximum 1 API call per address per day
10
+ */
11
+
12
+ import { BaseCache } from '../core/base-cache';
13
+ import type { CacheConfig } from '../types';
14
+
15
+ const log = require('@pioneer-platform/loggerdog')();
16
+
17
+ /**
18
+ * Zapper portfolio data structure
19
+ */
20
+ export interface ZapperPortfolioData {
21
+ address: string;
22
+ balances: Array<{
23
+ balance: string;
24
+ chain: string;
25
+ networkId: string;
26
+ symbol: string;
27
+ ticker: string;
28
+ name: string;
29
+ caip: string;
30
+ tokenAddress?: string;
31
+ tokenType: string;
32
+ priceUsd: string;
33
+ valueUsd: string;
34
+ icon?: string;
35
+ display?: string[];
36
+ decimals?: number;
37
+ appId?: string;
38
+ groupId?: string;
39
+ metaType?: string;
40
+ pubkey?: string;
41
+ }>;
42
+ tokens?: any[];
43
+ nfts: any[];
44
+ totalNetWorth: number;
45
+ totalBalanceUsdTokens: number;
46
+ totalBalanceUSDApp: number;
47
+ nftUsdNetWorth: { [address: string]: string };
48
+ }
49
+
50
+ /**
51
+ * Zapper NFT data structure
52
+ */
53
+ export interface ZapperNFTData {
54
+ items: Array<{
55
+ tokenId: string;
56
+ name: string;
57
+ description?: string;
58
+ collection: {
59
+ address: string;
60
+ name: string;
61
+ network: string;
62
+ type: string;
63
+ };
64
+ estimatedValue: { valueUsd: number };
65
+ mediasV3: {
66
+ images: {
67
+ edges: Array<{
68
+ node: {
69
+ originalUri: string;
70
+ thumbnail: string;
71
+ }
72
+ }>
73
+ }
74
+ }
75
+ }>;
76
+ totalCount: number;
77
+ totalBalanceUSD: number;
78
+ }
79
+
80
+ /**
81
+ * Zapper apps data structure
82
+ */
83
+ export interface ZapperAppsData {
84
+ edges: Array<{
85
+ node: {
86
+ balanceUSD: number;
87
+ app: {
88
+ slug: string;
89
+ displayName: string;
90
+ };
91
+ network: {
92
+ name: string;
93
+ };
94
+ }
95
+ }>;
96
+ }
97
+
98
+ /**
99
+ * Zapper totals data structure
100
+ */
101
+ export interface ZapperTotalsData {
102
+ totalNetWorth: number;
103
+ fetchedAt: number;
104
+ }
105
+
106
+ /**
107
+ * ZapperCache - Caches Zapper API responses with 24-hour refresh cycle
108
+ *
109
+ * CRITICAL: Zapper API is EXPENSIVE - minimize calls!
110
+ * - First request: Blocks and waits for Zapper API
111
+ * - Cached data NEVER expires from Redis
112
+ * - Data stays fresh for 24 hours
113
+ * - After 24 hours: Returns cached data + triggers background refresh
114
+ * - Maximum 1 API call per address per day
115
+ */
116
+ export class ZapperCache extends BaseCache<ZapperPortfolioData | ZapperNFTData | ZapperAppsData | ZapperTotalsData> {
117
+ private zapperClient: any;
118
+
119
+ constructor(redis: any, zapperClient: any, config?: Partial<CacheConfig>) {
120
+ const defaultConfig: CacheConfig = {
121
+ name: 'zapper',
122
+ keyPrefix: 'zapper_v1:',
123
+ ttl: 0, // Ignored when enableTTL: false
124
+ staleThreshold: 24 * 60 * 60 * 1000, // 24 hours - ONLY refresh after this
125
+ enableTTL: false, // NEVER EXPIRE - data persists forever
126
+ queueName: 'cache-refresh',
127
+ enableQueue: true,
128
+ maxRetries: 3,
129
+ retryDelay: 5000,
130
+ blockOnMiss: true, // BLOCK on first request (no cached data yet)
131
+ enableLegacyFallback: false, // No legacy Zapper cache format
132
+ defaultValue: {
133
+ balances: [],
134
+ tokens: [],
135
+ nfts: [],
136
+ totalNetWorth: 0,
137
+ totalBalanceUsdTokens: 0,
138
+ totalBalanceUSDApp: 0,
139
+ nftUsdNetWorth: {}
140
+ },
141
+ useSyncFallback: false,
142
+ maxConcurrentJobs: 2, // Limit concurrent Zapper API calls
143
+ apiTimeout: 30000, // 30s timeout for Zapper API
144
+ logCacheHits: true,
145
+ logCacheMisses: true,
146
+ logRefreshJobs: true
147
+ };
148
+
149
+ super(redis, { ...defaultConfig, ...config });
150
+
151
+ if (!zapperClient) {
152
+ throw new Error('ZapperCache requires zapperClient');
153
+ }
154
+ this.zapperClient = zapperClient;
155
+ }
156
+
157
+ /**
158
+ * Build Redis key for Zapper data
159
+ *
160
+ * Key strategy: zapper_v1:{dataType}:{address}
161
+ *
162
+ * Examples:
163
+ * - zapper_v1:portfolio:0x1234...
164
+ * - zapper_v1:nfts:0x1234...
165
+ * - zapper_v1:apps:0x1234...
166
+ * - zapper_v1:totals:0x1234...
167
+ */
168
+ protected buildKey(params: Record<string, any>): string {
169
+ const { address, dataType } = params;
170
+
171
+ if (!address) {
172
+ throw new Error('ZapperCache.buildKey: address required');
173
+ }
174
+
175
+ if (!dataType) {
176
+ throw new Error('ZapperCache.buildKey: dataType required (portfolio, nfts, apps, totals)');
177
+ }
178
+
179
+ // Normalize address to lowercase
180
+ const normalizedAddress = address.toLowerCase();
181
+
182
+ // Validate Ethereum address format
183
+ if (!normalizedAddress.match(/^0x[a-f0-9]{40}$/)) {
184
+ throw new Error(`ZapperCache.buildKey: invalid Ethereum address format: ${address}`);
185
+ }
186
+
187
+ return `${this.config.keyPrefix}${dataType}:${normalizedAddress}`;
188
+ }
189
+
190
+ /**
191
+ * Fetch data from Zapper API
192
+ *
193
+ * CRITICAL: This is an EXPENSIVE operation!
194
+ * Only called on:
195
+ * 1. First request (cache miss)
196
+ * 2. Background refresh after 24 hours
197
+ */
198
+ protected async fetchFromSource(params: Record<string, any>): Promise<any> {
199
+ const tag = this.TAG + 'fetchFromSource | ';
200
+ const { address, dataType } = params;
201
+
202
+ try {
203
+ log.info(tag, `🌐 Fetching ${dataType} from Zapper API for ${address} (EXPENSIVE!)`);
204
+
205
+ let result: any;
206
+
207
+ switch (dataType) {
208
+ case 'portfolio':
209
+ result = await this.zapperClient.getPortfolio(address);
210
+ result.address = address;
211
+ break;
212
+
213
+ case 'nfts':
214
+ result = await this.zapperClient.getNFTs(address);
215
+ break;
216
+
217
+ case 'apps':
218
+ result = await this.zapperClient.getTokens(address);
219
+ break;
220
+
221
+ case 'totals':
222
+ const totalNetWorth = await this.zapperClient.getTotalNetworth(address);
223
+ result = {
224
+ totalNetWorth,
225
+ fetchedAt: Date.now()
226
+ };
227
+ break;
228
+
229
+ default:
230
+ throw new Error(`Unknown dataType: ${dataType}`);
231
+ }
232
+
233
+ log.info(tag, `✅ Successfully fetched ${dataType} for ${address} from Zapper`);
234
+ return result;
235
+
236
+ } catch (error) {
237
+ log.error(tag, `❌ Failed to fetch ${dataType} from Zapper for ${address}:`, error);
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Legacy cache fallback (not applicable for Zapper)
244
+ */
245
+ protected async getLegacyCached(params: Record<string, any>): Promise<any | null> {
246
+ // No legacy Zapper cache format exists
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Get portfolio data for an address
252
+ */
253
+ async getPortfolio(address: string, forceRefresh?: boolean): Promise<ZapperPortfolioData> {
254
+ const result = await this.get({ address, dataType: 'portfolio' }, forceRefresh);
255
+ return result.value as ZapperPortfolioData;
256
+ }
257
+
258
+ /**
259
+ * Get NFT data for an address
260
+ */
261
+ async getNFTs(address: string, forceRefresh?: boolean): Promise<ZapperNFTData> {
262
+ const result = await this.get({ address, dataType: 'nfts' }, forceRefresh);
263
+ return result.value as ZapperNFTData;
264
+ }
265
+
266
+ /**
267
+ * Get DeFi app positions for an address
268
+ */
269
+ async getApps(address: string, forceRefresh?: boolean): Promise<ZapperAppsData> {
270
+ const result = await this.get({ address, dataType: 'apps' }, forceRefresh);
271
+ return result.value as ZapperAppsData;
272
+ }
273
+
274
+ /**
275
+ * Get portfolio totals for an address
276
+ */
277
+ async getTotals(address: string, forceRefresh?: boolean): Promise<ZapperTotalsData> {
278
+ const result = await this.get({ address, dataType: 'totals' }, forceRefresh);
279
+ return result.value as ZapperTotalsData;
280
+ }
281
+
282
+ /**
283
+ * Clear cache for an address
284
+ * @param address - Ethereum address
285
+ * @param dataType - Optional: specific data type to clear, or all if omitted
286
+ */
287
+ async clearAddress(address: string, dataType?: string): Promise<number> {
288
+ const tag = this.TAG + 'clearAddress | ';
289
+
290
+ try {
291
+ const normalizedAddress = address.toLowerCase();
292
+ let cleared = 0;
293
+
294
+ if (dataType) {
295
+ // Clear specific data type
296
+ const key = this.buildKey({ address: normalizedAddress, dataType });
297
+ cleared = await this.redis.del(key);
298
+ log.info(tag, `Cleared ${dataType} cache for ${address}`);
299
+ } else {
300
+ // Clear all data types for this address
301
+ const pattern = `${this.config.keyPrefix}*:${normalizedAddress}`;
302
+ const keys = await this.redis.keys(pattern);
303
+
304
+ if (keys.length > 0) {
305
+ cleared = await this.redis.del(...keys);
306
+ }
307
+ log.info(tag, `Cleared ${cleared} cache entries for ${address}`);
308
+ }
309
+
310
+ return cleared;
311
+
312
+ } catch (error) {
313
+ log.error(tag, `Error clearing cache for ${address}:`, error);
314
+ return 0;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Get cache statistics for an address
320
+ */
321
+ async getAddressStats(address: string): Promise<{
322
+ address: string;
323
+ cached: string[];
324
+ ages: { [key: string]: number };
325
+ staleThreshold: number;
326
+ }> {
327
+ const tag = this.TAG + 'getAddressStats | ';
328
+
329
+ try {
330
+ const normalizedAddress = address.toLowerCase();
331
+ const pattern = `${this.config.keyPrefix}*:${normalizedAddress}`;
332
+ const keys = await this.redis.keys(pattern);
333
+
334
+ const cached: string[] = [];
335
+ const ages: { [key: string]: number } = {};
336
+
337
+ for (const key of keys) {
338
+ // Extract data type from key (e.g., "zapper_v1:portfolio:0x123..." -> "portfolio")
339
+ const parts = key.split(':');
340
+ const dataType = parts[1];
341
+
342
+ const data = await this.redis.get(key);
343
+
344
+ if (data) {
345
+ const parsed = JSON.parse(data);
346
+ cached.push(dataType);
347
+ ages[dataType] = Math.round((Date.now() - parsed.timestamp) / 1000);
348
+ }
349
+ }
350
+
351
+ return {
352
+ address: normalizedAddress,
353
+ cached,
354
+ ages,
355
+ staleThreshold: this.config.staleThreshold || 0
356
+ };
357
+
358
+ } catch (error) {
359
+ log.error(tag, `Error getting stats for ${address}:`, error);
360
+ return {
361
+ address: address.toLowerCase(),
362
+ cached: [],
363
+ ages: {},
364
+ staleThreshold: this.config.staleThreshold || 0
365
+ };
366
+ }
367
+ }
368
+ }