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(, {
+ 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,
+ 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 (
+
+
+ {strings('confirm.simulation.info_permit')}
+
+
+
+ {Array.isArray(tokenDetails) ? (
+
+ {tokenDetails.map(
+ (
+ { token, amount }: { token: string; amount: string },
+ i: number,
+ ) => (
+
+ ),
+ )}
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+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).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).mockReturnValue({
+ symbol: 'TST',
+ decimals: '4',
+ balance: undefined,
+ standard: TokenStandard.ERC20,
+ decimalsNumber: 4,
+ });
+
+ const { findByText } = renderWithProvider(
+ ,
+ { 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).mockReturnValue({
+ symbol: 'TST',
+ decimals: undefined,
+ balance: undefined,
+ standard: TokenStandard.ERC20,
+ decimalsNumber: 4,
+ });
+
+ renderWithProvider(
+ ,
+ { 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).mockReturnValue({
+ symbol: 'TST',
+ decimals: '4',
+ balance: undefined,
+ standard: TokenStandard.ERC20,
+ decimalsNumber: 4,
+ });
+
+ renderWithProvider(
+ ,
+ { 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(
+ ,
+ { 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 (
+
+
+
+
+
+ {credit && '+ '}
+ {debit && '- '}
+ {tokenValue !== null &&
+ shortenString(tokenValue || '', {
+ truncatedCharLimit: 15,
+ truncatedStartChars: 15,
+ truncatedEndChars: 0,
+ skipCharacterInEnd: true,
+ })}
+ {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
+ * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}}
+ */
+ setHasValueModalOpen(false)}>
+ setHasValueModalOpen(false)}
+ >
+
+
+ setHasValueModalOpen(false)}
+ iconName={IconName.ArrowLeft}
+ />
+
+ {labelChangeType}
+
+
+
+ {tokenValueMaxPrecision}
+
+
+
+
+ )}
+
+ );
+ };
+
+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 && }
>
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`] = `
{
+ const { value: details } =
+ useAsyncResult(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['getDetails']
+ >
+> & { decimalsNumber: number };
+
+export type TokenDetailsERC721 = Awaited<
+ ReturnType<
+ ReturnType['getDetails']
+ >
+>;
+
+export type TokenDetailsERC1155 = Awaited<
+ ReturnType<
+ ReturnType['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> => {
+ 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 => {
+ 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."
}
},