Skip to content

Commit 469a6ad

Browse files
authored
feat: add security badge on trending list (#29112)
## **Description** Adds security badges to the trending tokens list to display token security status (Verified, Warning/Spam, Malicious) inline with token names. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Adds security badges to trending tokens list. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3103?atlOrigin=eyJpIjoiMGU5NTM2ZTYwOGE5NGE0YzlkMjAyNWVkZmQ1NGE4NDciLCJwIjoiamlyYS1zbGFjay1pbnQifQ ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> <img width="407" height="842" alt="Screenshot 2026-04-21 at 15 13 42" src="https://github.com/user-attachments/assets/54b4deba-5961-4880-9598-82562fa8b2f4" /> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Primarily UI presentation changes plus a small refactor to share badge-mapping logic; minimal impact beyond how security status is displayed. > > **Overview** > Adds inline Security & Trust badges to `TrendingTokenRowItem`, showing a verified icon or a labeled warning/malicious pill next to the token name, and hides the badge for `Benign`/missing security data. > > Centralizes badge mapping into a new `getSecurityBadgeConfig` helper in `securityUtils` (and refactors token details to use it), with new unit tests for the helper plus UI tests covering badge rendering and minor style tweaks to prevent name/badge truncation issues. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d3fcf07. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent cc0dafa commit 469a6ad

6 files changed

Lines changed: 286 additions & 33 deletions

File tree

app/components/UI/SecurityTrust/utils/securityUtils.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
TokenSecurityData,
23
TokenSecurityFeature,
34
TokenSecurityFinancialStats,
45
} from '../types';
@@ -8,6 +9,7 @@ import {
89
getTop10HoldingPct,
910
formatCompactSupply,
1011
getResultTypeConfig,
12+
getSecurityBadgeConfig,
1113
} from './securityUtils';
1214
import {
1315
TextColor,
@@ -363,4 +365,96 @@ describe('securityUtils', () => {
363365
expect(formatCompactSupply(5_000_000, 0)).toBe('5.00M');
364366
});
365367
});
368+
369+
describe('getSecurityBadgeConfig', () => {
370+
it('returns verified badge config for Verified result type', () => {
371+
const config = getSecurityBadgeConfig({
372+
resultType: 'Verified',
373+
features: [],
374+
} as unknown as TokenSecurityData);
375+
376+
expect(config).toEqual({
377+
icon: IconName.VerifiedFilled,
378+
iconColor: IconColor.PrimaryDefault,
379+
label: null,
380+
bg: null,
381+
textColor: undefined,
382+
});
383+
});
384+
385+
it('returns null for Benign result type', () => {
386+
const config = getSecurityBadgeConfig({
387+
resultType: 'Benign',
388+
features: [],
389+
} as unknown as TokenSecurityData);
390+
391+
expect(config).toBeNull();
392+
});
393+
394+
it('returns warning badge config for Warning result type', () => {
395+
const config = getSecurityBadgeConfig({
396+
resultType: 'Warning',
397+
features: [],
398+
} as unknown as TokenSecurityData);
399+
400+
expect(config).toEqual({
401+
icon: IconName.Warning,
402+
iconColor: IconColor.WarningDefault,
403+
label: strings('security_trust.risky'),
404+
bg: 'bg-warning-muted',
405+
textColor: TextColor.WarningDefault,
406+
});
407+
});
408+
409+
it('returns warning badge config for Spam result type', () => {
410+
const config = getSecurityBadgeConfig({
411+
resultType: 'Spam',
412+
features: [],
413+
} as unknown as TokenSecurityData);
414+
415+
expect(config).toEqual({
416+
icon: IconName.Warning,
417+
iconColor: IconColor.WarningDefault,
418+
label: strings('security_trust.risky'),
419+
bg: 'bg-warning-muted',
420+
textColor: TextColor.WarningDefault,
421+
});
422+
});
423+
424+
it('returns danger badge config for Malicious result type', () => {
425+
const config = getSecurityBadgeConfig({
426+
resultType: 'Malicious',
427+
features: [],
428+
} as unknown as TokenSecurityData);
429+
430+
expect(config).toEqual({
431+
icon: IconName.Danger,
432+
iconColor: IconColor.ErrorDefault,
433+
label: strings('security_trust.malicious'),
434+
bg: 'bg-error-muted',
435+
textColor: TextColor.ErrorDefault,
436+
});
437+
});
438+
439+
it('returns null for undefined securityData', () => {
440+
const config = getSecurityBadgeConfig(undefined);
441+
442+
expect(config).toBeNull();
443+
});
444+
445+
it('returns null for null securityData', () => {
446+
const config = getSecurityBadgeConfig(null);
447+
448+
expect(config).toBeNull();
449+
});
450+
451+
it('returns null for unknown result type', () => {
452+
const config = getSecurityBadgeConfig({
453+
resultType: 'Unknown' as TokenSecurityData['resultType'],
454+
features: [],
455+
} as unknown as TokenSecurityData);
456+
457+
expect(config).toBeNull();
458+
});
459+
});
366460
});

app/components/UI/SecurityTrust/utils/securityUtils.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export interface ResultTypeConfig {
1919
iconColor?: IconColor;
2020
}
2121

22+
export interface SecurityBadgeConfig {
23+
icon: IconName;
24+
iconColor: IconColor;
25+
label: string | null;
26+
bg: string | null;
27+
textColor: TextColor | undefined;
28+
}
29+
2230
export const getResultTypeConfig = (
2331
resultType: string | undefined,
2432
): ResultTypeConfig => {
@@ -282,3 +290,43 @@ export const formatCompactSupply = (
282290
}
283291
return adjusted.toFixed(0);
284292
};
293+
294+
/**
295+
* Get security badge configuration based on security data result type.
296+
* Returns null for Benign tokens (no badge needed).
297+
*/
298+
export const getSecurityBadgeConfig = (
299+
securityData: TokenSecurityData | null | undefined,
300+
): SecurityBadgeConfig | null => {
301+
switch (securityData?.resultType) {
302+
case 'Verified':
303+
return {
304+
icon: IconName.VerifiedFilled,
305+
iconColor: IconColor.PrimaryDefault,
306+
label: null,
307+
bg: null,
308+
textColor: undefined,
309+
};
310+
case 'Benign':
311+
return null;
312+
case 'Warning':
313+
case 'Spam':
314+
return {
315+
icon: IconName.Warning,
316+
iconColor: IconColor.WarningDefault,
317+
label: strings('security_trust.risky'),
318+
bg: 'bg-warning-muted',
319+
textColor: TextColor.WarningDefault,
320+
};
321+
case 'Malicious':
322+
return {
323+
icon: IconName.Danger,
324+
iconColor: IconColor.ErrorDefault,
325+
label: strings('security_trust.malicious'),
326+
bg: 'bg-error-muted',
327+
textColor: TextColor.ErrorDefault,
328+
};
329+
default:
330+
return null;
331+
}
332+
};

app/components/UI/TokenDetails/components/AssetOverviewContent.tsx

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { formatAddressToAssetId } from '@metamask/bridge-controller';
6161
import type { TokenSecurityData } from '@metamask/assets-controllers';
6262
import SecurityTrustEntryCard from '../../SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard';
6363
import type { TokenDetailsRouteParams } from '../constants/constants';
64+
import { getSecurityBadgeConfig } from '../../SecurityTrust/utils/securityUtils';
6465
import {
6566
Box,
6667
BoxFlexDirection,
@@ -333,39 +334,10 @@ const AssetOverviewContent: React.FC<AssetOverviewContentProps> = ({
333334
[isMerklClaimingEnabled, token.chainId, token.address],
334335
);
335336

336-
const securityBadge = useMemo(() => {
337-
switch (securityData?.resultType) {
338-
case 'Verified':
339-
return {
340-
icon: IconName.VerifiedFilled,
341-
iconColor: IconColor.PrimaryDefault,
342-
label: null,
343-
bg: null,
344-
textColor: undefined,
345-
};
346-
case 'Benign':
347-
return null;
348-
case 'Warning':
349-
case 'Spam':
350-
return {
351-
icon: IconName.Warning,
352-
iconColor: IconColor.WarningDefault,
353-
label: strings('security_trust.risky'),
354-
bg: 'bg-warning-muted',
355-
textColor: TextColor.WarningDefault,
356-
};
357-
case 'Malicious':
358-
return {
359-
icon: IconName.Danger,
360-
iconColor: IconColor.ErrorDefault,
361-
label: strings('security_trust.malicious'),
362-
bg: 'bg-error-muted',
363-
textColor: TextColor.ErrorDefault,
364-
};
365-
default:
366-
return null;
367-
}
368-
}, [securityData?.resultType]);
337+
const securityBadge = useMemo(
338+
() => getSecurityBadgeConfig(securityData),
339+
[securityData],
340+
);
369341

370342
const handleSecurityBadgePress = useCallback(() => {
371343
if (!securityData?.resultType || securityData.resultType === 'Benign')

app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ const styleSheet = (_params: { theme: Theme }) =>
2323
flexDirection: 'row',
2424
alignItems: 'center',
2525
gap: 4,
26+
flexShrink: 1,
27+
},
28+
tokenName: {
29+
flexShrink: 1,
2630
},
2731
rightContainer: {
2832
display: 'flex',

app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,4 +1727,92 @@ describe('TrendingTokenRowItem', () => {
17271727
expect(mockOnPress).not.toHaveBeenCalled();
17281728
});
17291729
});
1730+
1731+
describe('security badge', () => {
1732+
it('renders verified icon badge for verified tokens', () => {
1733+
const token = createMockToken({
1734+
securityData: {
1735+
resultType: 'Verified',
1736+
features: [],
1737+
} as unknown as TrendingAsset['securityData'],
1738+
});
1739+
1740+
const { getByTestId } = renderWithProvider(
1741+
<TrendingTokenRowItem token={token} />,
1742+
{ state: mockState },
1743+
false,
1744+
);
1745+
1746+
expect(getByTestId('security-badge-icon')).toBeOnTheScreen();
1747+
});
1748+
1749+
it('renders warning badge with label for warning tokens', () => {
1750+
const token = createMockToken({
1751+
securityData: {
1752+
resultType: 'Warning',
1753+
features: [],
1754+
} as unknown as TrendingAsset['securityData'],
1755+
});
1756+
1757+
const { getByText } = renderWithProvider(
1758+
<TrendingTokenRowItem token={token} />,
1759+
{ state: mockState },
1760+
false,
1761+
);
1762+
1763+
expect(getByText('Risky')).toBeOnTheScreen();
1764+
});
1765+
1766+
it('renders malicious badge with label for malicious tokens', () => {
1767+
const token = createMockToken({
1768+
securityData: {
1769+
resultType: 'Malicious',
1770+
features: [],
1771+
} as unknown as TrendingAsset['securityData'],
1772+
});
1773+
1774+
const { getByText } = renderWithProvider(
1775+
<TrendingTokenRowItem token={token} />,
1776+
{ state: mockState },
1777+
false,
1778+
);
1779+
1780+
expect(getByText('Malicious')).toBeOnTheScreen();
1781+
});
1782+
1783+
it('does not render badge for benign tokens', () => {
1784+
const token = createMockToken({
1785+
securityData: {
1786+
resultType: 'Benign',
1787+
features: [],
1788+
} as unknown as TrendingAsset['securityData'],
1789+
});
1790+
1791+
const { queryByTestId, queryByText } = renderWithProvider(
1792+
<TrendingTokenRowItem token={token} />,
1793+
{ state: mockState },
1794+
false,
1795+
);
1796+
1797+
expect(queryByTestId('security-badge-icon')).toBeNull();
1798+
expect(queryByText('Risky')).toBeNull();
1799+
expect(queryByText('Malicious')).toBeNull();
1800+
});
1801+
1802+
it('does not render badge when securityData is undefined', () => {
1803+
const token = createMockToken({
1804+
securityData: undefined,
1805+
});
1806+
1807+
const { queryByTestId, queryByText } = renderWithProvider(
1808+
<TrendingTokenRowItem token={token} />,
1809+
{ state: mockState },
1810+
false,
1811+
);
1812+
1813+
expect(queryByTestId('security-badge-icon')).toBeNull();
1814+
expect(queryByText('Risky')).toBeNull();
1815+
expect(queryByText('Malicious')).toBeNull();
1816+
});
1817+
});
17301818
});

