Skip to content

Commit e3d58d3

Browse files
BitHighlanderclaude
andcommitted
fix: show Solana balance and SPL tokens in side panel
Three layered bugs were each sufficient to zero out Solana balance display; all three are fixed together because fixing any one alone leaves the chain still broken: 1. Wrong CAIP in shortListSymbolToCaip['SOL'] / shortListNameToCaip.solana. Previously pointed at wrapped-SOL SPL token (solana:.../solana:so111…, all lowercase). Now points at native SOL (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501), matching pioneer-caip's ChainToCaip and vault-v11's config. 2. Wrong Pioneer endpoint for Solana. /charts/portfolio returns empty {balances:[], tokens:[]} for any Solana pubkey (verified via direct curl). Vault-v11 uses /portfolio via pioneer.GetPortfolioBalances, which returns natives + SPL tokens in one flat array. Route Solana pubkeys to a third batch hitting /portfolio with the required key:public-* Authorization header; EVM/UTXO still go through /charts/portfolio for its richer Zapper/Unchained token data. 3. Response case mismatch. Pioneer echoes CAIP/networkId back in lowercase regardless of request casing. The side-panel asset list uses canonical mixed-case network IDs from ChainToNetworkId, so strict b.networkId === asset.networkId comparisons in Balances.tsx silently dropped every Solana entry. Rewrite Solana entries to canonical casing before they enter the merged balances array. Also eliminates a first-run race: the initial fetchBalancesFromPioneer() fired before prefetchSolanaPubkey() persisted the Solana pubkey, so run 1 never included Solana at all. Chain a forced refetch on prefetch resolution so the Solana entry lands in cachedBalances before the UI mounts. Verified against the live Pioneer API: for the exact address the client derives from the device at m/44'/501'/0'/0', /portfolio returns {native SOL + 3 SPL tokens}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1775ae commit e3d58d3

2 files changed

Lines changed: 80 additions & 18 deletions

File tree

chrome-extension/src/background/chainConfig.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const shortListSymbolToCaip: Record<string, string> = {
3939
AVAX: 'eip155:43114/slip44:60',
4040
BSC: 'eip155:56/slip44:60',
4141
BNB: 'eip155:56/slip44:60',
42-
SOL: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/solana:so11111111111111111111111111111111111111112',
42+
SOL: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
4343
};
4444

