@pioneer-platform/pioneer-sdk 8.15.31 → 8.15.33

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.
@@ -23,7 +23,7 @@ export function buildDashboardFromBalances(balances: any[], blockchains: string[
23
23
  networkPercentages: [],
24
24
  };
25
25
 
26
- let totalPortfolioValue = 0;
26
+ let totalPortfolioValueCents = 0; // Use cents for integer math
27
27
  const networksTemp: {
28
28
  networkId: string;
29
29
  totalValueUsd: number;
@@ -46,73 +46,81 @@ export function buildDashboardFromBalances(balances: any[], blockchains: string[
46
46
  });
47
47
 
48
48
  // Deduplicate balances based on caip + pubkey combination
49
+ // NOTE: Each pubkey (xpub/ypub/zpub) represents a DIFFERENT address space with potentially
50
+ // different balances - they are NOT duplicates! Keep all of them.
49
51
  const balanceMap = new Map();
50
52
 
51
- // Special handling for Bitcoin to work around API bug
52
- const isBitcoin = blockchain.includes('bip122:000000000019d6689c085ae165831e93');
53
- if (isBitcoin) {
54
- // Group Bitcoin balances by value to detect duplicates
55
- const bitcoinByValue = new Map();
56
- filteredBalances.forEach((balance) => {
57
- const valueKey = `${balance.balance}_${balance.valueUsd}`;
58
- if (!bitcoinByValue.has(valueKey)) {
59
- bitcoinByValue.set(valueKey, []);
60
- }
61
- bitcoinByValue.get(valueKey).push(balance);
62
- });
63
-
64
- // Check if all three address types have the same non-zero balance (API bug)
65
- for (const [valueKey, balances] of bitcoinByValue.entries()) {
66
- if (balances.length === 3 && parseFloat(balances[0].valueUsd || '0') > 0) {
67
- // Keep only the xpub (or first one if no xpub)
68
- const xpubBalance = balances.find((b) => b.pubkey?.startsWith('xpub')) || balances[0];
69
- const key = `${xpubBalance.caip}_${xpubBalance.pubkey || 'default'}`;
70
- balanceMap.set(key, xpubBalance);
71
- } else {
72
- // Add all balances normally
73
- balances.forEach((balance) => {
74
- const key = `${balance.caip}_${balance.pubkey || 'default'}`;
75
- balanceMap.set(key, balance);
76
- });
77
- }
53
+ // Standard deduplication for all networks (including Bitcoin)
54
+ // Only deduplicate if BOTH caip AND pubkey are identical (true duplicates)
55
+ filteredBalances.forEach((balance) => {
56
+ const key = `${balance.caip}_${balance.pubkey || 'default'}`;
57
+
58
+ // Only keep the first occurrence or the one with higher value for TRUE duplicates
59
+ if (
60
+ !balanceMap.has(key) ||
61
+ parseFloat(balance.valueUsd || '0') > parseFloat(balanceMap.get(key).valueUsd || '0')
62
+ ) {
63
+ balanceMap.set(key, balance);
64
+ } else {
65
+ // DEBUG: Log when we skip a balance due to deduplication
66
+ const existing = balanceMap.get(key);
67
+ const existingValue = parseFloat(existing.valueUsd || '0');
68
+ const newValue = parseFloat(balance.valueUsd || '0');
69
+
70
+ // Always log duplicates being skipped (removed the 0.01 threshold)
71
+ console.log(`⚠️ [BUILD-DASHBOARD] ${blockchain} dedup skipped (same caip+pubkey):`, {
72
+ caip: balance.caip,
73
+ pubkey: (balance.pubkey || 'default').substring(0, 20),
74
+ existing: existingValue,
75
+ new: newValue,
76
+ diff: Math.abs(existingValue - newValue)
77
+ });
78
78
  }
79
- } else {
80
- // Standard deduplication for non-Bitcoin networks
81
- filteredBalances.forEach((balance) => {
82
- const key = `${balance.caip}_${balance.pubkey || 'default'}`;
83
- // Only keep the first occurrence or the one with higher value
84
- if (
85
- !balanceMap.has(key) ||
86
- parseFloat(balance.valueUsd || '0') > parseFloat(balanceMap.get(key).valueUsd || '0')
87
- ) {
88
- balanceMap.set(key, balance);
89
- }
90
- });
91
- }
79
+ });
92
80
 
93
81
  const networkBalances = Array.from(balanceMap.values());
94
82
 
95
83
  // Ensure we're working with numbers for calculations
96
- const networkTotal = networkBalances.reduce((sum, balance, idx) => {
97
- const valueUsd =
84
+ // Accumulate in cents (integer math) to avoid floating point errors
85
+ const networkTotalCents = networkBalances.reduce((sumCents, balance, idx) => {
86
+ // Normalize valueUsd to number type
87
+ let valueUsd =
98
88
  typeof balance.valueUsd === 'string'
99
89
  ? parseFloat(balance.valueUsd)
100
90
  : balance.valueUsd || 0;
101
91
 
92
+ // Check for NaN values which can cause calculation errors
93
+ if (isNaN(valueUsd)) {
94
+ console.warn(`⚠️ [BUILD-DASHBOARD] NaN value detected for ${balance.caip}:`, balance.valueUsd);
95
+ return sumCents;
96
+ }
97
+
98
+ // Normalize the balance object to always use number type for valueUsd
99
+ balance.valueUsd = valueUsd;
100
+
101
+ // Convert to cents with higher precision to reduce rounding errors
102
+ // Use Math.round with extended precision, then scale back
103
+ const valueCents = Math.round(valueUsd * 100 * 1000) / 1000;
104
+
102
105
  // Debug first few balances for each network
103
106
  if (idx < 2) {
104
107
  console.log(`💰 [BUILD-DASHBOARD] ${blockchain} balance #${idx}:`, {
105
108
  caip: balance.caip,
106
109
  balance: balance.balance,
107
110
  valueUsd: balance.valueUsd,
111
+ valueUsdType: typeof balance.valueUsd,
108
112
  parsedValueUsd: valueUsd,
109
- runningSum: sum + valueUsd
113
+ valueCents: valueCents,
114
+ runningSumCents: sumCents + valueCents
110
115
  });
111
116
  }
112
117
 
113
- return sum + valueUsd;
118
+ return sumCents + valueCents;
114
119
  }, 0);
115
120
 
121
+ // Convert back to dollars
122
+ const networkTotal = networkTotalCents / 100;
123
+
116
124
  console.log(`💰 [BUILD-DASHBOARD] ${blockchain} totals:`, {
117
125
  balancesCount: networkBalances.length,
118
126
  networkTotal,
@@ -149,9 +157,13 @@ export function buildDashboardFromBalances(balances: any[], blockchains: string[
149
157
  totalNativeBalance,
150
158
  });
151
159
 
152
- totalPortfolioValue += networkTotal;
160
+ // Add to total using cents (integer math) to avoid floating point errors
161
+ totalPortfolioValueCents += networkTotalCents;
153
162
  }
154
163
 
164
+ // Convert total from cents to dollars
165
+ const totalPortfolioValue = totalPortfolioValueCents / 100;
166
+
155
167
  // Sort networks by USD value and assign to dashboard
156
168
  dashboardData.networks = networksTemp.sort((a, b) => b.totalValueUsd - a.totalValueUsd);
157
169
  dashboardData.totalValueUsd = totalPortfolioValue;
@@ -172,5 +184,33 @@ export function buildDashboardFromBalances(balances: any[], blockchains: string[
172
184
  dashboardData.networks.length
173
185
  } networks, $${totalPortfolioValue.toFixed(2)} total`,
174
186
  );
187
+
188
+ // ===== BALANCE RECONCILIATION CHECK =====
189
+ // Verify that dashboard total matches the sum of all input balances
190
+ const inputBalancesTotal = balances.reduce((sum, b) => {
191
+ const value = typeof b.valueUsd === 'string' ? parseFloat(b.valueUsd) : (b.valueUsd || 0);
192
+ return sum + (isNaN(value) ? 0 : value);
193
+ }, 0);
194
+
195
+ const difference = Math.abs(inputBalancesTotal - totalPortfolioValue);
196
+
197
+ if (difference > 0.01) {
198
+ console.error(`🚨 [BUILD-DASHBOARD] BALANCE MISMATCH DETECTED!`);
199
+ console.error(` Input balances total: $${inputBalancesTotal.toFixed(2)} USD`);
200
+ console.error(` Dashboard total: $${totalPortfolioValue.toFixed(2)} USD`);
201
+ console.error(` Difference: $${difference.toFixed(2)} USD`);
202
+ console.error(` This indicates lost balances during dashboard aggregation!`);
203
+
204
+ // Log per-network breakdown to help debug
205
+ console.error(` Network breakdown:`);
206
+ dashboardData.networks.forEach(network => {
207
+ console.error(` ${network.networkId}: $${network.totalValueUsd.toFixed(2)}`);
208
+ });
209
+ } else if (difference > 0.001) {
210
+ console.warn(`⚠️ [BUILD-DASHBOARD] Minor balance rounding difference: $${difference.toFixed(4)} USD`);
211
+ } else {
212
+ console.log(`✅ [BUILD-DASHBOARD] Balance reconciliation passed (diff: $${difference.toFixed(4)})`);
213
+ }
214
+
175
215
  return dashboardData;
176
216
  }
@@ -3,6 +3,100 @@
3
3
  import { logger as log } from './logger.js';
4
4
  const TAG = ' | portfolio-helpers | ';
5
5
 
6
+ // CAIP-based explorer URL mapping
7
+ const EXPLORER_BASE_URLS: Record<string, { address: string; tx: string }> = {
8
+ // Ethereum mainnet
9
+ 'eip155:1': {
10
+ address: 'https://etherscan.io/address/',
11
+ tx: 'https://etherscan.io/tx/'
12
+ },
13
+ // Polygon
14
+ 'eip155:137': {
15
+ address: 'https://polygonscan.com/address/',
16
+ tx: 'https://polygonscan.com/tx/'
17
+ },
18
+ // Base
19
+ 'eip155:8453': {
20
+ address: 'https://basescan.org/address/',
21
+ tx: 'https://basescan.org/tx/'
22
+ },
23
+ // BSC
24
+ 'eip155:56': {
25
+ address: 'https://bscscan.com/address/',
26
+ tx: 'https://bscscan.com/tx/'
27
+ },
28
+ // Monad
29
+ 'eip155:41454': {
30
+ address: 'https://explorer.monad.xyz/address/',
31
+ tx: 'https://explorer.monad.xyz/tx/'
32
+ },
33
+ // Hyperliquid
34
+ 'eip155:2868': {
35
+ address: 'https://app.hyperliquid.xyz/explorer/address/',
36
+ tx: 'https://app.hyperliquid.xyz/explorer/tx/'
37
+ },
38
+ // Bitcoin
39
+ 'bip122:000000000019d6689c085ae165831e93': {
40
+ address: 'https://blockstream.info/address/',
41
+ tx: 'https://blockstream.info/tx/'
42
+ },
43
+ // Litecoin
44
+ 'bip122:12a765e31ffd4059bada1e25190f6e98': {
45
+ address: 'https://blockchair.com/litecoin/address/',
46
+ tx: 'https://blockchair.com/litecoin/transaction/'
47
+ },
48
+ // Dogecoin
49
+ 'bip122:00000000001a91e3dace36e2be3bf030': {
50
+ address: 'https://dogechain.info/address/',
51
+ tx: 'https://dogechain.info/tx/'
52
+ },
53
+ // Bitcoin Cash
54
+ 'bip122:000000000000000000651ef99cb9fcbe': {
55
+ address: 'https://blockchair.com/bitcoin-cash/address/',
56
+ tx: 'https://blockchair.com/bitcoin-cash/transaction/'
57
+ },
58
+ // Dash
59
+ 'bip122:000007d91d1254d60e2dd1ae58038307': {
60
+ address: 'https://chainz.cryptoid.info/dash/address.dws?',
61
+ tx: 'https://chainz.cryptoid.info/dash/tx.dws?'
62
+ },
63
+ // DigiByte
64
+ 'bip122:4da631f2ac1bed857bd968c67c913978': {
65
+ address: 'https://digiexplorer.info/address/',
66
+ tx: 'https://digiexplorer.info/tx/'
67
+ },
68
+ // Zcash
69
+ 'bip122:00040fe8ec8471911baa1db1266ea15d': {
70
+ address: 'https://explorer.zcha.in/accounts/',
71
+ tx: 'https://explorer.zcha.in/transactions/'
72
+ },
73
+ // Cosmos Hub
74
+ 'cosmos:cosmoshub-4': {
75
+ address: 'https://www.mintscan.io/cosmos/address/',
76
+ tx: 'https://www.mintscan.io/cosmos/tx/'
77
+ },
78
+ // Osmosis
79
+ 'cosmos:osmosis-1': {
80
+ address: 'https://www.mintscan.io/osmosis/address/',
81
+ tx: 'https://www.mintscan.io/osmosis/tx/'
82
+ },
83
+ // THORChain
84
+ 'cosmos:thorchain-mainnet-v1': {
85
+ address: 'https://thorchain.net/address/',
86
+ tx: 'https://thorchain.net/tx/'
87
+ },
88
+ // Maya Protocol
89
+ 'cosmos:mayachain-mainnet-v1': {
90
+ address: 'https://www.mayascan.org/address/',
91
+ tx: 'https://www.mayascan.org/tx/'
92
+ },
93
+ // Ripple
94
+ 'ripple:4109c6f2045fc7eff4cde8f9905d19c2': {
95
+ address: 'https://xrpscan.com/account/',
96
+ tx: 'https://xrpscan.com/tx/'
97
+ }
98
+ };
99
+
6
100
  export function isCacheDataValid(portfolioData: any): boolean {
7
101
  // Check if networks data is reasonable (should be < 50 networks, not thousands)
8
102
  if (!portfolioData.networks || !Array.isArray(portfolioData.networks)) {
@@ -220,14 +314,54 @@ export function enrichBalancesWithAssetInfo(
220
314
  continue;
221
315
  }
222
316
 
317
+ // ===== CRITICAL: Normalize valueUsd to NUMBER type =====
318
+ // Ensures consistent type throughout the SDK to prevent calculation errors
319
+ if (typeof balance.valueUsd === 'string') {
320
+ balance.valueUsd = parseFloat(balance.valueUsd) || 0;
321
+ } else if (typeof balance.valueUsd !== 'number') {
322
+ balance.valueUsd = 0;
323
+ }
324
+
325
+ // Also normalize balance amount to number
326
+ if (typeof balance.balance === 'string') {
327
+ balance.balance = balance.balance; // Keep as string for precision (e.g., "0.00000001")
328
+ }
329
+
330
+ // Normalize priceUsd to number as well
331
+ if (typeof balance.priceUsd === 'string') {
332
+ balance.priceUsd = parseFloat(balance.priceUsd) || 0;
333
+ } else if (typeof balance.priceUsd !== 'number') {
334
+ balance.priceUsd = 0;
335
+ }
336
+
337
+ // Get networkId for explorer lookup
338
+ const networkId = caipToNetworkId(balance.caip);
339
+ const explorerUrls = EXPLORER_BASE_URLS[networkId];
340
+
341
+ // Generate explorer links if we have a pubkey/address and explorer URLs for this network
342
+ const explorerAddressLink = (explorerUrls && balance.pubkey)
343
+ ? explorerUrls.address + balance.pubkey
344
+ : undefined;
345
+
346
+ const explorerTxLink = explorerUrls?.tx; // Base URL for txs, actual txid added later
347
+
223
348
  Object.assign(balance, assetInfo, {
224
349
  type: balance.type || assetInfo.type,
225
350
  isNative: balance.isNative ?? assetInfo.isNative,
226
- networkId: caipToNetworkId(balance.caip),
351
+ networkId,
227
352
  icon: assetInfo.icon || 'https://pioneers.dev/coins/etherum.png',
228
353
  identifier: `${balance.caip}:${balance.pubkey}`,
229
354
  updated: Date.now(),
230
355
  color: assetInfo.color,
356
+ // CRITICAL: Always use decimals from assetInfo if available
357
+ decimals: assetInfo.decimals || balance.decimals || 8,
358
+ // Add explorer links
359
+ explorerAddressLink,
360
+ explorerTxLink,
361
+ explorer: explorerUrls?.address, // Base explorer URL
362
+ // Ensure these are numbers (redundant but explicit)
363
+ valueUsd: balance.valueUsd,
364
+ priceUsd: balance.priceUsd,
231
365
  });
232
366
 
233
367
  enrichedBalances.push(balance);