From 10a519161471f06e8f954ea1529c33dfbbf45aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:05:49 +0000 Subject: [PATCH 1/7] feat: allow override remote feature flags (#13156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR introduces the new environment variable `OVERRIDE_REMOTE_FEATURE_FLAGS`. When set to `true`, remote feature flag selectors are forced to use their fallback values. Allows easy feature flag value manipulation for testing purposes. Update your `.js.env` with the new entry provided at the example file ## **Related issues** Fixes: https://github.com/MetaMask/mobile-planning/issues/2118 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. --- .js.env.example | 5 +++++ .../controllers/RemoteFeatureFlagController/utils.ts | 4 ++++ app/selectors/featureFlagController/index.ts | 10 +++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.js.env.example b/.js.env.example index 0d1d2764861..9f4843e39eb 100644 --- a/.js.env.example +++ b/.js.env.example @@ -113,3 +113,8 @@ export MM_PERMISSIONS_SETTINGS_V1_ENABLED="" # Feature flag for Stablecoin Lending UI export MM_STABLECOIN_LENDING_UI_ENABLED="true" + +# Activates remote feature flag override mode. +# Remote feature flag values won't be updated, +# and selectors should return their fallback values +export OVERRIDE_REMOTE_FEATURE_FLAGS="false" diff --git a/app/core/Engine/controllers/RemoteFeatureFlagController/utils.ts b/app/core/Engine/controllers/RemoteFeatureFlagController/utils.ts index 0e4dac6c947..490b835f7ab 100644 --- a/app/core/Engine/controllers/RemoteFeatureFlagController/utils.ts +++ b/app/core/Engine/controllers/RemoteFeatureFlagController/utils.ts @@ -29,6 +29,8 @@ const getFeatureFlagAppDistribution = () => { } }; +export const isRemoteFeatureFlagOverrideActivated = process.env.OVERRIDE_REMOTE_FEATURE_FLAGS; + export const createRemoteFeatureFlagController = ({ state, messenger, @@ -52,6 +54,8 @@ export const createRemoteFeatureFlagController = ({ if (disabled) { Logger.log('Feature flag controller disabled'); + } else if (isRemoteFeatureFlagOverrideActivated) { + Logger.log('Remote feature flags override activated'); } else { remoteFeatureFlagController.updateRemoteFeatureFlags().then(() => { Logger.log('Feature flags updated'); diff --git a/app/selectors/featureFlagController/index.ts b/app/selectors/featureFlagController/index.ts index ea1c0ebc7d4..f6b211db457 100644 --- a/app/selectors/featureFlagController/index.ts +++ b/app/selectors/featureFlagController/index.ts @@ -1,11 +1,15 @@ import { createSelector } from 'reselect'; import { StateWithPartialEngine } from './types'; +import { isRemoteFeatureFlagOverrideActivated } from '../../core/Engine/controllers/RemoteFeatureFlagController/utils'; export const selectRemoteFeatureFlagControllerState = (state: StateWithPartialEngine) => state.engine.backgroundState.RemoteFeatureFlagController; export const selectRemoteFeatureFlags = createSelector( selectRemoteFeatureFlagControllerState, - (remoteFeatureFlagControllerState) => - remoteFeatureFlagControllerState?.remoteFeatureFlags ?? {} -); + (remoteFeatureFlagControllerState) => { + if (isRemoteFeatureFlagOverrideActivated) { + return {}; + } + return remoteFeatureFlagControllerState?.remoteFeatureFlags ?? {}; + }); From caaa77ce4540bb80f0fe456eee3e3dc0b89362a5 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 31 Jan 2025 16:28:42 +0530 Subject: [PATCH 2/7] feat: Adding token value field typed sign data tree. (#13223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Created token value component for typed sign data tree. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/4034 ## **Manual testing steps** 1. Go to test dapp 2. Submit permit / seaport request 3. Check token value in data tree ## **Screenshots/Recordings** https://github.com/user-attachments/assets/1c1ec906-81e7-4bbc-a6cc-f26517783b7f ## **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. --- .../components/Confirm/DataTree/DataField.tsx | 21 ++++++++ .../Confirm/DataTree/DataTree.test.tsx | 16 ++++-- .../components/Confirm/DataTree/DataTree.tsx | 3 ++ .../Info/TypedSignV3V4/Message.test.tsx | 45 +++++++++++++++++ .../Confirm/Info/TypedSignV3V4/Message.tsx | 49 +++++++++++++++--- .../Info/TypedSignV3V4/TypedSignV3V4.test.tsx | 26 +++++++--- .../InfoValue/TokenValue/TokenValue.test.tsx | 48 ++++++++++++++++++ .../InfoValue/TokenValue/TokenValue.tsx | 36 +++++++++++++ .../UI/InfoRow/InfoValue/TokenValue/index.ts | 1 + ...useTokenDecimalsInTypedSignRequest.test.ts | 50 +++++++++++++++++++ .../useTokenDecimalsInTypedSignRequest.ts | 28 +++++++++++ .../confirmations/utils/signature.test.ts | 41 ++++++++++++++- .../Views/confirmations/utils/signature.ts | 35 ++++++++++--- .../confirmations/utils/signatures.test.ts | 17 +++++-- .../Views/confirmations/utils/signatures.ts | 8 ++- 15 files changed, 392 insertions(+), 32 deletions(-) create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.test.tsx create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.test.tsx create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.tsx create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/index.ts create mode 100644 app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.test.ts create mode 100644 app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.ts diff --git a/app/components/Views/confirmations/components/Confirm/DataTree/DataField.tsx b/app/components/Views/confirmations/components/Confirm/DataTree/DataField.tsx index 938fef0fd28..bec552dd2b9 100644 --- a/app/components/Views/confirmations/components/Confirm/DataTree/DataField.tsx +++ b/app/components/Views/confirmations/components/Confirm/DataTree/DataField.tsx @@ -13,6 +13,7 @@ import { import Address from '../../UI/InfoRow/InfoValue/Address'; import InfoDate from '../../UI/InfoRow/InfoValue/InfoDate'; import InfoRow from '../../UI/InfoRow'; +import TokenValue from '../../UI/InfoRow/InfoValue/TokenValue'; import DataTree from './DataTree'; enum Field { @@ -41,10 +42,25 @@ const FIELD_DATE_PRIMARY_TYPES: Record = { [Field.ValidTo]: [...PRIMARY_TYPES_ORDER], }; +const FIELD_TOKEN_UTILS_PRIMARY_TYPES: Record = { + [Field.Amount]: [...PRIMARY_TYPES_PERMIT], + [Field.BuyAmount]: [...PRIMARY_TYPES_ORDER], + [Field.EndAmount]: [...PRIMARY_TYPES_ORDER], + [Field.SellAmount]: [...PRIMARY_TYPES_ORDER], + [Field.StartAmount]: [...PRIMARY_TYPES_ORDER], + [Field.Value]: [...PRIMARY_TYPES_PERMIT], +}; + function isDateField(label: string, primaryType?: PrimaryType) { return (FIELD_DATE_PRIMARY_TYPES[label] || [])?.includes(primaryType || ''); } +function isTokenValueField(label: string, primaryType?: PrimaryType) { + return (FIELD_TOKEN_UTILS_PRIMARY_TYPES[label] || [])?.includes( + primaryType || '', + ); +} + const createStyles = (depth: number) => StyleSheet.create({ container: { @@ -66,6 +82,7 @@ const DataField = memo( label, primaryType, type, + tokenDecimals, value, }: { chainId: string; @@ -73,6 +90,7 @@ const DataField = memo( label: string; primaryType?: PrimaryType; type: string; + tokenDecimals?: number; value: string; }) => { const styles = createStyles(depth); @@ -88,6 +106,8 @@ const DataField = memo( ) : ( ); + } else if (isTokenValueField(label, primaryType)) { + fieldDisplay = ; } else if (typeof value === 'object' && value !== null) { fieldDisplay = ( ); } else { diff --git a/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.test.tsx b/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.test.tsx index 8cf8288c8ce..f7471cf71ae 100644 --- a/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.test.tsx @@ -37,12 +37,16 @@ const mockSanitizedTypedSignV3Message = { value: NONE_DATE_VALUE, type: 'uint256', }, + buyAmount: { + value: 10000, + type: 'uint256', + }, contents: { value: 'Hello, Bob!', type: 'string' }, }; describe('NoChangeSimulation', () => { - it('displays types sign v1 message', async () => { - const { getByText, queryByText } = renderWithProvider( + it('should display types sign v1 message correctly', async () => { + const { getByText } = renderWithProvider( { expect(getByText('Hi, Alice!')).toBeDefined(); expect(getByText('A Number')).toBeDefined(); expect(getByText('1337')).toBeDefined(); - // date field not supported for v1 - expect(queryByText('15 March 2022, 15:57')).toBeNull(); }); it('displays types sign v3/v4 message', async () => { @@ -72,6 +74,7 @@ describe('NoChangeSimulation', () => { data={mockSanitizedTypedSignV3Message as unknown as DataTreeInput} chainId="0x1" primaryType={PrimaryTypeOrder.Order} + tokenDecimals={2} />, { state: { @@ -88,7 +91,12 @@ describe('NoChangeSimulation', () => { expect(getByText('To')).toBeDefined(); expect(getByText('Bob')).toBeDefined(); // date field displayed for permit types + expect(getByText('End Time')).toBeDefined(); expect(getByText('15 March 2022, 15:57')).toBeDefined(); + expect(getByText('Start Time')).toBeDefined(); expect(getByText('None')).toBeDefined(); + // token value field + expect(getByText('Buy Amount')).toBeDefined(); + expect(getByText('100')).toBeDefined(); }); }); diff --git a/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.tsx b/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.tsx index 63664d6b6bd..72731a8085f 100644 --- a/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.tsx +++ b/app/components/Views/confirmations/components/Confirm/DataTree/DataTree.tsx @@ -18,11 +18,13 @@ const DataTree = ({ chainId, depth = 0, primaryType, + tokenDecimals, }: { data: DataTreeInput; chainId: string; depth?: number; primaryType?: PrimaryType; + tokenDecimals?: number; }) => ( {Object.keys(data).map((dataKey: string, index: number) => { @@ -34,6 +36,7 @@ const DataTree = ({ label={dataKey} key={`${dataKey}-${index}`} primaryType={primaryType} + tokenDecimals={tokenDecimals} {...datum} /> ); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.test.tsx new file mode 100644 index 00000000000..ce10035a81a --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; + +import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; +import { typedSignV4ConfirmationState } from '../../../../../../../util/test/confirm-data-helpers'; +// eslint-disable-next-line import/no-namespace +import * as SignatureRequestHook from '../../../../hooks/useSignatureRequest'; +import Message from './Message'; + +jest.mock('../../../../hooks/useTokenDecimalsInTypedSignRequest', () => ({ + useTokenDecimalsInTypedSignRequest: () => 2, +})); + +describe('Message', () => { + it('render correctly for V4 permit', async () => { + const { getAllByText, getByText } = renderWithProvider(, { + state: typedSignV4ConfirmationState, + }); + expect(getAllByText('Message')).toHaveLength(1); + fireEvent.press(getByText('Message')); + expect(getAllByText('Message')).toHaveLength(3); + expect(getByText('Primary type')).toBeDefined(); + expect(getByText('Permit')).toBeDefined(); + expect(getByText('Owner')).toBeDefined(); + expect(getByText('0x935E7...05477')).toBeDefined(); + expect(getByText('Spender')).toBeDefined(); + expect(getByText('0x5B38D...eddC4')).toBeDefined(); + expect(getByText('Value')).toBeDefined(); + expect(getByText('30')).toBeDefined(); + expect(getByText('Nonce')).toBeDefined(); + expect(getByText('0')).toBeDefined(); + expect(getByText('Deadline')).toBeDefined(); + expect(getByText('09 June 3554, 16:53')).toBeDefined(); + }); + + it('render null if signature request is not found', async () => { + jest + .spyOn(SignatureRequestHook, 'useSignatureRequest') + .mockReturnValue(undefined); + const { queryByText } = renderWithProvider(, { + state: typedSignV4ConfirmationState, + }); + expect(queryByText('Message')).toBeNull(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.tsx index 93ca51bbc5d..01eaf8080b1 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Message.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Hex } from '@metamask/utils'; +import React, { useMemo } from 'react'; +import { Hex, isValidHexAddress } from '@metamask/utils'; import { Text, View } from 'react-native'; import { parseSanitizeTypedDataMessage } from '../../../../utils/signatures'; @@ -8,11 +8,33 @@ import { useSignatureRequest } from '../../../../hooks/useSignatureRequest'; import { useStyles } from '../../../../../../../component-library/hooks'; import { useTypedSignSimulationEnabled } from '../../../../hooks/useTypedSignSimulationEnabled'; import InfoRow from '../../../UI/InfoRow'; +import { useTokenDecimalsInTypedSignRequest } from '../../../../hooks/useTokenDecimalsInTypedSignRequest'; import DataTree from '../../DataTree'; import SignatureMessageSection from '../../SignatureMessageSection'; import { DataTreeInput } from '../../DataTree/DataTree'; import styleSheet from './Message.styles'; +/** + * If a token contract is found within the dataTree, fetch the token decimal of this contract + * to be utilized for displaying token amounts of the dataTree. + * + * @param dataTreeData + */ +export const getTokenContractInDataTree = ( + dataTreeData: DataTreeInput, +): Hex | undefined => { + if (!dataTreeData || Array.isArray(dataTreeData)) { + return undefined; + } + + const tokenContract = dataTreeData.token?.value as Hex; + if (!tokenContract || !isValidHexAddress(tokenContract)) { + return undefined; + } + + return tokenContract; +}; + const Message = () => { const signatureRequest = useSignatureRequest(); const isSimulationSupported = useTypedSignSimulationEnabled(); @@ -23,13 +45,25 @@ const Message = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const typedSignData = signatureRequest?.messageParams?.data as any; - if (!typedSignData) { + const { + domain: { verifyingContract } = { verifyingContract: '' }, + sanitizedMessage, + primaryType, + } = useMemo( + () => parseSanitizeTypedDataMessage(typedSignData), + [typedSignData], + ); + + const tokenDecimals = useTokenDecimalsInTypedSignRequest( + signatureRequest, + sanitizedMessage?.value as unknown as DataTreeInput, + verifyingContract, + ); + + if (!signatureRequest) { return null; } - const { sanitizedMessage, primaryType } = - parseSanitizeTypedDataMessage(typedSignData); - return ( { {primaryType} } diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.test.tsx index 55d916500e0..b4e9e47d0fe 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; import { @@ -6,7 +7,6 @@ import { typedSignV4ConfirmationState, } from '../../../../../../../util/test/confirm-data-helpers'; import TypedSignV3V4 from './TypedSignV3V4'; -import { fireEvent } from '@testing-library/react-native'; jest.mock('../../../../../../../core/Engine', () => ({ resetState: jest.fn(), @@ -17,6 +17,10 @@ jest.mock('../../../../../../../core/Engine', () => ({ }, })); +jest.mock('../../../../hooks/useTokenDecimalsInTypedSignRequest', () => ({ + useTokenDecimalsInTypedSignRequest: () => 2, +})); + describe('TypedSignV3V4', () => { it('should contained required text', async () => { const { getByText } = renderWithProvider(, { @@ -29,7 +33,7 @@ describe('TypedSignV3V4', () => { expect(getByText('Mail')).toBeDefined(); }); - it('should not display primaty type if simulation section is displayed', async () => { + it('does not display first row (Primary Type) if simulation section is displayed', async () => { const { getByText, queryByText } = renderWithProvider(, { state: typedSignV4ConfirmationState, }); @@ -42,13 +46,21 @@ describe('TypedSignV3V4', () => { it('should show detailed message when message section is clicked', async () => { const { getByText, getAllByText } = renderWithProvider(, { - state: typedSignV3ConfirmationState, + state: typedSignV4ConfirmationState, }); fireEvent.press(getByText('Message')); expect(getAllByText('Message')).toHaveLength(3); - expect(getByText('From')).toBeDefined(); - expect(getByText('Cow')).toBeDefined(); - expect(getByText('To')).toBeDefined(); - expect(getByText('Bob')).toBeDefined(); + expect(getAllByText('Primary type')).toBeDefined(); + expect(getAllByText('Permit')).toBeDefined(); + expect(getAllByText('Owner')).toBeDefined(); + expect(getAllByText('0x935E7...05477')).toBeDefined(); + expect(getAllByText('Spender')).toBeDefined(); + expect(getAllByText('0x5B38D...eddC4')).toBeDefined(); + expect(getAllByText('Value')).toBeDefined(); + expect(getAllByText('30')).toBeDefined(); + expect(getAllByText('Nonce')).toBeDefined(); + expect(getAllByText('0')).toBeDefined(); + expect(getAllByText('Deadline')).toBeDefined(); + expect(getAllByText('09 June 3554, 16:53')).toBeDefined(); }); }); diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.test.tsx b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.test.tsx new file mode 100644 index 00000000000..2b041d73a0c --- /dev/null +++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.test.tsx @@ -0,0 +1,48 @@ +import BigNumber from 'bignumber.js'; +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import TokenValue from './TokenValue'; + +describe('TokenValue', () => { + it('should render value correctly', () => { + const { getByText } = render( + , + ); + expect(getByText('1')).toBeDefined(); + }); + + it('should render BigNumber value correctly', () => { + const { getByText } = render( + , + ); + expect(getByText('1')).toBeDefined(); + }); + + it('should handle small decimal values', () => { + const { getByText } = render(); + expect(getByText('0.001')).toBeDefined(); + }); + + it('should handle large numbers', () => { + const { getByText } = render( + , + ); + expect(getByText('123,456,789')).toBeDefined(); + }); + + it('should handle zero value', () => { + const { getByText } = render(); + expect(getByText('0')).toBeDefined(); + }); + + it('should handle very long value with undefined decimals', () => { + const { getByText } = render(); + expect(getByText('1,000,000,000,0...')).toBeDefined(); + }); + + it('should handle very small numbers', () => { + const { getByText } = render(); + expect(getByText('< 0.000001')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.tsx b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.tsx new file mode 100644 index 00000000000..4ca85cf22ac --- /dev/null +++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/TokenValue.tsx @@ -0,0 +1,36 @@ +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../../../../UI/SimulationDetails/formatAmount'; +import { calcTokenAmount } from '../../../../../../../../util/transactions'; +import { shortenString } from '../../../../../../../../util/notifications'; +import TextWithTooltip from '../../../TextWithTooltip'; + +interface TokenValueProps { + value: number | string | BigNumber; + decimals?: number; +} + +const TokenValue = ({ value, decimals }: TokenValueProps) => { + const tokenValue = calcTokenAmount(value, decimals); + + const tokenText = formatAmount('en-US', tokenValue); + const tokenTextMaxPrecision = formatAmountMaxPrecision('en-US', tokenValue); + + return ( + + ); +}; + +export default TokenValue; diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/index.ts b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/index.ts new file mode 100644 index 00000000000..2d11612bdae --- /dev/null +++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/TokenValue/index.ts @@ -0,0 +1 @@ +export { default } from './TokenValue'; diff --git a/app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.test.ts b/app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.test.ts new file mode 100644 index 00000000000..9806ab7126d --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.test.ts @@ -0,0 +1,50 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { typedSignV4SignatureRequest } from '../../../../util/test/confirm-data-helpers'; +import { DataTreeInput } from '../components/Confirm/DataTree/DataTree'; +import { parseSanitizeTypedDataMessage } from '../utils/signatures'; +// eslint-disable-next-line import/no-namespace +import * as TokenDecimalHook from './useGetTokenStandardAndDetails'; +import { useTokenDecimalsInTypedSignRequest } from './useTokenDecimalsInTypedSignRequest'; + +describe('useTokenDecimalsInTypedSignRequest', () => { + const signatureRequest = typedSignV4SignatureRequest; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const typedSignData = signatureRequest?.messageParams?.data as any; + const { domain: { verifyingContract } = {}, sanitizedMessage } = + parseSanitizeTypedDataMessage(typedSignData); + + it('returns correct decimal value for typed sign signature request', () => { + jest + .spyOn(TokenDecimalHook, 'useGetTokenStandardAndDetails') + .mockReturnValue({ + details: { + decimalsNumber: 2, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const { result } = renderHook(() => + useTokenDecimalsInTypedSignRequest( + typedSignV4SignatureRequest, + sanitizedMessage?.value as unknown as DataTreeInput, + verifyingContract, + ), + ); + expect(result.current).toStrictEqual(2); + }); + + it('returns undefined if no data is found for the token', () => { + jest + .spyOn(TokenDecimalHook, 'useGetTokenStandardAndDetails') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockReturnValue({} as any); + const { result } = renderHook(() => + useTokenDecimalsInTypedSignRequest( + typedSignV4SignatureRequest, + sanitizedMessage?.value as unknown as DataTreeInput, + verifyingContract, + ), + ); + expect(result.current).toStrictEqual(undefined); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.ts b/app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.ts new file mode 100644 index 00000000000..9198ff3bed5 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenDecimalsInTypedSignRequest.ts @@ -0,0 +1,28 @@ +import { SignatureRequest } from '@metamask/signature-controller'; +import { DataTreeInput } from '../components/Confirm/DataTree/DataTree'; +import { getTokenContractInDataTree } from '../components/Confirm/Info/TypedSignV3V4/Message'; +import { isRecognizedPermit, isRecognizedOrder } from '../utils/signature'; +import { useGetTokenStandardAndDetails } from './useGetTokenStandardAndDetails'; + +export const useTokenDecimalsInTypedSignRequest = ( + signatureRequest: SignatureRequest | undefined, + data: DataTreeInput, + verifyingContract: string, +) => { + const isPermit = isRecognizedPermit(signatureRequest); + const isOrder = isRecognizedOrder(signatureRequest); + const verifyingContractAddress = + isPermit || isOrder ? verifyingContract : undefined; + const { + details: { decimalsNumber: verifyingContractTokenDecimalsNumber } = {}, + } = useGetTokenStandardAndDetails(verifyingContractAddress); + + const tokenContract = getTokenContractInDataTree( + data as unknown as DataTreeInput, + ); + const { details: { decimalsNumber } = {} } = + useGetTokenStandardAndDetails(tokenContract); + return typeof decimalsNumber === 'number' + ? decimalsNumber + : verifyingContractTokenDecimalsNumber; +}; diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts index 9dfa6ed2939..e6e61f49066 100644 --- a/app/components/Views/confirmations/utils/signature.test.ts +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -3,8 +3,12 @@ import { isRecognizedPermit, isTypedSignV3V4Request, getSignatureRequestPrimaryType, + isRecognizedOrder, } from './signature'; -import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; +import { + PRIMARY_TYPES_ORDER, + PRIMARY_TYPES_PERMIT, +} from '../constants/signatures'; import { SignatureRequest, SignatureRequestType, @@ -92,6 +96,41 @@ describe('Signature Utils', () => { }); }); + describe('isRecognizedOrder', () => { + it('should return true for recognized order types', () => { + const mockRequest: SignatureRequest = { + messageParams: { + data: JSON.stringify({ + primaryType: PRIMARY_TYPES_ORDER[0], + }), + version: 'V3', + }, + type: SignatureRequestType.TypedSign, + } as SignatureRequest; + + expect(isRecognizedOrder(mockRequest)).toBe(true); + }); + + it('should return false for unrecognized order types', () => { + const mockRequest: SignatureRequest = { + messageParams: { + data: JSON.stringify({ + primaryType: 'UnrecognizedType', + }), + version: 'V3', + }, + type: SignatureRequestType.TypedSign, + } as SignatureRequest; + + expect(isRecognizedOrder(mockRequest)).toBe(false); + }); + + it('should return false for typed sign V1 request', () => { + expect(isRecognizedOrder(typedSignV1SignatureRequest)).toBe(false); + expect(isRecognizedOrder(personalSignSignatureRequest)).toBe(false); + }); + }); + describe('isTypedSignV3V4Request', () => { it('return true for typed sign V3, V4 messages', () => { expect(isTypedSignV3V4Request(typedSignV3SignatureRequest)).toBe(true); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts index 80d6dfc1d73..8e39b06a8f1 100644 --- a/app/components/Views/confirmations/utils/signature.ts +++ b/app/components/Views/confirmations/utils/signature.ts @@ -3,9 +3,14 @@ import { SignatureRequest, SignatureRequestType, } from '@metamask/signature-controller'; -import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import { + PRIMARY_TYPES_ORDER, + PRIMARY_TYPES_PERMIT, + PrimaryType, +} from '../constants/signatures'; + /** * The contents of this file have been taken verbatim from * metamask-extension/shared/modules/transaction.utils.ts @@ -72,12 +77,10 @@ export const isTypedSignV3V4Request = (signatureRequest: SignatureRequest) => { ); }; -/** - * Returns true if the request is a recognized Permit Typed Sign signature request - * - * @param request - The signature request to check - */ -export const isRecognizedPermit = (request: SignatureRequest) => { +const isRecognizedOfType = ( + request: SignatureRequest | undefined, + types: PrimaryType[], +) => { if ( !request || request.type !== SignatureRequestType.TypedSign || @@ -89,9 +92,25 @@ export const isRecognizedPermit = (request: SignatureRequest) => { const data = (request as SignatureRequest).messageParams?.data as string; const { primaryType } = parseTypedDataMessage(data); - return PRIMARY_TYPES_PERMIT.includes(primaryType); + return types.includes(primaryType); }; +/** + * Returns true if the request is a recognized Permit Typed Sign signature request + * + * @param request - The signature request to check + */ +export const isRecognizedPermit = (request?: SignatureRequest) => + isRecognizedOfType(request, PRIMARY_TYPES_PERMIT); + +/** + * Returns true if the request is a recognized Order Typed Sign signature request + * + * @param request - The signature request to check + */ +export const isRecognizedOrder = (request?: SignatureRequest) => + isRecognizedOfType(request, PRIMARY_TYPES_ORDER); + /** * Returns primary type of typed signature request * diff --git a/app/components/Views/confirmations/utils/signatures.test.ts b/app/components/Views/confirmations/utils/signatures.test.ts index 085c18762d3..2f53b567d40 100644 --- a/app/components/Views/confirmations/utils/signatures.test.ts +++ b/app/components/Views/confirmations/utils/signatures.test.ts @@ -28,14 +28,25 @@ const mockParsedSignV3Message = { type: 'Mail', }; +const mockV3MessageDomain = { + chainId: 1, + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1', +}; + describe('Signature utils', () => { describe('parseSanitizeTypedDataMessage', () => { it('should return parsed and sanitized types signature message', async () => { - const { sanitizedMessage, primaryType } = parseSanitizeTypedDataMessage( - JSON.stringify(mockTypedSignV3Message), - ); + const { sanitizedMessage, primaryType, domain } = + parseSanitizeTypedDataMessage(JSON.stringify(mockTypedSignV3Message)); expect(primaryType).toBe('Mail'); expect(sanitizedMessage).toEqual(mockParsedSignV3Message); + expect(domain).toEqual(mockV3MessageDomain); + }); + it('return empty object if no data is passed', async () => { + const result = parseSanitizeTypedDataMessage(''); + expect(result).toMatchObject({}); }); }); }); diff --git a/app/components/Views/confirmations/utils/signatures.ts b/app/components/Views/confirmations/utils/signatures.ts index 04d41b6b19c..59ce17c9288 100644 --- a/app/components/Views/confirmations/utils/signatures.ts +++ b/app/components/Views/confirmations/utils/signatures.ts @@ -39,7 +39,11 @@ const parseTypedDataMessage = (dataToParse: string) => { }; export const parseSanitizeTypedDataMessage = (dataToParse: string) => { - const { message, primaryType, types } = parseTypedDataMessage(dataToParse); + if (!dataToParse) { + return {}; + } + const { message, primaryType, types, domain } = + parseTypedDataMessage(dataToParse); const sanitizedMessage = sanitizeMessage(message, primaryType, types); - return { sanitizedMessage, primaryType }; + return { sanitizedMessage, primaryType, domain }; }; From 75fc8135e9a26dccc2035afdc2980b98fd283e04 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 31 Jan 2025 12:48:14 +0000 Subject: [PATCH 3/7] feat: update notification codeowners (#13269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cleans up the notification codeowner paths. This can be more restrictive once push notifications work is completed. ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. --- .github/CODEOWNERS | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 58ceabd2486..bd45695eab5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,12 +56,10 @@ app/components/UI/Swaps @MetaMask/swaps-engineers # Notifications Team app/components/Views/Notifications @MetaMask/notifications app/components/Views/Settings/NotificationsSettings @MetaMask/notifications -app/components/UI/Notifications @MetaMask/notifications -app/reducers/notification @MetaMask/notifications -app/actions/notification @MetaMask/notifications -app/selectors/notification @MetaMask/notifications -app/util/notifications @MetaMask/notifications -app/store/util/notifications @MetaMask/notifications +**/Notifications/** @MetaMask/notifications +**/Notification/** @MetaMask/notifications +**/notifications/** @MetaMask/notifications +**/notification/** @MetaMask/notifications # Identity Team app/actions/identity @MetaMask/identity From 041ef73946d97428786aefab897d7f0dd502cb32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:03:08 +0000 Subject: [PATCH 4/7] fix: bump @react-native-community/blur to v4.4.1 (#13293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix android builds by bumping @react-native-community/blur to v4.4.1 ``` Execution failed for task ':app:checkProdDebugAarMetadata'. > Could not resolve all files for configuration ':app:prodDebugRuntimeClasspath'. > Failed to transform BlurView-version-2.0.3.aar (com.github.Dimezis:BlurView:version-2.0.3) to match attributes {artifactType=android-aar-metadata, org.gradle.category=library, org.gradle.libraryelements=jar, org.gradle.status=release, org.gradle.usage=java-runtime}. > Could not find BlurView-version-2.0.3.jar (com.github.Dimezis:BlurView:version-2.0.3). ``` > [4.4.1](https://github.com/react-native-community/react-native-blur/compare/v4.4.0...v4.4.1) (2024-08-29) Bug Fixes upgrade BlurView android dependency to 2.0.4 https://github.com/Kureev/react-native-blur/releases/tag/v4.4.1 ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. --- ios/Podfile.lock | 4 ++-- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9a7f974f282..3271e8eb44e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -594,7 +594,7 @@ PODS: - React-Core - react-native-blob-util (0.19.9): - React-Core - - react-native-blur (4.4.0): + - react-native-blur (4.4.1): - RCT-Folly (= 2021.07.22.00) - React-Core - react-native-branch (5.6.2): @@ -1360,7 +1360,7 @@ SPEC CHECKSUMS: react-native-ble-plx: c08c34c162509ec466c68a7cdc86b69c12e6efdd react-native-blob-jsi-helper: bd7509e50b0f906044c53ad7ab767786054424c9 react-native-blob-util: 6560d6fc4b940ec140f9c3ebe21c8669b1df789b - react-native-blur: 7c03644c321696ccec9778447180e0f9339b3604 + react-native-blur: 4024ea270983c8b3575768b1d7ecc94c1374f8b3 react-native-branch: 76e1f947b40597727e6faa5cba5824d7ecf6c6b0 react-native-camera: 1e6fefa515d3af8b4aeaca3a8bffa2925252c4ea react-native-compat: 8050db8973090f2c764807e7fa74f163f78e4c32 diff --git a/package.json b/package.json index b1411fc1318..cf4b6a25602 100644 --- a/package.json +++ b/package.json @@ -216,7 +216,7 @@ "@notifee/react-native": "^9.0.0", "@react-native-async-storage/async-storage": "^1.23.1", "@react-native-clipboard/clipboard": "1.8.4", - "@react-native-community/blur": "^4.4.0", + "@react-native-community/blur": "^4.4.1", "@react-native-community/checkbox": "^0.5.17", "@react-native-community/datetimepicker": "^7.5.0", "@react-native-community/netinfo": "^9.5.0", diff --git a/yarn.lock b/yarn.lock index fce91a385e7..dbb50f31043 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6479,10 +6479,10 @@ resolved "https://registry.yarnpkg.com/@react-native-clipboard/clipboard/-/clipboard-1.8.4.tgz#4bc1fb00643688e489d8220cd635844ab5c066f9" integrity sha512-poFq3RvXzkbXcqoQNssbZ+aNbCRzBFAWkR9QL7u9xNMgsyWZtk7d16JQoaBo8D2E+kKi+/9JOiVQzA5w+9N67w== -"@react-native-community/blur@^4.4.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@react-native-community/blur/-/blur-4.4.0.tgz#b2440dab17d94e480fbc4470e03155573b5b7375" - integrity sha512-P+xdT2LIq1ewOsF3zx7C0nu4dj7nxl2NVTsMXEzRDjM3bWMdrrEbTRA7uwPV5ngn7/BXIommBPlT/JW4SAedrw== +"@react-native-community/blur@^4.4.1": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@react-native-community/blur/-/blur-4.4.1.tgz#72cbc0be5a84022c33091683ec6888925ebcca6e" + integrity sha512-XBSsRiYxE/MOEln2ayunShfJtWztHwUxLFcSL20o+HNNRnuUDv+GXkF6FmM2zE8ZUfrnhQ/zeTqvnuDPGw6O8A== "@react-native-community/checkbox@^0.5.17": version "0.5.17" From 411bad5076c28c0919d2abe94ce68bc71ff03b88 Mon Sep 17 00:00:00 2001 From: Ziad Saab Date: Fri, 31 Jan 2025 10:46:36 -0500 Subject: [PATCH 5/7] fix: Adjust browser display when multiple tabs are present (#13294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes the browser view when multiple tabs are open ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to the browser 2. Open multiple tabs 3. Observe that the view doesn't get pushed down ## **Screenshots/Recordings** ### **Before** image (notice that there are two tabs open) ### **After** image (notice that there are two tabs open) ## **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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- app/components/Views/BrowserTab/BrowserTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Views/BrowserTab/BrowserTab.tsx b/app/components/Views/BrowserTab/BrowserTab.tsx index be5271e2b9a..a60d347e0bc 100644 --- a/app/components/Views/BrowserTab/BrowserTab.tsx +++ b/app/components/Views/BrowserTab/BrowserTab.tsx @@ -1307,10 +1307,10 @@ export const BrowserTab: React.FC = ({ Date: Sat, 1 Feb 2025 02:53:02 +0900 Subject: [PATCH 6/7] fix: prev network eth swap send main branch (#13283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes an issue where if you switched networks and went to Swap/Send, the gas token balance would be for the previous network. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/13194 ## **Manual testing steps** 1. Switch networks 2. Go to swap/send 3. See balance is correct ## **Screenshots/Recordings** ### **Before** ### **After** ## **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 - [ ] I’ve included tests if applicable - [ ] 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. --------- Co-authored-by: Bryan Fullam Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com> --- app/components/UI/Swaps/index.js | 11 ++++++----- .../SendFlow/AddressFrom/AddressFrom.test.tsx | 7 +++++++ .../SendFlow/AddressFrom/AddressFrom.tsx | 8 +++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index 5808688e3eb..39bc76b001c 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -77,7 +77,7 @@ import { selectCurrentCurrency, } from '../../../selectors/currencyRateController'; import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; -import { selectAccounts } from '../../../selectors/accountTrackerController'; +import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; import { selectContractBalances } from '../../../selectors/tokenBalancesController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import AccountSelector from '../Ramp/components/AccountSelector'; @@ -182,7 +182,7 @@ const MAX_TOP_ASSETS = 20; function SwapsAmountView({ swapsTokens, swapsControllerTokens, - accounts, + accountsByChainId, selectedAddress, chainId, selectedNetworkClientId, @@ -196,6 +196,7 @@ function SwapsAmountView({ currentCurrency, setLiveness, }) { + const accounts = accountsByChainId[chainId]; const navigation = useNavigation(); const route = useRoute(); const { colors } = useTheme(); @@ -963,9 +964,9 @@ SwapsAmountView.propTypes = { tokensWithBalance: PropTypes.arrayOf(PropTypes.object), tokensTopAssets: PropTypes.arrayOf(PropTypes.object), /** - * Map of accounts to information objects including balances + * Map of chainId to accounts to information objects including balances */ - accounts: PropTypes.object, + accountsByChainId: PropTypes.object, /** * A string that represents the selected address */ @@ -1011,7 +1012,7 @@ SwapsAmountView.propTypes = { const mapStateToProps = (state) => ({ swapsTokens: swapsTokensSelector(state), swapsControllerTokens: swapsControllerTokens(state), - accounts: selectAccounts(state), + accountsByChainId: selectAccountsByChainId(state), balances: selectContractBalances(state), selectedAddress: selectSelectedInternalAccountFormattedAddress(state), conversionRate: selectConversionRate(state), diff --git a/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.test.tsx b/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.test.tsx index b3b8bbe1535..3d05f2027a3 100644 --- a/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.test.tsx +++ b/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.test.tsx @@ -51,6 +51,13 @@ const mockInitialState = { balance: '0x0', }, }, + accountsByChainId: { + '0x1': { + '0xd018538C87232FF95acbCe4870629b75640a78E7': { + balance: '0x0', + }, + }, + }, }, AccountsController: { internalAccounts: { diff --git a/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.tsx b/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.tsx index 7f9302b6598..932bec37747 100644 --- a/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.tsx +++ b/app/components/Views/confirmations/SendFlow/AddressFrom/AddressFrom.tsx @@ -1,14 +1,12 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; - import { useNavigation } from '@react-navigation/native'; - import { newAssetTransaction, setSelectedAsset, } from '../../../../../actions/transaction'; import Routes from '../../../../../constants/navigation/Routes'; -import { selectAccounts } from '../../../../../selectors/accountTrackerController'; +import { selectAccountsByChainId } from '../../../../../selectors/accountTrackerController'; import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; import { doENSReverseLookup } from '../../../../../util/ENSUtils'; import { renderFromWei, hexToBN } from '../../../../../util/number'; @@ -27,11 +25,11 @@ const SendFlowAddressFrom = ({ }: SFAddressFromProps) => { const navigation = useNavigation(); - const accounts = useSelector(selectAccounts); - const ticker = useSelector((state: RootState) => selectNativeCurrencyByChainId(state, chainId as Hex), ); + const accountsByChainId = useSelector(selectAccountsByChainId); + const accounts = accountsByChainId[chainId]; const selectedInternalAccount = useSelector(selectSelectedInternalAccount); const checksummedSelectedAddress = selectedInternalAccount From 711a583de3319a7f7773e31150f06b99361c20c9 Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Fri, 31 Jan 2025 19:05:44 +0100 Subject: [PATCH 7/7] chore: move metrics identify to state listener (#13203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - moves identify call to active app state listener - updates unit tests - also fixes `trackEvent` mock wrongly being used as async in app/core/AppStateEventListener.test.ts ## **Related issues** Fixes https://github.com/MetaMask/mobile-planning/issues/2119 ## **Manual testing steps** ```gherkin Feature: identify users Scenario: identify on app state change to active Given an already onboarded app And user opted in for metrics When you open the app in local dev mode Then you have the `IDENTIFY event saved` log ``` ## **Screenshots/Recordings** ### **Before** IDENTIFY event sent but only on app open and not when foregrounding (active) ### **After** ``` INFO IDENTIFY event saved {"traits": {"Batch account balance requests": "ON", "Enable OpenSea API": "ON", "NFT Autodetection": "ON", "Theme": "light", "applicationVersion": "7.37.1", "currentBuildNumber": "1520", "deviceBrand": "Apple", "operatingSystemVersion": "18.2", "platform": "ios", "security_providers": "blockaid", "token_detection_enable": "ON"}, "type": "identify", "userId": "150739a9-38b7-4098-b1f0-7afbba0b2e5d"} INFO TRACK event saved {"event": "App Opened", "properties": {}, "type": "track"} INFO Sent 2 events ``` ## **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. --- app/components/Nav/App/index.js | 12 +------ app/components/Nav/App/index.test.tsx | 4 --- app/core/AppStateEventListener.test.ts | 45 +++++++++++++++++++++++++- app/core/AppStateEventListener.ts | 17 +++++++++- 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index c95d5205c9c..f6b50e6f1c5 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -109,8 +109,6 @@ import SDKSessionModal from '../../Views/SDK/SDKSessionModal/SDKSessionModal'; import ExperienceEnhancerModal from '../../../../app/components/Views/ExperienceEnhancerModal'; import { MetaMetrics } from '../../../core/Analytics'; import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; -import generateDeviceAnalyticsMetaData from '../../../util/metrics/DeviceAnalyticsMetaData/generateDeviceAnalyticsMetaData'; -import generateUserSettingsAnalyticsMetaData from '../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData'; import LedgerSelectAccount from '../../Views/LedgerSelectAccount'; import OnboardingSuccess from '../../Views/OnboardingSuccess'; import DefaultSettings from '../../Views/OnboardingSuccess/DefaultSettings'; @@ -745,15 +743,7 @@ const App = (props) => { useEffect(() => { const initMetrics = async () => { - const metrics = MetaMetrics.getInstance(); - await metrics.configure(); - // identify user with the latest traits - // run only after the MetaMetrics is configured - const consolidatedTraits = { - ...generateDeviceAnalyticsMetaData(), - ...generateUserSettingsAnalyticsMetaData(), - }; - await metrics.addTraitsToUser(consolidatedTraits); + await MetaMetrics.getInstance().configure(); }; initMetrics().catch((err) => { diff --git a/app/components/Nav/App/index.test.tsx b/app/components/Nav/App/index.test.tsx index 68faee0918a..6271239052e 100644 --- a/app/components/Nav/App/index.test.tsx +++ b/app/components/Nav/App/index.test.tsx @@ -61,10 +61,6 @@ describe('App', () => { ); await waitFor(() => { expect(mockMetrics.configure).toHaveBeenCalledTimes(1); - expect(mockMetrics.addTraitsToUser).toHaveBeenNthCalledWith(1, { - deviceProp: 'Device value', - userProp: 'User value', - }); }); }); }); diff --git a/app/core/AppStateEventListener.test.ts b/app/core/AppStateEventListener.test.ts index 02606e78299..860b8d70a00 100644 --- a/app/core/AppStateEventListener.test.ts +++ b/app/core/AppStateEventListener.test.ts @@ -19,7 +19,7 @@ jest.mock('./processAttribution', () => ({ jest.mock('./Analytics/MetaMetrics'); const mockMetrics = { - trackEvent: jest.fn().mockImplementation(() => Promise.resolve()), + trackEvent: jest.fn(), enable: jest.fn(() => Promise.resolve()), addTraitsToUser: jest.fn(() => Promise.resolve()), isEnabled: jest.fn(() => true), @@ -105,6 +105,49 @@ describe('AppStateEventListener', () => { ); }); + it('identifies user when app becomes active', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); + + mockAppStateListener('active'); + jest.advanceTimersByTime(2000); + + expect(mockMetrics.addTraitsToUser).toHaveBeenCalledTimes(1); + expect(mockMetrics.addTraitsToUser).toHaveBeenCalledWith({ + 'Batch account balance requests': 'OFF', + 'Enable OpenSea API': 'OFF', + 'NFT Autodetection': 'OFF', + 'Theme': undefined, + 'applicationVersion': expect.any(Promise), + 'currentBuildNumber': expect.any(Promise), + 'deviceBrand': 'Apple', + 'operatingSystemVersion': 'ios', + 'platform': 'ios', + 'security_providers': '', + 'token_detection_enable': 'OFF', + }); + }); + + it('logs error when identifying user fails', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); + const testError = new Error('Test error'); + mockMetrics.addTraitsToUser.mockImplementation(() => { + throw testError; + }); + + mockAppStateListener('active'); + jest.advanceTimersByTime(2000); + + expect(Logger.error).toHaveBeenCalledWith( + testError, + 'AppStateManager: Error processing app state change' + ); + expect(mockMetrics.trackEvent).not.toHaveBeenCalled(); + }); + it('handles errors gracefully', () => { jest .spyOn(ReduxService, 'store', 'get') diff --git a/app/core/AppStateEventListener.ts b/app/core/AppStateEventListener.ts index f6be9afc39c..e9c7e0e79a9 100644 --- a/app/core/AppStateEventListener.ts +++ b/app/core/AppStateEventListener.ts @@ -5,6 +5,9 @@ import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; import { processAttribution } from './processAttribution'; import DevLogger from './SDKConnect/utils/DevLogger'; import ReduxService from './redux'; +import generateDeviceAnalyticsMetaData from '../util/metrics'; +import generateUserSettingsAnalyticsMetaData + from '../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData'; export class AppStateEventListener { private appStateSubscription: @@ -48,6 +51,18 @@ export class AppStateEventListener { currentDeeplink: this.currentDeeplink, store: ReduxService.store, }); + const metrics = MetaMetrics.getInstance(); + // identify user with the latest traits + const consolidatedTraits = { + ...generateDeviceAnalyticsMetaData(), + ...generateUserSettingsAnalyticsMetaData(), + }; + metrics.addTraitsToUser(consolidatedTraits).catch((error) => { + Logger.error( + error as Error, + 'AppStateManager: Error adding traits to user', + ); + }); const appOpenedEventBuilder = MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.APP_OPENED); if (attribution) { const { attributionId, utm, ...utmParams } = attribution; @@ -57,7 +72,7 @@ export class AppStateEventListener { ); appOpenedEventBuilder.addProperties({ attributionId, ...utmParams }); } - MetaMetrics.getInstance().trackEvent(appOpenedEventBuilder.build()); + metrics.trackEvent(appOpenedEventBuilder.build()); } catch (error) { Logger.error( error as Error,