@pioneer-platform/pioneer-cache 1.1.24 → 1.5.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 +24 -0
- package/dist/stores/balance-cache.js +6 -1
- package/dist/stores/portfolio-cache.d.ts +6 -0
- package/dist/stores/portfolio-cache.js +131 -4
- package/package.json +1 -1
- package/src/stores/balance-cache.ts +6 -1
- package/src/stores/portfolio-cache.ts +170 -6
- package/src/stores/balance-cache.ts.bak +0 -345
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @pioneer-platform/pioneer-cache
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- chore: chore: chore: feat(pioneer): implement end-to-end Solana transaction signing
|
|
8
|
+
|
|
9
|
+
## 1.4.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- chore: chore: feat(pioneer): implement end-to-end Solana transaction signing
|
|
14
|
+
|
|
15
|
+
## 1.3.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- chore: feat(pioneer): implement end-to-end Solana transaction signing
|
|
20
|
+
|
|
21
|
+
## 1.2.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- feat(pioneer): implement end-to-end Solana transaction signing
|
|
26
|
+
|
|
3
27
|
## 1.1.24
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
|
@@ -97,7 +97,12 @@ class BalanceCache extends base_cache_1.BaseCache {
|
|
|
97
97
|
async fetchFromSource(params) {
|
|
98
98
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
99
99
|
try {
|
|
100
|
-
|
|
100
|
+
// PHASE 2: Normalize CAIP to lowercase at entry point for consistency
|
|
101
|
+
const normalizedParams = {
|
|
102
|
+
caip: params.caip.toLowerCase(),
|
|
103
|
+
pubkey: params.pubkey
|
|
104
|
+
};
|
|
105
|
+
const { caip, pubkey } = normalizedParams;
|
|
101
106
|
// Log sanitized pubkey for debugging
|
|
102
107
|
log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
|
|
103
108
|
// Fetch balance using balance module (still uses real pubkey)
|
|
@@ -55,11 +55,17 @@ export declare class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
55
55
|
* Not cryptographic - just needs to be stable and collision-resistant
|
|
56
56
|
*/
|
|
57
57
|
private simpleHash;
|
|
58
|
+
/**
|
|
59
|
+
* Fetch stable coin balances for EVM addresses
|
|
60
|
+
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
61
|
+
*/
|
|
62
|
+
private fetchStableCoins;
|
|
58
63
|
/**
|
|
59
64
|
* Fetch portfolio from blockchain APIs
|
|
60
65
|
*
|
|
61
66
|
* This is the SLOW operation that happens in the background
|
|
62
67
|
* It fetches balances for all pubkeys and enriches with pricing
|
|
68
|
+
* INTEGRATED: Now includes stable coin balances automatically
|
|
63
69
|
*/
|
|
64
70
|
protected fetchFromSource(params: Record<string, any>): Promise<PortfolioData>;
|
|
65
71
|
/**
|
|
@@ -85,11 +85,120 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
85
85
|
}
|
|
86
86
|
return Math.abs(hash).toString(36);
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Fetch stable coin balances for EVM addresses
|
|
90
|
+
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
91
|
+
*/
|
|
92
|
+
async fetchStableCoins(primaryAddress, pubkeys) {
|
|
93
|
+
const tag = this.TAG + 'fetchStableCoins | ';
|
|
94
|
+
const stableCharts = [];
|
|
95
|
+
try {
|
|
96
|
+
// Stable coins configuration (same as GetStableCoins endpoint)
|
|
97
|
+
const STABLE_COINS = {
|
|
98
|
+
'eip155:1': [
|
|
99
|
+
{ symbol: 'USDC', name: 'USD Coin', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
|
|
100
|
+
{ symbol: 'USDT', name: 'Tether USD', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
|
|
101
|
+
{ symbol: 'LINK', name: 'Chainlink', address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
|
|
102
|
+
],
|
|
103
|
+
'eip155:137': [
|
|
104
|
+
{ symbol: 'USDC', name: 'USD Coin (Polygon)', address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', decimals: 6, coingeckoId: 'usd-coin' },
|
|
105
|
+
{ symbol: 'USDT', name: 'Tether USD (Polygon)', address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', decimals: 6, coingeckoId: 'tether' },
|
|
106
|
+
],
|
|
107
|
+
'eip155:8453': [
|
|
108
|
+
{ symbol: 'USDC', name: 'USD Coin (Base)', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, coingeckoId: 'usd-coin' },
|
|
109
|
+
],
|
|
110
|
+
'eip155:56': [
|
|
111
|
+
{ symbol: 'USDT', name: 'Tether USD (BSC)', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
|
|
112
|
+
{ symbol: 'USDC', name: 'USD Coin (BSC)', address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
// Extract EVM networks from pubkeys
|
|
116
|
+
const evmNetworks = [...new Set(pubkeys
|
|
117
|
+
.filter(p => p.caip && p.caip.startsWith('eip155:'))
|
|
118
|
+
.map(p => p.caip.split('/')[0]))];
|
|
119
|
+
if (evmNetworks.length === 0) {
|
|
120
|
+
log.debug(tag, 'No EVM networks found, skipping stable coin fetch');
|
|
121
|
+
return stableCharts;
|
|
122
|
+
}
|
|
123
|
+
log.info(tag, `Fetching stable coins for ${evmNetworks.length} EVM networks`);
|
|
124
|
+
// Initialize eth-network module for token balance queries
|
|
125
|
+
let ethNetwork;
|
|
126
|
+
try {
|
|
127
|
+
ethNetwork = require('@pioneer-platform/eth-network');
|
|
128
|
+
await ethNetwork.init({});
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
log.error(tag, 'Failed to initialize eth-network:', error);
|
|
132
|
+
return stableCharts;
|
|
133
|
+
}
|
|
134
|
+
// Fetch stable coins for each network
|
|
135
|
+
for (const networkId of evmNetworks) {
|
|
136
|
+
const stableCoins = STABLE_COINS[networkId];
|
|
137
|
+
if (!stableCoins || stableCoins.length === 0) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
log.debug(tag, `Checking ${stableCoins.length} stable coins on ${networkId}`);
|
|
141
|
+
// Fetch all token balances for this network in parallel
|
|
142
|
+
const tokenPromises = stableCoins.map(async (tokenConfig) => {
|
|
143
|
+
try {
|
|
144
|
+
const balance = await ethNetwork.getBalanceTokenByNetwork(networkId, primaryAddress, tokenConfig.address);
|
|
145
|
+
const balanceNum = parseFloat(balance);
|
|
146
|
+
// Skip zero balances
|
|
147
|
+
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Get price from markets module
|
|
151
|
+
let priceUsd = 0;
|
|
152
|
+
try {
|
|
153
|
+
const tokenCaip = `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`;
|
|
154
|
+
priceUsd = await this.marketsModule.getAssetPriceByCaip(tokenCaip);
|
|
155
|
+
if (isNaN(priceUsd) || priceUsd < 0) {
|
|
156
|
+
priceUsd = 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (priceError) {
|
|
160
|
+
log.warn(tag, `Error fetching price for ${tokenConfig.symbol}:`, priceError);
|
|
161
|
+
}
|
|
162
|
+
const valueUsd = balanceNum * priceUsd;
|
|
163
|
+
const chartData = {
|
|
164
|
+
caip: `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`,
|
|
165
|
+
pubkey: primaryAddress,
|
|
166
|
+
networkId,
|
|
167
|
+
symbol: tokenConfig.symbol,
|
|
168
|
+
name: tokenConfig.name,
|
|
169
|
+
balance: balance,
|
|
170
|
+
priceUsd,
|
|
171
|
+
valueUsd,
|
|
172
|
+
icon: '',
|
|
173
|
+
type: 'token',
|
|
174
|
+
decimal: tokenConfig.decimals
|
|
175
|
+
};
|
|
176
|
+
log.info(tag, `✅ ${tokenConfig.symbol}: ${balance} ($${valueUsd.toFixed(2)})`);
|
|
177
|
+
return chartData;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
log.error(tag, `Error fetching ${tokenConfig.symbol}:`, error);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
const results = await Promise.all(tokenPromises);
|
|
185
|
+
const validTokens = results.filter((t) => t !== null);
|
|
186
|
+
stableCharts.push(...validTokens);
|
|
187
|
+
}
|
|
188
|
+
log.info(tag, `Fetched ${stableCharts.length} stable coin balances`);
|
|
189
|
+
return stableCharts;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
log.error(tag, 'Error fetching stable coins:', error);
|
|
193
|
+
return stableCharts;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
88
196
|
/**
|
|
89
197
|
* Fetch portfolio from blockchain APIs
|
|
90
198
|
*
|
|
91
199
|
* This is the SLOW operation that happens in the background
|
|
92
200
|
* It fetches balances for all pubkeys and enriches with pricing
|
|
201
|
+
* INTEGRATED: Now includes stable coin balances automatically
|
|
93
202
|
*/
|
|
94
203
|
async fetchFromSource(params) {
|
|
95
204
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
@@ -165,14 +274,32 @@ class PortfolioCache extends base_cache_1.BaseCache {
|
|
|
165
274
|
}
|
|
166
275
|
});
|
|
167
276
|
const results = await Promise.all(balancePromises);
|
|
168
|
-
// Filter out nulls
|
|
277
|
+
// Filter out nulls
|
|
169
278
|
const validCharts = results.filter((c) => c !== null);
|
|
170
|
-
|
|
279
|
+
charts.push(...validCharts);
|
|
280
|
+
// INTEGRATED: Fetch stable coins for EVM addresses (background operation)
|
|
281
|
+
// This ensures stable coins are ALWAYS in the cache and available instantly
|
|
282
|
+
const evmPubkey = pubkeys.find((p) => p.caip && p.caip.startsWith('eip155:'));
|
|
283
|
+
if (evmPubkey) {
|
|
284
|
+
const primaryAddress = evmPubkey.pubkey;
|
|
285
|
+
log.info(tag, `Fetching stable coins for EVM address: ${primaryAddress.substring(0, 10)}...`);
|
|
286
|
+
const stableCoins = await this.fetchStableCoins(primaryAddress, pubkeys);
|
|
287
|
+
// Add stable coins to charts (deduplicate by CAIP+pubkey)
|
|
288
|
+
for (const stableCoin of stableCoins) {
|
|
289
|
+
const isDuplicate = charts.some(c => c.caip === stableCoin.caip && c.pubkey === stableCoin.pubkey);
|
|
290
|
+
if (!isDuplicate) {
|
|
291
|
+
charts.push(stableCoin);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
log.info(tag, `Added ${stableCoins.length} stable coins to portfolio`);
|
|
295
|
+
}
|
|
296
|
+
// Calculate total value
|
|
297
|
+
const totalValueUsd = charts.reduce((sum, c) => sum + c.valueUsd, 0);
|
|
171
298
|
const fetchTime = Date.now() - startTime;
|
|
172
|
-
log.info(tag, `✅ Fetched portfolio: ${
|
|
299
|
+
log.info(tag, `✅ Fetched portfolio: ${charts.length} assets (including stable coins), $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
|
|
173
300
|
return {
|
|
174
301
|
pubkeys,
|
|
175
|
-
charts
|
|
302
|
+
charts,
|
|
176
303
|
totalValueUsd,
|
|
177
304
|
timestamp: Date.now()
|
|
178
305
|
};
|
package/package.json
CHANGED
|
@@ -129,7 +129,12 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
129
129
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
130
130
|
|
|
131
131
|
try {
|
|
132
|
-
|
|
132
|
+
// PHASE 2: Normalize CAIP to lowercase at entry point for consistency
|
|
133
|
+
const normalizedParams = {
|
|
134
|
+
caip: params.caip.toLowerCase(),
|
|
135
|
+
pubkey: params.pubkey
|
|
136
|
+
};
|
|
137
|
+
const { caip, pubkey } = normalizedParams;
|
|
133
138
|
|
|
134
139
|
// Log sanitized pubkey for debugging
|
|
135
140
|
log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
|
|
@@ -124,11 +124,150 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
124
124
|
return Math.abs(hash).toString(36);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Fetch stable coin balances for EVM addresses
|
|
129
|
+
* INTEGRATED: Part of portfolio background refresh, no direct blockchain queries from endpoints
|
|
130
|
+
*/
|
|
131
|
+
private async fetchStableCoins(
|
|
132
|
+
primaryAddress: string,
|
|
133
|
+
pubkeys: Array<{ pubkey: string; caip: string }>
|
|
134
|
+
): Promise<ChartData[]> {
|
|
135
|
+
const tag = this.TAG + 'fetchStableCoins | ';
|
|
136
|
+
const stableCharts: ChartData[] = [];
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// Stable coins configuration (same as GetStableCoins endpoint)
|
|
140
|
+
const STABLE_COINS: Record<string, Array<{
|
|
141
|
+
symbol: string;
|
|
142
|
+
name: string;
|
|
143
|
+
address: string;
|
|
144
|
+
decimals: number;
|
|
145
|
+
coingeckoId: string;
|
|
146
|
+
}>> = {
|
|
147
|
+
'eip155:1': [
|
|
148
|
+
{ symbol: 'USDC', name: 'USD Coin', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
|
|
149
|
+
{ symbol: 'USDT', name: 'Tether USD', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
|
|
150
|
+
{ symbol: 'LINK', name: 'Chainlink', address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
|
|
151
|
+
],
|
|
152
|
+
'eip155:137': [
|
|
153
|
+
{ symbol: 'USDC', name: 'USD Coin (Polygon)', address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', decimals: 6, coingeckoId: 'usd-coin' },
|
|
154
|
+
{ symbol: 'USDT', name: 'Tether USD (Polygon)', address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', decimals: 6, coingeckoId: 'tether' },
|
|
155
|
+
],
|
|
156
|
+
'eip155:8453': [
|
|
157
|
+
{ symbol: 'USDC', name: 'USD Coin (Base)', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, coingeckoId: 'usd-coin' },
|
|
158
|
+
],
|
|
159
|
+
'eip155:56': [
|
|
160
|
+
{ symbol: 'USDT', name: 'Tether USD (BSC)', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
|
|
161
|
+
{ symbol: 'USDC', name: 'USD Coin (BSC)', address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Extract EVM networks from pubkeys
|
|
166
|
+
const evmNetworks = [...new Set(
|
|
167
|
+
pubkeys
|
|
168
|
+
.filter(p => p.caip && p.caip.startsWith('eip155:'))
|
|
169
|
+
.map(p => p.caip.split('/')[0])
|
|
170
|
+
)];
|
|
171
|
+
|
|
172
|
+
if (evmNetworks.length === 0) {
|
|
173
|
+
log.debug(tag, 'No EVM networks found, skipping stable coin fetch');
|
|
174
|
+
return stableCharts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log.info(tag, `Fetching stable coins for ${evmNetworks.length} EVM networks`);
|
|
178
|
+
|
|
179
|
+
// Initialize eth-network module for token balance queries
|
|
180
|
+
let ethNetwork: any;
|
|
181
|
+
try {
|
|
182
|
+
ethNetwork = require('@pioneer-platform/eth-network');
|
|
183
|
+
await ethNetwork.init({});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
log.error(tag, 'Failed to initialize eth-network:', error);
|
|
186
|
+
return stableCharts;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fetch stable coins for each network
|
|
190
|
+
for (const networkId of evmNetworks) {
|
|
191
|
+
const stableCoins = STABLE_COINS[networkId];
|
|
192
|
+
if (!stableCoins || stableCoins.length === 0) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
log.debug(tag, `Checking ${stableCoins.length} stable coins on ${networkId}`);
|
|
197
|
+
|
|
198
|
+
// Fetch all token balances for this network in parallel
|
|
199
|
+
const tokenPromises = stableCoins.map(async (tokenConfig) => {
|
|
200
|
+
try {
|
|
201
|
+
const balance = await ethNetwork.getBalanceTokenByNetwork(
|
|
202
|
+
networkId,
|
|
203
|
+
primaryAddress,
|
|
204
|
+
tokenConfig.address
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const balanceNum = parseFloat(balance);
|
|
208
|
+
|
|
209
|
+
// Skip zero balances
|
|
210
|
+
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Get price from markets module
|
|
215
|
+
let priceUsd = 0;
|
|
216
|
+
try {
|
|
217
|
+
const tokenCaip = `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`;
|
|
218
|
+
priceUsd = await this.marketsModule.getAssetPriceByCaip(tokenCaip);
|
|
219
|
+
if (isNaN(priceUsd) || priceUsd < 0) {
|
|
220
|
+
priceUsd = 0;
|
|
221
|
+
}
|
|
222
|
+
} catch (priceError) {
|
|
223
|
+
log.warn(tag, `Error fetching price for ${tokenConfig.symbol}:`, priceError);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const valueUsd = balanceNum * priceUsd;
|
|
227
|
+
|
|
228
|
+
const chartData: ChartData = {
|
|
229
|
+
caip: `${networkId}/erc20:${tokenConfig.address.toLowerCase()}`,
|
|
230
|
+
pubkey: primaryAddress,
|
|
231
|
+
networkId,
|
|
232
|
+
symbol: tokenConfig.symbol,
|
|
233
|
+
name: tokenConfig.name,
|
|
234
|
+
balance: balance,
|
|
235
|
+
priceUsd,
|
|
236
|
+
valueUsd,
|
|
237
|
+
icon: '',
|
|
238
|
+
type: 'token',
|
|
239
|
+
decimal: tokenConfig.decimals
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
log.info(tag, `✅ ${tokenConfig.symbol}: ${balance} ($${valueUsd.toFixed(2)})`);
|
|
243
|
+
return chartData;
|
|
244
|
+
|
|
245
|
+
} catch (error) {
|
|
246
|
+
log.error(tag, `Error fetching ${tokenConfig.symbol}:`, error);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const results = await Promise.all(tokenPromises);
|
|
252
|
+
const validTokens = results.filter((t): t is ChartData => t !== null);
|
|
253
|
+
stableCharts.push(...validTokens);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
log.info(tag, `Fetched ${stableCharts.length} stable coin balances`);
|
|
257
|
+
return stableCharts;
|
|
258
|
+
|
|
259
|
+
} catch (error) {
|
|
260
|
+
log.error(tag, 'Error fetching stable coins:', error);
|
|
261
|
+
return stableCharts;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
127
265
|
/**
|
|
128
266
|
* Fetch portfolio from blockchain APIs
|
|
129
|
-
*
|
|
267
|
+
*
|
|
130
268
|
* This is the SLOW operation that happens in the background
|
|
131
269
|
* It fetches balances for all pubkeys and enriches with pricing
|
|
270
|
+
* INTEGRATED: Now includes stable coin balances automatically
|
|
132
271
|
*/
|
|
133
272
|
protected async fetchFromSource(params: Record<string, any>): Promise<PortfolioData> {
|
|
134
273
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
@@ -216,17 +355,42 @@ export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
|
216
355
|
});
|
|
217
356
|
|
|
218
357
|
const results = await Promise.all(balancePromises);
|
|
219
|
-
|
|
220
|
-
// Filter out nulls
|
|
358
|
+
|
|
359
|
+
// Filter out nulls
|
|
221
360
|
const validCharts = results.filter((c): c is ChartData => c !== null);
|
|
222
|
-
|
|
361
|
+
charts.push(...validCharts);
|
|
362
|
+
|
|
363
|
+
// INTEGRATED: Fetch stable coins for EVM addresses (background operation)
|
|
364
|
+
// This ensures stable coins are ALWAYS in the cache and available instantly
|
|
365
|
+
const evmPubkey = pubkeys.find(
|
|
366
|
+
(p: any) => p.caip && p.caip.startsWith('eip155:')
|
|
367
|
+
);
|
|
368
|
+
if (evmPubkey) {
|
|
369
|
+
const primaryAddress = evmPubkey.pubkey;
|
|
370
|
+
log.info(tag, `Fetching stable coins for EVM address: ${primaryAddress.substring(0, 10)}...`);
|
|
371
|
+
const stableCoins = await this.fetchStableCoins(primaryAddress, pubkeys);
|
|
372
|
+
|
|
373
|
+
// Add stable coins to charts (deduplicate by CAIP+pubkey)
|
|
374
|
+
for (const stableCoin of stableCoins) {
|
|
375
|
+
const isDuplicate = charts.some(c =>
|
|
376
|
+
c.caip === stableCoin.caip && c.pubkey === stableCoin.pubkey
|
|
377
|
+
);
|
|
378
|
+
if (!isDuplicate) {
|
|
379
|
+
charts.push(stableCoin);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
log.info(tag, `Added ${stableCoins.length} stable coins to portfolio`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Calculate total value
|
|
386
|
+
const totalValueUsd = charts.reduce((sum, c) => sum + c.valueUsd, 0);
|
|
223
387
|
|
|
224
388
|
const fetchTime = Date.now() - startTime;
|
|
225
|
-
log.info(tag, `✅ Fetched portfolio: ${
|
|
389
|
+
log.info(tag, `✅ Fetched portfolio: ${charts.length} assets (including stable coins), $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
|
|
226
390
|
|
|
227
391
|
return {
|
|
228
392
|
pubkeys,
|
|
229
|
-
charts
|
|
393
|
+
charts,
|
|
230
394
|
totalValueUsd,
|
|
231
395
|
timestamp: Date.now()
|
|
232
396
|
};
|
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
BalanceCache - Balance-specific cache implementation
|
|
3
|
-
|
|
4
|
-
Extends BaseCache with balance-specific logic.
|
|
5
|
-
All common logic is inherited from BaseCache.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { BaseCache } from '../core/base-cache';
|
|
9
|
-
import type { CacheConfig } from '../types';
|
|
10
|
-
import crypto from 'crypto';
|
|
11
|
-
|
|
12
|
-
const log = require('@pioneer-platform/loggerdog')();
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Hash pubkey/xpub for privacy-protecting cache keys
|
|
16
|
-
* Uses SHA-256 for fast, deterministic, one-way hashing
|
|
17
|
-
*/
|
|
18
|
-
function hashPubkey(pubkey: string, salt: string = ''): string {
|
|
19
|
-
// Detect if this is an xpub (don't lowercase) or regular address (lowercase)
|
|
20
|
-
const isXpub = pubkey.match(/^[xyz]pub[1-9A-HJ-NP-Za-km-z]{100,}/);
|
|
21
|
-
const normalized = isXpub ? pubkey.trim() : pubkey.trim().toLowerCase();
|
|
22
|
-
|
|
23
|
-
const hash = crypto.createHash('sha256');
|
|
24
|
-
hash.update(normalized);
|
|
25
|
-
if (salt) hash.update(salt);
|
|
26
|
-
|
|
27
|
-
// Return first 128 bits (32 hex chars) for shorter Redis keys
|
|
28
|
-
return hash.digest('hex').substring(0, 32);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Sanitize pubkey for safe logging
|
|
33
|
-
*/
|
|
34
|
-
function sanitizePubkey(pubkey: string): string {
|
|
35
|
-
if (!pubkey || pubkey.length < 12) return '[invalid]';
|
|
36
|
-
|
|
37
|
-
// Check if xpub (show first 8, last 8)
|
|
38
|
-
if (pubkey.match(/^[xyz]pub/)) {
|
|
39
|
-
return `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Regular address (show first 6, last 4)
|
|
43
|
-
return `${pubkey.substring(0, 6)}...${pubkey.substring(pubkey.length - 4)}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Balance data structure
|
|
48
|
-
*/
|
|
49
|
-
export interface BalanceData {
|
|
50
|
-
caip: string;
|
|
51
|
-
pubkey: string;
|
|
52
|
-
balance: string;
|
|
53
|
-
priceUsd?: string;
|
|
54
|
-
valueUsd?: string;
|
|
55
|
-
symbol?: string;
|
|
56
|
-
name?: string;
|
|
57
|
-
networkId?: string;
|
|
58
|
-
type?: string; // ✅ PHASE 1: Asset type (native, token, unknown)
|
|
59
|
-
isNative?: boolean; // ✅ PHASE 1: Whether asset is native to chain (backward compat)
|
|
60
|
-
fetchedAt?: number; // Unix timestamp when balance was fetched
|
|
61
|
-
fetchedAtISO?: string; // ISO 8601 timestamp
|
|
62
|
-
fetchedAt?: number; // Unix timestamp when balance was fetched from blockchain
|
|
63
|
-
fetchedAtISO?: string; // ISO 8601 string for display (e.g., "2025-01-11T12:34:56.789Z")
|
|
64
|
-
dataSource?: string; // Data source description (e.g., "Bitcoin Blockbook", "Ethereum RPC")
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* BalanceCache - Caches blockchain balance data
|
|
69
|
-
*/
|
|
70
|
-
export class BalanceCache extends BaseCache<BalanceData> {
|
|
71
|
-
private balanceModule: any;
|
|
72
|
-
|
|
73
|
-
constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>) {
|
|
74
|
-
const defaultConfig: CacheConfig = {
|
|
75
|
-
name: 'balance',
|
|
76
|
-
keyPrefix: 'balance_v2:',
|
|
77
|
-
ttl: 0, // Ignored when enableTTL: false
|
|
78
|
-
staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
|
|
79
|
-
enableTTL: false, // NEVER EXPIRE - data persists forever
|
|
80
|
-
queueName: 'cache-refresh',
|
|
81
|
-
enableQueue: true,
|
|
82
|
-
maxRetries: 3,
|
|
83
|
-
retryDelay: 10000,
|
|
84
|
-
blockOnMiss: true, // Wait for fresh data on first request - users need real balances!
|
|
85
|
-
enableLegacyFallback: true,
|
|
86
|
-
defaultValue: {
|
|
87
|
-
caip: '',
|
|
88
|
-
pubkey: '',
|
|
89
|
-
balance: '0',
|
|
90
|
-
},
|
|
91
|
-
maxConcurrentJobs: 10,
|
|
92
|
-
apiTimeout: 15000,
|
|
93
|
-
logCacheHits: false,
|
|
94
|
-
logCacheMisses: true,
|
|
95
|
-
logRefreshJobs: true
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
super(redis, { ...defaultConfig, ...config });
|
|
99
|
-
this.balanceModule = balanceModule;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Build Redis key for balance data
|
|
104
|
-
* Format: balance_v2:caip:hashedPubkey
|
|
105
|
-
*
|
|
106
|
-
* PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
|
|
107
|
-
* - One-way: cannot recover pubkey from hash
|
|
108
|
-
* - Deterministic: same pubkey always produces same key
|
|
109
|
-
* - Fast: ~0.02ms per hash
|
|
110
|
-
*/
|
|
111
|
-
protected buildKey(params: Record<string, any>): string {
|
|
112
|
-
const { caip, pubkey } = params;
|
|
113
|
-
if (!caip || !pubkey) {
|
|
114
|
-
throw new Error('BalanceCache.buildKey: caip and pubkey required');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const normalizedCaip = caip.toLowerCase();
|
|
118
|
-
|
|
119
|
-
// Hash pubkey for privacy protection
|
|
120
|
-
const hashedPubkey = hashPubkey(pubkey, caip);
|
|
121
|
-
|
|
122
|
-
return `${this.config.keyPrefix}${normalizedCaip}:${hashedPubkey}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Fetch balance from blockchain via balance module
|
|
127
|
-
*/
|
|
128
|
-
protected async fetchFromSource(params: Record<string, any>): Promise<BalanceData> {
|
|
129
|
-
const tag = this.TAG + 'fetchFromSource | ';
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const { caip, pubkey } = params;
|
|
133
|
-
|
|
134
|
-
// Log sanitized pubkey for debugging
|
|
135
|
-
log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
|
|
136
|
-
|
|
137
|
-
// Fetch balance using balance module (still uses real pubkey)
|
|
138
|
-
const asset = { caip };
|
|
139
|
-
const owner = { pubkey };
|
|
140
|
-
const balanceInfo = await this.balanceModule.getBalance(asset, owner);
|
|
141
|
-
|
|
142
|
-
if (!balanceInfo || !balanceInfo.balance) {
|
|
143
|
-
log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
|
|
144
|
-
const now = Date.now();
|
|
145
|
-
return {
|
|
146
|
-
caip,
|
|
147
|
-
pubkey,
|
|
148
|
-
balance: '0',
|
|
149
|
-
fetchedAt: now,
|
|
150
|
-
fetchedAtISO: new Date(now).toISOString()
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Get asset metadata
|
|
155
|
-
const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
|
|
156
|
-
const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
|
|
157
|
-
|
|
158
|
-
const assetInfo = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()] || {};
|
|
159
|
-
const networkId = caipToNetworkId(caip);
|
|
160
|
-
|
|
161
|
-
// Use actual node URL if available, otherwise construct generic description
|
|
162
|
-
let dataSource = 'Unknown';
|
|
163
|
-
if (balanceInfo.nodeUrl) {
|
|
164
|
-
// Actual node URL returned from network module
|
|
165
|
-
dataSource = balanceInfo.nodeUrl;
|
|
166
|
-
} else {
|
|
167
|
-
// Fallback to generic description if nodeUrl not available
|
|
168
|
-
const chainName = assetInfo.name || 'Unknown Chain';
|
|
169
|
-
if (caip.startsWith('bip122:')) {
|
|
170
|
-
dataSource = `${chainName} (Blockbook API)`;
|
|
171
|
-
} else if (caip.startsWith('eip155:')) {
|
|
172
|
-
const isToken = caip.includes('/erc20:');
|
|
173
|
-
dataSource = isToken ? `${chainName} (RPC - ERC20)` : `${chainName} (RPC)`;
|
|
174
|
-
} else if (caip.startsWith('cosmos:')) {
|
|
175
|
-
dataSource = `${chainName} (LCD API)`;
|
|
176
|
-
} else if (caip.startsWith('ripple:')) {
|
|
177
|
-
dataSource = `${chainName} (JSON-RPC)`;
|
|
178
|
-
} else {
|
|
179
|
-
dataSource = `${chainName} (API)`;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Capture fetch timestamp for freshness tracking
|
|
184
|
-
const now = Date.now();
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
caip,
|
|
188
|
-
pubkey,
|
|
189
|
-
balance: balanceInfo.balance,
|
|
190
|
-
symbol: assetInfo.symbol,
|
|
191
|
-
name: assetInfo.name,
|
|
192
|
-
networkId,
|
|
193
|
-
fetchedAt: now,
|
|
194
|
-
fetchedAtISO: new Date(now).toISOString(),
|
|
195
|
-
dataSource
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
} catch (error) {
|
|
199
|
-
log.error(tag, 'Error fetching balance:', error);
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Try to get balance from legacy cache format
|
|
206
|
-
*/
|
|
207
|
-
protected async getLegacyCached(params: Record<string, any>): Promise<BalanceData | null> {
|
|
208
|
-
const tag = this.TAG + 'getLegacyCached | ';
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
const { caip, pubkey } = params;
|
|
212
|
-
const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
|
|
213
|
-
const networkId = caipToNetworkId(caip);
|
|
214
|
-
|
|
215
|
-
// Try legacy format: cache:balance:pubkey:asset
|
|
216
|
-
const legacyKey = `cache:balance:${pubkey}:${networkId}`;
|
|
217
|
-
const legacyData = await this.redis.get(legacyKey);
|
|
218
|
-
|
|
219
|
-
if (legacyData) {
|
|
220
|
-
const parsed = JSON.parse(legacyData);
|
|
221
|
-
if (parsed.balance !== undefined) {
|
|
222
|
-
return {
|
|
223
|
-
caip,
|
|
224
|
-
pubkey,
|
|
225
|
-
balance: typeof parsed.balance === 'string' ? parsed.balance : String(parsed.balance)
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return null;
|
|
231
|
-
|
|
232
|
-
} catch (error) {
|
|
233
|
-
log.error(tag, 'Error getting legacy cached balance:', error);
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Get balance for a specific asset and pubkey
|
|
240
|
-
* Convenience method that wraps base get()
|
|
241
|
-
*/
|
|
242
|
-
async getBalance(caip: string, pubkey: string, waitForFresh?: boolean): Promise<BalanceData> {
|
|
243
|
-
const result = await this.get({ caip, pubkey }, waitForFresh);
|
|
244
|
-
return result.value || this.config.defaultValue;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Get balances for multiple assets (batch operation)
|
|
249
|
-
* OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
|
|
250
|
-
*/
|
|
251
|
-
async getBatchBalances(items: Array<{ caip: string; pubkey: string }>, waitForFresh?: boolean): Promise<BalanceData[]> {
|
|
252
|
-
const tag = this.TAG + 'getBatchBalances | ';
|
|
253
|
-
const startTime = Date.now();
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
|
|
257
|
-
|
|
258
|
-
// Build all Redis keys
|
|
259
|
-
const keys = items.map(item => this.buildKey({ caip: item.caip, pubkey: item.pubkey }));
|
|
260
|
-
|
|
261
|
-
// PERF: Use MGET to fetch all keys in ONE Redis round-trip
|
|
262
|
-
const cachedValues = await this.redis.mget(...keys);
|
|
263
|
-
|
|
264
|
-
// Process results
|
|
265
|
-
const results: BalanceData[] = [];
|
|
266
|
-
const missedItems: Array<{ caip: string; pubkey: string; index: number }> = [];
|
|
267
|
-
|
|
268
|
-
for (let i = 0; i < items.length; i++) {
|
|
269
|
-
const item = items[i];
|
|
270
|
-
const cached = cachedValues[i];
|
|
271
|
-
|
|
272
|
-
if (cached) {
|
|
273
|
-
try {
|
|
274
|
-
const parsed = JSON.parse(cached);
|
|
275
|
-
if (parsed.value && parsed.value.caip && parsed.value.pubkey) {
|
|
276
|
-
results[i] = parsed.value;
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
} catch (e) {
|
|
280
|
-
log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Cache miss - record for fetching
|
|
285
|
-
missedItems.push({ ...item, index: i });
|
|
286
|
-
results[i] = this.config.defaultValue; // Placeholder
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const responseTime = Date.now() - startTime;
|
|
290
|
-
const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
|
|
291
|
-
log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
|
|
292
|
-
|
|
293
|
-
// If we have cache misses and blocking is enabled, fetch them
|
|
294
|
-
if (missedItems.length > 0) {
|
|
295
|
-
const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
|
|
296
|
-
|
|
297
|
-
if (shouldBlock) {
|
|
298
|
-
log.info(tag, `Fetching ${missedItems.length} cache misses...`);
|
|
299
|
-
const fetchStart = Date.now();
|
|
300
|
-
|
|
301
|
-
// Fetch all misses in parallel
|
|
302
|
-
const fetchPromises = missedItems.map(async (item) => {
|
|
303
|
-
try {
|
|
304
|
-
// Use fetchFresh to ensure Redis is updated and requests are deduplicated
|
|
305
|
-
const freshData = await this.fetchFresh({ caip: item.caip, pubkey: item.pubkey });
|
|
306
|
-
results[item.index] = freshData;
|
|
307
|
-
} catch (error) {
|
|
308
|
-
log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
|
|
309
|
-
const now = Date.now();
|
|
310
|
-
results[item.index] = {
|
|
311
|
-
caip: item.caip,
|
|
312
|
-
pubkey: item.pubkey,
|
|
313
|
-
balance: '0',
|
|
314
|
-
fetchedAt: now,
|
|
315
|
-
fetchedAtISO: new Date(now).toISOString()
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
await Promise.all(fetchPromises);
|
|
321
|
-
log.info(tag, `Fetched ${missedItems.length} misses in ${Date.now() - fetchStart}ms`);
|
|
322
|
-
} else {
|
|
323
|
-
// Non-blocking: trigger background refresh for misses
|
|
324
|
-
missedItems.forEach(item => {
|
|
325
|
-
this.triggerAsyncRefresh({ caip: item.caip, pubkey: item.pubkey }, 'high');
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return results;
|
|
331
|
-
|
|
332
|
-
} catch (error) {
|
|
333
|
-
log.error(tag, 'Error in batch balance request:', error);
|
|
334
|
-
// Return defaults for all items
|
|
335
|
-
const now = Date.now();
|
|
336
|
-
return items.map(item => ({
|
|
337
|
-
caip: item.caip,
|
|
338
|
-
pubkey: item.pubkey,
|
|
339
|
-
balance: '0',
|
|
340
|
-
fetchedAt: now,
|
|
341
|
-
fetchedAtISO: new Date(now).toISOString()
|
|
342
|
-
}));
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|