diff --git a/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts new file mode 100644 index 00000000000..a5d937a948c --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts @@ -0,0 +1,49 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +// External dependencies. +import { Theme } from '../../../../util/theme/models'; + +/** + * Style sheet input parameters. + */ +export interface ButtonPillStyleSheetVars { + isDisabled: boolean; + isPressed: boolean; +} + +/** + * Style sheet function for ButtonPill component + * + * @param params Style sheet params + * @param params.theme Theme object + * @param params.vars Arbitrary inputs this style sheet depends on + * @returns StyleSheet object + */ +const styleSheet = (params: { + theme: Theme; + vars: ButtonPillStyleSheetVars; +}) => { + const { + theme: { colors }, + vars: { isDisabled, isPressed } + } = params; + + return StyleSheet.create({ + base: { + backgroundColor: colors.background.alternative, + color: colors.text.default, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 99, + opacity: isDisabled ? 0.5 : 1, + ...(isPressed && { + backgroundColor: colors.background.alternativePressed, + }), + }, + }); +}; + +export default styleSheet; diff --git a/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx new file mode 100644 index 00000000000..41933943f63 --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx @@ -0,0 +1,15 @@ +// Third party dependencies. +import React from 'react'; +import { render } from '@testing-library/react-native'; + +// Internal dependencies. +import ButtonPill from './ButtonPill'; + +describe('ButtonPill', () => { + it('should render correctly', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx new file mode 100644 index 00000000000..2b158cdce6d --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx @@ -0,0 +1,69 @@ +// Third party dependencies. +import React, { useCallback, useState } from 'react'; +import { GestureResponderEvent, TouchableOpacity, TouchableOpacityProps } from 'react-native'; + +// External dependencies. +import { useStyles } from '../../../hooks'; + +// Internal dependencies. +import stylesheet from './ButtonPill.styles'; + +/** + * ButtonPill component props. + */ +export interface ButtonPillProps extends TouchableOpacityProps { + /** + * Optional param to disable the button. + */ + isDisabled?: boolean; +} + +const ButtonPill = ({ + onPress, + onPressIn, + onPressOut, + style, + isDisabled = false, + children, + ...props +}: ButtonPillProps) => { + const [isPressed, setIsPressed] = useState(false); + const { styles } = useStyles(stylesheet, { + style, + isPressed, + isDisabled, + }); + + const triggerOnPressedIn = useCallback( + (e: GestureResponderEvent) => { + setIsPressed(true); + onPressIn?.(e); + }, + [setIsPressed, onPressIn], + ); + + const triggerOnPressedOut = useCallback( + (e: GestureResponderEvent) => { + setIsPressed(false); + onPressOut?.(e); + }, + [setIsPressed, onPressOut], + ); + + return ( + + {children} + + ); +}; + +export default ButtonPill; diff --git a/app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap b/app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap new file mode 100644 index 00000000000..c3e3b781a54 --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ButtonPill should render correctly 1`] = ` + +`; diff --git a/app/component-library/components-temp/Buttons/ButtonPill/index.ts b/app/component-library/components-temp/Buttons/ButtonPill/index.ts new file mode 100644 index 00000000000..d983c349bc5 --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/index.ts @@ -0,0 +1 @@ +export { default } from './ButtonPill'; diff --git a/app/components/UI/Name/Name.tsx b/app/components/UI/Name/Name.tsx index 9c554d8759a..bddd22806d4 100644 --- a/app/components/UI/Name/Name.tsx +++ b/app/components/UI/Name/Name.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TextProps, View } from 'react-native'; +import { TextProps, View, ViewStyle } from 'react-native'; import { useStyles } from '../../../component-library/hooks'; import Text, { @@ -34,11 +34,12 @@ const NameLabel: React.FC<{ ); }; -const UnknownEthereumAddress: React.FC<{ address: string }> = ({ address }) => { +const UnknownEthereumAddress: React.FC<{ address: string, style?: ViewStyle }> = ({ address, style }) => { const displayNameVariant = DisplayNameVariant.Unknown; const { styles } = useStyles(styleSheet, { displayNameVariant }); + return ( - + {renderShortAddress(address, 5)} @@ -52,6 +53,7 @@ const Name: React.FC = ({ type, value, variation, + style, }) => { if (type !== NameType.EthereumAddress) { throw new Error('Unsupported NameType: ' + type); @@ -69,11 +71,11 @@ const Name: React.FC = ({ }); if (variant === DisplayNameVariant.Unknown) { - return ; + return ; } return ( - + @@ -2793,6 +2794,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "padding": 0, }, undefined, + undefined, ] } > @@ -4192,6 +4194,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "padding": 0, }, undefined, + undefined, ] } > @@ -4381,6 +4384,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "padding": 0, }, undefined, + undefined, ] } > @@ -5780,6 +5784,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "padding": 0, }, undefined, + undefined, ] } > @@ -5969,6 +5974,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "padding": 0, }, undefined, + undefined, ] } > @@ -7368,6 +7374,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "padding": 0, }, undefined, + undefined, ] } > @@ -7557,6 +7564,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "padding": 0, }, undefined, + undefined, ] } > @@ -8503,6 +8511,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -8833,6 +8842,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -9127,6 +9137,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -11609,6 +11620,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -11939,6 +11951,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -12081,6 +12094,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index f25e42498a0..b630bd9b7d0 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -591,6 +591,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -2088,6 +2089,7 @@ exports[`OrderDetails renders a completed order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3599,6 +3601,7 @@ exports[`OrderDetails renders a created order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -5032,6 +5035,7 @@ exports[`OrderDetails renders a failed order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6543,6 +6547,7 @@ exports[`OrderDetails renders a pending order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -9077,6 +9082,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -10615,6 +10621,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -12146,6 +12153,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription "padding": 0, }, undefined, + undefined, ] } > @@ -13620,6 +13628,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap index 2e25a8d696d..e5a9d3b334f 100644 --- a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap +++ b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap @@ -495,6 +495,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -780,6 +781,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -1082,6 +1084,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -1942,6 +1945,7 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -2227,6 +2231,7 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -2529,6 +2534,7 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3385,6 +3391,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3634,6 +3641,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3885,6 +3893,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6582,6 +6591,7 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6831,6 +6841,7 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -7082,6 +7093,7 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -7811,6 +7823,7 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, + undefined, ] } > @@ -8096,6 +8109,7 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, + undefined, ] } > @@ -8398,6 +8412,7 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, + undefined, ] } > @@ -9949,6 +9964,7 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, + undefined, ] } > @@ -10234,6 +10250,7 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, + undefined, ] } > @@ -10536,6 +10553,7 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 3ad1e4f304e..97f6fd0365a 100644 --- a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -35,6 +35,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -276,6 +277,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -414,6 +416,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3543,6 +3546,7 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3815,6 +3819,7 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -5018,6 +5023,7 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, + undefined, ] } > @@ -5290,6 +5296,7 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, + undefined, ] } > @@ -5589,6 +5596,7 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap index d181f8b8542..cffaf29508d 100644 --- a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap @@ -566,6 +566,7 @@ exports[`Regions View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -1345,6 +1346,7 @@ exports[`Regions View renders correctly while loading 1`] = ` undefined, undefined, undefined, + undefined, ] } > @@ -2573,6 +2575,7 @@ exports[`Regions View renders correctly with no data 1`] = ` undefined, undefined, undefined, + undefined, ] } > @@ -3822,6 +3825,7 @@ exports[`Regions View renders correctly with selectedRegion 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -4620,6 +4624,7 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -4944,6 +4949,7 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` undefined, undefined, undefined, + undefined, { "backgroundColor": "#ffffff", "borderWidth": 0, @@ -5793,6 +5799,7 @@ exports[`Regions View renders correctly with unsupportedRegion 2`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6117,6 +6124,7 @@ exports[`Regions View renders correctly with unsupportedRegion 2`] = ` undefined, undefined, undefined, + undefined, { "backgroundColor": "#ffffff", "borderWidth": 0, @@ -6966,6 +6974,7 @@ exports[`Regions View renders regions modal when pressing select button 1`] = ` "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/components/Box.tsx b/app/components/UI/Ramp/components/Box.tsx index 41974701101..54eb1b9cd0e 100644 --- a/app/components/UI/Ramp/components/Box.tsx +++ b/app/components/UI/Ramp/components/Box.tsx @@ -21,6 +21,9 @@ const createStyles = (colors: Colors) => label: { marginVertical: 8, }, + noBorder: { + borderWidth: 0, + }, highlighted: { borderColor: colors.primary.default, }, @@ -38,6 +41,7 @@ interface Props { style?: StyleProp; thin?: boolean; activeOpacity?: number; + noBorder?: boolean; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any onPress?: () => any; @@ -56,6 +60,7 @@ const Box: React.FC = ({ accessible, accessibilityLabel, compact, + noBorder, ...props }: Props) => { const { colors } = useTheme(); @@ -77,6 +82,7 @@ const Box: React.FC = ({ thin && styles.thin, highlighted && styles.highlighted, compact && styles.compact, + noBorder && styles.noBorder, style, ]} {...props} diff --git a/app/components/UI/SimulationDetails/types.ts b/app/components/UI/SimulationDetails/types.ts index 93468745472..d409c22ebf4 100644 --- a/app/components/UI/SimulationDetails/types.ts +++ b/app/components/UI/SimulationDetails/types.ts @@ -39,6 +39,17 @@ export type AssetIdentifier = Readonly< NativeAssetIdentifier | TokenAssetIdentifier >; +export enum TokenStandard { + /** A token that conforms to the ERC20 standard. */ + ERC20 = 'ERC20', + /** A token that conforms to the ERC721 standard. */ + ERC721 = 'ERC721', + /** A token that conforms to the ERC1155 standard. */ + ERC1155 = 'ERC1155', + /** Not a token, but rather the base asset of the selected chain. */ + none = 'NONE', +} + /** * Describes a change in an asset's balance to a user's wallet. */ diff --git a/app/components/Views/confirmations/Confirm/Confirm.tsx b/app/components/Views/confirmations/Confirm/Confirm.tsx index 8149098a80a..a13ec947e8d 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.tsx @@ -20,7 +20,7 @@ const Confirm = () => { } return ( - + diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx new file mode 100644 index 00000000000..2b814f9629b --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import renderWithProvider from '../../../../../../../../../util/test/renderWithProvider'; +import { typedSignV4ConfirmationState } from '../../../../../../../../../util/test/confirm-data-helpers'; +import PermitSimulation from './TypedSignPermit'; + +jest.mock('../../../../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: () => 'mainnet', + }, + }, +})); + +describe('PermitSimulation', () => { + it('should render correctly for personal sign', async () => { + const { getByText } = renderWithProvider(<PermitSimulation />, { + state: typedSignV4ConfirmationState, + }); + + expect(getByText('Estimated changes')).toBeDefined(); + expect(getByText('You’re giving the spender permission to spend this many tokens from your account.')).toBeDefined(); + expect(getByText('Spending cap')).toBeDefined(); + expect(getByText('3,000')).toBeDefined(); + expect(getByText('0xCcCCc...ccccC')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx new file mode 100644 index 00000000000..bb326cb1463 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Hex } from '@metamask/utils'; + +import { strings } from '../../../../../../../../../../locales/i18n'; +import { useStyles } from '../../../../../../../../../component-library/hooks'; +import Engine from '../../../../../../../../../core/Engine'; +import { safeToChecksumAddress } from '../../../../../../../../../util/address'; +import { PrimaryType } from '../../../../../../constants/signatures'; +import { useSignatureRequest } from '../../../../../../hooks/useSignatureRequest'; +import { parseTypedDataMessage } from '../../../../../../utils/signature'; +import InfoRow from '../../../../../UI/InfoRow'; +import InfoSection from '../../../../../UI/InfoRow/InfoSection'; +import PermitSimulationValueDisplay from '../components/ValueDisplay'; + +const styleSheet = () => + StyleSheet.create({ + permitValues: { + display: 'flex', + flexDirection: 'column', + gap: 2, + }, + }); + +function extractTokenDetailsByPrimaryType( + message: Record<string, unknown>, + primaryType: PrimaryType, +): object[] | unknown { + let tokenDetails; + + switch (primaryType) { + case PrimaryType.PermitBatch: + case PrimaryType.PermitSingle: + tokenDetails = message?.details; + break; + case PrimaryType.PermitBatchTransferFrom: + case PrimaryType.PermitTransferFrom: + tokenDetails = message?.permitted; + break; + default: + break; + } + + const isNonArrayObject = tokenDetails && !Array.isArray(tokenDetails); + return isNonArrayObject ? [tokenDetails] : tokenDetails; +} + +const PermitSimulation = () => { + const { NetworkController } = Engine.context; + const { styles } = useStyles(styleSheet, {}); + + const signatureRequest = useSignatureRequest(); + + const chainId = signatureRequest?.chainId as Hex; + const msgData = signatureRequest?.messageParams?.data; + + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + + if (!msgData) { + return null; + } + + const { + domain: { verifyingContract }, + message, + message: { tokenId }, + primaryType, + } = parseTypedDataMessage(msgData as string); + + const tokenDetails = extractTokenDetailsByPrimaryType(message, primaryType); + + const isNFT = tokenId !== undefined; + const labelChangeType = isNFT + ? strings('confirm.simulation.label_change_type_permit_nft') + : strings('confirm.simulation.label_change_type_permit'); + + return ( + <InfoSection> + <InfoRow + label={strings('confirm.simulation.title')} + tooltip={strings('confirm.simulation.tooltip')} + > + {strings('confirm.simulation.info_permit')} + </InfoRow> + + <InfoRow label={labelChangeType}> + {Array.isArray(tokenDetails) ? ( + <View style={styles.permitValues}> + {tokenDetails.map( + ( + { token, amount }: { token: string; amount: string }, + i: number, + ) => ( + <PermitSimulationValueDisplay + key={`${token}-${i}`} + labelChangeType={labelChangeType} + networkClientId={networkClientId} + primaryType={primaryType} + tokenContract={safeToChecksumAddress(token)} + value={amount} + chainId={chainId} + /> + ), + )} + </View> + ) : ( + <PermitSimulationValueDisplay + labelChangeType={labelChangeType} + networkClientId={networkClientId} + tokenContract={verifyingContract} + value={message.value} + tokenId={message.tokenId} + chainId={chainId} + /> + )} + </InfoRow> + </InfoSection> + ); +}; + +export default PermitSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts new file mode 100644 index 00000000000..2dd1a7b5f59 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts @@ -0,0 +1 @@ +export { default } from './TypedSignPermit'; 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 new file mode 100644 index 00000000000..6414e76e0ba --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts @@ -0,0 +1,89 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '@metamask/design-tokens'; +import { fontStyles, colors as importedColors } from '../../../../../../../../../../styles/common'; + +const styleSheet = (colors: Theme['colors']) => + StyleSheet.create({ + wrapper: { + marginLeft: 'auto', + maxWidth: '100%', + alignSelf: 'flex-end', + justifyContent: 'flex-end', + borderWidth: 0, + padding: 0, + }, + + flexRowTokenValueAndAddress: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + borderColor: importedColors.transparent, + borderWidth: 0, + padding: 0, + }, + tokenAddress: { + marginStart: 4, + }, + tokenValueTooltipContent: { + borderRadius: 12, + paddingHorizontal: 8, + paddingTop: 4, + paddingBottom: 4, + textAlign: 'center', + }, + valueAndAddress: { + paddingVertical: 4, + paddingLeft: 8, + paddingRight: 8, + gap: 5, + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + }, + valueIsCredit: { + backgroundColor: colors.success.muted, + color: colors.success.default, + }, + valueIsDebit: { + backgroundColor: colors.error.muted, + color: colors.error.default, + }, + valueModal: { + backgroundColor: colors.background.alternative, + paddingTop: 24, + paddingBottom: 34, + paddingHorizontal: 16, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + }, + valueModalHeader: { + alignItems: 'center', + display: 'flex', + flexDirection: 'row', + paddingBottom: 16, + position: 'relative', + textAlign: 'center', + width: '100%', + }, + valueModalHeaderIcon: { + position: 'absolute', + top: 0, + left: 0, + }, + valueModalHeaderText: { + color: colors.text.default, + ...fontStyles.bold, + fontSize: 14, + fontWeight: '700', + textAlign: 'center', + width: '100%', + // height of header icon + minHeight: 24, + }, + valueModalText: { + textAlign: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx new file mode 100644 index 00000000000..8decf038ea1 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx @@ -0,0 +1,162 @@ + +import React from 'react'; +import { act } from '@testing-library/react-native'; +import SimulationValueDisplay from './ValueDisplay'; + +import { memoizedGetTokenStandardAndDetails } from '../../../../../../../utils/token'; +import useGetTokenStandardAndDetails from '../../../../../../../hooks/useGetTokenStandardAndDetails'; +import { TokenStandard } from '../../../../../../../../../UI/SimulationDetails/types'; +import { getTokenDetails } from '../../../../../../../../../../util/address'; +import { backgroundState } from '../../../../../../../../../../util/test/initial-root-state'; +import renderWithProvider from '../../../../../../../../../../util/test/renderWithProvider'; +import { useMetrics } from '../../../../../../../../../hooks/useMetrics'; +import { MetricsEventBuilder } from '../../../../../../../../../../core/Analytics/MetricsEventBuilder'; + +const mockInitialState = { + engine: { + backgroundState, + }, +}; + +const mockTrackEvent = jest.fn(); + +jest.mock('../../../../../../../../../hooks/useMetrics'); +jest.mock('../../../../../../../hooks/useGetTokenStandardAndDetails'); + + +jest.mock('../../../../../../../../../../util/address', () => ({ + getTokenDetails: jest.fn(), + renderShortAddress: jest.requireActual('../../../../../../../../../../util/address').renderShortAddress +})); + +describe('SimulationValueDisplay', () => { + beforeEach(() => { + (useMetrics as jest.MockedFn<typeof useMetrics>).mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: MetricsEventBuilder.createEventBuilder, + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createDataDeletionTask: jest.fn(), + checkDataDeleteStatus: jest.fn(), + getDeleteRegulationCreationDate: jest.fn(), + getDeleteRegulationId: jest.fn(), + isDataRecorded: jest.fn(), + isEnabled: jest.fn(), + getMetaMetricsId: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + + /** Reset memoized function using getTokenStandardAndDetails for each test */ + memoizedGetTokenStandardAndDetails?.cache?.clear?.(); + }); + + it('renders component correctly', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + const { findByText } = renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(await findByText('0.432')).toBeDefined(); + }); + + it('should invoke method to track missing decimal information for ERC20 tokens only once', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: undefined, + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('should not invoke method to track missing decimal information for ERC20 tokens', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + describe('when token is an ERC721 token', () => { + beforeEach(() => { + jest.mocked(getTokenDetails).mockResolvedValue({ + name: 'TST', + symbol: 'TST', + standard: TokenStandard.ERC721, + }); + }); + + it('should not invoke method to track missing decimal information', async () => { + renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Withdraw'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + tokenId={'1234'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); +}); 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 new file mode 100644 index 00000000000..e59dcf7d43e --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx @@ -0,0 +1,208 @@ +import React, { useMemo, 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 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 Text from '../../../../../../../../../../component-library/components/Texts/Text'; + +import { IndividualFiatDisplay } from '../../../../../../../../../UI/SimulationDetails/FiatDisplay/FiatDisplay'; +import { + formatAmount, + 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 { useTheme } from '../../../../../../../../../../util/theme'; +import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; + +import useGetTokenStandardAndDetails from '../../../../../../../hooks/useGetTokenStandardAndDetails'; +import useTrackERC20WithoutDecimalInformation from '../../../../../../../hooks/useTrackERC20WithoutDecimalInformation'; +import { TokenDetailsERC20 } from '../../../../../../../utils/token'; +import BottomModal from '../../../../../../UI/BottomModal'; + +import styleSheet from './ValueDisplay.styles'; + +interface SimulationValueDisplayParams { + /** ID of the associated chain. */ + chainId: Hex; + + /** Change type to be displayed in value tooltip */ + labelChangeType: string; + + /** The network client ID */ + networkClientId?: NetworkClientId; + + /** + * The ethereum token contract address. It is expected to be in hex format. + * We currently accept strings since we have a patch that accepts a custom string + * {@see .yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch} + */ + tokenContract: Hex | string | undefined; + + // Optional + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; + + /** The primaryType of the typed sign message */ + primaryType?: string; + + /** The tokenId for NFT */ + tokenId?: string; + + /** The token amount */ + value?: number | string; +} + +const SimulationValueDisplay: React.FC< + SimulationValueDisplayParams +> = ({ + chainId, + labelChangeType, + networkClientId, + primaryType, + tokenContract, + tokenId, + value, + credit, + debit, +}) => { + const [hasValueModalOpen, setHasValueModalOpen] = useState(false); + + const { colors } = useTheme(); + const styles = styleSheet(colors); + + const contractExchangeRates = useSelector(selectContractExchangeRates); + const exchangeRate = + tokenContract && contractExchangeRates + ? contractExchangeRates[tokenContract as `0x${string}`]?.price + : undefined; + + const tokenDetails = useGetTokenStandardAndDetails(tokenContract, networkClientId); + const { decimalsNumber: tokenDecimals } = tokenDetails; + + useTrackERC20WithoutDecimalInformation( + chainId, + tokenContract, + 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 { tokenValue, tokenValueMaxPrecision } = useMemo(() => { + if (!value || tokenId) { + return { tokenValue: null, tokenValueMaxPrecision: null }; + } + + const tokenAmount = calcTokenAmount(value, tokenDecimals); + + return { + tokenValue: formatAmount('en-US', tokenAmount), + tokenValueMaxPrecision: formatAmountMaxPrecision('en-US', tokenAmount), + }; + }, [tokenDecimals, tokenId, value]); + + /** Temporary error capturing as we are building out Permit Simulations */ + if (!tokenContract) { + Logger.error( + new Error( + `SimulationValueDisplay: Token contract address is missing where primaryType === ${primaryType}`, + ), + ); + return null; + } + + function handlePressTokenValue() { + setHasValueModalOpen(true); + } + + return ( + <Box style={styles.wrapper}> + <Box style={styles.flexRowTokenValueAndAddress}> + <View style={styles.valueAndAddress}> + <ButtonPill + onPress={handlePressTokenValue} + onPressIn={handlePressTokenValue} + onPressOut={handlePressTokenValue} + style={[credit && styles.valueIsCredit, debit && styles.valueIsDebit]} + > + <Text> + {credit && '+ '} + {debit && '- '} + {tokenValue !== null && + shortenString(tokenValue || '', { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + })} + {tokenId && `#${tokenId}`} + </Text> + </ButtonPill> + <Box compact noBorder style={styles.tokenAddress}> + <Address address={tokenContract} chainId={chainId} /> + </Box> + </View> + </Box> + <Box compact noBorder> + {/* + TODO - add fiat shorten prop after tooltip logic has been updated + {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} + */} + {fiatValue && <IndividualFiatDisplay fiatAmount={fiatValue} /* shorten*/ />} + </Box> + {hasValueModalOpen && ( + /** + * TODO replace BottomModal instances with BottomSheet + * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}} + */ + <BottomModal onClose={() => setHasValueModalOpen(false)}> + <TouchableOpacity + activeOpacity={1} + onPress={() => setHasValueModalOpen(false)} + > + <View style={styles.valueModal} > + <View style={styles.valueModalHeader}> + <ButtonIcon + iconColor={IconColor.Default} + size={ButtonIconSizes.Sm} + style={styles.valueModalHeaderIcon} + onPress={() => setHasValueModalOpen(false)} + iconName={IconName.ArrowLeft} + /> + <Text style={styles.valueModalHeaderText}> + {labelChangeType} + </Text> + </View> + <Text style={styles.valueModalText}> + {tokenValueMaxPrecision} + </Text> + </View> + </TouchableOpacity> + </BottomModal> + )} + </Box> + ); + }; + +export default SimulationValueDisplay; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts new file mode 100644 index 00000000000..33cde05cb62 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './ValueDisplay'; 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 1fd23c2fbeb..57ba7552428 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx @@ -1,19 +1,27 @@ 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'; const TypedSignV3V4 = () => { const { approvalRequest } = useApprovalRequest(); + const useSimulation = useSelector( + selectUseTransactionSimulations, + ); if (!approvalRequest) { return null; } + const isPermit = isRecognizedPermit(approvalRequest); + return ( <> - {/* SIMULATION TO BE ADDED */} + {isPermit && useSimulation && <PermitSimulation />} <InfoRowOrigin /> <Message /> </> diff --git a/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx b/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx index adef046bb01..725d332f132 100644 --- a/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx +++ b/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx @@ -8,15 +8,21 @@ import styleSheet from './BottomModal.styles'; const OPAQUE_GRAY = '#414141'; interface BottomModalProps { + canCloseOnBackdropClick?: boolean; children: ReactChild; onClose?: () => void; hideBackground?: boolean; } +/** + * TODO replace BottomModal instances with BottomSheet + * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}} + */ const BottomModal = ({ + canCloseOnBackdropClick = true, children, hideBackground, - onClose, + onClose }: BottomModalProps) => { const { colors } = useTheme(); const { styles } = useStyles(styleSheet, {}); @@ -32,6 +38,7 @@ const BottomModal = ({ animationInTiming={600} animationOutTiming={600} onBackButtonPress={onClose} + onBackdropPress={canCloseOnBackdropClick ? onClose : undefined} onSwipeComplete={onClose} swipeDirection={'down'} propagateSwipe diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap index c0a8749fcc1..6d6fbd986dc 100644 --- a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap +++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap @@ -3,17 +3,20 @@ exports[`InfoAddress should match snapshot 1`] = ` <View style={ - { - "alignItems": "center", - "alignSelf": "center", - "backgroundColor": "#f2f4f6", - "borderRadius": 99, - "flexDirection": "row", - "gap": 5, - "paddingLeft": 8, - "paddingRight": 8, - "paddingVertical": 4, - } + [ + { + "alignItems": "center", + "alignSelf": "center", + "backgroundColor": "#f2f4f6", + "borderRadius": 99, + "flexDirection": "row", + "gap": 5, + "paddingLeft": 8, + "paddingRight": 8, + "paddingVertical": 4, + }, + undefined, + ] } > <SvgMock diff --git a/app/components/Views/confirmations/constants/signatures.ts b/app/components/Views/confirmations/constants/signatures.ts new file mode 100644 index 00000000000..79a6dfdf184 --- /dev/null +++ b/app/components/Views/confirmations/constants/signatures.ts @@ -0,0 +1,36 @@ +/** + * The contents of this file have been taken verbatim from + * metamask-extension/shared/constants/signatures.ts + * + * If updating, please be mindful of this or delete this comment. + */ + +export enum PrimaryTypeOrder { + Order = 'Order', + OrderComponents = 'OrderComponents', +} + +export enum PrimaryTypePermit { + Permit = 'Permit', + PermitBatch = 'PermitBatch', + PermitBatchTransferFrom = 'PermitBatchTransferFrom', + PermitSingle = 'PermitSingle', + PermitTransferFrom = 'PermitTransferFrom', +} + +/** + * EIP-712 Permit PrimaryTypes + */ +export const PrimaryType = { + ...PrimaryTypeOrder, + ...PrimaryTypePermit, +} as const; + +// Create a type from the const object +export type PrimaryType = (typeof PrimaryType)[keyof typeof PrimaryType]; + +export const PRIMARY_TYPES_ORDER: PrimaryTypeOrder[] = + Object.values(PrimaryTypeOrder); +export const PRIMARY_TYPES_PERMIT: PrimaryTypePermit[] = + Object.values(PrimaryTypePermit); +export const PRIMARY_TYPES: PrimaryType[] = Object.values(PrimaryType); diff --git a/app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts b/app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts new file mode 100644 index 00000000000..a361c9fbcb5 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts @@ -0,0 +1,50 @@ +import { NetworkClientId } from '@metamask/network-controller'; +import { Hex } from '@metamask/utils'; + +import { TokenStandard } from '../../../UI/SimulationDetails/types'; +import { useAsyncResult } from '../../../hooks/useAsyncResult'; +import { + ERC20_DEFAULT_DECIMALS, + parseTokenDetailDecimals, + memoizedGetTokenStandardAndDetails, + TokenDetailsERC20, +} from '../utils/token'; + +/** + * Returns token details for a given token contract + * + * @param tokenAddress + * @returns + */ +const useGetTokenStandardAndDetails = ( + tokenAddress?: Hex | string | undefined, + networkClientId?: NetworkClientId, +) => { + const { value: details } = + useAsyncResult<TokenDetailsERC20 | null>(async () => { + if (!tokenAddress) { + return Promise.resolve(null); + } + + return (await memoizedGetTokenStandardAndDetails({ + tokenAddress, + networkClientId, + })) as TokenDetailsERC20; + }, [tokenAddress]); + + if (!details) { + return { decimalsNumber: undefined }; + } + + const { decimals, standard } = details || {}; + + if (standard === TokenStandard.ERC20) { + const parsedDecimals = + parseTokenDetailDecimals(decimals) ?? ERC20_DEFAULT_DECIMALS; + details.decimalsNumber = parsedDecimals; + } + + return details; +}; + +export default useGetTokenStandardAndDetails; diff --git a/app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts b/app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts new file mode 100644 index 00000000000..6bf37651a5c --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { Hex } from '@metamask/utils'; + +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { TokenStandard } from '../../../../components/UI/SimulationDetails/types'; +import { useMetrics } from '../../../../components/hooks/useMetrics'; +import { parseTokenDetailDecimals, TokenDetailsERC20 } from '../utils/token'; + +/** + * Track event that number of decimals in ERC20 is not obtained + * + * @param chainId + * @param tokenAddress + * @param tokenDetails + * @param metricLocation + */ +const useTrackERC20WithoutDecimalInformation = ( + chainId: Hex, + tokenAddress: Hex | string | undefined, + tokenDetails?: TokenDetailsERC20, + metricLocation: string = 'signature_confirmation', +) => { + const { trackEvent, createEventBuilder } = useMetrics(); + + useEffect(() => { + if (chainId === undefined || tokenDetails === undefined) { + return; + } + const { decimals, standard } = tokenDetails || {}; + + if (standard !== TokenStandard.ERC20) { return; } + + const parsedDecimals = parseTokenDetailDecimals(decimals); + + if (parsedDecimals === undefined) { + trackEvent(createEventBuilder(MetaMetricsEvents.INCOMPLETE_ASSET_DISPLAYED) + .addProperties({ + token_decimals_available: false, + asset_address: tokenAddress, + asset_type: TokenStandard.ERC20, + chain_id: chainId, + location: metricLocation, + ui_customizations: ['redesigned_confirmation'], + }) + .build()); + } + }, [chainId, tokenAddress, tokenDetails, metricLocation, trackEvent, createEventBuilder]); +}; + +export default useTrackERC20WithoutDecimalInformation; diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts new file mode 100644 index 00000000000..13be1389591 --- /dev/null +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -0,0 +1,72 @@ +import { ApprovalRequest } from '@metamask/approval-controller'; +import { parseTypedDataMessage, isRecognizedPermit } from './signature'; +import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; + +describe('Signature Utils', () => { + describe('parseTypedDataMessage', () => { + it('should parse typed data message correctly', () => { + const data = JSON.stringify({ + message: { + value: '123' + } + }); + const result = parseTypedDataMessage(data); + expect(result).toEqual({ + message: { + value: '123' + } + }); + }); + + it('parses message.value as a string', () => { + const result = parseTypedDataMessage( + '{"test": "dummy", "message": { "value": 3000123} }', + ); + expect(result.message.value).toBe('3000123'); + }); + + + it('should handle large message values. This prevents native JS number coercion when the value is greater than Number.MAX_SAFE_INTEGER.', () => { + const largeValue = '123456789012345678901234567890'; + const data = JSON.stringify({ + message: { + value: largeValue + } + }); + const result = parseTypedDataMessage(data); + expect(result.message.value).toBe(largeValue); + }); + + it('throw error for invalid typedDataMessage', () => { + expect(() => { + parseTypedDataMessage(''); + }).toThrow(new Error('Unexpected end of JSON input')); + }); + }); + + describe('isRecognizedPermit', () => { + it('should return true for recognized permit types', () => { + const mockRequest: ApprovalRequest<{ data: string }> = { + requestData: { + data: JSON.stringify({ + primaryType: PRIMARY_TYPES_PERMIT[0] + }) + } + } as ApprovalRequest<{ data: string }>; + + expect(isRecognizedPermit(mockRequest)).toBe(true); + }); + + it('should return false for unrecognized permit types', () => { + const mockRequest: ApprovalRequest<{ data: string }> = { + requestData: { + data: JSON.stringify({ + primaryType: 'UnrecognizedType' + }) + } + } as ApprovalRequest<{ data: string }>; + + expect(isRecognizedPermit(mockRequest)).toBe(false); + }); + }); +}); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts new file mode 100644 index 00000000000..73c19e2b9b7 --- /dev/null +++ b/app/components/Views/confirmations/utils/signature.ts @@ -0,0 +1,55 @@ +import { ApprovalRequest } from '@metamask/approval-controller'; +import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; + +/** + * The contents of this file have been taken verbatim from + * metamask-extension/shared/modules/transaction.utils.ts + * + * If updating, please be mindful of this or delete this comment. + */ + +const REGEX_MESSAGE_VALUE_LARGE = /"message"\s*:\s*\{[^}]*"value"\s*:\s*(\d{15,})/u; + +function extractLargeMessageValue(dataToParse: string): string | undefined { + if (typeof dataToParse !== 'string') { + return undefined; + } + return dataToParse.match(REGEX_MESSAGE_VALUE_LARGE)?.[1]; +} + +/** + * JSON.parse has a limitation which coerces values to scientific notation if numbers are greater than + * Number.MAX_SAFE_INTEGER. This can cause a loss in precision. + * + * Aside from precision concerns, if the value returned was a large number greater than 15 digits, + * e.g. 3.000123123123121e+26, passing the value to BigNumber will throw the error: + * Error: new BigNumber() number type has more than 15 significant digits + * + * Note that using JSON.parse reviver cannot help since the value will be coerced by the time it + * reaches the reviver function. + * + * This function has a workaround to extract the large value from the message and replace + * the message value with the string value. + * + * @param dataToParse + * @returns + */ +export const parseTypedDataMessage = (dataToParse: string) => { + const result = JSON.parse(dataToParse); + + const messageValue = extractLargeMessageValue(dataToParse); + if (result.message?.value) { + result.message.value = messageValue || String(result.message.value); + } + return result; +}; + +/** + * Returns true if the request is a recognized Permit Typed Sign signature request + * + * @param request - The confirmation request to check + */ +export const isRecognizedPermit = (approvalRequest: ApprovalRequest<{ data: string }>) => { + const { primaryType } = parseTypedDataMessage(approvalRequest.requestData.data); + return PRIMARY_TYPES_PERMIT.includes(primaryType); +}; diff --git a/app/components/Views/confirmations/utils/token.ts b/app/components/Views/confirmations/utils/token.ts new file mode 100644 index 00000000000..4014dd1f528 --- /dev/null +++ b/app/components/Views/confirmations/utils/token.ts @@ -0,0 +1,99 @@ +import { memoize } from 'lodash'; +import { Hex } from '@metamask/utils'; +import { AssetsContractController } from '@metamask/assets-controllers'; +import { NetworkClientId } from '@metamask/network-controller'; +import { getTokenDetails } from '../../../../util/address'; + +export type TokenDetailsERC20 = Awaited< + ReturnType< + ReturnType<AssetsContractController['getERC20Standard']>['getDetails'] + > +> & { decimalsNumber: number }; + +export type TokenDetailsERC721 = Awaited< + ReturnType< + ReturnType<AssetsContractController['getERC721Standard']>['getDetails'] + > +>; + +export type TokenDetailsERC1155 = Awaited< + ReturnType< + ReturnType<AssetsContractController['getERC1155Standard']>['getDetails'] + > +>; + +export type TokenDetails = + | TokenDetailsERC20 + | TokenDetailsERC721 + | TokenDetailsERC1155; + +export const ERC20_DEFAULT_DECIMALS = 18; + +export const parseTokenDetailDecimals = ( + decStr?: string, +): number | undefined => { + if (!decStr) { + return undefined; + } + + for (const radix of [10, 16]) { + const parsedDec = parseInt(decStr, radix); + if (isFinite(parsedDec)) { + return parsedDec; + } + } + return undefined; +}; + +export const memoizedGetTokenStandardAndDetails = memoize( + async ({ + tokenAddress, + tokenId, + userAddress, + networkClientId, + }: { + tokenAddress?: Hex | string; + userAddress?: string; + tokenId?: string; + networkClientId?: NetworkClientId; + }): Promise<TokenDetails | Record<string, never>> => { + try { + if (!tokenAddress) { + return {}; + } + + return (await getTokenDetails( + tokenAddress, + userAddress, + tokenId, + networkClientId, + )) as TokenDetails; + } catch { + return {}; + } + }, +); + +/** + * Fetches the decimals for the given token address. + * + * @param address - The ethereum token contract address. It is expected to be in hex format. + * We currently accept strings since we have a patch that accepts a custom string + * {@see .yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch} + */ +export const fetchErc20Decimals = async ( + address: Hex | string, + networkClientId?: NetworkClientId, +): Promise<number> => { + try { + const { decimals: decStr } = (await memoizedGetTokenStandardAndDetails({ + tokenAddress: address, + networkClientId, + })) as TokenDetailsERC20; + const decimals = parseTokenDetailDecimals(decStr); + + return decimals ?? ERC20_DEFAULT_DECIMALS; + } catch { + return ERC20_DEFAULT_DECIMALS; + } +}; diff --git a/app/util/address/index.ts b/app/util/address/index.ts index c3ce4fc2043..a961ed6cda5 100644 --- a/app/util/address/index.ts +++ b/app/util/address/index.ts @@ -640,12 +640,14 @@ export const getTokenDetails = async ( tokenAddress: string, userAddress?: string, tokenId?: string, + networkClientId?: NetworkClientId, ) => { const { AssetsContractController } = Engine.context; const tokenData = await AssetsContractController.getTokenStandardAndDetails( tokenAddress, userAddress, tokenId, + networkClientId, ); const { standard, name, symbol, decimals } = tokenData; if (standard === ERC721 || standard === ERC1155) { diff --git a/app/util/notifications/methods/common.test.ts b/app/util/notifications/methods/common.test.ts index a60317e5ec1..d5ea5ddc285 100644 --- a/app/util/notifications/methods/common.test.ts +++ b/app/util/notifications/methods/common.test.ts @@ -1,6 +1,7 @@ import { formatMenuItemDate, parseNotification, + shortenString, getLeadingZeroCount, formatAmount, getUsdAmount, @@ -218,3 +219,30 @@ describe('parseNotification', () => { }); }); }); + +describe('shortenString', () => { + it('should return the same string if it is shorter than TRUNCATED_NAME_CHAR_LIMIT', () => { + expect(shortenString('string')).toStrictEqual('string'); + }); + + it('should return the shortened string according to the specified options', () => { + expect( + shortenString('0x1234567890123456789012345678901234567890', { + truncatedCharLimit: 10, + truncatedStartChars: 4, + truncatedEndChars: 4, + }), + ).toStrictEqual('0x12...7890'); + }); + + it('should shorten the string and remove all characters from the end if skipCharacterInEnd is true', () => { + expect( + shortenString('0x1234567890123456789012345678901234567890', { + truncatedCharLimit: 10, + truncatedStartChars: 4, + truncatedEndChars: 4, + skipCharacterInEnd: true, + }), + ).toStrictEqual('0x12...'); + }); +}); diff --git a/app/util/notifications/methods/common.ts b/app/util/notifications/methods/common.ts index 5c90b4d5b5d..2ee0ce6e18d 100644 --- a/app/util/notifications/methods/common.ts +++ b/app/util/notifications/methods/common.ts @@ -313,20 +313,25 @@ export const TRUNCATED_ADDRESS_END_CHARS = 5; */ export function shortenString( stringToShorten = '', - { truncatedCharLimit, truncatedStartChars, truncatedEndChars } = { - truncatedCharLimit: TRUNCATED_NAME_CHAR_LIMIT, - truncatedStartChars: TRUNCATED_ADDRESS_START_CHARS, - truncatedEndChars: TRUNCATED_ADDRESS_END_CHARS, - }, + { + truncatedCharLimit = TRUNCATED_NAME_CHAR_LIMIT, + truncatedStartChars = TRUNCATED_ADDRESS_START_CHARS, + truncatedEndChars = TRUNCATED_ADDRESS_END_CHARS, + skipCharacterInEnd = false, + }: { + truncatedCharLimit?: number; + truncatedStartChars?: number; + truncatedEndChars?: number; + skipCharacterInEnd?: boolean; + } = {}, ) { if (stringToShorten.length < truncatedCharLimit) { return stringToShorten; } - return `${stringToShorten.slice( - 0, - truncatedStartChars, - )}...${stringToShorten.slice(-truncatedEndChars)}`; + return `${stringToShorten.slice(0, truncatedStartChars)}...${ + skipCharacterInEnd ? '' : stringToShorten.slice(-truncatedEndChars) + }`; } export const sortNotifications = ( diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts index 408f70b8788..535b3cf4d4a 100644 --- a/app/util/test/confirm-data-helpers.ts +++ b/app/util/test/confirm-data-helpers.ts @@ -1,4 +1,8 @@ -import { MessageParamsTyped } from '@metamask/signature-controller'; +import { + MessageParamsTyped, + SignatureRequestStatus, + SignatureRequestType +} from '@metamask/signature-controller'; import { backgroundState } from './initial-root-state'; import { Hex } from '@metamask/utils'; @@ -209,6 +213,61 @@ export const typedSignV3ConfirmationState = { }, }; +export const typedSignV4ConfirmationState = { + engine: { + backgroundState: { + ...backgroundState, + ApprovalController: { + pendingApprovals: { + 'fb2029e1-b0ab-11ef-9227-05a11087c334': { + id: 'fb2029e1-b0ab-11ef-9227-05a11087c334', + origin: 'metamask.github.io', + type: 'eth_signTypedData', + time: 1733143817088, + requestData: { + data: '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"0x935e73edb9ff52e23bac7f7e043a1ecd06d05477","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + version: 'V4', + requestId: 14, + signatureMethod: 'eth_signTypedData_v4', + origin: 'https://metamask.github.io', + metamaskId: 'fb2029e0-b0ab-11ef-9227-05a11087c334', + meta: { + url: 'https://metamask.github.io/test-dapp/', + title: 'E2E Test Dapp', + icon: { uri: 'https://metamask.github.io/metamask-fox.svg' }, + analytics: { request_source: 'In-App-Browser' }, + }, + }, + requestState: null, + expectsResult: true, + }, + }, + pendingApprovalCount: 1, + approvalFlows: [], + }, + SignatureController: { + signatureRequests: { + 'fb2029e1-b0ab-11ef-9227-05a11087c334': { + id: 'fb2029e1-b0ab-11ef-9227-05a11087c334', + chainId: '0x1' as Hex, + type: SignatureRequestType.TypedSign, + messageParams: { + data: '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"0x935e73edb9ff52e23bac7f7e043a1ecd06d05477","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + metamaskId: 'fb2029e0-b0ab-11ef-9227-05a11087c334', + origin: 'https://metamask.github.io' + }, + networkClientId: '1', + status: SignatureRequestStatus.Unapproved, + time: 1733143817088 + }, + }, + }, + }, + }, +}; + export const securityAlertResponse = { block: 21572398, result_type: 'Malicious', diff --git a/locales/languages/en.json b/locales/languages/en.json index ef1821c1fff..861594b3a49 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3607,8 +3607,11 @@ "balance": "Balance", "network": "Network", "simulation": { - "title": "Estimated changes", + "info_permit": "You’re giving the spender permission to spend this many tokens from your account.", + "label_change_type_permit": "Spending cap", + "label_change_type_permit_nft": "Withdraw", "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." } },