Skip to content

Commit 73f97de

Browse files
xavier-brochardclaudecursoragentzone-live
authored
fix: match Quick Buy token icon and chain badge styles to homepage token list (TSA-647) (MetaMask#31459)
## **Description** <!-- mms-check: type=text required=true --> The Quick Buy sheet rendered its token icon + chain badge differently from the rest of the app: the network badge was the fixed-size, square-cornered `BadgeNetwork` from the design system package, oversized relative to the token avatar, and token artwork resolution missed the homepage's checksummed-address CDN fallback (so tokens like CAKE showed different artwork than the homepage token list). Fix — reuse the homepage Token list pattern (`TokenListItem` / `AssetLogo`) via a new shared `QuickBuyTokenIcon` component: - Fully round design-system `AvatarToken`, wrapped in the component-library `BadgeWrapper`/`Badge` (variant `Network`, circular, scaled to half the avatar, bottom-right) — exactly what `TokenListItem` uses. - Same data sources: `getNetworkImageSource` for the badge; token image → `getBridgeTokenImageSource` (keeps natives working via SLIP-44 asset ids) → `getFallbackAssetImageUrls` (lowercased + checksummed CDN variants), cycled by the shared `useSmartImageFallback` hook. - Adopted in the "Pay with / Receive" footer pill (size Sm) and the token-select rows (size Md); removed the now-dead `BadgeNetwork` plumbing. Note: one expected `@typescript-eslint/no-deprecated` warning for the component-library `Badge` — the homepage `TokenListItem` carries the identical unsuppressed warning; matching it is intentional. Jira: [TSA-647](https://consensyssoftware.atlassian.net/browse/TSA-647) ## **Changelog** <!-- mms-check: type=changelog required=true --> CHANGELOG entry: Fixed token icon and network badge styling in the Quick Buy sheet to match the wallet token list ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: [TSA-647](https://consensyssoftware.atlassian.net/browse/TSA-647) ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Quick Buy token icon and chain badge styles Scenario: user compares Quick Buy icons with the homepage token list Given the user holds CAKE on BNB Chain When the user opens the Quick Buy sheet and the "Pay with" picker Then the token icon is fully round with the same artwork as the homepage token list And the network badge is circular, smaller than the token icon, anchored bottom-right ``` ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> ### **Before** NA ### **After** Tokens view: <img width="436" height="87" alt="Screenshot 2026-06-11 at 16 50 47" src="https://github.com/user-attachments/assets/20168d22-81a5-4564-9bd2-55a2b4b053f7" /> Quickbuy view: <img width="439" height="423" alt="Screenshot 2026-06-12 at 16 53 02" src="https://github.com/user-attachments/assets/8f88c9b4-9162-462c-8592-bc4b2fdf6176" /> <img width="418" height="383" alt="Screenshot 2026-06-11 at 16 51 51" src="https://github.com/user-attachments/assets/6f739090-579e-41ca-976e-941f1fc7c2b1" /> ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- Generated with the help of the pr-description AI skill --> [TSA-647]: https://consensyssoftware.atlassian.net/browse/TSA-647?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scoped UI and image-resolution changes in Quick Buy plus a small, tested adjustment to the shared image-fallback hook; no auth, payments, or data-path changes. > > **Overview** > Aligns **Quick Buy** token icons and chain badges with the homepage token list (TSA-647) by introducing **`QuickBuyTokenIcon`**, used in the pay-with footer pill and pay-with rows instead of design-system `BadgeNetwork` + ad hoc `AvatarToken` wiring. > > The shared component uses component-library **`BadgeWrapper`/`Badge` (Network)** for the circular bottom-right chain badge, and resolves artwork like **`AssetLogo`**: token image → bridge/native source → **`getFallbackAssetImageUrls`** (including checksummed CDN), with **`useSmartImageFallback`** cycling on load errors. > > **`useSmartImageFallback`** now **resets the fallback index when the `sources` array changes**, so switching tokens after image errors does not stick on a stale fallback index; coverage is added in hook and component tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ad88e1f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Xavier Brochard <xavier-brochard@users.noreply.github.com> Co-authored-by: Antonio Regadas <antonio.regadas@consensys.net>
1 parent 2f327cd commit 73f97de

6 files changed

Lines changed: 270 additions & 65 deletions

File tree

app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,56 @@ describe('useSmartImageFallback', () => {
135135
expect(result.current.source).toStrictEqual(updatedSources[0]);
136136
});
137137

138+
it('resets fallback index when sources change after onError cycles', () => {
139+
const initialSources = createSourcesFromUris([
140+
'https://example.com/reset-token-a-primary.png',
141+
'https://example.com/reset-token-a-fallback.png',
142+
]);
143+
const newSources = createSourcesFromUris([
144+
'https://example.com/reset-token-b-primary.png',
145+
'https://example.com/reset-token-b-fallback.png',
146+
]);
147+
148+
const { result, rerender } = renderHook(
149+
({ hookSources }) => useSmartImageFallback(hookSources),
150+
{ initialProps: { hookSources: initialSources } },
151+
);
152+
153+
act(() => {
154+
result.current.onError();
155+
});
156+
expect(result.current.source).toStrictEqual(initialSources[1]);
157+
158+
rerender({ hookSources: newSources });
159+
160+
expect(result.current.source).toStrictEqual(newSources[0]);
161+
});
162+
163+
it('preserves fallback index when sources reference changes but URIs stay the same', () => {
164+
const makeSources = () =>
165+
createSourcesFromUris([
166+
'https://example.com/stable-ref-primary.png',
167+
'https://example.com/stable-ref-fallback.png',
168+
]);
169+
170+
const { result, rerender } = renderHook(
171+
({ hookSources }) => useSmartImageFallback(hookSources),
172+
{ initialProps: { hookSources: makeSources() } },
173+
);
174+
175+
act(() => {
176+
result.current.onError();
177+
});
178+
const sourceAfterError = result.current.source;
179+
expect(sourceAfterError).toStrictEqual({
180+
uri: 'https://example.com/stable-ref-fallback.png',
181+
});
182+
183+
rerender({ hookSources: makeSources() });
184+
185+
expect(result.current.source).toStrictEqual(sourceAfterError);
186+
});
187+
138188
it('persists dead images across hook instances', () => {
139189
const sources = createSourcesFromUris([
140190
'https://example.com/persist-primary.png',

app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'react';
1+
import { useMemo, useRef, useState } from 'react';
22
import { ImageURISource } from 'react-native';
33
import { createRotatingSet } from './AssetLogo.utils';
44

@@ -11,6 +11,12 @@ export function useSmartImageFallback(sources: ImageURISource[]) {
1111
const LAST_ITEM_INDEX = validSources.length - 1;
1212

1313
const [index, setIndex] = useState(0);
14+
const uriKey = sources.map((s) => s.uri ?? '').join('\0');
15+
const prevUriKeyRef = useRef(uriKey);
16+
if (prevUriKeyRef.current !== uriKey) {
17+
prevUriKeyRef.current = uriKey;
18+
setIndex(0);
19+
}
1420
const currentSource: ImageURISource | undefined =
1521
validSources[index] || sources[sources.length - 1];
1622

app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,19 @@ import {
77
Text,
88
TextVariant,
99
TextColor,
10-
AvatarToken,
1110
AvatarTokenSize,
1211
Icon,
1312
IconColor,
1413
IconName,
1514
IconSize,
16-
BadgeWrapper,
17-
BadgeWrapperPosition,
18-
BadgeNetwork,
1915
} from '@metamask/design-system-react-native';
2016
import { TouchableOpacity } from 'react-native';
2117
import { strings } from '../../../../../../../../locales/i18n';
2218
import QuickBuyConfirmButton from '../QuickBuyConfirmButton';
2319
import QuickBuyBanners from '../QuickBuyBanners';
2420
import { useQuickBuyContext } from '../useQuickBuyContext';
2521
import { QuickBuyPercentageSlider } from './QuickBuyPercentageSlider';
26-
import { getNetworkImageSource } from '../../../../../../../util/networks';
27-
import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource';
22+
import QuickBuyTokenIcon from './QuickBuyTokenIcon';
2823

2924
const QuickBuyActionFooter: React.FC = () => {
3025
const {
@@ -41,7 +36,6 @@ const QuickBuyActionFooter: React.FC = () => {
4136
isHardwareSolanaBlocked,
4237
tradeMode,
4338
sourceToken,
44-
sourceChainId,
4539
sourceBalanceFiat,
4640
destBalanceFiat,
4741
destToken,
@@ -51,23 +45,9 @@ const QuickBuyActionFooter: React.FC = () => {
5145
} = useQuickBuyContext();
5246

5347
const pickerToken = tradeMode === 'sell' ? selectedDestStable : sourceToken;
54-
const pickerChainId =
55-
tradeMode === 'sell'
56-
? (selectedDestStable?.chainId as
57-
| import('@metamask/utils').Hex
58-
| undefined)
59-
: sourceChainId;
60-
// Both balances are driven by live, selector-backed state (TSA-632):
61-
// `sourceBalanceFiat` from `useLatestBalance` re-keyed off the live cached
62-
// balance, and `destBalanceFiat` resynced from the reactive receive-token
63-
// list. Either updates the pill the moment the underlying balance changes.
6448
const pickerBalanceFiat =
6549
tradeMode === 'sell' ? destBalanceFiat : sourceBalanceFiat;
6650

67-
const networkImage = pickerChainId
68-
? getNetworkImageSource({ chainId: pickerChainId })
69-
: undefined;
70-
7151
return (
7252
<Box twClassName="px-4 pb-4">
7353
{/* Slider — reduced top padding to tighten gap with the amount section */}
@@ -106,24 +86,10 @@ const QuickBuyActionFooter: React.FC = () => {
10686
gap={2}
10787
>
10888
{pickerToken ? (
109-
networkImage ? (
110-
<BadgeWrapper
111-
position={BadgeWrapperPosition.BottomRight}
112-
badge={<BadgeNetwork src={networkImage} />}
113-
>
114-
<AvatarToken
115-
size={AvatarTokenSize.Sm}
116-
name={pickerToken.symbol}
117-
src={getBridgeTokenImageSource(pickerToken)}
118-
/>
119-
</BadgeWrapper>
120-
) : (
121-
<AvatarToken
122-
size={AvatarTokenSize.Sm}
123-
name={pickerToken.symbol}
124-
src={getBridgeTokenImageSource(pickerToken)}
125-
/>
126-
)
89+
<QuickBuyTokenIcon
90+
token={pickerToken}
91+
size={AvatarTokenSize.Sm}
92+
/>
12793
) : null}
12894
<Text variant={TextVariant.BodySm} color={TextColor.TextDefault}>
12995
{pickerToken

app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import {
2-
AvatarToken,
32
AvatarTokenSize,
4-
BadgeNetwork,
5-
BadgeWrapper,
6-
BadgeWrapperPosition,
73
Box,
84
BoxAlignItems,
95
BoxFlexDirection,
@@ -17,10 +13,9 @@ import {
1713
} from '@metamask/design-system-react-native';
1814
import React, { useMemo } from 'react';
1915
import { TouchableOpacity } from 'react-native';
20-
import { getNetworkImageSource } from '../../../../../../../util/networks';
2116
import type { BridgeToken } from '../../../../../../UI/Bridge/types';
22-
import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource';
2317
import { getTokenKey } from '../tokenKey';
18+
import QuickBuyTokenIcon from './QuickBuyTokenIcon';
2419

2520
interface QuickBuyPayWithRowProps {
2621
token: BridgeToken;
@@ -51,7 +46,6 @@ const QuickBuyPayWithRow: React.FC<QuickBuyPayWithRowProps> = ({
5146
isSelected,
5247
onPress,
5348
}) => {
54-
const networkImage = getNetworkImageSource({ chainId: token.chainId });
5549
const tokenKey = getTokenKey(token);
5650
const cryptoBalanceLabel = useMemo(
5751
() => formatTokenBalance(token.balance, token.symbol),
@@ -71,24 +65,7 @@ const QuickBuyPayWithRow: React.FC<QuickBuyPayWithRowProps> = ({
7165
gap={4}
7266
twClassName={`px-4 py-2 ${isSelected ? 'bg-muted' : ''}`}
7367
>
74-
{networkImage ? (
75-
<BadgeWrapper
76-
position={BadgeWrapperPosition.BottomRight}
77-
badge={<BadgeNetwork src={networkImage} />}
78-
>
79-
<AvatarToken
80-
size={AvatarTokenSize.Md}
81-
name={token.symbol}
82-
src={getBridgeTokenImageSource(token)}
83-
/>
84-
</BadgeWrapper>
85-
) : (
86-
<AvatarToken
87-
size={AvatarTokenSize.Md}
88-
name={token.symbol}
89-
src={getBridgeTokenImageSource(token)}
90-
/>
91-
)}
68+
<QuickBuyTokenIcon token={token} size={AvatarTokenSize.Md} />
9269

9370
<Box twClassName="flex-1" gap={1}>
9471
<Box
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
AvatarToken,
3+
AvatarTokenSize,
4+
} from '@metamask/design-system-react-native';
5+
import { fireEvent, render, screen } from '@testing-library/react-native';
6+
import React from 'react';
7+
import { BADGENETWORK_TEST_ID } from '../../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants';
8+
import { getFallbackAssetImageUrls } from '../../../../../../UI/Assets/components/AssetLogo/AssetLogo.utils';
9+
import type { BridgeToken } from '../../../../../../UI/Bridge/types';
10+
import QuickBuyTokenIcon, {
11+
QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID,
12+
} from './QuickBuyTokenIcon';
13+
14+
// CAKE (PancakeSwap) on BSC — a token whose static-CDN artwork lives under the
15+
// checksummed address, the case TSA-647 calls out.
16+
const CAKE_ADDRESS = '0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82';
17+
const BSC_CHAIN_ID = '0x38';
18+
19+
const createToken = (overrides: Partial<BridgeToken> = {}): BridgeToken => ({
20+
symbol: 'CAKE',
21+
name: 'PancakeSwap',
22+
address: CAKE_ADDRESS,
23+
decimals: 18,
24+
chainId: BSC_CHAIN_ID,
25+
...overrides,
26+
});
27+
28+
describe('QuickBuyTokenIcon', () => {
29+
it('renders a round AvatarToken from the token image with the requested size', () => {
30+
const token = createToken({ image: 'https://example.com/cake-own.png' });
31+
32+
render(<QuickBuyTokenIcon token={token} size={AvatarTokenSize.Sm} />);
33+
34+
const avatar = screen.UNSAFE_getByType(AvatarToken);
35+
expect(avatar.props).toStrictEqual(
36+
expect.objectContaining({
37+
name: 'CAKE',
38+
src: { uri: 'https://example.com/cake-own.png' },
39+
size: AvatarTokenSize.Sm,
40+
}),
41+
);
42+
});
43+
44+
it('renders the component-library network badge used by the homepage token list', () => {
45+
const token = createToken({ image: 'https://example.com/cake-badge.png' });
46+
47+
render(<QuickBuyTokenIcon token={token} />);
48+
49+
expect(screen.getByTestId(BADGENETWORK_TEST_ID)).toBeOnTheScreen();
50+
});
51+
52+
it('falls back to the shared static-CDN token icon when the token has no image', () => {
53+
const token = createToken({ image: undefined });
54+
const expectedFallbackUrl = getFallbackAssetImageUrls(
55+
BSC_CHAIN_ID,
56+
CAKE_ADDRESS,
57+
)?.[0];
58+
59+
render(<QuickBuyTokenIcon token={token} />);
60+
61+
const avatar = screen.UNSAFE_getByType(AvatarToken);
62+
expect(avatar.props.src).toStrictEqual({ uri: expectedFallbackUrl });
63+
});
64+
65+
it('resolves the same fallback image list as the homepage token list', () => {
66+
// The hook receives the token image first, then the static-CDN fallbacks
67+
// (lowercased + checksummed address variants) used by AssetLogo.
68+
const token = createToken({ image: 'https://example.com/cake-list.png' });
69+
const fallbackUrls = getFallbackAssetImageUrls(BSC_CHAIN_ID, CAKE_ADDRESS);
70+
71+
render(<QuickBuyTokenIcon token={token} />);
72+
73+
fireEvent(
74+
screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID),
75+
'error',
76+
{ nativeEvent: {} },
77+
);
78+
79+
const avatar = screen.UNSAFE_getByType(AvatarToken);
80+
expect(avatar.props.src).toStrictEqual({ uri: fallbackUrls?.[0] });
81+
});
82+
83+
it('cycles through to the checksummed CDN variant when earlier images fail', () => {
84+
const token = createToken({ image: 'https://example.com/cake-cycle.png' });
85+
const fallbackUrls = getFallbackAssetImageUrls(BSC_CHAIN_ID, CAKE_ADDRESS);
86+
87+
render(<QuickBuyTokenIcon token={token} />);
88+
89+
// own image fails -> lowercase CDN fails -> checksummed CDN remains.
90+
fireEvent(
91+
screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID),
92+
'error',
93+
{ nativeEvent: {} },
94+
);
95+
fireEvent(
96+
screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID),
97+
'error',
98+
{ nativeEvent: {} },
99+
);
100+
101+
const avatar = screen.UNSAFE_getByType(AvatarToken);
102+
expect(avatar.props.src).toStrictEqual({ uri: fallbackUrls?.[1] });
103+
});
104+
});

0 commit comments

Comments
 (0)