From ff19cedd830fe63fad0117b7d912dabf2d3be65d Mon Sep 17 00:00:00 2001 From: digiwand <20778143+digiwand@users.noreply.github.com> Date: Thu, 16 Jan 2025 07:37:26 -0800 Subject: [PATCH] feat: Redesign Signature Decoding Simulation (#12994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add Signature Decoding Simulation UI This logic mirrors the extension with slight modifications due to mobile/extension parity differences. In addition: - updated ValueDisplay value logic to no longer use useMemo Todo in follow-up PRs: - Add additional tests https://github.com/MetaMask/metamask-mobile/issues/13023 - Add "Unlimited" text support https://github.com/MetaMask/metamask-mobile/issues/13022 - Investigate "useExternalServices" setting. This does not seem to exist in mobile https://github.com/MetaMask/metamask-mobile/issues/13024 ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3876 Related: https://github.com/MetaMask/metamask-mobile/pull/12994 (Replaces Ramp/Box usages with View) ## **Manual testing steps** 1. Set REDESIGNED_SIGNATURE_REQUEST to true in js.env 2. Enable confirmation_redesign in Launch Darkly 3. Turn on Improved Signatures setting 4. Turn on Simulation setting 5. Test various v3 and v4 signTypedData signatures - Example dapp: https://develop.d3bkcslj57l47p.amplifyapp.com/ → "Permit 2 - Single" button ## **Screenshots/Recordings** ### **Before** ### **After** The values in this screenshot will be replaced by "Unlimited" ## **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 | 3 + .../Views/confirmations/Confirm/Confirm.tsx | 2 +- .../TypedSignV3V4/Simulation/Simulation.tsx | 31 +++ .../Simulation/Static/Static.tsx | 55 +++++ .../TypedSignV3V4/Simulation/Static/index.ts | 1 + .../TypedSignDecoded.test.tsx | 167 ++++++++++++++ .../TypedSignDecoded/TypedSignDecoded.tsx | 213 ++++++++++++++++++ .../Simulation/TypedSignDecoded/index.ts | 1 + .../NativeValueDisplay/NativeValueDisplay.tsx | 153 +++++++++++++ .../components/NativeValueDisplay/index.ts | 1 + .../ValueDisplay/ValueDisplay.styles.ts | 7 +- .../components/ValueDisplay/ValueDisplay.tsx | 51 ++--- .../Info/TypedSignV3V4/Simulation/index.ts | 1 + .../Info/TypedSignV3V4/TypedSignV3V4.tsx | 23 +- .../components/SignatureRequest/Root/Root.tsx | 2 +- .../confirmations/hooks/useApprovalRequest.ts | 6 +- .../useConfirmationRedesignEnabled.test.ts | 2 +- .../hooks/useConfirmationRedesignEnabled.ts | 4 +- .../hooks/useTypedSignSimulationEnabled.ts | 66 ++++++ .../confirmations/utils/signature.test.ts | 14 +- .../Views/confirmations/utils/signature.ts | 14 +- app/core/AppConstants.ts | 1 + app/core/Engine/Engine.ts | 4 + app/selectors/currencyRateController.test.ts | 36 +++ app/selectors/currencyRateController.ts | 25 +- app/util/number/index.js | 18 +- app/util/number/index.test.ts | 37 +++ locales/languages/en.json | 11 +- 28 files changed, 873 insertions(+), 76 deletions(-) create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts create mode 100644 app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts diff --git a/.js.env.example b/.js.env.example index 38daeab2a2c..0d1d2764861 100644 --- a/.js.env.example +++ b/.js.env.example @@ -66,6 +66,9 @@ export SEGMENT_FLUSH_INTERVAL="1" # example for flush when 1 event is queued export SEGMENT_FLUSH_EVENT_LIMIT="1" +# URL of the decoding API used to provide additional data from signature requests +export DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1' + # URL of security alerts API used to validate dApp requests. export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io" diff --git a/app/components/Views/confirmations/Confirm/Confirm.tsx b/app/components/Views/confirmations/Confirm/Confirm.tsx index a13ec947e8d..4a18a99d7d1 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.tsx @@ -8,7 +8,7 @@ import Footer from '../components/Confirm/Footer'; import Info from '../components/Confirm/Info'; import SignatureBlockaidBanner from '../components/Confirm/SignatureBlockaidBanner'; import Title from '../components/Confirm/Title'; -import useConfirmationRedesignEnabled from '../hooks/useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from '../hooks/useConfirmationRedesignEnabled'; import styleSheet from './Confirm.styles'; const Confirm = () => { diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx new file mode 100644 index 00000000000..981a9355911 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { useTypedSignSimulationEnabled } from '../../../../../hooks/useTypedSignSimulationEnabled'; +import { isRecognizedPermit } from '../../../../../utils/signature'; +import { useSignatureRequest } from '../../../../../hooks/useSignatureRequest'; +import DecodedSimulation from './TypedSignDecoded'; +import PermitSimulation from './TypedSignPermit'; + +const TypedSignV3V4Simulation: React.FC = () => { + const signatureRequest = useSignatureRequest(); + const isPermit = signatureRequest && isRecognizedPermit(signatureRequest); + const isSimulationSupported = useTypedSignSimulationEnabled(); + + if (!isSimulationSupported || !signatureRequest) { + return null; + } + + const { decodingData, decodingLoading } = signatureRequest; + const hasDecodingData = !( + (!decodingLoading && decodingData === undefined) || + decodingData?.error + ); + + if (!hasDecodingData && isPermit) { + return ; + } + + return ; +}; + +export default TypedSignV3V4Simulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx new file mode 100644 index 00000000000..febb2b71115 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useStyles } from '../../../../../../../../../component-library/hooks'; +import InfoRow from '../../../../../UI/InfoRow'; +import InfoSection from '../../../../../UI/InfoRow/InfoSection'; +import Loader from '../../../../../../../../../component-library/components-temp/Loader'; + +const styleSheet = () => StyleSheet.create({ + base: { + display: 'flex', + justifyContent: 'space-between', + }, + loaderContainer: { + display: 'flex', + justifyContent: 'center', + }, +}); + +const StaticSimulation: React.FC<{ + title: string; + titleTooltip: string; + description?: string; + simulationElements: React.ReactNode; + isLoading?: boolean; + isCollapsed?: boolean; +}> = ({ + title, + titleTooltip, + description, + simulationElements, + isLoading, + isCollapsed = false, +}) => { + const { styles } = useStyles(styleSheet, {}); + + return( + + + + {description} + + {isLoading ? ( + + + + ) : ( + simulationElements + )} + + + ); +}; + +export default StaticSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts new file mode 100644 index 00000000000..58015012827 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts @@ -0,0 +1 @@ +export { default } from './Static'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx new file mode 100644 index 00000000000..c37fc11bc81 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { + DecodingDataChangeType, + DecodingDataStateChanges, + SignatureRequest, +} from '@metamask/signature-controller'; + +import { strings } from '../../../../../../../../../../locales/i18n'; +import { typedSignV4ConfirmationState } from '../../../../../../../../../util/test/confirm-data-helpers'; +import renderWithProvider from '../../../../../../../../../util/test/renderWithProvider'; +import TypedSignDecoded, { getStateChangeToolip, getStateChangeType, StateChangeType } from './TypedSignDecoded'; + +const stateChangesApprove = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '12345', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, +]; + +const stateChangesListingERC1155: DecodingDataStateChanges = [ + { + assetType: 'NATIVE', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '900000000000000000', + contractAddress: '', + }, + { + assetType: 'ERC1155', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', + tokenID: '77789', + }, +]; + +const stateChangesNftListing: DecodingDataStateChanges = [ + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '22222', + }, + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +]; + +const stateChangesNftBidding: DecodingDataStateChanges = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Bidding, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '189', + }, + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +]; + +const mockState = (mockStateChanges: DecodingDataStateChanges, stubDecodingLoading: boolean = false) => { + const clonedMockState = cloneDeep(typedSignV4ConfirmationState); + const request = clonedMockState.engine.backgroundState.SignatureController.signatureRequests['fb2029e1-b0ab-11ef-9227-05a11087c334'] as SignatureRequest; + request.decodingLoading = stubDecodingLoading; + request.decodingData = { + stateChanges: mockStateChanges, + }; + + return clonedMockState; +}; + +describe('DecodedSimulation', () => { + it('renders for ERC20 approval', async () => { + const { getByText } = renderWithProvider(, { + state: mockState(stateChangesApprove), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('12,345')).toBeDefined(); + }); + + it('renders for ERC712 token', async () => { + const { getByText } = renderWithProvider(, { + state: mockState(stateChangesNftListing), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Listing price')).toBeDefined(); + expect(await getByText('You list')).toBeDefined(); + expect(await getByText('#22222')).toBeDefined(); + }); + + it('renders for ERC1155 token', async () => { + const { getByText } = renderWithProvider(, { + state: mockState(stateChangesListingERC1155), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('You receive')).toBeDefined(); + expect(await getByText('You list')).toBeDefined(); + expect(await getByText('#77789')).toBeDefined(); + }); + + it('renders label only once if there are multiple state changes of same changeType', async () => { + const { getAllByText } = renderWithProvider(, { + state: mockState([stateChangesApprove[0], stateChangesApprove[0], stateChangesApprove[0]]), + }); + + expect(await getAllByText('12,345')).toHaveLength(3); + expect(await getAllByText('Spending cap')).toHaveLength(1); + }); + + it('renders unavailable message if no state change is returned', async () => { + const { getByText } = renderWithProvider(, { + state: mockState([]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Unavailable')).toBeDefined(); + }); + + describe('getStateChangeToolip', () => { + it('return correct tooltip when permit is for listing NFT', () => { + const tooltip = getStateChangeToolip( + StateChangeType.NFTListingReceive, + ); + expect(tooltip).toBe(strings('confirm.simulation.decoded_tooltip_list_nft')); + }); + + it('return correct tooltip when permit is for bidding NFT', () => { + const tooltip = getStateChangeToolip( + StateChangeType.NFTBiddingReceive, + ); + expect(tooltip).toBe(strings('confirm.simulation.decoded_tooltip_bid_nft')); + }); + }); + + describe('getStateChangeType', () => { + it('return correct state change type for NFT listing receive', () => { + const stateChange = getStateChangeType(stateChangesNftListing, stateChangesNftListing[1]); + expect(stateChange).toBe(StateChangeType.NFTListingReceive); + }); + + it('return correct state change type for NFT bidding receive', () => { + const stateChange = getStateChangeType(stateChangesNftBidding, stateChangesNftBidding[1]); + expect(stateChange).toBe(StateChangeType.NFTBiddingReceive); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx new file mode 100644 index 00000000000..3e712edf440 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx @@ -0,0 +1,213 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { + DecodingDataChangeType, + DecodingDataStateChange, + DecodingDataStateChanges, +} from '@metamask/signature-controller'; +import { Hex } from '@metamask/utils'; + +import { TokenStandard } from '../../../../../../../../UI/SimulationDetails/types'; +import Text from '../../../../../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../../../../../locales/i18n'; +import { useSignatureRequest } from '../../../../../../hooks/useSignatureRequest'; +import InfoRow from '../../../../../UI/InfoRow'; +import NativeValueDisplay from '../components/NativeValueDisplay'; +import SimulationValueDisplay from '../components/ValueDisplay'; +import StaticSimulation from '../Static'; + +const styles = { + unavailableContainer: { + paddingHorizontal: 8, + paddingBottom: 8, + }, +}; + +export enum StateChangeType { + NFTListingReceive = 'NFTListingReceive', + NFTBiddingReceive = 'NFTBiddingReceive', +} + +export const getStateChangeType = ( + stateChangeList: DecodingDataStateChanges | null, + stateChange: DecodingDataStateChange, +): StateChangeType | undefined => { + if (stateChange.changeType === DecodingDataChangeType.Receive) { + if ( + stateChangeList?.some( + (change) => + change.changeType === DecodingDataChangeType.Listing && + change.assetType === TokenStandard.ERC721, + ) + ) { + return StateChangeType.NFTListingReceive; + } + if ( + stateChange.assetType === TokenStandard.ERC721 && + stateChangeList?.some( + (change) => change.changeType === DecodingDataChangeType.Bidding, + ) + ) { + return StateChangeType.NFTBiddingReceive; + } + } + return undefined; +}; + +export const getStateChangeToolip = ( + nftTransactionType: StateChangeType | undefined, +): string | undefined => { + if (nftTransactionType === StateChangeType.NFTListingReceive) { + return strings('confirm.simulation.decoded_tooltip_list_nft'); + } else if (nftTransactionType === StateChangeType.NFTBiddingReceive) { + return strings('confirm.simulation.decoded_tooltip_bid_nft'); + } + return undefined; +}; + +const stateChangeOrder = { + [DecodingDataChangeType.Transfer]: 1, + [DecodingDataChangeType.Listing]: 2, + [DecodingDataChangeType.Approve]: 3, + [DecodingDataChangeType.Revoke]: 4, + [DecodingDataChangeType.Bidding]: 5, + [DecodingDataChangeType.Receive]: 6, +}; + +const getStateChangeLabelMap = ( + changeType: string, + stateChangeType?: StateChangeType, +) => ({ + [DecodingDataChangeType.Transfer]: strings('confirm.simulation.label_change_type_transfer'), + [DecodingDataChangeType.Receive]: + stateChangeType === StateChangeType.NFTListingReceive + ? strings('confirm.simulation.label_change_type_nft_listing') + : strings('confirm.simulation.label_change_type_receive'), + [DecodingDataChangeType.Approve]: strings('confirm.simulation.label_change_type_permit'), + [DecodingDataChangeType.Revoke]: strings('confirm.simulation.label_change_type_permit'), + [DecodingDataChangeType.Bidding]: strings('confirm.simulation.label_change_type_bidding'), + [DecodingDataChangeType.Listing]: strings('confirm.simulation.label_change_type_listing'), + }[changeType]); + +const StateChangeRow = ({ + stateChangeList, + stateChange, + chainId, + shouldDisplayLabel, +}: { + stateChangeList: DecodingDataStateChanges | null; + stateChange: DecodingDataStateChange; + chainId: Hex; + shouldDisplayLabel: boolean; +}) => { + const { assetType, changeType, amount, contractAddress, tokenID } = + stateChange; + const nftTransactionType = getStateChangeType(stateChangeList, stateChange); + const tooltip = shouldDisplayLabel ? getStateChangeToolip(nftTransactionType) : undefined; + // todo: add + // const canDisplayValueAsUnlimited = + // assetType === TokenStandard.ERC20 && + // (changeType === DecodingDataChangeType.Approve || + // changeType === DecodingDataChangeType.Revoke); + + const changeLabel = shouldDisplayLabel + ? getStateChangeLabelMap(changeType, nftTransactionType) + : ''; + + return ( + + {(assetType === TokenStandard.ERC20 || + assetType === TokenStandard.ERC721 || + assetType === TokenStandard.ERC1155) && ( + + )} + {assetType === 'NATIVE' && ( + + )} + + ); +}; + +const DecodedSimulation: React.FC = () => { + const signatureRequest = useSignatureRequest(); + + const chainId = signatureRequest?.chainId as Hex; + const { decodingLoading, decodingData } = signatureRequest ?? {}; + + const stateChangeFragment = useMemo(() => { + const orderedStateChanges = [...(decodingData?.stateChanges ?? [])].sort((c1, c2) => + stateChangeOrder[c1.changeType] > stateChangeOrder[c2.changeType] + ? 1 + : -1, + ); + const stateChangesGrouped: Record = ( + orderedStateChanges ?? [] + ).reduce>( + (result, stateChange) => { + result[stateChange.changeType] = [ + ...(result[stateChange.changeType] ?? []), + stateChange, + ]; + return result; + }, + {}, + ); + + return Object.entries(stateChangesGrouped).flatMap(([_, changeList]) => + changeList.map((change: DecodingDataStateChange, index: number) => ( + + )), + ); + }, [chainId, decodingData?.stateChanges]); + + return ( + + {strings('confirm.simulation.unavailable')} + + ) + } + isLoading={decodingLoading} + isCollapsed={decodingLoading || !stateChangeFragment.length} + /> + ); +}; + +export default DecodedSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts new file mode 100644 index 00000000000..f4bd7dcc026 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts @@ -0,0 +1 @@ +export { default } from './TypedSignDecoded'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx new file mode 100644 index 00000000000..54ad3cd28e8 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { Text,TouchableOpacity, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { RootState } from '../../../../../../../../../../reducers'; +import { selectConversionRateByChainId } from '../../../../../../../../../../selectors/currencyRateController'; +import { useTheme } from '../../../../../../../../../../util/theme'; + +import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; +import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; +import ButtonIcon from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon'; +import { IconName , IconColor } from '../../../../../../../../../../component-library/components/Icons/Icon'; + +import AssetPill from '../../../../../../../../../UI/SimulationDetails/AssetPill/AssetPill'; +import { IndividualFiatDisplay } from '../../../../../../../../../UI/SimulationDetails/FiatDisplay/FiatDisplay'; +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../../../../../../UI/SimulationDetails/formatAmount'; +import { AssetType } from '../../../../../../../../../UI/SimulationDetails/types'; +import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { isNumberValue } from '../../../../../../../../../../util/number'; +import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; +import BottomModal from '../../../../../../UI/BottomModal'; + +/** + * Reusing ValueDisplay styles for now. See issue to handle abstracting UI + * @see {@link https://github.com/MetaMask/metamask-mobile/issues/12974} + */ +import styleSheet from '../ValueDisplay/ValueDisplay.styles'; + +const NATIVE_DECIMALS = 18; + +interface PermitSimulationValueDisplayParams { + /** ID of the associated chain. */ + chainId: Hex; + + /** Change type to be displayed in value tooltip */ + labelChangeType: string; + + /** The token amount */ + value: number | string; + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; +} + +const NativeValueDisplay: React.FC = ({ + chainId, + credit, + debit, + labelChangeType, + value, +}) => { + const [hasValueModalOpen, setHasValueModalOpen] = useState(false); + + const { colors } = useTheme(); + const styles = styleSheet(colors); + + const conversionRate = useSelector((state: RootState) => + selectConversionRateByChainId(state, chainId), + ); + + const tokenAmount = isNumberValue(value) ? calcTokenAmount(value, NATIVE_DECIMALS) : null; + const isValidTokenAmount = tokenAmount !== null && tokenAmount !== undefined && tokenAmount instanceof BigNumber; + + const fiatValue = isValidTokenAmount && conversionRate + ? tokenAmount.times(String(conversionRate)).toNumber() + : undefined; + + const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; + const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; + + function handlePressTokenValue() { + setHasValueModalOpen(true); + } + + return ( + + + + + + {credit && '+ '} + {debit && '- '} + {tokenValue !== null && + shortenString(tokenValue || '', { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + })} + + + + + + + + + {/** + TODO - add fiat shorten prop after tooltip logic has been updated + {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} + */} + {fiatValue !== undefined && ( + + )} + + {hasValueModalOpen && ( + /** + * TODO replace BottomModal instances with BottomSheet + * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}} + */ + setHasValueModalOpen(false)}> + setHasValueModalOpen(false)} + > + + + setHasValueModalOpen(false)} + iconName={IconName.ArrowLeft} + /> + + {labelChangeType} + + + + {tokenValueMaxPrecision} + + + + + )} + + ); +}; + +export default NativeValueDisplay; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts new file mode 100644 index 00000000000..1e6534cfb1c --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './NativeValueDisplay'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts index 6414e76e0ba..9a1da0579cc 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts @@ -12,7 +12,9 @@ const styleSheet = (colors: Theme['colors']) => borderWidth: 0, padding: 0, }, - + fiatDisplay: { + paddingEnd: 8, + }, flexRowTokenValueAndAddress: { display: 'flex', flexDirection: 'row', @@ -22,7 +24,7 @@ const styleSheet = (colors: Theme['colors']) => borderWidth: 0, padding: 0, }, - tokenAddress: { + marginStart4: { marginStart: 4, }, tokenValueTooltipContent: { @@ -35,7 +37,6 @@ const styleSheet = (colors: Theme['colors']) => valueAndAddress: { paddingVertical: 4, paddingLeft: 8, - paddingRight: 8, gap: 5, flexDirection: 'row', alignItems: 'center', diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx index e59dcf7d43e..dddcc95d8de 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx @@ -1,8 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import { NetworkClientId } from '@metamask/network-controller'; import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; @@ -16,13 +17,13 @@ import { formatAmountMaxPrecision, } from '../../../../../../../../../UI/SimulationDetails/formatAmount'; -import Box from '../../../../../../../../../UI/Ramp/components/Box'; import Address from '../../../../../../UI/InfoRow/InfoValue/Address/Address'; import { selectContractExchangeRates } from '../../../../../../../../../../selectors/tokenRatesController'; import Logger from '../../../../../../../../../../util/Logger'; import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { isNumberValue } from '../../../../../../../../../../util/number'; import { useTheme } from '../../../../../../../../../../util/theme'; import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; @@ -101,26 +102,15 @@ const SimulationValueDisplay: React.FC< tokenDetails as TokenDetailsERC20, ); - const fiatValue = useMemo(() => { - if (exchangeRate && value && !tokenId) { - const tokenAmount = calcTokenAmount(value, tokenDecimals); - return tokenAmount.multipliedBy(exchangeRate).toNumber(); - } - return undefined; - }, [exchangeRate, tokenDecimals, tokenId, value]); + const tokenAmount = isNumberValue(value) && !tokenId ? calcTokenAmount(value, tokenDecimals) : null; + const isValidTokenAmount = tokenAmount !== null && tokenAmount !== undefined && tokenAmount instanceof BigNumber; - const { tokenValue, tokenValueMaxPrecision } = useMemo(() => { - if (!value || tokenId) { - return { tokenValue: null, tokenValueMaxPrecision: null }; - } + const fiatValue = isValidTokenAmount && exchangeRate && !tokenId + ? tokenAmount.multipliedBy(exchangeRate).toNumber() + : undefined; - const tokenAmount = calcTokenAmount(value, tokenDecimals); - - return { - tokenValue: formatAmount('en-US', tokenAmount), - tokenValueMaxPrecision: formatAmountMaxPrecision('en-US', tokenAmount), - }; - }, [tokenDecimals, tokenId, value]); + const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; + const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; /** Temporary error capturing as we are building out Permit Simulations */ if (!tokenContract) { @@ -137,10 +127,11 @@ const SimulationValueDisplay: React.FC< } return ( - - + + {credit && '+ '} {debit && '- '} - {tokenValue !== null && + {tokenValue !== null && shortenString(tokenValue || '', { truncatedCharLimit: 15, truncatedStartChars: 15, @@ -159,18 +150,18 @@ const SimulationValueDisplay: React.FC< {tokenId && `#${tokenId}`} - +
- + - - - {/* + + + {/** TODO - add fiat shorten prop after tooltip logic has been updated {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} */} {fiatValue && } - + {hasValueModalOpen && ( /** * TODO replace BottomModal instances with BottomSheet @@ -201,7 +192,7 @@ const SimulationValueDisplay: React.FC< )} - + ); }; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts new file mode 100644 index 00000000000..50cee91255f --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts @@ -0,0 +1 @@ +export { default } from './Simulation'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx index 57ba7552428..2caee661061 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx @@ -1,31 +1,14 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import { selectUseTransactionSimulations } from '../../../../../../../selectors/preferencesController'; -import useApprovalRequest from '../../../../hooks/useApprovalRequest'; -import { isRecognizedPermit } from '../../../../utils/signature'; import InfoRowOrigin from '../Shared/InfoRowOrigin'; -import PermitSimulation from './Simulation/TypedSignPermit'; import Message from './Message'; +import TypedSignV3V4Simulation from './Simulation'; -const TypedSignV3V4 = () => { - const { approvalRequest } = useApprovalRequest(); - const useSimulation = useSelector( - selectUseTransactionSimulations, - ); - - if (!approvalRequest) { - return null; - } - - const isPermit = isRecognizedPermit(approvalRequest); - - return ( +const TypedSignV3V4 = () => ( <> - {isPermit && useSimulation && } + ); -}; export default TypedSignV3V4; diff --git a/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx b/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx index f610b170a57..9e8bb043755 100644 --- a/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx +++ b/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx @@ -6,7 +6,7 @@ import { useSelector } from 'react-redux'; import setSignatureRequestSecurityAlertResponse from '../../../../../../actions/signatureRequest'; import { store } from '../../../../../../store'; import { useTheme } from '../../../../../../util/theme'; -import useConfirmationRedesignEnabled from '../../../hooks/useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from '../../../hooks/useConfirmationRedesignEnabled'; import PersonalSign from '../../PersonalSign'; import TypedSign from '../../TypedSign'; import { MessageParams } from '../types'; diff --git a/app/components/Views/confirmations/hooks/useApprovalRequest.ts b/app/components/Views/confirmations/hooks/useApprovalRequest.ts index 0eb31b4a468..61128e7895c 100644 --- a/app/components/Views/confirmations/hooks/useApprovalRequest.ts +++ b/app/components/Views/confirmations/hooks/useApprovalRequest.ts @@ -1,10 +1,10 @@ -import Engine from '../../../../core/Engine'; import { useCallback, useMemo } from 'react'; -import { providerErrors } from '@metamask/rpc-errors'; import { useSelector } from 'react-redux'; -import { selectPendingApprovals } from '../../../../selectors/approvalController'; import { cloneDeep, isEqual } from 'lodash'; import { ApprovalRequest } from '@metamask/approval-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../../../core/Engine'; +import { selectPendingApprovals } from '../../../../selectors/approvalController'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts index ffa03104883..20640e9606b 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts @@ -5,7 +5,7 @@ import { personalSignatureConfirmationState } from '../../../../util/test/confir // eslint-disable-next-line import/no-namespace import * as QRHardwareAwareness from './useQRHardwareAwareness'; -import useConfirmationRedesignEnabled from './useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from './useConfirmationRedesignEnabled'; jest.mock('../../../../core/Engine', () => ({ getTotalFiatAccountBalance: () => ({ tokenFiat: 10 }), diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts index 43ec8489268..9ca0e9919cc 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts @@ -7,7 +7,7 @@ import { selectRemoteFeatureFlags } from '../../../../selectors/featureFlagContr import useApprovalRequest from './useApprovalRequest'; import useQRHardwareAwareness from './useQRHardwareAwareness'; -const useConfirmationRedesignEnabled = () => { +export const useConfirmationRedesignEnabled = () => { const { approvalRequest } = useApprovalRequest(); const { isSigningQRObject, isSyncingQRHardware } = useQRHardwareAwareness(); const { confirmation_redesign } = useSelector(selectRemoteFeatureFlags); @@ -42,5 +42,3 @@ const useConfirmationRedesignEnabled = () => { return { isRedesignedEnabled }; }; - -export default useConfirmationRedesignEnabled; diff --git a/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts b/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts new file mode 100644 index 00000000000..0553f8fcae2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts @@ -0,0 +1,66 @@ +import { useSelector } from 'react-redux'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import { MessageParamsTyped, SignatureRequest, SignatureRequestType } from '@metamask/signature-controller'; +import { selectUseTransactionSimulations } from '../../../../selectors/preferencesController'; +import { isRecognizedPermit, parseTypedDataMessage } from '../utils/signature'; +import { useSignatureRequest } from './useSignatureRequest'; + +const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ + { + domainName: 'Seaport', + primaryTypeList: ['BulkOrder'], + versionList: ['1.4', '1.5', '1.6'], + }, + { + domainName: 'Seaport', + primaryTypeList: ['OrderComponents'], + }, +]; + +const isNonPermitSupportedByDecodingAPI = ( + signatureRequest: SignatureRequest, +) => { + const data = signatureRequest.messageParams?.data as string; + if (!data) { return false; } + + const { + domain: { name, version }, + primaryType, + } = parseTypedDataMessage(data); + + return NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( + ({ domainName, primaryTypeList, versionList }) => + name === domainName && + primaryTypeList.includes(primaryType) && + (!versionList || versionList.includes(version)), + ); +}; + +export function useTypedSignSimulationEnabled() { + const signatureRequest = useSignatureRequest(); + const useTransactionSimulations = useSelector( + selectUseTransactionSimulations, + ); + + if (!signatureRequest) { + return undefined; + } + + const requestType = signatureRequest.type; + const signatureMethod = (signatureRequest.messageParams as MessageParamsTyped)?.version; + + const isTypedSignV3V4 = requestType === SignatureRequestType.TypedSign && ( + signatureMethod === SignTypedDataVersion.V3 || + signatureMethod === SignTypedDataVersion.V4 + ); + const isPermit = isRecognizedPermit(signatureRequest); + + const nonPermitSupportedByDecodingAPI: boolean = + isTypedSignV3V4 && isNonPermitSupportedByDecodingAPI(signatureRequest); + + return ( + useTransactionSimulations && + isTypedSignV3V4 && + (isPermit || nonPermitSupportedByDecodingAPI) + ); +} diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts index 13be1389591..08472ef3f27 100644 --- a/app/components/Views/confirmations/utils/signature.test.ts +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -1,6 +1,6 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; import { parseTypedDataMessage, isRecognizedPermit } from './signature'; import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; +import { SignatureRequest } from '@metamask/signature-controller'; describe('Signature Utils', () => { describe('parseTypedDataMessage', () => { @@ -46,25 +46,25 @@ describe('Signature Utils', () => { describe('isRecognizedPermit', () => { it('should return true for recognized permit types', () => { - const mockRequest: ApprovalRequest<{ data: string }> = { - requestData: { + const mockRequest: SignatureRequest = { + messageParams: { data: JSON.stringify({ primaryType: PRIMARY_TYPES_PERMIT[0] }) } - } as ApprovalRequest<{ data: string }>; + } as SignatureRequest; expect(isRecognizedPermit(mockRequest)).toBe(true); }); it('should return false for unrecognized permit types', () => { - const mockRequest: ApprovalRequest<{ data: string }> = { - requestData: { + const mockRequest: SignatureRequest = { + messageParams: { data: JSON.stringify({ primaryType: 'UnrecognizedType' }) } - } as ApprovalRequest<{ data: string }>; + } as SignatureRequest; expect(isRecognizedPermit(mockRequest)).toBe(false); }); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts index 73c19e2b9b7..9035a2b3087 100644 --- a/app/components/Views/confirmations/utils/signature.ts +++ b/app/components/Views/confirmations/utils/signature.ts @@ -1,4 +1,4 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; +import { SignatureRequest } from '@metamask/signature-controller'; import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; /** @@ -47,9 +47,15 @@ export const parseTypedDataMessage = (dataToParse: string) => { /** * Returns true if the request is a recognized Permit Typed Sign signature request * - * @param request - The confirmation request to check + * @param request - The signature request to check */ -export const isRecognizedPermit = (approvalRequest: ApprovalRequest<{ data: string }>) => { - const { primaryType } = parseTypedDataMessage(approvalRequest.requestData.data); +export const isRecognizedPermit = (request: SignatureRequest) => { + if (!request) { + return false; + } + + const data = (request as SignatureRequest).messageParams?.data as string; + + const { primaryType } = parseTypedDataMessage(data); return PRIMARY_TYPES_PERMIT.includes(primaryType); }; diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 7d132663576..7f48671fa74 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -137,6 +137,7 @@ export default { 'https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/', STAKING_RISK_DISCLOSURE: 'https://consensys.io/staking-risk-disclosures', }, + DECODING_API_URL: process.env.DECODING_API_URL || 'https://signature-insights.api.cx.metamask.io/v1', ERRORS: { INFURA_BLOCKED_MESSAGE: 'EthQuery - RPC Error - This service is not available in your country', diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index a0fe49693c3..740c50e7b45 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1441,6 +1441,10 @@ export class Engine { }), // This casting expected due to mismatch of browser and react-native version of Sentry traceContext trace: trace as unknown as SignatureControllerOptions['trace'], + decodingApiUrl: AppConstants.DECODING_API_URL, + // TODO: check preferences useExternalServices + isDecodeSignatureRequestEnabled: () => + preferencesController.state.useTransactionSimulations, }), LoggingController: loggingController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) diff --git a/app/selectors/currencyRateController.test.ts b/app/selectors/currencyRateController.test.ts index e48f6b957a4..61a29d38137 100644 --- a/app/selectors/currencyRateController.test.ts +++ b/app/selectors/currencyRateController.test.ts @@ -2,6 +2,7 @@ import { selectConversionRate, selectCurrentCurrency, selectCurrencyRates, + selectConversionRateByChainId, } from './currencyRateController'; import { isTestNet } from '../../app/util/networks'; import { CurrencyRateState } from '@metamask/assets-controllers'; @@ -64,6 +65,41 @@ describe('CurrencyRateController Selectors', () => { }); }); + describe('selectConversionRateByChainId', () => { + const mockChainId = '1'; + const mockNativeCurrency = 'ETH'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns undefined if on a testnet and fiat is disabled', () => { + (isTestNet as jest.Mock).mockReturnValue(true); + + const result = selectConversionRateByChainId.resultFunc( + mockCurrencyRateState.currencyRates as unknown as CurrencyRateState['currencyRates'], + mockChainId as `0x${string}`, + false, + mockNativeCurrency, + ); + + expect(result).toBeUndefined(); + }); + + it('returns the conversion rate for the native currency of the chain id', () => { + (isTestNet as jest.Mock).mockReturnValue(false); + + const result = selectConversionRateByChainId.resultFunc( + mockCurrencyRateState.currencyRates as unknown as CurrencyRateState['currencyRates'], + mockChainId as `0x${string}`, + true, + mockNativeCurrency, + ); + + expect(result).toBe(3000); + }); + }); + describe('selectCurrentCurrency', () => { it('returns the current currency from the state', () => { const result = selectCurrentCurrency.resultFunc( diff --git a/app/selectors/currencyRateController.ts b/app/selectors/currencyRateController.ts index 715ebeb4e8b..296cf3eedd5 100644 --- a/app/selectors/currencyRateController.ts +++ b/app/selectors/currencyRateController.ts @@ -1,7 +1,11 @@ import { createSelector } from 'reselect'; import { CurrencyRateState } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; -import { selectChainId, selectTicker } from './networkController'; +import { + selectChainId, + selectNativeCurrencyByChainId, + selectTicker, +} from './networkController'; import { isTestNet } from '../../app/util/networks'; const selectCurrencyRateControllerState = (state: RootState) => @@ -56,3 +60,22 @@ export const selectConversionRateFoAllChains = createSelector( (currencyRateControllerState: CurrencyRateState) => currencyRateControllerState?.currencyRates, ); + +export const selectConversionRateByChainId = createSelector( + selectConversionRateFoAllChains, + (_state: RootState, chainId: string) => chainId, + (state: RootState) => state.settings.showFiatOnTestnets, + selectNativeCurrencyByChainId, + ( + currencyRates: CurrencyRateState['currencyRates'], + chainId, + showFiatOnTestnets, + nativeCurrency, + ) => { + if (isTestNet(chainId) && !showFiatOnTestnets) { + return undefined; + } + + return currencyRates?.[nativeCurrency]?.conversionRate; + }, +); diff --git a/app/util/number/index.js b/app/util/number/index.js index 894716562aa..05e8cbececd 100644 --- a/app/util/number/index.js +++ b/app/util/number/index.js @@ -349,7 +349,7 @@ export function isBN(value) { /** * Determines if a string is a valid decimal * - * @param {string} value - String to check + * @param {number | string} value - String to check * @returns {boolean} - True if the string is a valid decimal */ export function isDecimal(value) { @@ -380,6 +380,22 @@ export function isNumber(str) { return regex.number.test(str); } +/** + * Determines if a value is a number + * + * @param {number | string | null | undefined} value - Value to check + * @returns {boolean} - True if the value is a valid number + */ +export function isNumberValue(value) { + if (value === null || value === undefined) { return false; } + + if (typeof value === 'number') { + return !Number.isNaN(value) && Number.isFinite(value); + } + + return isDecimal(value); +} + export const dotAndCommaDecimalFormatter = (value) => { const valueStr = String(value); diff --git a/app/util/number/index.test.ts b/app/util/number/index.test.ts index 436f18c0c29..5849713f223 100644 --- a/app/util/number/index.test.ts +++ b/app/util/number/index.test.ts @@ -20,6 +20,7 @@ import { isBN, isDecimal, isNumber, + isNumberValue, isNumberScientificNotationWhenString, isZeroValue, limitToMaximumDecimalPlaces, @@ -925,6 +926,42 @@ describe('Number utils :: isNumber', () => { }); }); +describe('Number utils :: isNumberValue', () => { + it('should return true for valid number types', () => { + expect(isNumberValue(1650.7)).toBe(true); + expect(isNumberValue(1000)).toBe(true); + expect(isNumberValue(0.0001)).toBe(true); + expect(isNumberValue(-0.0001)).toBe(true); + expect(isNumberValue(1)).toBe(true); + expect(isNumberValue(1e-10)).toBe(true); + }); + + it('should be a valid number string types', () => { + expect(isNumberValue('1650.7')).toBe(true); + expect(isNumberValue('1000')).toBe(true); + expect(isNumberValue('.01')).toBe(true); + expect(isNumberValue('0.0001')).toBe(true); + expect(isNumberValue('0001')).toBe(true); + expect(isNumberValue('-0.0001')).toBe(true); + expect(isNumberValue('1')).toBe(true); + expect(isNumberValue('1e-10')).toBe(true); + }); + + it('should not be a valid number ', () => { + expect(isNumberValue('..7')).toBe(false); + expect(isNumberValue('1..1')).toBe(false); + expect(isNumberValue('0..')).toBe(false); + expect(isNumberValue('a.0001')).toBe(false); + expect(isNumberValue('00a01')).toBe(false); + expect(isNumberValue('1,.')).toBe(false); + expect(isNumberValue('1,')).toBe(false); + expect(isNumberValue('.')).toBe(false); + expect(isNumberValue('a¡1')).toBe(false); + expect(isNumberValue(undefined)).toBe(false); + expect(isNumberValue(null)).toBe(false); + }); +}); + describe('Number utils :: dotAndCommaDecimalFormatter', () => { it('should return the number if it does not contain a dot or comma', () => { expect(dotAndCommaDecimalFormatter('1650')).toBe('1650'); diff --git a/locales/languages/en.json b/locales/languages/en.json index 861594b3a49..f9bb4d1f0c5 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3607,12 +3607,21 @@ "balance": "Balance", "network": "Network", "simulation": { + "decoded_tooltip_bid_nft": "The NFT will be reflected in your wallet, when the bid is accepted.", + "decoded_tooltip_list_nft": "Expect changes only if someone buys your NFTs.", "info_permit": "You’re giving the spender permission to spend this many tokens from your account.", + "label_change_type_bidding": "You bid", + "label_change_type_listing": "You list", + "label_change_type_nft_listing": "Listing price", "label_change_type_permit": "Spending cap", "label_change_type_permit_nft": "Withdraw", + "label_change_type_receive": "You receive", + "label_change_type_revoke": "Revoke", + "label_change_type_transfer": "You send", "personal_sign_info": "You’re signing into a site and there are no predicted changes to your account.", "title": "Estimated changes", - "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee." + "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", + "unavailable": "Unavailable" } }, "change_in_simulation_modal": {