app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ import {
2222
isCaipChainId,
2323
parseCaipChainId,
2424
} from '@metamask/utils';
25+
import {
26+
Box,
27+
BoxFlexDirection,
28+
BoxAlignItems,
29+
Icon,
30+
IconSize,
31+
Text as DesignSystemText,
32+
TextVariant as DesignSystemTextVariant,
33+
FontWeight,
34+
} from '@metamask/design-system-react-native';
35+
import { getSecurityBadgeConfig } from '../../../SecurityTrust/utils/securityUtils';
2536

2637
/**
2738
* Converts CAIP chain ID to hex chain ID
@@ -223,6 +234,11 @@ const TrendingTokenRowItem = ({
223234
[caipChainId],
224235
);
225236

237+
const securityBadge = useMemo(
238+
() => getSecurityBadgeConfig(token.securityData),
239+
[token.securityData],
240+
);
241+
226242
// Parse price change percentage from API (comes as string like "-3.44" or "+0.456")
227243
// Use the correct field based on selected time option
228244
const priceChangeFieldKey = getPriceChangeFieldKey(selectedTimeOption);
@@ -335,9 +351,40 @@ const TrendingTokenRowItem = ({
335351
color={TextColor.Default}
336352
numberOfLines={1}
337353
ellipsizeMode="tail"
354+
style={styles.tokenName}
338355
>
339356
{token?.name ?? token?.symbol}
340357
</Text>
358+
{securityBadge && securityBadge.label === null && (
359+
<Icon
360+
name={securityBadge.icon}
361+
size={IconSize.Sm}
362+
color={securityBadge.iconColor}
363+
testID="security-badge-icon"
364+
/>
365+
)}
366+
{securityBadge && securityBadge.label !== null && (
367+
<Box
368+
flexDirection={BoxFlexDirection.Row}
369+
alignItems={BoxAlignItems.Center}
370+
twClassName={`rounded min-w-[22px] px-1.5 gap-1 shrink-0 ${securityBadge.bg}`}
371+
>
372+
<Icon
373+
name={securityBadge.icon}
374+
size={IconSize.Sm}
375+
color={securityBadge.iconColor}
376+
/>
377+
<DesignSystemText
378+
variant={DesignSystemTextVariant.BodySm}
379+
color={securityBadge.textColor}
380+
fontWeight={FontWeight.Medium}
381+
numberOfLines={1}
382+
twClassName="whitespace-nowrap"
383+
>
384+
{securityBadge.label}
385+
</DesignSystemText>
386+
</Box>
387+
)}
341388
</View>
342389
<Text variant={TextVariant.BodySM} color={TextColor.Alternative}>
343390
{formatMarketStats(

0 commit comments

Comments
 (0)