Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions chrome-extension/src/background/chainConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const shortListSymbolToCaip: Record<string, string> = {
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<string, string> = {
Expand All @@ -60,7 +60,7 @@ export const shortListNameToCaip: Record<string, string> = {
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 ----
Expand Down
118 changes: 102 additions & 16 deletions chrome-extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ let ADDRESS = '';
// ---- Balance fetching via Pioneer API ----
let cachedBalances: any[] = [];
let balancesFetchInProgress: Promise<any[]> | 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:')))];
Expand All @@ -134,6 +146,7 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise<any[]> {
// Deduplicate concurrent calls — but honor forceRefresh
if (balancesFetchInProgress && !forceRefresh) return balancesFetchInProgress;

const myFetchId = ++latestFetchId;
const thisPromise: Promise<any[]> = (async () => {
try {
const allPubkeys = wallet.getPubkeys();
Expand Down Expand Up @@ -181,22 +194,25 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise<any[]> {
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 }),
Expand All @@ -217,14 +233,68 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise<any[]> {
}
};

// 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');
Expand Down Expand Up @@ -323,10 +393,21 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise<any[]> {
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);
Expand Down Expand Up @@ -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');
}
Expand Down
9 changes: 8 additions & 1 deletion pages/side-panel/src/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading