diff --git a/chrome-extension/src/background/chainConfig.ts b/chrome-extension/src/background/chainConfig.ts index d782d36..317f36e 100644 --- a/chrome-extension/src/background/chainConfig.ts +++ b/chrome-extension/src/background/chainConfig.ts @@ -39,7 +39,7 @@ export const shortListSymbolToCaip: Record = { AVAX: 'eip155:43114/slip44:60', BSC: 'eip155:56/slip44:60', BNB: 'eip155:56/slip44:60', - SOL: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/solana:so11111111111111111111111111111111111111112', + SOL: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', }; export const shortListNameToCaip: Record = { @@ -60,7 +60,7 @@ export const shortListNameToCaip: Record = { ripple: 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144', optimism: 'eip155:10/slip44:60', base: 'eip155:8453/slip44:60', - solana: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/solana:so11111111111111111111111111111111111111112', + solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', }; // ---- bip32ToAddressNList ---- diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index c044161..d89ebd8 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -126,6 +126,18 @@ let ADDRESS = ''; // ---- Balance fetching via Pioneer API ---- let cachedBalances: any[] = []; let balancesFetchInProgress: Promise | null = null; +// Monotonic sequence so an earlier, slower fetch can't clobber a later fetch's +// result when they overlap. Bumped each time a new fetch actually starts work +// (not for calls that return the in-flight dedup promise). +let latestFetchId = 0; + +function pushBalancesUpdated() { + chrome.runtime + .sendMessage({ type: 'BALANCES_UPDATED' }) + .catch(() => { + // No popup/sidebar listening — ignore. + }); +} // All EVM CAPIPs (deduplicated) — used to fan out EVM wildcard addresses const EVM_CAIPS = [...new Set(Object.values(shortListSymbolToCaip).filter(caip => caip.startsWith('eip155:')))]; @@ -134,6 +146,7 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { // Deduplicate concurrent calls — but honor forceRefresh if (balancesFetchInProgress && !forceRefresh) return balancesFetchInProgress; + const myFetchId = ++latestFetchId; const thisPromise: Promise = (async () => { try { const allPubkeys = wallet.getPubkeys(); @@ -181,22 +194,25 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { console.log(`[fetchBalances] Sending ${pioneerPubkeys.length} pubkeys to Pioneer API`); console.log(`[fetchBalances] Sample pubkeys:`, pioneerPubkeys.slice(0, 3)); - // Use /api/v1/charts/portfolio endpoint — blocking, includes Zapper/Unchained token fetch - const portfolioUrl = `${PIONEER_API}/api/v1/charts/portfolio`; + // Two Pioneer endpoints, chosen per chain: + // /charts/portfolio — EVM + UTXO + Cosmos (unauthenticated, returns tokens via Zapper/Unchained). + // Returns EMPTY for Solana, so Solana pubkeys must NOT go here. + // /portfolio — Solana (authenticated). Returns natives + SPL tokens in one flat array. + const chartsPortfolioUrl = `${PIONEER_API}/api/v1/charts/portfolio`; - // Split into address-based (EVM, Cosmos, etc.) and xpub-based (UTXO) batches - // to prevent a bad xpub from poisoning the entire request - const addressPubkeys = pioneerPubkeys.filter( + const solanaPubkeys = pioneerPubkeys.filter(p => p.caip.toLowerCase().startsWith('solana:')); + const nonSolana = pioneerPubkeys.filter(p => !p.caip.toLowerCase().startsWith('solana:')); + const addressPubkeys = nonSolana.filter( p => !p.pubkey.startsWith('xpub') && !p.pubkey.startsWith('zpub') && !p.pubkey.startsWith('ypub'), ); - const xpubPubkeys = pioneerPubkeys.filter( + const xpubPubkeys = nonSolana.filter( p => p.pubkey.startsWith('xpub') || p.pubkey.startsWith('zpub') || p.pubkey.startsWith('ypub'), ); const fetchBatch = async (batch: typeof pioneerPubkeys, label: string) => { if (batch.length === 0) return { balances: [] as any[], tokens: [] as any[] }; try { - const response = await fetch(portfolioUrl, { + const response = await fetch(chartsPortfolioUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkeys: batch, forceRefresh }), @@ -217,14 +233,68 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { } }; - // Fetch both batches in parallel - const [addressResult, xpubResult] = await Promise.all([ + // Pioneer echoes CAIP/networkId back in lowercase even when sent mixed-case. + // The side-panel asset list uses canonical mixed-case network IDs from + // ChainToNetworkId, so strict b.networkId === asset.networkId matches fail. + // Rewrite Solana entries back to canonical casing before returning. + const SOL_NETWORK_CANONICAL = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const normalizeSolanaCasing = (entry: any) => { + const caip = entry.caip || ''; + const netId = entry.networkId || ''; + if (netId.toLowerCase() === SOL_NETWORK_CANONICAL.toLowerCase()) { + entry.networkId = SOL_NETWORK_CANONICAL; + } + if (caip.toLowerCase().startsWith(SOL_NETWORK_CANONICAL.toLowerCase() + '/')) { + entry.caip = SOL_NETWORK_CANONICAL + caip.slice(SOL_NETWORK_CANONICAL.length); + } + return entry; + }; + + const fetchSolanaBatch = async (batch: typeof pioneerPubkeys) => { + if (batch.length === 0) return { balances: [] as any[], tokens: [] as any[] }; + try { + const url = `${PIONEER_API}/api/v1/portfolio${forceRefresh ? '?forceRefresh=true' : ''}`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Pioneer requires a queryKey — any public key works for read-only lookups. + Authorization: `key:public-${Date.now()}`, + }, + body: JSON.stringify({ pubkeys: batch }), + }); + if (!response.ok) { + console.warn(`[fetchBalances] solana batch returned ${response.status}`); + return { balances: [] as any[], tokens: [] as any[] }; + } + const json = await response.json(); + const allEntries: any[] = json?.balances || []; + const natives: any[] = []; + const tokens: any[] = []; + for (const raw of allEntries) { + const entry = normalizeSolanaCasing({ ...raw }); + const caipPath = (entry.caip || '').split('/')[1] || ''; + const isToken = + entry.type === 'token' || caipPath.startsWith('token:') || caipPath.startsWith('spl:'); + if (isToken) tokens.push(entry); + else natives.push(entry); + } + console.log(`[fetchBalances] solana batch: ${natives.length} natives, ${tokens.length} tokens`); + return { balances: natives, tokens }; + } catch (e: any) { + console.warn('[fetchBalances] solana batch error:', e.message); + return { balances: [] as any[], tokens: [] as any[] }; + } + }; + + const [addressResult, xpubResult, solanaResult] = await Promise.all([ fetchBatch(addressPubkeys, 'address'), fetchBatch(xpubPubkeys, 'xpub'), + fetchSolanaBatch(solanaPubkeys), ]); - const rawBalances: any[] = [...addressResult.balances, ...xpubResult.balances]; - const rawTokens: any[] = [...addressResult.tokens, ...xpubResult.tokens]; + const rawBalances: any[] = [...addressResult.balances, ...xpubResult.balances, ...solanaResult.balances]; + const rawTokens: any[] = [...addressResult.tokens, ...xpubResult.tokens, ...solanaResult.tokens]; if (rawBalances.length === 0 && rawTokens.length === 0) { console.warn('[fetchBalances] Pioneer returned 0 balances for', pioneerPubkeys.length, 'pubkeys'); @@ -323,10 +393,21 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { console.warn('[fetchBalances] Custom chain enrichment error:', e.message); } - cachedBalances = balances; console.log( `[fetchBalances] Got ${balances.length} balance entries (${balances.filter((b: any) => b.isNative).length} native, ${balances.filter((b: any) => !b.isNative).length} tokens)`, ); + // Only commit to the cache and notify listeners if we are still the most + // recent fetch. Without this guard an earlier, slower request that started + // before the Solana pubkey existed could finish after a later forced + // refetch and clobber the cache back to a pre-Solana snapshot. + if (myFetchId === latestFetchId) { + cachedBalances = balances; + pushBalancesUpdated(); + } else { + console.log( + `[fetchBalances] discarding result from superseded fetch #${myFetchId} (latest: #${latestFetchId})`, + ); + } return balances; } catch (e: any) { console.error('[fetchBalances] Error:', e.message || e); @@ -438,12 +519,17 @@ const onStart = async function () { await web3ProviderStorage.saveWeb3Provider(defaultProvider); } - // Fetch balances in background (non-blocking) + // Fetch balances in background (non-blocking). First pass covers EVM/UTXO + // quickly — on first run the Solana pubkey hasn't been derived yet, so it + // won't be in this request. fetchBalancesFromPioneer().catch(e => console.warn(tag, 'Initial balance fetch failed:', e)); - // Prefetch Solana pubkey so it shows up in the network dropdown without - // waiting for a dapp request. Non-blocking, silently no-ops in watch-only. - prefetchSolanaPubkey().catch(() => {}); + // Prefetch Solana pubkey so it shows up in the network dropdown. Once the + // pubkey is registered, force a second balance fetch so Solana natives + + // SPL tokens land in cachedBalances (fixes first-run race). + prefetchSolanaPubkey() + .then(() => fetchBalancesFromPioneer(true)) + .catch(() => {}); } else { console.error(tag, 'FAILED TO INIT, No Ethereum address found'); } diff --git a/pages/side-panel/src/SidePanel.tsx b/pages/side-panel/src/SidePanel.tsx index eceb7d2..a8207ba 100644 --- a/pages/side-panel/src/SidePanel.tsx +++ b/pages/side-panel/src/SidePanel.tsx @@ -167,13 +167,20 @@ const SidePanel = () => { if (message.type === 'ASSET_CONTEXT_CLEARED') { setSelectedAsset(null); } + // Background pushes this after cachedBalances is refreshed. Without it, + // cold-start shows a pre-Solana snapshot because the panel only fetches + // once on state=5 and never re-queries when the background later lands + // Solana + SPL tokens after the initial fetch. + if (message.type === 'BALANCES_UPDATED') { + fetchTotalBalance(); + } }; chrome.runtime.onMessage.addListener(messageListener); return () => { chrome.runtime.onMessage.removeListener(messageListener); }; - }, [onAssetDetailOpen]); + }, [onAssetDetailOpen, fetchTotalBalance]); // Format currency for display const formatCurrency = (value: number) => { diff --git a/pages/side-panel/src/components/Balances.tsx b/pages/side-panel/src/components/Balances.tsx index 981d9b8..235555c 100644 --- a/pages/side-panel/src/components/Balances.tsx +++ b/pages/side-panel/src/components/Balances.tsx @@ -1,5 +1,17 @@ import React, { useState, useEffect } from 'react'; -import { Flex, Spinner, Avatar, Box, Text, Badge, Card, Stack, HStack } from '@chakra-ui/react'; +import { + Flex, + Spinner, + Avatar, + Box, + Text, + Badge, + Card, + Stack, + HStack, + Skeleton, + SkeletonCircle, +} from '@chakra-ui/react'; import { ChevronRightIcon } from '@chakra-ui/icons'; import AssetSelect from './AssetSelect'; import { COIN_MAP_LONG, NetworkIdToChain } from '@extension/shared'; @@ -41,27 +53,30 @@ const Balances = ({ onSelectAsset }: BalancesProps) => { const formatUsd = (value: string) => parseFloat(value).toFixed(2); - // Fetch assets and balances on mount + // Fetch assets once; refresh balances on mount and on background BALANCES_UPDATED push useEffect(() => { - const fetchData = async () => { - setLoading(true); - - // GET_ASSETS now includes both static and custom chains - chrome.runtime.sendMessage({ type: 'GET_ASSETS' }, response => { - if (response?.assets) { - setAssets(response.assets); - } - }); + chrome.runtime.sendMessage({ type: 'GET_ASSETS' }, response => { + if (response?.assets) setAssets(response.assets); + }); + const refreshBalances = () => { chrome.runtime.sendMessage({ type: 'GET_APP_BALANCES' }, response => { - if (response?.balances) { - setBalances(response.balances); - } + if (response?.balances) setBalances(response.balances); setLoading(false); }); }; - fetchData(); + refreshBalances(); + + // Cold-start: background may land Solana + SPL tokens after the panel + // mounts and paints a pre-Solana snapshot. Listen for BALANCES_UPDATED + // pushes so the UI reflects the latest cache without the user having to + // refresh manually. + const listener = (message: any) => { + if (message?.type === 'BALANCES_UPDATED') refreshBalances(); + }; + chrome.runtime.onMessage.addListener(listener); + return () => chrome.runtime.onMessage.removeListener(listener); }, []); // Sort assets by total USD value descending @@ -80,12 +95,193 @@ const Balances = ({ onSelectAsset }: BalancesProps) => { } if (loading) { + const SkeletonRow = ({ delay = 0 }: { delay?: number }) => ( + + + + + + + + + + + + + + + + ); + return ( - - - - Loading balances… - + + + + {/* Subtle KK watermark behind everything */} + + + + + {/* Hero spinner above the skeletons */} + + + {/* Soft pulsing glow */} + + {/* Outer ring — clockwise, teal */} + + {/* Middle ring — counter-clockwise, paler */} + + {/* Inner ring — clockwise, thin */} + + {/* Breathing center dot */} + + + + Fetching balances + + + + {/* Skeleton rows matching the real asset card layout */} + + + + + + + ); } diff --git a/pages/side-panel/src/components/Tokens.tsx b/pages/side-panel/src/components/Tokens.tsx index f00091a..ec0c958 100644 --- a/pages/side-panel/src/components/Tokens.tsx +++ b/pages/side-panel/src/components/Tokens.tsx @@ -87,6 +87,15 @@ export const Tokens = ({ asset, networkId }: TokensProps) => { useEffect(() => { fetchTokens(); loadCustomTokens(); + // Refresh token list when background pushes a balance update — otherwise + // a user viewing the asset detail during a cold-start Solana refetch would + // see stale "No tokens" after the background lands SPL tokens. + const listener = (message: any) => { + if (message?.type === 'BALANCES_UPDATED') fetchTokens(); + }; + chrome.runtime.onMessage.addListener(listener); + return () => chrome.runtime.onMessage.removeListener(listener); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [asset, networkId]); // Load custom tokens from storage