Skip to content

Resolve asset icons by what renders, and refresh cached icons against the asset lists #3004

Description

@piyalbasu

TL;DR

Asset icons come from several sources that can disagree — the curated asset
lists and the issuer's SEP-1 TOML — and any of them can carry a URL that no
browser will render. Resolution today picks one URL, hands it to an <img>, and
never revisits it: if it doesn't load, nothing tries the next source, and once an
icon is cached we keep it for the life of the install even after a list publishes
a different one.

Three changes:

  1. Resolution returns every candidate URL in priority order instead of one,
    and the icon component walks them on error. The browser verifies each as part
    of the render it was already doing — nothing is probed ahead of time, and
    nothing is added before first paint.
  2. On a 7-day timer, cached icons are compared against the current lists.
    Where they differ, the new candidates are tried and the first that loads
    replaces the cached one. Entries that still agree are left untouched, so
    nothing correct is discarded and no icon blanks while it re-resolves.
  3. The asset lists become the preferred source everywhere, with the SEP-1
    TOML kept as a lazy last resort for assets no enabled list carries.

The governing rule throughout is prefer a stale icon over no icon: every
ambiguous case — no list carries the asset, no candidate loads, the TOML fetch
fails — keeps what is already cached. A refresh may replace one working icon
with another, never end with less than it started with.

Details (for agents)

Problem

The resolver can neither tell a good claim from a bad one nor notice when one
changes. Three gaps:

Order is treated as validity. #2994 made the highest-priority list win
rather than the last one read — the right tiebreak, but not a correctness check.
getIconFromTokenLists
returns one URL straight to an <img>. If it does not load, nothing tries the
next source, even when another list carries one that works.

Cached icons are never revisited. getAssetIcons
gates on the cache before reading any list data, and the second-pass lookup only
considers assets missing from it.
Once an asset resolves, list data is never consulted for it again — so a rotated
icon URL is invisible for the life of the install, and if the old URL still
loads, nothing ever signals anything is wrong.

Nothing observes list change. The icon pass is triggered by an effect keyed
on balances, guarded by a deep compare against the previous balance set; its
comment states the intent outright:

const getData = async () => {
if (
accountBalances &&
!isEqual(accountBalances, previousAccountBalancesRef.current) && // unless balances have changed, don't fetch icons; the cache should be hydrated already
!isScanAppended // start fetching icons on the first scan-less balance fetch
) {
previousAccountBalancesRef.current = accountBalances;
await fetchIconsData();
}
};
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountBalances]);

For a steady-state user — same assets, warm cache — the lookup pass never runs
again.

Two things compound this: retryAssetIcon
re-resolves through the TOML alone and never re-checks the lists, and
AssetIcon latches its error state
— once an image fails the row keeps the broken-image glyph for the life of the
component, so a recovery is invisible until the popup is reopened.

Proposal

1. Resolution returns ordered candidates. getIconFromTokenLists becomes a
collector: every matching icon URL across all enabled lists, deduped, in list
order. Order decides try-order, not truth. (#2993 already wrote this piece as
getIconCandidatesFromTokenLists.) Build the candidate map with a single pass
over the lists — Map<"code:issuer" | contract, string[]> — so per-asset
resolution is O(1) rather than a walk; today's shape is O(A·N) with a regex
compiled inside the inner record loop, and indexing makes the whole pass
O(N + A).

2. AssetIcon walks the candidates. onError advances an index instead of
latching hasError. The broken-image glyph appears only after the last
candidate fails, and the first URL that fires onLoad is what gets cached.

3. Refresh cached icons against the lists on a TTL. Read from the cache and
render exactly as today. Separately, if the refresh TTL has expired:

  1. Load the asset lists (reusing cachedTokenLists when the session already has
    them) and compute the candidate URLs for each cached asset.
  2. If the cached URL is still among that asset's candidates, leave it alone.
  3. If the candidates differ — including where the cached URL came from the TOML
    and a list has since started carrying the asset — walk them in order and load
    each until one succeeds. The first that loads replaces the cached entry.
  4. For an asset no enabled list carries, re-resolve through the issuer TOML:
    one batched /ledger-key/accounts call covering every such issuer, then the
    TOML fetches in parallel. A newly listed image replaces the cached entry; a
    failed fetch, unreachable domain, or TOML with no image is a no-op.
  5. Reset the TTL.

Suggested TTL: 7 days, matching the <storageKey>_date convention in
background/helpers/cachedFetch.ts. One scalar key beside the icon map — an
absent value reads as expired, so existing installs refresh on first run. No
entry-shape change, no migration.

Mechanically this is one change to the pass-2 guard:

if (Object.keys(assetsWithoutIcons).length > 0 || refreshDue)

The balance-change guards upstream stay as they are. Because the
previous-balances ref is per-mount and the popup remounts on every open, that
guard passes once per popup open — so the TTL gets its chance without any new
alarm, service-worker timer, or trigger surface.

