Skip to content

Commit f5e436e

Browse files
imblue-dabadeesethkfman
andauthored
feat: malicious token screening on transactions (#22688)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Introduces token screening on incoming tokens received in transactions. This comes in the form of two different alerts (yellow and red). ## **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: Added an alert if an incoming token is malicious or suspicious. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Token screening on incoming tokens in transactions Scenario: user initiates a transaction where they receive malicious tokens Given the user is on the `Transaction request` screen And they are receiving tokens that is flagged as malicious When user views the screen Then they will see a red alert on `You receive` And a red `Review alert` button Scenario: user initiates a transaction where they receive suspicious tokens Given the user is on the `Transaction request` screen And they are receiving tokens that is flagged as suspicious When user views the screen Then they will see a yellow alert on `You receive` ``` - For a malicious token, you can swap for `0x69e8b9528cabda89fe846c67675b5d73d463a916` on a swap website. - For a suspicious token, you can swap for `0xd0cd466b34a24fcb2f87676278af2005ca8a78c4` on a swap website. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> **1. No yellow alert** <img width="402" height="853" alt="Screenshot 2025-11-18 at 12 00 50 PM" src="https://github.com/user-attachments/assets/c176193c-26ff-44eb-9b10-160381da3356" /> **2. No red alert** <img width="398" height="843" alt="Screenshot 2025-11-18 at 12 01 34 PM" src="https://github.com/user-attachments/assets/148f9c4e-6f5c-4d79-b0d2-11932c39dbfb" /> ### **After** <!-- [screenshots/recordings] --> **1. Yellow alert** <img width="386" height="841" alt="Screenshot 2025-11-18 at 11 41 51 AM" src="https://github.com/user-attachments/assets/4028a106-357a-42fd-a7e9-2b60301b1185" /> **2. Red alert** <img width="383" height="618" alt="Screenshot 2025-11-19 at 9 15 25 AM" src="https://github.com/user-attachments/assets/01e04dfe-5abc-42fa-84f6-dca1bf05477c" /> **When you click the inline alert** <img width="403" height="667" alt="Screenshot 2025-11-19 at 9 15 40 AM" src="https://github.com/user-attachments/assets/1ddeed60-64e7-4c2e-8305-3fecca11e715" /> **When you click the 'Review alert' button** <img width="389" height="641" alt="Screenshot 2025-11-19 at 9 17 41 AM" src="https://github.com/user-attachments/assets/9c305ec1-534e-49a6-bc8c-6ea39a4d28cc" /> ## **Pre-merge author checklist** - [ ] 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. ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds malicious/suspicious token screening to transaction confirmations with inline alerts, selectors, strings, and tests. > > - **Confirmations UI/UX**: > - Show `AlertRow` on `BalanceChangeRow` label when `hasIncomingTokens` is true, with style override in `alert-row.styles`. > - Compute `hasIncomingTokens` in `BalanceChangeList` and pass to rows. > - **Alerts System**: > - New `useTokenTrustSignalAlerts` hook to derive alerts from token scan results; integrated into `useConfirmationAlerts`. > - Added `RowAlertKey.IncomingTokens` and new alert keys `TokenTrustSignalMalicious`/`TokenTrustSignalWarning` with metrics mappings. > - **State Selectors**: > - New `selectMultipleTokenScanResults` to read `PhishingController.tokenScanCache` for multiple tokens. > - **Localization**: > - English strings for malicious/suspicious token alerts. > - **Dependencies**: > - Upgrade `@metamask/phishing-controller` to `^16.1.0`. > - **Tests**: > - Unit tests for `BalanceChangeRow`, `AlertRow`, `useTokenTrustSignalAlerts`, `useConfirmationAlerts`, and phishing selectors. > - **Test fixtures**: > - Update initial background state to include `addressScanCache` and `tokenScanCache`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ff7ae25. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: sethkfman <setk.kaufman@consensys.net>
1 parent df8533b commit f5e436e

File tree

19 files changed

+786
-17
lines changed

19 files changed

+786
-17
lines changed

app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import BalanceChangeRow from '../BalanceChangeRow/BalanceChangeRow';
88
import { BalanceChange } from '../types';
99
import { TotalFiatDisplay } from '../FiatDisplay/FiatDisplay';
1010
import styleSheet from './BalanceChangeList.styles';
11+
1112
interface BalanceChangeListProperties extends ViewProps {
1213
heading: string;
1314
balanceChanges: BalanceChange[];
@@ -28,6 +29,12 @@ const BalanceChangeList: React.FC<BalanceChangeListProperties> = ({
2829
[sortedBalanceChanges],
2930
);
3031

32+
const hasIncomingTokens = useMemo(
33+
() =>
34+
balanceChanges.some((balanceChange) => balanceChange.amount.isPositive()),
35+
[balanceChanges],
36+
);
37+
3138
if (sortedBalanceChanges.length === 0) {
3239
return null;
3340
}
@@ -45,6 +52,7 @@ const BalanceChangeList: React.FC<BalanceChangeListProperties> = ({
4552
label={index === 0 ? heading : undefined}
4653
balanceChange={balanceChange}
4754
showFiat={!showFiatTotal}
55+
hasIncomingTokens={hasIncomingTokens}
4856
/>
4957
))}
5058
{showFiatTotal && (

app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,41 @@ describe('BalanceChangeList', () => {
8080

8181
expect(getByTestId('edit-spending-cap-button')).toBeTruthy();
8282
});
83+
84+
it('renders an alert row if there are incoming tokens and a label is provided', () => {
85+
const { getByTestId, queryByTestId } = render(
86+
<BalanceChangeRow
87+
showFiat={false}
88+
balanceChange={balanceChangeMock}
89+
label="You received"
90+
hasIncomingTokens
91+
/>,
92+
);
93+
expect(getByTestId('info-row')).toBeTruthy();
94+
expect(queryByTestId('balance-change-row-label')).toBeNull();
95+
});
96+
97+
it('does not render an alert row if there are no incoming tokens', () => {
98+
const { getByTestId, queryByTestId } = render(
99+
<BalanceChangeRow
100+
showFiat={false}
101+
balanceChange={balanceChangeMock}
102+
label="You received"
103+
hasIncomingTokens={false}
104+
/>,
105+
);
106+
expect(getByTestId('balance-change-row-label')).toBeTruthy();
107+
expect(queryByTestId('info-row')).toBeNull();
108+
});
109+
110+
it('does not render an alert row if no label is provided', () => {
111+
const { queryByTestId } = render(
112+
<BalanceChangeRow
113+
showFiat={false}
114+
balanceChange={balanceChangeMock}
115+
hasIncomingTokens
116+
/>,
117+
);
118+
expect(queryByTestId('info-row')).toBeNull();
119+
});
83120
});

app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import AmountPill from '../AmountPill/AmountPill';
1414
import AssetPill from '../AssetPill/AssetPill';
1515
import { IndividualFiatDisplay } from '../FiatDisplay/FiatDisplay';
1616
import styleSheet from './BalanceChangeRow.styles';
17+
import AlertRow from '../../../Views/confirmations/components/UI/info-row/alert-row';
18+
import { RowAlertKey } from '../../../Views/confirmations/components/UI/info-row/alert-row/constants';
19+
import alertRowStyleSheet from '../../../Views/confirmations/components/UI/info-row/alert-row/alert-row.styles';
1720

1821
interface BalanceChangeRowProperties extends ViewProps {
1922
approveMethod?: ApproveMethod;
@@ -24,6 +27,7 @@ interface BalanceChangeRowProperties extends ViewProps {
2427
newSpendingCap: string,
2528
) => Promise<void>;
2629
showFiat?: boolean;
30+
hasIncomingTokens?: boolean;
2731
}
2832

2933
const BalanceChangeRow: React.FC<BalanceChangeRowProperties> = ({
@@ -32,23 +36,42 @@ const BalanceChangeRow: React.FC<BalanceChangeRowProperties> = ({
3236
label,
3337
onApprovalAmountUpdate,
3438
showFiat,
39+
hasIncomingTokens,
3540
}) => {
3641
const { styles } = useStyles(styleSheet, {});
42+
const { styles: alertRowStyles } = useStyles(alertRowStyleSheet, {});
3743
const { asset, amount, fiatAmount, isAllApproval, isUnlimitedApproval } =
3844
balanceChange;
3945
const isERC20 = balanceChange.asset.type === AssetType.ERC20;
4046
const shouldShowEditSpendingCapButton = isERC20 && onApprovalAmountUpdate;
47+
48+
const renderLabel = () => {
49+
if (!label) {
50+
return null;
51+
}
52+
if (hasIncomingTokens) {
53+
return (
54+
<AlertRow
55+
alertField={RowAlertKey.IncomingTokens}
56+
label={label}
57+
style={alertRowStyles.alertRowOverride}
58+
/>
59+
);
60+
}
61+
return (
62+
<Text
63+
testID="balance-change-row-label"
64+
variant={TextVariant.BodyMDMedium}
65+
color={TextColor.Alternative}
66+
>
67+
{label}
68+
</Text>
69+
);
70+
};
71+
4172
return (
4273
<View style={styles.container}>
43-
{label && (
44-
<Text
45-
testID="balance-change-row-label"
46-
variant={TextVariant.BodyMDMedium}
47-
color={TextColor.Alternative}
48-
>
49-
{label}
50-
</Text>
51-
)}
74+
{renderLabel()}
5275
<View style={styles.pillContainer}>
5376
<View style={styles.pills}>
5477
{shouldShowEditSpendingCapButton ? (

app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const styleSheet = () =>
66
paddingBottom: 4,
77
paddingHorizontal: 8,
88
},
9+
alertRowOverride: {
10+
marginLeft: 0,
11+
paddingLeft: 0,
12+
},
913
});
1014

1115
export default styleSheet;

app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Severity } from '../../../../types/alerts';
66
import { IconName } from '../../../../../../../component-library/components/Icons/Icon';
77
import { useConfirmationAlertMetrics } from '../../../../hooks/metrics/useConfirmationAlertMetrics';
88
import { InfoRowVariant } from '../info-row';
9+
import styleSheet from './alert-row.styles';
910

1011
jest.mock('../../../../context/alert-system-context', () => ({
1112
useAlerts: jest.fn(),
@@ -135,4 +136,19 @@ describe('AlertRow', () => {
135136
expect(getByText(CHILDREN_MOCK)).toBeDefined();
136137
expect(queryByTestId('inline-alert')).toBeNull();
137138
});
139+
140+
it('renders with the given style if provided', () => {
141+
const props = { ...baseProps, style: { backgroundColor: 'red' } };
142+
const { getByTestId } = render(<AlertRow {...props} />);
143+
const infoRow = getByTestId('info-row');
144+
expect(infoRow.props.style.backgroundColor).toBe('red');
145+
});
146+
147+
it('renders with styles.infoRowOverride if no style is provided', () => {
148+
const styles = styleSheet();
149+
const { getByTestId } = render(<AlertRow {...baseProps} />);
150+
const infoRow = getByTestId('info-row');
151+
152+
expect(infoRow.props.style).toMatchObject(styles.infoRowOverride);
153+
});
138154
});

app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const AlertRow = ({
4444
const { fieldAlerts } = useAlerts();
4545
const alertSelected = fieldAlerts.find((a) => a.field === alertField);
4646
const { styles } = useStyles(styleSheet, {});
47-
const { rowVariant } = props;
47+
const { rowVariant, style } = props;
4848

4949
if (!alertSelected && isShownWithAlertsOnly) {
5050
return null;
@@ -66,7 +66,7 @@ const AlertRow = ({
6666
return (
6767
<InfoRow
6868
{...alertRowProps}
69-
style={isSmall ? undefined : styles.infoRowOverride}
69+
style={style ?? (isSmall ? undefined : styles.infoRowOverride)}
7070
labelChildren={inlineAlert}
7171
/>
7272
);

app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export enum RowAlertKey {
99
PayWithFee = 'payWithFee',
1010
PendingTransaction = 'pendingTransaction',
1111
RequestFrom = 'requestFrom',
12+
IncomingTokens = 'incomingTokens',
1213
}

app/components/Views/confirmations/constants/alerts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ export enum AlertKeys {
1313
PerpsDepositMinimum = 'perps_deposit_minimum',
1414
PerpsHardwareAccount = 'perps_hardware_account',
1515
SignedOrSubmitted = 'signed_or_submitted',
16+
TokenTrustSignalMalicious = 'token_trust_signal_malicious',
17+
TokenTrustSignalWarning = 'token_trust_signal_warning',
1618
}

app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa
1717
import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert';
1818
import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert';
1919
import { useBurnAddressAlert } from './useBurnAddressAlert';
20+
import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts';
2021

2122
jest.mock('./useBlockaidAlerts');
2223
jest.mock('./useDomainMismatchAlerts');
@@ -29,6 +30,7 @@ jest.mock('./useInsufficientPayTokenBalanceAlert');
2930
jest.mock('./useNoPayTokenQuotesAlert');
3031
jest.mock('./useInsufficientPredictBalanceAlert');
3132
jest.mock('./useBurnAddressAlert');
33+
jest.mock('./useTokenTrustSignalAlerts');
3234

3335
describe('useConfirmationAlerts', () => {
3436
const ALERT_MESSAGE_MOCK = 'This is a test alert message.';
@@ -133,6 +135,15 @@ describe('useConfirmationAlerts', () => {
133135
},
134136
];
135137

138+
const mockTokenTrustSignalAlerts: Alert[] = [
139+
{
140+
key: 'TokenTrustSignalAlert',
141+
title: 'Test Token Trust Signal Alert',
142+
message: ALERT_MESSAGE_MOCK,
143+
severity: Severity.Danger,
144+
},
145+
];
146+
136147
beforeEach(() => {
137148
jest.clearAllMocks();
138149
(useBlockaidAlerts as jest.Mock).mockReturnValue([]);
@@ -146,6 +157,7 @@ describe('useConfirmationAlerts', () => {
146157
(useNoPayTokenQuotesAlert as jest.Mock).mockReturnValue([]);
147158
(useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue([]);
148159
(useBurnAddressAlert as jest.Mock).mockReturnValue([]);
160+
(useTokenTrustSignalAlerts as jest.Mock).mockReturnValue([]);
149161
});
150162

151163
it('returns empty array if no alerts', () => {
@@ -211,6 +223,9 @@ describe('useConfirmationAlerts', () => {
211223
mockInsufficientPredictBalanceAlert,
212224
);
213225
(useBurnAddressAlert as jest.Mock).mockReturnValue(mockBurnAddressAlert);
226+
(useTokenTrustSignalAlerts as jest.Mock).mockReturnValue(
227+
mockTokenTrustSignalAlerts,
228+
);
214229
const { result } = renderHookWithProvider(() => useConfirmationAlerts(), {
215230
state: siweSignatureConfirmationState,
216231
});
@@ -225,6 +240,7 @@ describe('useConfirmationAlerts', () => {
225240
...mockNoPayTokenQuotesAlert,
226241
...mockInsufficientPredictBalanceAlert,
227242
...mockBurnAddressAlert,
243+
...mockTokenTrustSignalAlerts,
228244
...mockUpgradeAccountAlert,
229245
]);
230246
});

app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa
1111
import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert';
1212
import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert';
1313
import { useBurnAddressAlert } from './useBurnAddressAlert';
14+
import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts';
1415

1516
function useSignatureAlerts(): Alert[] {
1617
const domainMismatchAlerts = useDomainMismatchAlerts();
@@ -28,6 +29,7 @@ function useTransactionAlerts(): Alert[] {
2829
const noPayTokenQuotesAlert = useNoPayTokenQuotesAlert();
2930
const insufficientPredictBalanceAlert = useInsufficientPredictBalanceAlert();
3031
const burnAddressAlert = useBurnAddressAlert();
32+
const tokenTrustSignalAlerts = useTokenTrustSignalAlerts();
3133

3234
return useMemo(
3335
() => [
@@ -39,6 +41,7 @@ function useTransactionAlerts(): Alert[] {
3941
...noPayTokenQuotesAlert,
4042
...insufficientPredictBalanceAlert,
4143
...burnAddressAlert,
44+
...tokenTrustSignalAlerts,
4245
],
4346
[
4447
insufficientBalanceAlert,
@@ -49,6 +52,7 @@ function useTransactionAlerts(): Alert[] {
4952
noPayTokenQuotesAlert,
5053
insufficientPredictBalanceAlert,
5154
burnAddressAlert,
55+
tokenTrustSignalAlerts,
5256
],
5357
);
5458
}

0 commit comments

Comments
 (0)