diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts index ce91f38650b9..65dcaf3ed72a 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts @@ -2,6 +2,7 @@ export enum RowAlertKey { Amount = 'amount', AccountTypeUpgrade = 'accountTypeUpgrade', BatchedApprovals = 'batchedApprovals', + BurnAddress = 'burnAddress', Blockaid = 'blockaid', EstimatedFee = 'estimatedFee', PayWith = 'payWith', diff --git a/app/components/Views/confirmations/components/UI/info-row/info-row.styles.ts b/app/components/Views/confirmations/components/UI/info-row/info-row.styles.ts index dcc88fb4bfc6..5eb1380b517d 100644 --- a/app/components/Views/confirmations/components/UI/info-row/info-row.styles.ts +++ b/app/components/Views/confirmations/components/UI/info-row/info-row.styles.ts @@ -33,6 +33,9 @@ const styleSheet = (params: { theme: Theme }) => { paddingBottom: 8, paddingHorizontal: 8, }, + labelContainerWithoutLabel: { + marginLeft: -4, + }, }); }; diff --git a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx index c6792c3a6312..833378372d5f 100644 --- a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx @@ -15,7 +15,7 @@ import styleSheet from './info-row.styles'; import CopyIcon from './copy-icon/copy-icon'; export interface InfoRowProps { - label: string; + label?: string; children?: ReactNode | string; onTooltipPress?: () => void; tooltip?: ReactNode; @@ -48,6 +48,7 @@ const InfoRow = ({ withIcon, }: InfoRowProps) => { const { styles } = useStyles(styleSheet, {}); + const hasLabel = Boolean(label); const ValueComponent = typeof children === 'string' ? ( @@ -62,7 +63,7 @@ const InfoRow = ({ style={{ ...styles.container, ...style }} testID={testID ?? 'info-row'} > - {Boolean(label) && ( + {hasLabel && ( {label} @@ -77,6 +78,16 @@ const InfoRow = ({ )} )} + {!hasLabel && labelChildren && ( + + {labelChildren} + + )} {valueOnNewLine ? null : ValueComponent} {copyText && ( diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx index 2b782f1e4695..b3ab3735c00d 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx @@ -12,7 +12,9 @@ import Icon, { } from '../../../../../../../component-library/components/Icons/Icon'; import { NameType } from '../../../../../../UI/Name/Name.types'; import { useTransferRecipient } from '../../../../hooks/transactions/useTransferRecipient'; +import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import InfoSection from '../../../UI/info-row/info-section'; +import AlertRow from '../../../UI/info-row/alert-row'; import styleSheet from './from-to-row.styles'; const FromToRow = () => { @@ -54,12 +56,15 @@ const FromToRow = () => { - + {/* Intentional empty label to trigger the alert row without a label */} + + + diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index 08977a67eb3e..fab61e762fc6 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -1,6 +1,7 @@ export enum AlertKeys { BatchedUnusedApprovals = 'batched_unused_approvals', Blockaid = 'blockaid', + BurnAddress = 'burn_address', DomainMismatch = 'domain_mismatch', InsufficientBalance = 'insufficient_balance', InsufficientPayTokenBalance = 'insufficient_pay_token_balance', diff --git a/app/components/Views/confirmations/hooks/alerts/useBurnAddressAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useBurnAddressAlert.test.ts new file mode 100644 index 000000000000..5d1041754e5f --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useBurnAddressAlert.test.ts @@ -0,0 +1,142 @@ +import { useBurnAddressAlert } from './useBurnAddressAlert'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { + useNestedTransactionTransferRecipients, + useTransferRecipient, +} from '../transactions/useTransferRecipient'; +import { Severity } from '../../types/alerts'; +import { AlertKeys } from '../../constants/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; + +jest.mock('../transactions/useTransferRecipient'); + +describe('useBurnAddressAlert', () => { + const BURN_ADDRESS_1 = '0x0000000000000000000000000000000000000000'; + const BURN_ADDRESS_2 = '0x000000000000000000000000000000000000dead'; + const NORMAL_ADDRESS = '0x1234567890123456789012345678901234567890'; + + beforeEach(() => { + jest.clearAllMocks(); + (useTransferRecipient as jest.Mock).mockReturnValue(undefined); + (useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([]); + }); + + it('returns empty array when no recipient addresses are present', () => { + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toEqual([]); + }); + + it('returns empty array when transaction recipient is a normal address', () => { + (useTransferRecipient as jest.Mock).mockReturnValue(NORMAL_ADDRESS); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toEqual([]); + }); + + it('returns empty array when nested transaction recipients are normal addresses', () => { + (useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([ + NORMAL_ADDRESS, + '0xabcdef1234567890abcdef1234567890abcdef12', + ]); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toEqual([]); + }); + + it('returns burn address alert when transaction recipient is first burn address', () => { + (useTransferRecipient as jest.Mock).mockReturnValue(BURN_ADDRESS_1); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + key: AlertKeys.BurnAddress, + field: RowAlertKey.BurnAddress, + severity: Severity.Danger, + isBlocking: false, + }); + expect(result.current[0].title).toBeDefined(); + expect(result.current[0].message).toBeDefined(); + }); + + it('returns burn address alert when transaction recipient is second burn address', () => { + (useTransferRecipient as jest.Mock).mockReturnValue(BURN_ADDRESS_2); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + key: AlertKeys.BurnAddress, + field: RowAlertKey.BurnAddress, + severity: Severity.Danger, + isBlocking: false, + }); + }); + + it('returns burn address alert when transaction recipient is burn address with uppercase letters', () => { + (useTransferRecipient as jest.Mock).mockReturnValue( + '0x0000000000000000000000000000000000000000'.toUpperCase(), + ); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].key).toBe(AlertKeys.BurnAddress); + }); + + it('returns burn address alert when nested transaction contains burn address', () => { + (useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([ + NORMAL_ADDRESS, + BURN_ADDRESS_1, + ]); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + key: AlertKeys.BurnAddress, + field: RowAlertKey.BurnAddress, + severity: Severity.Danger, + isBlocking: false, + }); + }); + + it('returns burn address alert when nested transaction contains burn address with mixed case', () => { + (useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([ + '0x000000000000000000000000000000000000dEaD', + ]); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].key).toBe(AlertKeys.BurnAddress); + }); + + it('returns single burn address alert when both transaction and nested transactions contain burn addresses', () => { + (useTransferRecipient as jest.Mock).mockReturnValue(BURN_ADDRESS_1); + (useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([ + BURN_ADDRESS_2, + ]); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].key).toBe(AlertKeys.BurnAddress); + }); + + it('returns single burn address alert when nested transactions contain multiple burn addresses', () => { + (useNestedTransactionTransferRecipients as jest.Mock).mockReturnValue([ + BURN_ADDRESS_1, + NORMAL_ADDRESS, + BURN_ADDRESS_2, + ]); + + const { result } = renderHookWithProvider(() => useBurnAddressAlert()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].key).toBe(AlertKeys.BurnAddress); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useBurnAddressAlert.ts b/app/components/Views/confirmations/hooks/alerts/useBurnAddressAlert.ts new file mode 100644 index 000000000000..c243d018fd7a --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useBurnAddressAlert.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { Alert, Severity } from '../../types/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { LOWER_CASED_BURN_ADDRESSES } from '../../utils/send-address-validations'; +import { strings } from '../../../../../../locales/i18n'; +import { + useNestedTransactionTransferRecipients, + useTransferRecipient, +} from '../transactions/useTransferRecipient'; + +export function useBurnAddressAlert(): Alert[] { + const transactionMetaRecipient = useTransferRecipient(); + const nestedTransactionRecipients = useNestedTransactionTransferRecipients(); + + const hasBurnAddressRecipient = useMemo(() => { + const hasBurnAddressInTransactionMetaRecipient = + LOWER_CASED_BURN_ADDRESSES.includes( + transactionMetaRecipient?.toLowerCase() ?? '', + ); + const hasBurnAddressNestedTransactionRecipient = + nestedTransactionRecipients.some((recipient) => + LOWER_CASED_BURN_ADDRESSES.includes(recipient.toLowerCase()), + ); + + return ( + hasBurnAddressInTransactionMetaRecipient || + hasBurnAddressNestedTransactionRecipient + ); + }, [transactionMetaRecipient, nestedTransactionRecipients]); + + return useMemo(() => { + if (!hasBurnAddressRecipient) { + return []; + } + + return [ + { + key: AlertKeys.BurnAddress, + field: RowAlertKey.BurnAddress, + message: strings('alert_system.burn_address.message'), + title: strings('alert_system.burn_address.title'), + severity: Severity.Danger, + isBlocking: false, + }, + ]; + }, [hasBurnAddressRecipient]); +} diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index db0c044218f5..9b3f5173f185 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -18,6 +18,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert'; import { useInsufficientPayTokenNativeAlert } from './useInsufficientPayTokenNativeAlert'; import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert'; +import { useBurnAddressAlert } from './useBurnAddressAlert'; jest.mock('./useBlockaidAlerts'); jest.mock('./useDomainMismatchAlerts'); @@ -31,6 +32,7 @@ jest.mock('./useInsufficientPayTokenBalanceAlert'); jest.mock('./useNoPayTokenQuotesAlert'); jest.mock('./useInsufficientPayTokenNativeAlert'); jest.mock('./useInsufficientPredictBalanceAlert'); +jest.mock('./useBurnAddressAlert'); describe('useConfirmationAlerts', () => { const ALERT_MESSAGE_MOCK = 'This is a test alert message.'; @@ -144,6 +146,14 @@ describe('useConfirmationAlerts', () => { severity: Severity.Danger, }, ]; + const mockBurnAddressAlert: Alert[] = [ + { + key: 'BurnAddressAlert', + title: 'Test Burn Address Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Danger, + }, + ]; beforeEach(() => { jest.clearAllMocks(); @@ -159,6 +169,7 @@ describe('useConfirmationAlerts', () => { (useNoPayTokenQuotesAlert as jest.Mock).mockReturnValue([]); (useInsufficientPayTokenNativeAlert as jest.Mock).mockReturnValue([]); (useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue([]); + (useBurnAddressAlert as jest.Mock).mockReturnValue([]); }); it('returns empty array if no alerts', () => { @@ -229,6 +240,7 @@ describe('useConfirmationAlerts', () => { (useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue( mockInsufficientPredictBalanceAlert, ); + (useBurnAddressAlert as jest.Mock).mockReturnValue(mockBurnAddressAlert); const { result } = renderHookWithProvider(() => useConfirmationAlerts(), { state: siweSignatureConfirmationState, }); @@ -244,6 +256,7 @@ describe('useConfirmationAlerts', () => { ...mockNoPayTokenQuotesAlert, ...mockInsufficientPayTokenNativeAlert, ...mockInsufficientPredictBalanceAlert, + ...mockBurnAddressAlert, ...mockUpgradeAccountAlert, ]); }); diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index 8e57005efa7b..d57c848fe71b 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -12,6 +12,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert'; import { useInsufficientPayTokenNativeAlert } from './useInsufficientPayTokenNativeAlert'; import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert'; +import { useBurnAddressAlert } from './useBurnAddressAlert'; function useSignatureAlerts(): Alert[] { const domainMismatchAlerts = useDomainMismatchAlerts(); @@ -30,6 +31,7 @@ function useTransactionAlerts(): Alert[] { const noPayTokenQuotesAlert = useNoPayTokenQuotesAlert(); const insufficientPayTokenNativeAlert = useInsufficientPayTokenNativeAlert(); const insufficientPredictBalanceAlert = useInsufficientPredictBalanceAlert(); + const burnAddressAlert = useBurnAddressAlert(); return useMemo( () => [ @@ -42,6 +44,7 @@ function useTransactionAlerts(): Alert[] { ...noPayTokenQuotesAlert, ...insufficientPayTokenNativeAlert, ...insufficientPredictBalanceAlert, + ...burnAddressAlert, ], [ insufficientBalanceAlert, @@ -53,6 +56,7 @@ function useTransactionAlerts(): Alert[] { noPayTokenQuotesAlert, insufficientPayTokenNativeAlert, insufficientPredictBalanceAlert, + burnAddressAlert, ], ); } diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index 6b047b263366..3a810a93a098 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -108,6 +108,7 @@ function getAlertNames(alerts: Alert[]): string[] { const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.BatchedUnusedApprovals]: 'batched_unused_approvals', [AlertKeys.Blockaid]: 'blockaid', + [AlertKeys.BurnAddress]: 'burn_address', [AlertKeys.DomainMismatch]: 'domain_mismatch', [AlertKeys.InsufficientBalance]: 'insufficient_balance', [AlertKeys.InsufficientPayTokenBalance]: 'insufficient_funds', diff --git a/app/components/Views/confirmations/hooks/send/useContacts.test.ts b/app/components/Views/confirmations/hooks/send/useContacts.test.ts index dd9a5562b500..4afd02b3201f 100644 --- a/app/components/Views/confirmations/hooks/send/useContacts.test.ts +++ b/app/components/Views/confirmations/hooks/send/useContacts.test.ts @@ -292,5 +292,33 @@ describe('useContacts', () => { '0x1234567890123456789012345678901234567890', ); }); + + it('filters out zero address burn address', () => { + const burnAddressBook = { + '1': { + contact1: mockEvmContact1, + burnContact: { + name: 'Burn Address', + address: '0x0000000000000000000000000000000000000000', + }, + }, + }; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectAddressBook) { + return burnAddressBook; + } + return {}; + }); + + const { result } = renderHook(() => useContacts()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].address).toBe(mockEvmContact1.address); + expect( + result.current.find( + (c) => c.address === '0x0000000000000000000000000000000000000000', + ), + ).toBeUndefined(); + }); }); }); diff --git a/app/components/Views/confirmations/hooks/send/useContacts.ts b/app/components/Views/confirmations/hooks/send/useContacts.ts index e552a1f910f7..132ec0bb350c 100644 --- a/app/components/Views/confirmations/hooks/send/useContacts.ts +++ b/app/components/Views/confirmations/hooks/send/useContacts.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { selectAddressBook } from '../../../../../selectors/addressBookController'; import { type RecipientType } from '../../components/UI/recipient'; +import { LOWER_CASED_BURN_ADDRESSES } from '../../utils/send-address-validations'; import { useSendType } from './useSendType'; export const useContacts = () => { @@ -26,10 +27,11 @@ export const useContacts = () => { }); return flattenedContacts.filter((contact) => { - // Only possibility to check if the address is EVM compatible because contacts are only EVM compatible as of now if (isEvmSendType) { return ( - contact.address.startsWith('0x') && contact.address.length === 42 + contact.address.startsWith('0x') && + contact.address.length === 42 && + !LOWER_CASED_BURN_ADDRESSES.includes(contact.address.toLowerCase()) ); } return true; diff --git a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts index 6a9329dccb76..0f8e4f31c3b6 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts @@ -3,7 +3,10 @@ import { merge } from 'lodash'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { transferConfirmationState } from '../../../../../util/test/confirm-data-helpers'; -import { useTransferRecipient } from './useTransferRecipient'; +import { + useNestedTransactionTransferRecipients, + useTransferRecipient, +} from './useTransferRecipient'; const nativeTransferState = merge({}, transferConfirmationState, { engine: { @@ -41,6 +44,138 @@ const erc20TransferState = merge({}, transferConfirmationState, { }, }); +const noNestedTransactionsState = merge({}, transferConfirmationState, { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.simpleSend, + txParams: { + from: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + to: '0x97cb1fdd071da9960d38306c07f146bc98b21231', + }, + }, + ], + }, + }, + }, +}); + +const singleNativeNestedTransactionState = merge( + {}, + transferConfirmationState, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.batch, + txParams: { + from: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }, + nestedTransactions: [ + { + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + data: '0x654365436543', + value: '0x3B9ACA00', + type: TransactionType.simpleSend, + }, + ], + }, + ], + }, + }, + }, + }, +); + +const singleErc20NestedTransactionState = merge({}, transferConfirmationState, { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.batch, + txParams: { + from: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }, + nestedTransactions: [ + { + to: '0x6b175474e89094c44da98b954eedeac495271d0f', + data: '0xa9059cbb00000000000000000000000097cb1fdd071da9960d38306c07f146bc98b2d31700000000000000000000000000000000000000000000000000000000000f4240', + type: TransactionType.tokenMethodTransfer, + }, + ], + }, + ], + }, + }, + }, +}); + +const multipleNestedTransactionsState = merge({}, transferConfirmationState, { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.batch, + txParams: { + from: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }, + nestedTransactions: [ + { + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + data: '0x654365436543', + value: '0x3B9ACA00', + type: TransactionType.simpleSend, + }, + { + to: '0xbc2114a988e9cef5ba63548d432024f34b487048', + data: '0x789078907890', + value: '0x1DCD6500', + type: TransactionType.simpleSend, + }, + ], + }, + ], + }, + }, + }, +}); + +const mixedNestedTransactionsState = merge({}, transferConfirmationState, { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.batch, + txParams: { + from: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }, + nestedTransactions: [ + { + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + data: '0x654365436543', + value: '0x3B9ACA00', + type: TransactionType.simpleSend, + }, + { + to: '0x6b175474e89094c44da98b954eedeac495271d0f', + data: '0xa9059cbb00000000000000000000000097cb1fdd071da9960d38306c07f146bc98b2d31700000000000000000000000000000000000000000000000000000000000f4240', + type: TransactionType.tokenMethodTransfer, + }, + ], + }, + ], + }, + }, + }, +}); + describe('useTransferRecipient', () => { it('returns the correct recipient for native transfer', async () => { const { result } = renderHookWithProvider(() => useTransferRecipient(), { @@ -58,3 +193,70 @@ describe('useTransferRecipient', () => { expect(result.current).toBe('0x97cb1fdD071da9960d38306C07F146bc98b2D317'); }); }); + +describe('useNestedTransactionTransferRecipients', () => { + it('returns empty array when no nested transactions', async () => { + const { result } = renderHookWithProvider( + () => useNestedTransactionTransferRecipients(), + { + state: noNestedTransactionsState, + }, + ); + + expect(result.current).toEqual([]); + }); + + it('returns the correct recipient for single native nested transaction', async () => { + const { result } = renderHookWithProvider( + () => useNestedTransactionTransferRecipients(), + { + state: singleNativeNestedTransactionState, + }, + ); + + expect(result.current).toEqual([ + '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + ]); + }); + + it('returns the correct recipient for single erc20 nested transaction', async () => { + const { result } = renderHookWithProvider( + () => useNestedTransactionTransferRecipients(), + { + state: singleErc20NestedTransactionState, + }, + ); + + expect(result.current).toEqual([ + '0x97cb1fdD071da9960d38306C07F146bc98b2D317', + ]); + }); + + it('returns the correct recipients for multiple native nested transactions', async () => { + const { result } = renderHookWithProvider( + () => useNestedTransactionTransferRecipients(), + { + state: multipleNestedTransactionsState, + }, + ); + + expect(result.current).toEqual([ + '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + '0xbc2114a988e9cef5ba63548d432024f34b487048', + ]); + }); + + it('returns the correct recipients for mixed nested transactions', async () => { + const { result } = renderHookWithProvider( + () => useNestedTransactionTransferRecipients(), + { + state: mixedNestedTransactionsState, + }, + ); + + expect(result.current).toEqual([ + '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + '0x97cb1fdD071da9960d38306C07F146bc98b2D317', + ]); + }); +}); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts index e872cb6d5f0f..03b428ab63be 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts @@ -1,22 +1,66 @@ -import { TransactionType } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, + NestedTransactionMetadata, +} from '@metamask/transaction-controller'; import { parseStandardTokenTransactionData } from '../../utils/transaction'; import { useTransactionMetadataRequest } from './useTransactionMetadataRequest'; -export function useTransferRecipient() { +export function useTransferRecipient(): string | undefined { const transactionMetadata = useTransactionMetadataRequest(); - const transactionData = parseStandardTokenTransactionData( - transactionMetadata?.txParams?.data, - ); if (!transactionMetadata) { - return null; + return undefined; } + return getRecipientFromTransactionMetadata(transactionMetadata); +} + +export function useNestedTransactionTransferRecipients(): string[] { + const transactionMetadata = useTransactionMetadataRequest(); + + if (!transactionMetadata?.nestedTransactions?.length) { + return []; + } + + return transactionMetadata.nestedTransactions + .map(getRecipientFromNestedTransactionMetadata) + .filter((recipient): recipient is string => recipient !== undefined); +} + +function getRecipientFromNestedTransactionMetadata( + nestedTransactionMetadata: NestedTransactionMetadata, +): string | undefined { + const { type, data, to } = nestedTransactionMetadata; + return getRecipientByType(type as TransactionType, data ?? '', to ?? ''); +} + +function getRecipientFromTransactionMetadata( + transactionMetadata: TransactionMeta, +): string | undefined { const { type, txParams } = transactionMetadata; - const { to: transactionTo } = txParams; + return getRecipientByType( + type as TransactionType, + txParams?.data ?? '', + txParams?.to ?? '', + ); +} + +function getRecipientByType( + type: TransactionType, + data: string, + transactionTo: string, +): string | undefined { + const dataRecipient = getTransactionDataRecipient(data); + const paramsRecipient = transactionTo; + + return type === TransactionType.simpleSend ? paramsRecipient : dataRecipient; +} + +function getTransactionDataRecipient(data: string): string | undefined { + const transactionData = parseStandardTokenTransactionData(data); - const transferTo = - transactionData?.args?._to || transactionData?.args?.to || transactionTo; + const transferTo = transactionData?.args?._to || transactionData?.args?.to; - return type === TransactionType.simpleSend ? transactionTo : transferTo; + return transferTo; } diff --git a/app/components/Views/confirmations/utils/generic.test.ts b/app/components/Views/confirmations/utils/generic.test.ts index 2a1e9db9cf53..898d64905152 100644 --- a/app/components/Views/confirmations/utils/generic.test.ts +++ b/app/components/Views/confirmations/utils/generic.test.ts @@ -1,24 +1,125 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { TokenI } from '../../../UI/Tokens/types'; import { getHostFromUrl, isNativeToken } from './generic'; describe('generic utils', () => { describe('getHostFromUrl', () => { - it('should return correct value', async () => { - expect(getHostFromUrl('')).toBe(undefined); - expect(getHostFromUrl('https://www.dummy.com')).toBe('www.dummy.com'); + it('returns undefined when url is empty', () => { + const result = getHostFromUrl(''); + + expect(result).toBe(undefined); + }); + + it('returns host when url is valid', () => { + const result = getHostFromUrl('https://www.dummy.com'); + + expect(result).toBe('www.dummy.com'); }); }); + describe('isNativeToken', () => { - it('should return correct value', async () => { - expect(isNativeToken({ isNative: true, isETH: false } as TokenI)).toBe( - true, - ); - expect(isNativeToken({ isNative: false, isETH: true } as TokenI)).toBe( - true, - ); - expect(isNativeToken({ isNative: false, isETH: false } as TokenI)).toBe( - false, - ); + it('returns true when isNative is true', () => { + const token = { + isNative: true, + isETH: false, + address: '0x1234', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(true); + }); + + it('returns true when isETH is true', () => { + const token = { + isNative: false, + isETH: true, + address: '0x1234', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(true); + }); + + it('returns true when address matches native token address for Ethereum mainnet', () => { + const token = { + isNative: false, + isETH: false, + address: '0x0000000000000000000000000000000000000000', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(true); + }); + + it('returns true when address matches native token address for Polygon', () => { + const token = { + isNative: false, + isETH: false, + address: '0x0000000000000000000000000000000000001010', + chainId: CHAIN_IDS.POLYGON, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(true); + }); + + it('returns false when all conditions are false', () => { + const token = { + isNative: false, + isETH: false, + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(false); + }); + + it('returns false when address does not match native token address', () => { + const token = { + isNative: false, + isETH: false, + address: '0x1234567890123456789012345678901234567890', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(false); + }); + + it('returns true when isNative is true regardless of address', () => { + const token = { + isNative: true, + isETH: false, + address: '0x1234567890123456789012345678901234567890', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(true); + }); + + it('returns true when isETH is true regardless of address', () => { + const token = { + isNative: false, + isETH: true, + address: '0x1234567890123456789012345678901234567890', + chainId: CHAIN_IDS.MAINNET, + } as TokenI; + + const result = isNativeToken(token); + + expect(result).toBe(true); }); }); }); diff --git a/app/components/Views/confirmations/utils/generic.ts b/app/components/Views/confirmations/utils/generic.ts index c2c7b7100395..47f4e3354ede 100644 --- a/app/components/Views/confirmations/utils/generic.ts +++ b/app/components/Views/confirmations/utils/generic.ts @@ -1,7 +1,9 @@ +import { Hex } from '@metamask/utils'; import Routes from '../../../../constants/navigation/Routes'; import Engine from '../../../../core/Engine'; import { NavigationRoute } from '../../../UI/Carousel/types'; import { TokenI } from '../../../UI/Tokens/types'; +import { getNativeTokenAddress } from './asset'; export const getHostFromUrl = (url: string) => { if (!url) { @@ -15,8 +17,13 @@ export const getHostFromUrl = (url: string) => { return; }; -export const isNativeToken = (selectedAsset: TokenI) => - selectedAsset.isNative || selectedAsset.isETH; +export const isNativeToken = (selectedAsset: TokenI) => { + const { isNative, isETH, chainId } = selectedAsset; + const nativeTokenAddress = getNativeTokenAddress(chainId as Hex); + const isNativeTokenAddress = selectedAsset.address === nativeTokenAddress; + + return isNative || isETH || isNativeTokenAddress; +}; export function createSmartAccountNavigationDetails(): NavigationRoute { if (Engine.context.PreferencesController.state.smartAccountOptIn === true) { diff --git a/app/components/Views/confirmations/utils/send-address-validations.ts b/app/components/Views/confirmations/utils/send-address-validations.ts index 160b61d9d919..accf771042c3 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.ts @@ -12,7 +12,7 @@ import { import { memoizedGetTokenStandardAndDetails } from './token'; import { isBtcMainnetAddress } from '../../../../core/Multichain/utils'; -const LOWER_CASED_BURN_ADDRESSES = [ +export const LOWER_CASED_BURN_ADDRESSES = [ '0x0000000000000000000000000000000000000000', '0x000000000000000000000000000000000000dead', ]; diff --git a/app/components/Views/confirmations/utils/send.ts b/app/components/Views/confirmations/utils/send.ts index 4df739f27e08..669ecf5822e4 100644 --- a/app/components/Views/confirmations/utils/send.ts +++ b/app/components/Views/confirmations/utils/send.ts @@ -244,7 +244,7 @@ export const submitEvmTransaction = async ({ const trxnParams = prepareEVMTransaction(asset, { from, to, value }); let transactionType; - if (asset.isNative) { + if (isNativeToken(asset)) { transactionType = TransactionType.simpleSend; } else if (asset.standard === TokenStandard.ERC20) { transactionType = TransactionType.tokenMethodTransfer; diff --git a/locales/languages/en.json b/locales/languages/en.json index b8c12cd80311..1c63b4b45d2b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -95,6 +95,10 @@ "perps_hardware_account": { "title": "Wallet not supported", "message": "Perps doesn't support hardware wallets.\nSwitch accounts to continue funding." + }, + "burn_address": { + "message": "You're sending your assets to a burn address. If you continue, you'll lose your assets.", + "title": "Sending Assets to Burn Address" } }, "blockaid_banner": {