Constraints

Each is worth a test.

  • Prefer a stale icon over no icon. No candidates, none loaded, TOML fetch
    failed, TOML lists no image — in all of them, keep what is cached.
  • The render path never waits. The cache read stays synchronous: a cache hit
    renders immediately, with no list fetch. The refresh runs in the existing
    async post-render pass and swaps a replaced URL in afterward.
  • The swap has to survive the memo. shouldAssetIconSkipUpdate
    compares assetIcons, isSuspicious and isMalicious — not icon. If the
    prop carrying the candidates is not in the comparator, the refresh resolves
    correctly and renders nothing, and the whole mechanism is silently inert.
  • Loading a candidate is confined to the refresh. Nothing probes a URL ahead
    of render — that shape was explored in fix(icons): keep the asset icon that actually loads, not the first one listed #2993 and rejected. The only
    load-before-adopt is refresh step 3: after render, at most once per TTL, and
    only for assets whose candidate set actually changed. It is needed because
    adopting optimistically risks replacing a working icon with a broken one.
  • The refresh honours source precedence. A TOML lookup happens only for
    assets no enabled list carries; an asset the lists answer must never trigger a
    ledger-key or TOML request, on any path.

Source precedence

getAssetIcons already prefers the lists. Two places do not:

  • Sign-transaction resolves TOML before the lists — getIconUrlFromIssuer first,
    reaching the lists only on a miss, and again in the second branch at
    :276.
    Paying two round trips for assets already answerable in memory. Flip to match
    every other surface.
  • retryAssetIcon consults only the TOML, abandoning the preferred source
    the moment an icon fails. Replaced here by the candidate walk, which is
    list-first by construction.

TOML is resolved lazily, only once the list candidates are exhausted — never
fetched merely to build a candidate list, which would put two round trips per
asset on the resolution path.

The refresh upgrades TOML-derived entries to list URLs. Such a URL is by
definition not among the list candidates, so once a list starts carrying the
asset the refresh adopts one. This falls out of the refresh rule with no special
case and is how list-preference holds over time rather than only at first
resolution — do not optimize by skipping TOML-derived entries.

The recurring fan-out to issuer-controlled domains is an accepted tradeoff; the
TTL length is the lever if it ever matters.

Surfaces

The account view, asset search, sign-transaction rows and history rows resolve
icons three different ways today, and search renders list URLs directly,
bypassing the icon cache entirely — so a picker and the account list can
disagree within a single session. All four should share one resolution path:
same candidates, same fallback, same cache.

Cost

  • In-memory: the three default lists total ~148 records. Per-asset
    resolution today is O(A·N); indexed, the whole pass is O(N + A).
  • Network, steady state: unchanged. The cache read serves every render.
  • Network, refresh tick: ~54 KB across the three default lists plus the
    SEP-0042 schema, sub-second in parallel, off the critical path, at most once
    per TTL window. Plus one image load per asset whose candidates changed
    (normally none), and for assets no list carries, one batched ledger-key call
    plus a parallel TOML fetch each.

Adjacent cleanup

Contract matching uses new RegExp(contractId, "i")
where a case-insensitive equality check is meant — currently a substring match,
compiled once per record inside the inner loop. Same function, worth fixing
while it is open.

Acceptance criteria

  1. An asset carried by two lists, where the higher-priority list's URL does not
    load, renders the second list's icon rather than the broken-image glyph.
  2. That fallback adds no network request before first paint; a cache hit still
    renders at current speed.
  3. An asset whose list entry URL changes upstream picks up the new URL within
    one TTL window, without the icon blanking in the meantime.
  4. An asset dropped from every list keeps rendering its cached icon.
  5. A refresh in which no new candidate loads leaves the cached icon in place.
  6. The refresh issues no ledger-key or TOML request for any asset an enabled
    list carries, and at most one batched ledger-key call in total.
  7. An asset no list carries adopts a new icon when its issuer's TOML lists a
    different one.
  8. That same asset keeps its cached icon when the TOML fetch fails, the domain
    is unreachable, or the TOML no longer lists an image.
  9. An asset whose icon was TOML-derived adopts a list URL once any enabled list
    starts carrying it.
  10. Sign-transaction resolves an asset the lists carry without issuing a
    ledger-key or TOML request.
  11. A URL that fails after a successful render recovers on the next candidate
    without requiring the popup to be reopened.

Related: #2994 (ordering fix this builds on), #2993 (probe approach,
rejected), #2992 (proposes expiring the cache instead; recommend closing as
superseded).

Activity

  1. CassioMG commented on Sep 14, 2026

    @CassioMG
    Contributor

    We should double check the TTL on mobile and adjust to 7 days as well. Also check if there is other stuff to be adjusted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions