@pioneer-platform/pioneer-cache 1.6.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 +12 -0
- package/dist/core/cache-manager.d.ts +7 -1
- package/dist/core/cache-manager.js +27 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/stores/zapper-cache.d.ts +163 -0
- package/dist/stores/zapper-cache.js +239 -0
- package/package.json +1 -1
- package/src/core/cache-manager.ts +36 -3
- package/src/index.ts +2 -0
- package/src/stores/zapper-cache.ts +368 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.7.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- chore: chore: chore: chore: chore: feat(pioneer): implement end-to-end Solana transaction signing
|
|
14
|
+
|
|
3
15
|
## 1.6.0
|
|
4
16
|
|
|
5
17
|
### 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
|
@@ -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
|
+
}
|