Skip to content

Commit aa91983

Browse files
authored
feat: Implement extra validations and alert when sending burn address in EVM transfer transactions (#21772)
<!-- 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? --> This PR aims to integrate extra validations and alert when sending to burn address in EVM transfer transactions. The list of the additions: 1. Extend `isNative` check to check asset address when creating transaction in send flow 2. Implement new "burnAddress" alert in transfer confirmations - this will also be raised if nested transactions have burn address too. 3. Filter contacts that has burn address to prevent proceeding to confirmations ## **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` 4. 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: Implement extra validations and alert when sending burn address in EVM transfer transactions ## **Related issues** Fixes: MetaMask/MetaMask-planning#6124 ## **Manual testing steps** - You shouldn't be able to proceed to confirmation when you type `0x0000000000000000000000000000000000000000` as recipient - Only possibility is to get a confirmation with burn address alert is from test-dapp 1. Go into the section where you can type recipient 2. Type `0x0000000000000000000000000000000000000000` to "To" and type something in "Amount" 0x123 3. When you create transaction, you should be able to see confirmation ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="559" height="1062" alt="Screenshot 2025-10-28 at 11 46 23" src="https://github.com/user-attachments/assets/b4a13f94-7131-4622-9fb5-db30fd38a56b" /> <img width="559" height="1062" alt="Screenshot 2025-10-28 at 11 46 17" src="https://github.com/user-attachments/assets/6a792754-0721-45a0-87dd-80e129165c36" /> ## **Pre-merge author checklist** - [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. ## **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 burn-address detection across send/confirm flows (alerting, contact filtering, nested recipient support) and updates native-token checks for transaction typing. > > - **Confirmations**: > - **Burn-address alert**: New `useBurnAddressAlert`, `AlertKeys.BurnAddress`, `RowAlertKey.BurnAddress`, i18n strings, and metrics mapping; integrated into `useConfirmationAlerts` and rendered in `from-to-row` via `AlertRow`. > - **Recipient detection**: Refactor `useTransferRecipient` and add `useNestedTransactionTransferRecipients` to extract recipients from nested transactions. > - **UI**: `InfoRow` now supports optional `label` and renders `labelChildren` without a label; added `labelContainerWithoutLabel` style. > - **Send flow**: > - **Contacts**: `useContacts` filters out EVM burn addresses using exported `LOWER_CASED_BURN_ADDRESSES`. > - **Native token check**: `isNativeToken` now matches chain-specific native token addresses (`getNativeTokenAddress`); used in `prepareEVMTransaction`/`submitEvmTransaction` for typing. > - **Validation/Utils**: > - Export `LOWER_CASED_BURN_ADDRESSES` from `send-address-validations` for reuse. > - **Tests**: Added/updated unit tests for burn-address alert, confirmation alerts aggregation, contacts filtering, recipient hooks (incl. nested), and `generic.isNativeToken` behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2d0f1aa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e4821b5 commit aa91983

File tree

19 files changed

+655
-38
lines changed

19 files changed

+655
-38
lines changed

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
@@ -2,6 +2,7 @@ export enum RowAlertKey {
22
Amount = 'amount',
33
AccountTypeUpgrade = 'accountTypeUpgrade',
44
BatchedApprovals = 'batchedApprovals',
5+
BurnAddress = 'burnAddress',
56
Blockaid = 'blockaid',
67
EstimatedFee = 'estimatedFee',
78
PayWith = 'payWith',

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const styleSheet = (params: { theme: Theme }) => {
3333
paddingBottom: 8,
3434
paddingHorizontal: 8,
3535
},
36+
labelContainerWithoutLabel: {
37+
marginLeft: -4,
38+
},
3639
});
3740
};
3841

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import styleSheet from './info-row.styles';
1515
import CopyIcon from './copy-icon/copy-icon';
1616