4545
export const shortListNameToCaip: Record<string, string> = {
@@ -60,7 +60,7 @@ export const shortListNameToCaip: Record<string, string> = {
6060
ripple: 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144',
6161
optimism: 'eip155:10/slip44:60',
6262
base: 'eip155:8453/slip44:60',
63-
solana: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/solana:so11111111111111111111111111111111111111112',
63+
solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
6464
};
6565

6666
// ---- bip32ToAddressNList ----

chrome-extension/src/background/index.ts

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -181,22 +181,25 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise<any[]> {
181181
console.log(`[fetchBalances] Sending ${pioneerPubkeys.length} pubkeys to Pioneer API`);
182182
console.log(`[fetchBalances] Sample pubkeys:`, pioneerPubkeys.slice(0, 3));
183183

184-
// Use /api/v1/charts/portfolio endpoint — blocking, includes Zapper/Unchained token fetch
185-
const portfolioUrl = `${PIONEER_API}/api/v1/charts/portfolio`;
186-
187-
// Split into address-based (EVM, Cosmos, etc.) and xpub-based (UTXO) batches
188-
// to prevent a bad xpub from poisoning the entire request
189-
const addressPubkeys = pioneerPubkeys.filter(
184+
// Two Pioneer endpoints, chosen per chain:
185+
// /charts/portfolio — EVM + UTXO + Cosmos (unauthenticated, returns tokens via Zapper/Unchained).
186+
// Returns EMPTY for Solana, so Solana pubkeys must NOT go here.
187+
// /portfolio — Solana (authenticated). Returns natives + SPL tokens in one flat array.
188+
const chartsPortfolioUrl = `${PIONEER_API}/api/v1/charts/portfolio`;
189+
190+
const solanaPubkeys = pioneerPubkeys.filter(p => p.caip.toLowerCase().startsWith('solana:'));
191+
const nonSolana = pioneerPubkeys.filter(p => !p.caip.toLowerCase().startsWith('solana:'));
192+
const addressPubkeys = nonSolana.filter(
190193
p => !p.pubkey.startsWith('xpub') && !p.pubkey.startsWith('zpub') && !p.pubkey.startsWith('ypub'),
191194
);
192-
const xpubPubkeys = pioneerPubkeys.filter(
195+
const xpubPubkeys = nonSolana.filter(
193196
p => p.pubkey.startsWith('xpub') || p.pubkey.startsWith('zpub') || p.pubkey.startsWith('ypub'),
194197
);
195198

196199
const fetchBatch = async (batch: typeof pioneerPubkeys, label: string) => {
197200
if (batch.length === 0) return { balances: [] as any[], tokens: [] as any[] };
198201
try {
199-
const response = await fetch(portfolioUrl, {
202+
const response = await fetch(chartsPortfolioUrl, {
200203
method: 'POST',
201204
headers: { 'Content-Type': 'application/json' },
202205
body: JSON.stringify({ pubkeys: batch, forceRefresh }),
@@ -217,14 +220,68 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise<any[]> {
217220
}
218221
};
219222

220-
// Fetch both batches in parallel
221-
const [addressResult, xpubResult] = await Promise.all([
223+
// Pioneer echoes CAIP/networkId back in lowercase even when sent mixed-case.
224+
// The side-panel asset list uses canonical mixed-case network IDs from
225+
// ChainToNetworkId, so strict b.networkId === asset.networkId matches fail.
226+
// Rewrite Solana entries back to canonical casing before returning.
227+
const SOL_NETWORK_CANONICAL = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
228+
const normalizeSolanaCasing = (entry: any) => {
229+
const caip = entry.caip || '';
230+
const netId = entry.networkId || '';
231+
if (netId.toLowerCase() === SOL_NETWORK_CANONICAL.toLowerCase()) {
232+
entry.networkId = SOL_NETWORK_CANONICAL;
233+
}
234+
if (caip.toLowerCase().startsWith(SOL_NETWORK_CANONICAL.toLowerCase() + '/')) {
235+
entry.caip = SOL_NETWORK_CANONICAL + caip.slice(SOL_NETWORK_CANONICAL.length);
236+
}
237+
return entry;
238+
};
239+
240+
const fetchSolanaBatch = async (batch: typeof pioneerPubkeys) => {
241+
if (batch.length === 0) return { balances: [] as any[], tokens: [] as any[] };
242+
try {
243+
const url = `${PIONEER_API}/api/v1/portfolio${forceRefresh ? '?forceRefresh=true' : ''}`;
244+
const response = await fetch(url, {
245+
method: 'POST',
246+
headers: {
247+
'Content-Type': 'application/json',
248+
// Pioneer requires a queryKey — any public key works for read-only lookups.
249+
Authorization: `key:public-${Date.now()}`,
250+
},
251+
body: JSON.stringify({ pubkeys: batch }),
252+
});
253+
if (!response.ok) {
254+
console.warn(`[fetchBalances] solana batch returned ${response.status}`);
255+
return { balances: [] as any[], tokens: [] as any[] };
256+
}
257+
const json = await response.json();
258+
const allEntries: any[] = json?.balances || [];
259+
const natives: any[] = [];
260+
const tokens: any[] = [];
261+
for (const raw of allEntries) {
262+
const entry = normalizeSolanaCasing({ ...raw });
263+
const caipPath = (entry.caip || '').split('/')[1] || '';
264+
const isToken =
265+
entry.type === 'token' || caipPath.startsWith('token:') || caipPath.startsWith('spl:');
266+
if (isToken) tokens.push(entry);
267+
else natives.push(entry);
268+
}
269+
console.log(`[fetchBalances] solana batch: ${natives.length} natives, ${tokens.length} tokens`);
270+
return { balances: natives, tokens };
271+
} catch (e: any) {
272+
console.warn('[fetchBalances] solana batch error:', e.message);
273+
return { balances: [] as any[], tokens: [] as any[] };
274+
}
275+
};
276+
277+
const [addressResult, xpubResult, solanaResult] = await Promise.all([
222278
fetchBatch(addressPubkeys, 'address'),
223279
fetchBatch(xpubPubkeys, 'xpub'),
280+
fetchSolanaBatch(solanaPubkeys),
224281
]);
225282

226-
const rawBalances: any[] = [...addressResult.balances, ...xpubResult.balances];
227-
const rawTokens: any[] = [...addressResult.tokens, ...xpubResult.tokens];
283+
const rawBalances: any[] = [...addressResult.balances, ...xpubResult.balances, ...solanaResult.balances];
284+
const rawTokens: any[] = [...addressResult.tokens, ...xpubResult.tokens, ...solanaResult.tokens];
228285

229286
if (rawBalances.length === 0 && rawTokens.length === 0) {
230287
console.warn('[fetchBalances] Pioneer returned 0 balances for', pioneerPubkeys.length, 'pubkeys');
@@ -438,12 +495,17 @@ const onStart = async function () {
438495
await web3ProviderStorage.saveWeb3Provider(defaultProvider);
439496
}
440497

441-
// Fetch balances in background (non-blocking)
498+
// Fetch balances in background (non-blocking). First pass covers EVM/UTXO
499+
// quickly — on first run the Solana pubkey hasn't been derived yet, so it
500+
// won't be in this request.
442501
fetchBalancesFromPioneer().catch(e => console.warn(tag, 'Initial balance fetch failed:', e));
443502

444-
// Prefetch Solana pubkey so it shows up in the network dropdown without
445-
// waiting for a dapp request. Non-blocking, silently no-ops in watch-only.
446-
prefetchSolanaPubkey().catch(() => {});
503+
// Prefetch Solana pubkey so it shows up in the network dropdown. Once the
504+
// pubkey is registered, force a second balance fetch so Solana natives +
505+
// SPL tokens land in cachedBalances (fixes first-run race).
506+
prefetchSolanaPubkey()
507+
.then(() => fetchBalancesFromPioneer(true))
508+
.catch(() => {});
447509
} else {
448510
console.error(tag, 'FAILED TO INIT, No Ethereum address found');
449511
}

0 commit comments

Comments
 (0)