fix(icons): keep the asset icon that actually loads, not the first one listed - #2993
fix(icons): keep the asset icon that actually loads, not the first one listed#2993piyalbasu wants to merge 9 commits into
Conversation
…e listed Asset icons come from sources we don't control, and any of them can carry a url that no longer serves an image. Resolution committed to whichever list it read last and handed that url straight to the <img>, so a dead url meant no icon. When the image then failed, the retry wrote a null into the icon cache that persists to disk, and the lookup reads that null as "already tried, never look again" — so one transient failure removed the icon for good. getAssetIcons now collects every icon url the user's lists offer for an asset, loads them in turn, and keeps the first that renders. Only a url we have seen render reaches the cache, so a later cache hit is trusted without re-probing. The issuer TOML stays the last resort and gets the same treatment. - iconProbe: canLoadIcon (an <img> load, since the extension declares no host_permissions and a cross-origin fetch would be CORS-gated) and firstLoadableIconUrl, which spends one 1.2s budget across all candidates so the worst case doesn't scale with how many lists carry the asset. Measured: real icons load in 130-300ms including cold DNS and TLS. - getIconCandidatesFromTokenLists: pure collector returning every matching url, deduped, in list order — which now decides probe order, not priority. getIconFromTokenLists stays as the un-probed single-icon path for callers that render directly (asset search, sign-transaction, history). - cacheAssetIcon: a falsy iconUrl deletes the entry instead of persisting null, restoring the "retry on next load" semantics getAssetIcons documents. - retryAssetIcon: re-resolves through the same chain with the url that just failed removed from the running, so it can't return the same broken src. Surfaced by USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q, carried by both the Soroswap list (direct https icon) and the LOBSTR list (ipfs.io gateway icon). LOBSTR won on ordering alone, and the issuer publishes no home_domain — its master key weight is 0, so it never can — leaving the TOML fallback permanently unreachable and null as the terminal value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-9a89b8e0ed33a997b435 |
There was a problem hiding this comment.
Pull request overview
Improves balance icon resolution by probing candidate URLs and avoiding persistent negative cache entries.
Changes:
- Collects and probes token-list/TOML icon candidates.
- Revises cache clearing and retry behavior.
- Adds regression and probe tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
@shared/api/helpers/iconProbe.ts |
Adds bounded image probing. |
@shared/api/helpers/getIconFromTokenList.ts |
Collects ordered, deduplicated candidates. |
@shared/api/internal.ts |
Integrates probing and retry logic. |
extension/src/background/messageListener/handlers/cacheAssetIcon.ts |
Deletes cleared cache entries. |
extension/src/popup/components/account/AccountAssets/index.tsx |
Supplies token lists during retries. |
@shared/api/helpers/__tests__/iconProbe.test.ts |
Tests probing behavior. |
extension/src/popup/helpers/__tests__/getIconFromTokenLists.test.js |
Tests candidate collection. |
@shared/api/__tests__/internal.test.ts |
Tests resolution and retries. |
extension/src/background/messageListener/handlers/__tests__/cacheAssetIcon.test.ts |
Tests cache deletion. |
extension/src/popup/views/__tests__/Account.test.tsx |
Updates account icon mocks. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ff the render path Five defects from the review of this PR, in rough order of impact: - Migrate away the persisted nulls. cacheAssetIcon no longer writes them, but entries left by earlier versions survived the upgrade, and getAssetIcons reads a cached null as "don't look again". Those wallets could not recover on their own: with no icon there is no <img>, so nothing fires the onError that would trigger a retry. Without this sweep the fix never reached the wallets that actually have the bug. - Resolve icons concurrently. getAssetIcons is awaited before the balances render, so probing one asset at a time put an image round-trip per asset in front of the user where there used to be an in-memory scan. Split the loop into a synchronous cache pass and a bounded-concurrency lookup pass, so the cost is roughly one asset's rather than the sum. - Stop the un-probed path seeding the shared cache. getIconFromTokenLists wrote its first candidate — nothing had loaded it — into the same store getAssetIcons trusts without re-loading, so opening asset search or a history row could hand the account view a dead icon and undo the probing entirely. - Clear a rejected TOML url in retryAssetIcon. getIconUrlFromIssuer caches what the toml claims before we load it; leaving a rejected url behind meant the next open served it from cache, recreating the loop this PR fixes. - Match contract assets by contract id in retryAssetIcon. It passed the key as an issuer unconditionally, but soroban balances arrive with a contract id there and the lists key those by `contract` — so the retry was dead code for exactly the assets most likely to be listed. Mirrors the split getAssetIcons already used. Deferred: the persisted null was also the only cross-session negative cache, so an asset with genuinely no icon now re-runs the chain on every cold open. That wants a TTL or attempt count rather than a permanent verdict, and is left for a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
@shared/api/helpers/iconProbe.ts:84
- A candidate that never emits
loadorerrorreceives the entire remaining budget, so after it times out the deadline has expired and every later URL is skipped. Thus a hanging first URL still prevents a valid second URL from being selected. Probe candidates concurrently under the shared deadline, or reserve budget for later candidates.
const remaining = deadline - Date.now();
if (remaining <= 0) {
return undefined;
}
if (await probe(url, remaining)) {
@shared/api/internal.ts:1569
- Returning a replacement map does not display the replacement in the current popup.
AssetIconsets its localhasErrorto true on the original failure, and line 109 then keepsimgSrcpinned toImageMissingIconeven aftercanonicalAssetchanges. Reset the error/loading state when the resolved URL changes (and add a retry rendering test).
if (tokenListIcon) {
await cacheAssetIconUrl(canonical, tokenListIcon);
newAssetIcons[canonical] = tokenListIcon;
return newAssetIcons;
| * the sum, while the limit stops a large wallet from opening a connection per | ||
| * held asset at once. | ||
| */ | ||
| export const ICON_LOOKUP_CONCURRENCY = 8; |
Probing was scoped to the account view, so asset search, sign-transaction rows and history rows still took the first url any list offered without checking it. That left the reported bug live in three of the four places an icon appears — asset search most visibly, since that is where you look a token up before adding it. getIconFromTokenLists now does the loading itself and caches only what it has confirmed renders, which makes it the single icon-resolution path: getAssetIcons had grown a private copy of the same logic, now removed. Two things keep that off the critical path, since these callers resolve icons a row at a time: - Probe verdicts are memoized per url for the session, so an asset repeated across many history rows is loaded once, and a dead url costs its budget once rather than per row. - Views that fan out resolve concurrently: asset search maps its result rows through mapWithConcurrency, and history warms every candidate on the page in one batch inside the pre-pass that already collects home domains, so the per-row lookups become memo hits. Sign-transaction keeps its loop as is: a transaction has few operations, and that path already awaits a Horizon and toml round trip per asset, which dwarfs an image load. Also adds a stubIconProbe test helper. Icon resolution now waits on an <img> load, and jsdom images fire neither load nor error, so an unstubbed test just loses its icons after the budget expires — surfacing as a missing element with nothing pointing at icons. Two tests hit this while writing that; the helper gives the next one an obvious fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st balances" This reverts commit 0787945. Extending the probe to asset search, sign-transaction and history needed a session-wide probe memo and a warm-up pass to stay affordable, and that machinery brought its own failures: - The history hook writes `icons[canonical] = iconUrl || null` into a map that is dispatched to Redux and later read by getAssetIcons as "already looked, skip". Since a probe can now return nothing, one slow icon host while viewing history removed that asset's icon from the account screen for the rest of the session — a milder replay of the bug this PR exists to fix. - The warm-up pass loaded every candidate rather than stopping at the first that works, and was awaited before history rendered: ~60 image loads for a busy page, and up to several seconds of blocked render against a dead host. It was added to avoid a serial cost and was worse than the cost. - The memo keyed verdicts on url alone while candidates are probed with whatever is left of the shared budget, so a candidate that merely ran out of time was recorded as permanently dead — contradicting the reasoning written into the budget constant, which assumes a slow candidate gets another chance. This PR is a hotfix. It goes back to fixing the reported bug where it was reported — the account view — which is the part that was reviewed and green. Covering the other three surfaces is still worth doing, but as its own change and in a different shape: hand the candidate list to the image component and let it fall through on error. That needs no probing ahead of render, no shared memo, and no added latency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l be a null Two small holes left by the review. firstLoadableIconUrl handed each candidate whatever was left of the shared budget, so a throttled host that never answers could spend all 1.2s and the healthy candidate behind it was never tried — the exact failure this change exists to prevent. It now splits what remains across the candidates still to try, and a candidate that answers quickly leaves its unused share to the rest. USDT0 happened to list its working url first, so the reported case worked by luck rather than by design. CacheAssetIconMessage.iconUrl was still declared string while a null is what clears an entry, so the handler's own test had to cast through unknown to compile. Widening the field to string | null makes senders and handler agree, and the cast goes away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Suppressed comments (1)
@shared/api/internal.ts:1431
- These concurrent workers each call
cacheAssetIconUrl, while the background handler updates the cache with a read-modify-write of the entireCACHED_ASSET_ICONS_IDmap. Two messages can read the same snapshot and then overwrite each other, so only the last resolved icon remains persisted. Keep image probes concurrent, but serialize cache writes or send one batched background update.
const resolvedIcons = await mapWithConcurrency(
needsLookup,
ICON_LOOKUP_CONCURRENCY,
({ key, code, contractId }) =>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
extension/src/popup/components/account/AccountAssets/index.tsx:272
- Passing alternate candidates into the retry does not make the replacement visible.
AssetIconsets its localhasErrortotrueon the failing image, and thereafter computesimgSrcasImageMissingIconregardless of the updatedassetIcons; this update cannot display the URL returned byretryAssetIconuntil a remount. Reset the child’s error/loading state when its canonical URL changes, or await a successful retry and clear the error state.
@shared/api/helpers/getIconFromTokenList.ts:98
getIconFromTokenListsis still used by asset search, account history, and sign-transaction, but returningcandidates[0]never checks whether it loads. A dead first URL therefore remains broken on three of the four surfaces, contrary to the PR’s all-surface resolution behavior. Route these callers through the load probe and cache only the successful result; history should batch/warm its candidates to avoid serial row latency.
icon: candidates[0],
@shared/api/helpers/iconProbe.ts:75
- The default path calls
canLoadIcondirectly, and this module has no per-URL verdict cache. Repeated resolutions therefore reload the same URL—including failures—and can consume the full budget again, so the session memoization promised in the PR is absent. Memoize both in-flight and settled results by URL and use that wrapper as the default probe.
budgetMs = ICON_LOAD_BUDGET_MS,
probe = canLoadIcon,
}: { budgetMs?: number; probe?: IconProbe } = {},
@shared/api/internal.ts:1431
- These workers also persist successful icons concurrently, but
cacheAssetIconperforms a non-atomic read/modify/write of the entire cache object. Two workers can read the same snapshot and the lastsetItemthen drops the other worker’s entry; the former serial loop avoided this race. Separate probing from persistence and serialize/batch cache updates, or make the background update path atomic.
const resolvedIcons = await mapWithConcurrency(
needsLookup,
ICON_LOOKUP_CONCURRENCY,
({ key, code, contractId }) =>
…ng its test Two more from PR review. The whole icon cache lives under one storage key, and every update reads it, changes an entry, and writes it back. Resolving icons concurrently means those updates overlap, and storage hands each handler its own deserialized copy — so two handlers read the same map, add different assets, and the later write discards the earlier one's icon, or undoes a clear. Updates are now chained so each works from the result of the last. The regression test needed care to be honest: a fake store that hands back the same object reference, or one that merely staggers reads with timers, both hide the race. It holds every read open until all handlers have read, and returns a copy per read, the way browser.storage does. The Account icon test stubbed the probe and never restored it. The suite's afterEach calls clearAllMocks, which drops call history but keeps the implementation, so the stub stood in for the real thing in every test after it and made the file order-dependent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
@shared/api/internal.ts:1586
- A successful retry cannot display this replacement in the current popup.
AssetIconsets its localhasErrortotruewhen the original URL fails, and while that remains true it always selectsImageMissingIcon; updating the parent map does not reset that state. Reset the row's error/loading state when its canonical icon URL changes, or clear it after the retry succeeds.
extension/src/popup/components/account/AccountAssets/index.tsx:272 - The retry result is applied only to this component's local state; the Redux icon cache still contains the failed truthy URL.
useGetBalancesprefers a non-empty Redux cache over background storage, andgetAssetIconstrusts that value, so navigating away and back in the same popup serves the broken URL again. DispatchsaveIconsForBalanceswith the replacement (or cleared entry) when the retry finishes.
This issue also appears on line 269 of the same file.
@shared/api/helpers/getIconFromTokenList.ts:98
getIconFromTokenListsstill returns the first listed URL without probing it. Its callers in asset search, sign-transaction, and history render this value directly, so a dead first URL still reproduces the bug on three of the four surfaces that the PR says are covered. Route this helper throughfirstLoadableIconUrland cache only the successful URL, or migrate all three callers to the shared probing resolver.
icon: candidates[0],
@shared/api/helpers/iconProbe.ts:75
- The default probe is
canLoadIconitself, so every call creates a newImage; there is no session-level URL verdict or shared in-flight promise. Repeated rows can therefore retry the same dead URL and repeatedly consume the timeout, contrary to the PR's memoization guarantee. Add a URL-keyed promise cache and use its wrapper as the default probe.
budgetMs = ICON_LOAD_BUDGET_MS,
probe = canLoadIcon,
}: { budgetMs?: number; probe?: IconProbe } = {},
extension/src/popup/components/account/AccountAssets/index.tsx:272
- This new token-list retry is still guarded by the component-wide
hasIconFetchRetriedflag. Once one row completes a retry, every other asset row'sonErrorreturns before using these lists, so a wallet with multiple stale cached icons can recover only the first one. Track retries per canonical asset (or perAssetIcon) instead.
// Session cache of the user's asset lists, populated by the balances
// fetch. Empty on a cold popup, in which case the retry degrades to
// the issuer-TOML lookup it used to do exclusively.
assetsListsData: cachedTokenLists,
The concurrency limit caps how many assets resolve simultaneously, not how long the pass takes. A wallet holding more uncached assets than the limit resolves them in waves, and each wave can spend the full per-asset budget — so against a dead icon host the waves add up. getAssetIcons is awaited before the balances render, which put that wait in front of the user. The token-list pass now runs under an overall deadline. Assets not reached in time fall through to the issuer-toml stage, which is a single parallel batch rather than waves, and are looked up again on the next load. The deadline is a parameter so tests can drive it without a slow test. In the ordinary case nothing is skipped: real icons load in 130-300ms, so even a large wallet finishes well inside the budget. It only bites when the icons themselves are not answering, which is exactly when the wait was worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
@shared/api/internal.ts:1596
- The replacement URL returned here is not displayed by the account row.
AssetIconsets its localhasErrortotruewhen the original image fails (AccountAssets/index.tsx:165-170), and while that flag remains true it forcesimgSrctoImageMissingIconinstead of the updated canonical URL (:108-110). Updating the parent map does not remount the row because its key remains the same, so this retry can successfully resolve and cache an alternate icon while the UI continues showing the missing-image state. Reset the row's error/loading state when its canonical URL changes (and add a rendered retry regression test).
| // as the token-list candidates. getIconUrlFromIssuer caches whatever | ||
| // the toml claims, so a url that fails here is cleared again rather | ||
| // than left behind for the next load to trust. | ||
| const usableIcon = await firstLoadableIconUrl(icon ? [icon] : []); |
| Date.now() >= lookupDeadline | ||
| ? Promise.resolve(undefined) | ||
| : resolveIconFromTokenLists({ |
AssetIcon set an error flag when an <img> failed and never cleared it, and the rendered src reads that flag first. So once an icon failed, the row kept the broken-image glyph for the life of the component even after a working url arrived — which is exactly what the retry path produces. Every retry improvement in this change was therefore invisible until the popup was reopened and the cached url was read back. The flag now clears whenever the resolved url changes, since a different url is a fresh attempt. Also pins the reported bug's own shape: USDT0 sits on the Soroswap list with a direct https icon and on the LOBSTR list with an ipfs.io gateway url. That gateway answers curl but returns 403 to a browser User-Agent, so the <img> could never load it. Resolution used to keep whichever list it read last, which handed the browser the one url it cannot fetch; the test pins that the earlier list wins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (2)
@shared/api/internal.ts:1441
lookupDeadlineonly prevents a new worker item from starting. An item begun just before the deadline still gets the full 1.2-second budget insideresolveIconFromTokenLists, so the documented 4-second ceiling (and smaller injected budgets) can be exceeded by up to another per-asset budget. Pass the remaining account budget intofirstLoadableIconUrl, or otherwise bound in-flight work by this deadline.
Date.now() >= lookupDeadline
@shared/api/helpers/getIconFromTokenList.ts:98
- This is not behavior-preserving for the out-of-scope callers as the PR description claims. The old outer loop overwrote
verifiedTokenfor every matching list (itsbreakonly exited the record loop), so it returned the last match;candidates[0]now makes asset search, signing, and history use the first match. Either preserve the old ordering for those callers or update the scope/impact description to acknowledge the behavior change.
icon: candidates[0],
|
Closing in favour of #2994, which fixes the reported USDT0 bug in four lines of production code. This PR grew well past what the bug needed. The reported failure has two causes — icon lookup keeping the last matching list instead of the first, and a "no icon" verdict being persisted to disk — and #2994 addresses exactly those. Everything else here was built around loading each candidate URL to check it renders before using it, and that machinery is what three rounds of review kept finding problems in: budget starvation, premature rejection on slow connections, a session memo that recorded timeouts as permanent failures, cache writes racing under concurrency. That idea is still worth having. Ordering only fixes the known case; a list shipping a URL browsers reject is caught only by actually loading it. But it needs a per-URL verdict cache and concurrency control to stay off the balances render path, and it deserves its own change rather than riding along with a hotfix. Two things here are worth salvaging separately:
Worth noting for whoever picks this up: LOBSTR's list ships an |
TL;DR
Asset icons come from sources we don't control — several curated token lists, plus the issuer's SEP-1 TOML — and any of them can carry a URL that no longer serves an image. Resolution used to take whichever URL it read last and hand it straight to the
<img>, so a dead URL meant no icon. Worse, when the image then failed, the retry wrote a "no icon" marker into the cache that persists to disk, and the lookup reads that marker as "we already tried, never look again" — so one transient failure removed the asset's icon permanently.Balance icons are now resolved by loading every URL the lists offer and keeping the first that actually renders, stopping there. Only a URL we've watched render reaches the cache, so a later cache hit is trusted without re-checking.
The whole search shares a single 1.2s budget, split across the candidates still to try — so one hanging host can neither blow the budget nor starve a healthy candidate behind it.
Scope: this is a hotfix for the account view, where the bug was reported. Asset search, sign-transaction rows and history rows still render the first listed URL unchecked; covering them needs a different shape and is called out as a follow-up below.
Reported against
USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q, which sits on both the Soroswap list (a direct HTTPS icon) and the LOBSTR list (anipfs.iogateway icon). LOBSTR won on ordering alone, and this issuer publishes no home domain — its master key weight is 0, so it never can — leaving the TOML fallback permanently unreachable and "no icon" as the final answer.Steps to reproduce
USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Qon Mainnet with the default asset lists enabled.ipfs.iogateway URL from the LOBSTR list rather than the direct HTTPS URL from the Soroswap list.Upgrade note
Wallets that already hit this have a persisted "no icon" marker on disk, and could not recover on their own — with no icon there is no
<img>, so nothing fires the error handler that would trigger a retry. A one-time migration clears those on upgrade, so the fix reaches the wallets that actually have the bug.Implementation details (for agents)
Root cause chain
getIconFromTokenListsiterated the lists in order but itsbreakonly exited the inner record loop, so the outer loop ran to completion and the last matching list overwrote every earlier match. Nothing ever checked whether the chosen URL loads.AccountAssetshandles the<img>onErrorby callingretryAssetIcon, which senticonUrl: nulltoCACHE_ASSET_ICONand then consulted onlygetIconUrlFromIssuer(home domain → SEP-1 TOML). The asset lists were never re-checked.cacheAssetIconpersisted thatnullverbatim intoCACHED_ASSET_ICONS_IDin the background'slocalStore, andgetCachedAssetIconListhands the raw map back including nulls.getAssetIconsshort-circuits on a cachednullwith a comment claiming the null lives only in Redux and will be retried on the next app load. That comment was wrong — the null was on disk, so the lookup was never attempted again.For USDT0 the TOML branch can never recover: Horizon reports
home_domain: nullfor the issuer withthresholds {0,0,0}and a single signer at weight 0. A validstellar.tomlwith the correctimagedoes exist atusdt0.to, but the wallet has no way to discover that domain.Key code
Candidate selection and the shared budget, split so no single host can starve the rest:
freighter/@shared/api/helpers/iconProbe.ts
Lines 70 to 98 in 39ed705
Why the budget is 1.2s:
freighter/@shared/api/helpers/iconProbe.ts
Lines 13 to 28 in 39ed705
The serialized cache write:
freighter/extension/src/background/messageListener/handlers/cacheAssetIcon.ts
Lines 21 to 51 in 39ed705
The migration that reaches already-broken wallets:
freighter/extension/src/background/helpers/dataStorage.ts
Lines 394 to 409 in 39ed705
What changed
@shared/api/helpers/iconProbe.ts—canLoadIconis an<img>load rather than afetch: the extension declares nohost_permissions, so a cross-origin fetch to an arbitrary icon host is CORS-gated while an image load is not. That confines this to a DOM context, which is fine — icon resolution only runs in the popup, never in the service worker. The probe is injectable, so tests drive selection logic without a network.ICON_LOAD_BUDGET_MS = 1200, spent as one deadline across all candidates, so the worst case doesn't scale with candidate count. Measured against the two real USDT0 candidates (~8KB PNGs): 130-300ms each including cold DNS + TLS.<img>srcis deliberately not cleared, so the browser finishes the abandoned request into its own HTTP cache and a merely-slow candidate resolves instantly next time.mapWithConcurrencyresolves several assets at once, sincegetAssetIconsis awaited before the balances render.getIconCandidatesFromTokenLists(new) — pure collector returning every matching icon URL, deduped, in list order. Order decides probe order, not priority.getIconFromTokenLists— unchanged in behavior for its existing callers: it still returns the first candidate without loading it. Those three surfaces are out of scope for this hotfix (see follow-ups).getAssetIcons— split into a synchronous cache pass and a concurrent lookup pass. A cache hit is not re-probed: it was proven loadable when written, and re-probing every load would defeat the cache; a URL that rots later is caught by theonError->retryAssetIconpath. The TOML fallback gets the same treatment, and a TOML URL that doesn't load is cleared rather than left for the next load to trust.cacheAssetIcon— a falsyiconUrlnowdeletes the entry instead of storingnull, and updates are serialized: the cache is one storage key, concurrent resolution means overlapping read-modify-writes, and storage hands each handler its own copy, so unchained writes lose icons.CacheAssetIconMessage.iconUrlis nowstring | nullso the delete command is part of the contract rather than something callers cast past.dropNullAssetIconCacheEntries— one-time migration (storage v5.46.0) sweeping the nulls earlier versions persisted, wired intoversionedMigration. Without it the fix never reaches the wallets that have the bug.retryAssetIcon— re-resolves through the same chain with the URL that just failed removed from the running, so it can never hand back the same brokensrc; clears a rejected TOML URL; and matches contract assets by contract id (Soroban balances arrive with a contract id as their key, and the lists key those bycontract).Verification
getAssetIconspicks the loadable candidate over the first-listed one, caches what it settled on rather than what it rejected, and doesn't re-probe a cache hit; the concurrency is pinned by measuring peak in-flight loads through the realgetAssetIcons, so it can't silently regress to serial;retryAssetIconnever re-offers the failed URL even when that URL would load; the migration keeps real icons while dropping nulls.tsc --noEmit: 243 errors onmaster, 243 here — no new ones. Those are pre-existingstellar-sdkv17 drift (xdr.encodeBytes,fromXdr/toXdrcasing) from the v17 back-port.Note for reviewers: the
pre-commithook (.husky/addTranslations.sh→yarn build:extension:translations) fails on a cleanmastercheckout for that same pre-existing v17 drift, so these commits were made with--no-verify. No CI job runs that build, so it doesn't affect this PR — but it does mean nobody can commit to this repo without the flag. Worth a separate fix.Follow-ups / out of scope
nullwas also the only cross-session negative cache. An asset with genuinely no discoverable icon now re-runs the chain on every cold popup open. That wants a TTL or an attempt count rather than a permanent verdict, and is deliberately left out of this PR.new RegExp(contractId, "i")where a case-insensitive string comparison is what's meant, which also makes it a substring match. Contract IDs are StrKey so there's no injection surface, but it's needlessly fragile.getIconCandidatesFromTokenListsreports onecanonicalAsset, taken from the first match. A caller passing acontractIdand anissuerIdthat disagree would get a canonical keyed on the contract. Nothing does today — neither backend attaches acontractIdto classic balances — but it's a trap for a future caller.