1717
export interface InfoRowProps {
18-
label: string;
18+
label?: string;
1919
children?: ReactNode | string;
2020
onTooltipPress?: () => void;
2121
tooltip?: ReactNode;
@@ -48,6 +48,7 @@ const InfoRow = ({
4848
withIcon,
4949
}: InfoRowProps) => {
5050
const { styles } = useStyles(styleSheet, {});
51+
const hasLabel = Boolean(label);
5152

5253
const ValueComponent =
5354
typeof children === 'string' ? (
@@ -62,7 +63,7 @@ const InfoRow = ({
6263
style={{ ...styles.container, ...style }}
6364
testID={testID ?? 'info-row'}
6465
>
65-
{Boolean(label) && (
66+
{hasLabel && (
6667
<View style={styles.labelContainer}>
6768
<Text variant={TextVariant.BodyMDMedium} color={variant}>
6869
{label}
@@ -77,6 +78,16 @@ const InfoRow = ({
7778
)}
7879
</View>
7980
)}
81+
{!hasLabel && labelChildren && (
82+
<View
83+
style={{
84+
...styles.labelContainer,
85+
...styles.labelContainerWithoutLabel,
86+
}}
87+
>
88+
{labelChildren}
89+
</View>
90+
)}
8091
{valueOnNewLine ? null : ValueComponent}
8192
{copyText && (
8293
<CopyIcon textToCopy={copyText ?? ''} color={IconColor.Muted} />

app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import Icon, {
1212
} from '../../../../../../../component-library/components/Icons/Icon';
1313
import { NameType } from '../../../../../../UI/Name/Name.types';
1414
import { useTransferRecipient } from '../../../../hooks/transactions/useTransferRecipient';
15+
import { RowAlertKey } from '../../../UI/info-row/alert-row/constants';
1516
import InfoSection from '../../../UI/info-row/info-section';
17+
import AlertRow from '../../../UI/info-row/alert-row';
1618
import styleSheet from './from-to-row.styles';
1719

1820
const FromToRow = () => {
@@ -54,12 +56,15 @@ const FromToRow = () => {
5456
</View>
5557

5658
<View style={[styles.nameContainer, styles.rightNameContainer]}>
57-
<Name
58-
type={NameType.EthereumAddress}
59-
value={toAddress}
60-
variation={chainId}
61-
maxCharLength={MAX_CHAR_LENGTH}
62-
/>
59+
{/* Intentional empty label to trigger the alert row without a label */}
60+
<AlertRow alertField={RowAlertKey.BurnAddress}>
61+
<Name
62+
type={NameType.EthereumAddress}
63+
value={toAddress as string}
64+
variation={chainId}
65+
maxCharLength={MAX_CHAR_LENGTH}
66+
/>
67+
</AlertRow>
6368
</View>
6469
</View>
6570
</InfoSection>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum AlertKeys {
22
BatchedUnusedApprovals = 'batched_unused_approvals',
33
Blockaid = 'blockaid',
4+
BurnAddress = 'burn_address',
45
DomainMismatch = 'domain_mismatch',
56
InsufficientBalance = 'insufficient_balance',
67
InsufficientPayTokenBalance = 'insufficient_pay_token_balance',
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { useBurnAddressAlert } from './useBurnAddressAlert';
2+
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
3+
import {
4+
useNestedTransactionTransferRecipients,
5+
useTransferRecipient,
6+
} from '../transactions/useTransferRecipient';
7+
import { Severity } from '../../types/alerts';
8+
import { AlertKeys } from '../../constants/alerts';
9+
import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants';
10+
11+
jest.mock('../transactions/useTransferRecipient');
12+
13+
describe('useBurnAddressAlert', () => {
14+
const BURN_ADDRESS_1 = '0x0000000000000000000000000000000000000000';
15+
const BURN_ADDRESS_2 = '0x000000000000000000000000000000000000dead';
16+
const NORMAL_ADDRESS = '0x1234567890123456789012345678901234567890';
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
(useTransferRecipient as jest.Mock).mockReturnValue(undefined);
21+
(useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([]);
22+
});
23+
24+
it('returns empty array when no recipient addresses are present', () => {
25+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
26+
27+
expect(result.current).toEqual([]);
28+
});
29+
30+
it('returns empty array when transaction recipient is a normal address', () => {
31+
(useTransferRecipient as jest.Mock).mockReturnValue(NORMAL_ADDRESS);
32+
33+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
34+
35+
expect(result.current).toEqual([]);
36+
});
37+
38+
it('returns empty array when nested transaction recipients are normal addresses', () => {
39+
(useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([
40+
NORMAL_ADDRESS,
41+
'0xabcdef1234567890abcdef1234567890abcdef12',
42+
]);
43+
44+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
45+
46+
expect(result.current).toEqual([]);
47+
});
48+
49+
it('returns burn address alert when transaction recipient is first burn address', () => {
50+
(useTransferRecipient as jest.Mock).mockReturnValue(BURN_ADDRESS_1);
51+
52+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
53+
54+
expect(result.current).toHaveLength(1);
55+
expect(result.current[0]).toMatchObject({
56+
key: AlertKeys.BurnAddress,
57+
field: RowAlertKey.BurnAddress,
58+
severity: Severity.Danger,
59+
isBlocking: false,
60+
});
61+
expect(result.current[0].title).toBeDefined();
62+
expect(result.current[0].message).toBeDefined();
63+
});
64+
65+
it('returns burn address alert when transaction recipient is second burn address', () => {
66+
(useTransferRecipient as jest.Mock).mockReturnValue(BURN_ADDRESS_2);
67+
68+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
69+
70+
expect(result.current).toHaveLength(1);
71+
expect(result.current[0]).toMatchObject({
72+
key: AlertKeys.BurnAddress,
73+
field: RowAlertKey.BurnAddress,
74+
severity: Severity.Danger,
75+
isBlocking: false,
76+
});
77+
});
78+
79+
it('returns burn address alert when transaction recipient is burn address with uppercase letters', () => {
80+
(useTransferRecipient as jest.Mock).mockReturnValue(
81+
'0x0000000000000000000000000000000000000000'.toUpperCase(),
82+
);
83+
84+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
85+
86+
expect(result.current).toHaveLength(1);
87+
expect(result.current[0].key).toBe(AlertKeys.BurnAddress);
88+
});
89+
90+
it('returns burn address alert when nested transaction contains burn address', () => {
91+
(useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([
92+
NORMAL_ADDRESS,
93+
BURN_ADDRESS_1,
94+
]);
95+
96+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
97+
98+
expect(result.current).toHaveLength(1);
99+
expect(result.current[0]).toMatchObject({
100+
key: AlertKeys.BurnAddress,
101+
field: RowAlertKey.BurnAddress,
102+
severity: Severity.Danger,
103+
isBlocking: false,
104+
});
105+
});
106+
107+
it('returns burn address alert when nested transaction contains burn address with mixed case', () => {
108+
(useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([
109+
'0x000000000000000000000000000000000000dEaD',
110+
]);
111+
112+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
113+
114+
expect(result.current).toHaveLength(1);
115+
expect(result.current[0].key).toBe(AlertKeys.BurnAddress);
116+
});
117+
118+
it('returns single burn address alert when both transaction and nested transactions contain burn addresses', () => {
119+
(useTransferRecipient as jest.Mock).mockReturnValue(BURN_ADDRESS_1);
120+
(useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([
121+
BURN_ADDRESS_2,
122+
]);
123+
124+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
125+
126+
expect(result.current).toHaveLength(1);
127+
expect(result.current[0].key).toBe(AlertKeys.BurnAddress);
128+
});
129+
130+
it('returns single burn address alert when nested transactions contain multiple burn addresses', () => {
131+
(useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([
132+
BURN_ADDRESS_1,
133+
NORMAL_ADDRESS,
134+
BURN_ADDRESS_2,
135+
]);
136+
137+
const { result } = renderHookWithProvider(() => useBurnAddressAlert());
138+
139+
expect(result.current).toHaveLength(1);
140+
expect(result.current[0].key).toBe(AlertKeys.BurnAddress);
141+
});
142+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useMemo } from 'react';
2+
import { Alert, Severity } from '../../types/alerts';
3+
import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants';
4+
import { AlertKeys } from '../../constants/alerts';
5+
import { LOWER_CASED_BURN_ADDRESSES } from '../../utils/send-address-validations';
6+
import { strings } from '../../../../../../locales/i18n';
7+
import {
8+
useNestedTransactionTransferRecipients,
9+
useTransferRecipient,
10+
} from '../transactions/useTransferRecipient';
11+
12+
export function useBurnAddressAlert(): Alert[] {
13+
const transactionMetaRecipient = useTransferRecipient();
14+
const nestedTransactionRecipients = useNestedTransactionTransferRecipients();
15+
16+
const hasBurnAddressRecipient = useMemo(() => {
17+
const hasBurnAddressInTransactionMetaRecipient =
18+
LOWER_CASED_BURN_ADDRESSES.includes(
19+
transactionMetaRecipient?.toLowerCase() ?? '',
20+
);
21+
const hasBurnAddressNestedTransactionRecipient =
22+
nestedTransactionRecipients.some((recipient) =>
23+
LOWER_CASED_BURN_ADDRESSES.includes(recipient.toLowerCase()),
24+
);
25+
26+
return (
27+
hasBurnAddressInTransactionMetaRecipient ||
28+
hasBurnAddressNestedTransactionRecipient
29+
);
30+
}, [transactionMetaRecipient, nestedTransactionRecipients]);
31+
32+
return useMemo(() => {
33+
if (!hasBurnAddressRecipient) {
34+
return [];
35+
}
36+
37+
return [
38+
{
39+
key: AlertKeys.BurnAddress,
40+
field: RowAlertKey.BurnAddress,
41+
message: strings('alert_system.burn_address.message'),
42+
title: strings('alert_system.burn_address.title'),
43+
severity: Severity.Danger,
44+
isBlocking: false,
45+
},
46+
];
47+
}, [hasBurnAddressRecipient]);
48+
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa
1818
import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert';
1919
import { useInsufficientPayTokenNativeAlert } from './useInsufficientPayTokenNativeAlert';
2020
import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert';
21+
import { useBurnAddressAlert } from './useBurnAddressAlert';
2122

2223
jest.mock('./useBlockaidAlerts');
2324
jest.mock('./useDomainMismatchAlerts');
@@ -31,6 +32,7 @@ jest.mock('./useInsufficientPayTokenBalanceAlert');
3132
jest.mock('./useNoPayTokenQuotesAlert');
3233
jest.mock('./useInsufficientPayTokenNativeAlert');
3334
jest.mock('./useInsufficientPredictBalanceAlert');
35+
jest.mock('./useBurnAddressAlert');
3436

3537
describe('useConfirmationAlerts', () => {
3638
const ALERT_MESSAGE_MOCK = 'This is a test alert message.';
@@ -144,6 +146,14 @@ describe('useConfirmationAlerts', () => {
144146
severity: Severity.Danger,
145147
},
146148
];
149+
const mockBurnAddressAlert: Alert[] = [
150+
{
151+
key: 'BurnAddressAlert',
152+
title: 'Test Burn Address Alert',
153+
message: ALERT_MESSAGE_MOCK,
154+
severity: Severity.Danger,
155+
},
156+
];
147157

148158
beforeEach(() => {
149159
jest.clearAllMocks();
@@ -159,6 +169,7 @@ describe('useConfirmationAlerts', () => {
159169
(useNoPayTokenQuotesAlert as jest.Mock).mockReturnValue([]);
160170
(useInsufficientPayTokenNativeAlert as jest.Mock).mockReturnValue([]);
161171
(useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue([]);
172+
(useBurnAddressAlert as jest.Mock).mockReturnValue([]);
162173
});
163174

164175
it('returns empty array if no alerts', () => {
@@ -229,6 +240,7 @@ describe('useConfirmationAlerts', () => {
229240
(useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue(
230241
mockInsufficientPredictBalanceAlert,
231242
);
243+
(useBurnAddressAlert as jest.Mock).mockReturnValue(mockBurnAddressAlert);
232244
const { result } = renderHookWithProvider(() => useConfirmationAlerts(), {
233245
state: siweSignatureConfirmationState,
234246
});
@@ -244,6 +256,7 @@ describe('useConfirmationAlerts', () => {
244256
...mockNoPayTokenQuotesAlert,
245257
...mockInsufficientPayTokenNativeAlert,
246258
...mockInsufficientPredictBalanceAlert,
259+
...mockBurnAddressAlert,
247260
...mockUpgradeAccountAlert,
248261
]);
249262
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa
1212
import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert';
1313
import { useInsufficientPayTokenNativeAlert } from './useInsufficientPayTokenNativeAlert';
1414
import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert';
15+
import { useBurnAddressAlert } from './useBurnAddressAlert';
1516

1617
function useSignatureAlerts(): Alert[] {
1718
const domainMismatchAlerts = useDomainMismatchAlerts();
@@ -30,6 +31,7 @@ function useTransactionAlerts(): Alert[] {
3031
const noPayTokenQuotesAlert = useNoPayTokenQuotesAlert();
3132
const insufficientPayTokenNativeAlert = useInsufficientPayTokenNativeAlert();
3233
const insufficientPredictBalanceAlert = useInsufficientPredictBalanceAlert();
34+
const burnAddressAlert = useBurnAddressAlert();
3335

3436
return useMemo(
3537
() => [
@@ -42,6 +44,7 @@ function useTransactionAlerts(): Alert[] {
4244
...noPayTokenQuotesAlert,
4345
...insufficientPayTokenNativeAlert,
4446
...insufficientPredictBalanceAlert,
47+
...burnAddressAlert,
4548
],
4649
[
4750
insufficientBalanceAlert,
@@ -53,6 +56,7 @@ function useTransactionAlerts(): Alert[] {
5356
noPayTokenQuotesAlert,
5457
insufficientPayTokenNativeAlert,
5558
insufficientPredictBalanceAlert,
59+
burnAddressAlert,
5660
],
5761
);
5862
}

app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ function getAlertNames(alerts: Alert[]): string[] {
108108
const ALERTS_NAME_METRICS: AlertNameMetrics = {
109109
[AlertKeys.BatchedUnusedApprovals]: 'batched_unused_approvals',
110110
[AlertKeys.Blockaid]: 'blockaid',
111+
[AlertKeys.BurnAddress]: 'burn_address',
111112
[AlertKeys.DomainMismatch]: 'domain_mismatch',
112113
[AlertKeys.InsufficientBalance]: 'insufficient_balance',
113114
[AlertKeys.InsufficientPayTokenBalance]: 'insufficient_funds',

0 commit comments

Comments
 (0)