diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d9eedf316c..f4242412746 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,3 +335,16 @@ jobs: echo "All jobs passed step skipped. Block PR." exit 1 fi + + log-merge-group-failure: + name: Log merge group failure + # Only run this job if the merge group event fails, skip on forks + if: ${{ github.event_name == 'merge_group' && failure() && !github.event.repository.fork }} + needs: + - check-all-jobs-pass + uses: metamask/github-tools/.github/workflows/log-merge-group-failure.yml@6bbad335a01fce1a9ec1eabd9515542c225d46c0 + secrets: + GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + GOOGLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + SPREADSHEET_ID: ${{ secrets.GOOGLE_MERGE_QUEUE_SPREADSHEET_ID }} + SHEET_NAME: ${{ secrets.GOOGLE_MERGE_QUEUE_SHEET_NAME }} diff --git a/.js.env.example b/.js.env.example index 38daeab2a2c..0d1d2764861 100644 --- a/.js.env.example +++ b/.js.env.example @@ -66,6 +66,9 @@ export SEGMENT_FLUSH_INTERVAL="1" # example for flush when 1 event is queued export SEGMENT_FLUSH_EVENT_LIMIT="1" +# URL of the decoding API used to provide additional data from signature requests +export DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1' + # URL of security alerts API used to validate dApp requests. export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io" diff --git a/app/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/Base/StatusText.js b/app/components/Base/StatusText.js index 7f337970011..dff6bae8d50 100644 --- a/app/components/Base/StatusText.js +++ b/app/components/Base/StatusText.js @@ -17,7 +17,7 @@ const styles = StyleSheet.create({ export const ConfirmedText = (props) => ( { const { colors } = useTheme(); return ( = ({ 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 ( - + ; +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/UI/SliderButton/__snapshots__/index.test.tsx.snap b/app/components/UI/SliderButton/__snapshots__/index.test.tsx.snap index db124959996..9f098027945 100644 --- a/app/components/UI/SliderButton/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/SliderButton/__snapshots__/index.test.tsx.snap @@ -18,7 +18,6 @@ exports[`SliderButton should render correctly 1`] = ` undefined, ] } - testID="swipe-to-swap-button" > ({ context: { NetworkController: { + setActiveNetwork: jest.fn(() => Promise.resolve()), getNetworkClientById: () => ({ configuration: { chainId: '0x1', @@ -55,14 +58,21 @@ jest.mock('../../../../../core/Engine', () => ({ jest.mock('../../hooks/useStakingEligibility', () => ({ __esModule: true, - default: () => ({ + default: jest.fn(() => ({ isEligible: true, - loading: false, - error: null, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValueOnce({ isEligible: true }), - }), + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest.fn().mockResolvedValue({ + isEligible: true, + }), + error: false, + })), +})); + +jest.mock('../../hooks/useStakingChain', () => ({ + __esModule: true, + default: jest.fn(() => ({ + isStakingSupportedChain: true, + })), })); const STATE_MOCK = { @@ -77,9 +87,9 @@ const STATE_MOCK = { }, }; -const renderComponent = () => +const renderComponent = (state = STATE_MOCK) => renderWithProvider(, { - state: STATE_MOCK, + state, }); describe('StakeButton', () => { @@ -92,11 +102,62 @@ describe('StakeButton', () => { expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined(); }); + it('navigates to Web view when stake button is pressed and user is not eligible', async () => { + (useStakingEligibility as jest.Mock).mockReturnValue({ + isEligible: false, + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest + .fn() + .mockResolvedValue({ isEligible: false }), + error: false, + }); + const { getByTestId } = renderComponent(); + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + params: { + newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`, + timestamp: expect.any(Number), + }, + screen: Routes.BROWSER.VIEW, + }); + }); + }); + it('navigates to Stake Input screen when stake button is pressed and user is eligible', async () => { + (useStakingEligibility as jest.Mock).mockReturnValue({ + isEligible: true, + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest + .fn() + .mockResolvedValue({ isEligible: true }), + error: false, + }); const { getByTestId } = renderComponent(); fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + screen: Routes.STAKING.STAKE, + }); + }); + }); + it('navigates to Stake Input screen when on unsupported network', async () => { + const UNSUPPORTED_NETWORK_STATE = { + engine: { + backgroundState: { + NetworkController: { + ...mockNetworkState({ + chainId: '0x89', // Polygon + }), + }, + }, + }, + }; + const { getByTestId } = renderComponent(UNSUPPORTED_NETWORK_STATE); + fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 838143c7b21..a68c483475c 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -8,12 +8,9 @@ import { createStackNavigator } from '@react-navigation/stack'; import { getAssetTestId } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; import { backgroundState } from '../../../util/test/initial-root-state'; import { strings } from '../../../../locales/i18n'; -import AppConstants from '../../../../app/core/AppConstants'; -import Routes from '../../../../app/constants/navigation/Routes'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; import Engine from '../../../core/Engine'; import { createTokensBottomSheetNavDetails } from './TokensBottomSheet'; -import useStakingEligibility from '../Stake/hooks/useStakingEligibility'; // eslint-disable-next-line import/no-namespace import * as networks from '../../../util/networks'; // eslint-disable-next-line import/no-namespace @@ -414,54 +411,6 @@ describe('Tokens', () => { ).toBeDefined(); }); - it('renders stake button correctly', () => { - const { getByTestId } = renderComponent(initialState); - - expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined(); - }); - - it('navigates to Web view when stake button is pressed and user is not eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: false, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: false }), - error: false, - }); - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - params: { - newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`, - timestamp: 123, - }, - screen: Routes.BROWSER.VIEW, - }); - }); - }); - - it('navigates to Stake Input screen when stake button is pressed and user is eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: true, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: true }), - error: false, - }); - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { - screen: Routes.STAKING.STAKE, - }); - }); - }); - it('should refresh tokens and call necessary controllers', async () => { const { getByTestId } = renderComponent(initialState); @@ -513,48 +462,6 @@ describe('Tokens', () => { }); }); - it('navigates to Stake Input screen only when eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: true, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: true }), - error: false, - }); - - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { - screen: Routes.STAKING.STAKE, - }); - }); - }); - - it('does not navigate to Stake Input screen if not eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: false, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: false }), - error: false, - }); - - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - - await waitFor(() => { - expect(mockNavigate).not.toHaveBeenCalledWith('StakeScreens', { - screen: Routes.STAKING.STAKE, - }); - }); - }); - it('calls onRefresh and updates state', async () => { const { getByTestId } = renderComponent(initialState); diff --git a/app/components/Views/confirmations/Confirm/Confirm.test.tsx b/app/components/Views/confirmations/Confirm/Confirm.test.tsx index 8007a4c9866..6f6da173c48 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.test.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.test.tsx @@ -57,7 +57,7 @@ describe('Confirm', () => { expect(getByText('Estimated changes')).toBeDefined(); expect( getByText( - 'You’re signing into a site and there are no predicted changes to your account.', + "You're signing into a site and there are no predicted changes to your account.", ), ).toBeDefined(); expect(getByText('Request from')).toBeDefined(); diff --git a/app/components/Views/confirmations/Confirm/Confirm.tsx b/app/components/Views/confirmations/Confirm/Confirm.tsx index 8149098a80a..4a18a99d7d1 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.tsx @@ -8,7 +8,7 @@ import Footer from '../components/Confirm/Footer'; import Info from '../components/Confirm/Info'; import SignatureBlockaidBanner from '../components/Confirm/SignatureBlockaidBanner'; import Title from '../components/Confirm/Title'; -import useConfirmationRedesignEnabled from '../hooks/useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from '../hooks/useConfirmationRedesignEnabled'; import styleSheet from './Confirm.styles'; const Confirm = () => { @@ -20,7 +20,7 @@ const Confirm = () => { } return ( - + diff --git a/app/components/Views/confirmations/Send/index.js b/app/components/Views/confirmations/Send/index.js index ad5fb240be5..906ef236b58 100644 --- a/app/components/Views/confirmations/Send/index.js +++ b/app/components/Views/confirmations/Send/index.js @@ -185,8 +185,8 @@ class Send extends PureComponent { * Resets gas and gasPrice of transaction */ async reset() { - const { transaction } = this.props; - const { gas, gasPrice } = await estimateGas(transaction); + const { networkClientId, transaction } = this.props; + const { gas, gasPrice } = await estimateGas(transaction, networkClientId); this.props.setTransactionObject({ gas: hexToBN(gas), gasPrice: hexToBN(gasPrice), @@ -320,8 +320,6 @@ class Send extends PureComponent { handleNewTxMeta = async ({ target_address, action, - chain_id = null, - function_name = null, // eslint-disable-line no-unused-vars parameters = null, }) => { const { addressBook, chainId, internalAccounts, selectedAddress } = @@ -408,7 +406,10 @@ class Send extends PureComponent { // if gas and gasPrice is not defined in the deeplink, we should define them if (!gas && !gasPrice) { - const { gas, gasPrice } = await estimateGas(this.props.transaction); + const { gas, gasPrice } = await estimateGas( + this.props.transaction, + this.props.networkClientId, + ); newTxMeta = { ...newTxMeta, gas, diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx index 0c533f651c9..31af8bd8955 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx @@ -12,7 +12,7 @@ describe('TypedSignV1', () => { expect(getByText('Estimated changes')).toBeDefined(); expect( getByText( - 'You’re signing into a site and there are no predicted changes to your account.', + "You're signing into a site and there are no predicted changes to your account.", ), ).toBeDefined(); expect(getByText('Request from')).toBeDefined(); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx new file mode 100644 index 00000000000..6f015ab9b16 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { + DecodingData, + DecodingDataChangeType, + DecodingDataStateChanges, + SignatureRequest, +} from '@metamask/signature-controller'; +import useGetTokenStandardAndDetails from '../../../../../hooks/useGetTokenStandardAndDetails'; +import { typedSignV4ConfirmationState } from '../../../../../../../../util/test/confirm-data-helpers'; +import renderWithProvider from '../../../../../../../../util/test/renderWithProvider'; +import { memoizedGetTokenStandardAndDetails } from '../../../../../utils/token'; +import TypedSignV3V4Simulation from './Simulation'; + +jest.mock('../../../../../hooks/useGetTokenStandardAndDetails'); + +jest.mock('../../../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: () => 'mainnet', + }, + }, +})); + +const stateChangesApprove = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '12345', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, +]; + +const mockState = ( + mockStateChanges: DecodingDataStateChanges, + { + mockDecodingDataProps, + stubDecodingLoading = false, + }: { + mockDecodingDataProps?: Partial<DecodingData>; + stubDecodingLoading?: boolean; + } = { + mockDecodingDataProps: {}, + stubDecodingLoading: false, + }, +) => { + const clonedMockState = cloneDeep(typedSignV4ConfirmationState); + const request = clonedMockState.engine.backgroundState.SignatureController + .signatureRequests[ + 'fb2029e1-b0ab-11ef-9227-05a11087c334' + ] as SignatureRequest; + + request.decodingLoading = stubDecodingLoading; + request.decodingData = { + ...mockDecodingDataProps, + stateChanges: mockStateChanges, + }; + + return clonedMockState; +}; + +describe('PermitSimulation', () => { + afterEach(() => { + jest.clearAllMocks(); + + /** Reset memoized function using getTokenStandardAndDetails for each test */ + memoizedGetTokenStandardAndDetails?.cache?.clear?.(); + }); + + it('renders DecodedSimulation loader if decodingLoading is true', async () => { + const { queryByTestId } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState(stateChangesApprove, { + stubDecodingLoading: true, + }), + }); + + expect(await queryByTestId('confirm-v3v4-simulation-loader')).toBeDefined(); + }); + + it('renders DecodingSimulation with "Unavailable" if decoding data is empty', async () => { + const { getByText } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState([]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Unavailable')).toBeDefined(); + }); + + it('renders DecodingSimulation for permits', async () => { + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: 'ERC20', + decimalsNumber: 4, + }); + + const { getByText } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState(stateChangesApprove), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('1.235')).toBeDefined(); + }); + + it('renders PermitSimulation if decoding api returns error', async () => { + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ + symbol: 'TST', + decimals: '2', + balance: undefined, + standard: 'ERC20', + decimalsNumber: 4, + }); + + const { getByText } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState([], { + mockDecodingDataProps: { + error: { message: 'some error', type: 'SOME_ERROR' }, + } as Partial<DecodingData>, + }), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('0.3')).toBeDefined(); + expect( + await getByText( + "You're giving the spender permission to spend this many tokens from your account.", + ), + ).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx new file mode 100644 index 00000000000..37265d0d0cd --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { useTypedSignSimulationEnabled } from '../../../../../hooks/useTypedSignSimulationEnabled'; +import { isRecognizedPermit } from '../../../../../utils/signature'; +import { useSignatureRequest } from '../../../../../hooks/useSignatureRequest'; +import DecodedSimulation from './TypedSignDecoded'; +import PermitSimulation from './TypedSignPermit'; + +const TypedSignV3V4Simulation: React.FC<object> = () => { + const signatureRequest = useSignatureRequest(); + const isPermit = signatureRequest && isRecognizedPermit(signatureRequest); + const isSimulationSupported = useTypedSignSimulationEnabled(); + + if (!isSimulationSupported || !signatureRequest) { + return null; + } + + const { decodingData, decodingLoading } = signatureRequest; + const hasValidDecodingData = !( + (!decodingLoading && decodingData === undefined) || + decodingData?.error + ); + + if (!hasValidDecodingData && isPermit) { + return <PermitSimulation />; + } + + return <DecodedSimulation />; +}; + +export default TypedSignV3V4Simulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx new file mode 100644 index 00000000000..237ceffd226 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import StaticSimulation from './Static'; + +const mockProps = { + title: 'Test Title', + titleTooltip: 'Test Tooltip', + description: 'Test Description', + simulationElements: <></>, +}; + +describe('StaticSimulation', () => { + it('renders correctly with basic props', () => { + const { getByText } = render(<StaticSimulation {...mockProps} />); + + expect(getByText('Test Title')).toBeDefined(); + expect(getByText('Test Description')).toBeDefined(); + }); + + it('shows loader when isLoading is true', () => { + const { queryByTestId } = render( + <StaticSimulation {...mockProps} isLoading />, + ); + + expect(queryByTestId('confirm-v3v4-simulation-loader')).toBeDefined(); + }); + + it('shows simulation elements when not loading', () => { + const simulationElements = <Text>Test Simulation</Text>; + const { getByText } = render( + <StaticSimulation + {...mockProps} + simulationElements={simulationElements} + isLoading={false} + />, + ); + + expect(getByText('Test Simulation')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx new file mode 100644 index 00000000000..febb2b71115 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useStyles } from '../../../../../../../../../component-library/hooks'; +import InfoRow from '../../../../../UI/InfoRow'; +import InfoSection from '../../../../../UI/InfoRow/InfoSection'; +import Loader from '../../../../../../../../../component-library/components-temp/Loader'; + +const styleSheet = () => StyleSheet.create({ + base: { + display: 'flex', + justifyContent: 'space-between', + }, + loaderContainer: { + display: 'flex', + justifyContent: 'center', + }, +}); + +const StaticSimulation: React.FC<{ + title: string; + titleTooltip: string; + description?: string; + simulationElements: React.ReactNode; + isLoading?: boolean; + isCollapsed?: boolean; +}> = ({ + title, + titleTooltip, + description, + simulationElements, + isLoading, + isCollapsed = false, +}) => { + const { styles } = useStyles(styleSheet, {}); + + return( + <View style={isCollapsed ? styles.base : {}}> + <InfoSection> + <InfoRow label={title} tooltip={titleTooltip}> + {description} + </InfoRow> + {isLoading ? ( + <View style={styles.loaderContainer}> + <Loader size={'small'} /> + </View> + ) : ( + simulationElements + )} + </InfoSection> + </View> + ); +}; + +export default StaticSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts new file mode 100644 index 00000000000..58015012827 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts @@ -0,0 +1 @@ +export { default } from './Static'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx new file mode 100644 index 00000000000..cc9da83b409 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { + DecodingDataChangeType, + DecodingDataStateChanges, + SignatureRequest, +} from '@metamask/signature-controller'; + +import { strings } from '../../../../../../../../../../locales/i18n'; +import { typedSignV4ConfirmationState } from '../../../../../../../../../util/test/confirm-data-helpers'; +import renderWithProvider from '../../../../../../../../../util/test/renderWithProvider'; +import TypedSignDecoded, { + getStateChangeToolip, + getStateChangeType, + StateChangeType, +} from './TypedSignDecoded'; + +const stateChangesApprove = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '12345', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, +]; + +const stateChangesListingERC1155: DecodingDataStateChanges = [ + { + assetType: 'NATIVE', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '900000000000000000', + contractAddress: '', + }, + { + assetType: 'ERC1155', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', + tokenID: '77789', + }, +]; + +const stateChangesNftListing: DecodingDataStateChanges = [ + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '22222', + }, + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +]; + +const stateChangesNftBidding: DecodingDataStateChanges = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Bidding, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '189', + }, + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +]; + +const mockState = ( + mockStateChanges: DecodingDataStateChanges, + stubDecodingLoading: boolean = false, +) => { + const clonedMockState = cloneDeep(typedSignV4ConfirmationState); + const request = clonedMockState.engine.backgroundState.SignatureController + .signatureRequests[ + 'fb2029e1-b0ab-11ef-9227-05a11087c334' + ] as SignatureRequest; + request.decodingLoading = stubDecodingLoading; + request.decodingData = { + stateChanges: mockStateChanges, + }; + + return clonedMockState; +}; + +describe('DecodedSimulation', () => { + it('renders for ERC20 approval', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState(stateChangesApprove), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('12,345')).toBeDefined(); + }); + + it('renders "Unlimited" for large values', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState([{ + ...stateChangesApprove[0], + amount: '1461501637330902918203684832716283019655932542975', + }]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('Unlimited')).toBeDefined(); + }); + + it('renders for ERC712 token', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState(stateChangesNftListing), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Listing price')).toBeDefined(); + expect(await getByText('You list')).toBeDefined(); + expect(await getByText('#22222')).toBeDefined(); + }); + + it('renders for ERC1155 token', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState(stateChangesListingERC1155), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('You receive')).toBeDefined(); + expect(await getByText('You list')).toBeDefined(); + expect(await getByText('#77789')).toBeDefined(); + }); + + it('renders label only once if there are multiple state changes of same changeType', async () => { + const { getAllByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState([ + stateChangesApprove[0], + stateChangesApprove[0], + stateChangesApprove[0], + ]), + }); + + expect(await getAllByText('12,345')).toHaveLength(3); + expect(await getAllByText('Spending cap')).toHaveLength(1); + }); + + it('renders unavailable message if no state change is returned', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState([]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Unavailable')).toBeDefined(); + }); + + describe('getStateChangeToolip', () => { + it('return correct tooltip when permit is for listing NFT', () => { + const tooltip = getStateChangeToolip(StateChangeType.NFTListingReceive); + expect(tooltip).toBe( + strings('confirm.simulation.decoded_tooltip_list_nft'), + ); + }); + + it('return correct tooltip when permit is for bidding NFT', () => { + const tooltip = getStateChangeToolip(StateChangeType.NFTBiddingReceive); + expect(tooltip).toBe( + strings('confirm.simulation.decoded_tooltip_bid_nft'), + ); + }); + }); + + describe('getStateChangeType', () => { + it('return correct state change type for NFT listing receive', () => { + const stateChange = getStateChangeType( + stateChangesNftListing, + stateChangesNftListing[1], + ); + expect(stateChange).toBe(StateChangeType.NFTListingReceive); + }); + + it('return correct state change type for NFT bidding receive', () => { + const stateChange = getStateChangeType( + stateChangesNftBidding, + stateChangesNftBidding[1], + ); + expect(stateChange).toBe(StateChangeType.NFTBiddingReceive); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx new file mode 100644 index 00000000000..2adc5d40fec --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx @@ -0,0 +1,212 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { + DecodingDataChangeType, + DecodingDataStateChange, + DecodingDataStateChanges, +} from '@metamask/signature-controller'; +import { Hex } from '@metamask/utils'; + +import { TokenStandard } from '../../../../../../../../UI/SimulationDetails/types'; +import Text from '../../../../../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../../../../../locales/i18n'; +import { useSignatureRequest } from '../../../../../../hooks/useSignatureRequest'; +import InfoRow from '../../../../../UI/InfoRow'; +import NativeValueDisplay from '../components/NativeValueDisplay'; +import SimulationValueDisplay from '../components/ValueDisplay'; +import StaticSimulation from '../Static'; + +const styles = { + unavailableContainer: { + paddingHorizontal: 8, + paddingBottom: 8, + }, +}; + +export enum StateChangeType { + NFTListingReceive = 'NFTListingReceive', + NFTBiddingReceive = 'NFTBiddingReceive', +} + +export const getStateChangeType = ( + stateChangeList: DecodingDataStateChanges | null, + stateChange: DecodingDataStateChange, +): StateChangeType | undefined => { + if (stateChange.changeType === DecodingDataChangeType.Receive) { + if ( + stateChangeList?.some( + (change) => + change.changeType === DecodingDataChangeType.Listing && + change.assetType === TokenStandard.ERC721, + ) + ) { + return StateChangeType.NFTListingReceive; + } + if ( + stateChange.assetType === TokenStandard.ERC721 && + stateChangeList?.some( + (change) => change.changeType === DecodingDataChangeType.Bidding, + ) + ) { + return StateChangeType.NFTBiddingReceive; + } + } + return undefined; +}; + +export const getStateChangeToolip = ( + nftTransactionType: StateChangeType | undefined, +): string | undefined => { + if (nftTransactionType === StateChangeType.NFTListingReceive) { + return strings('confirm.simulation.decoded_tooltip_list_nft'); + } else if (nftTransactionType === StateChangeType.NFTBiddingReceive) { + return strings('confirm.simulation.decoded_tooltip_bid_nft'); + } + return undefined; +}; + +const stateChangeOrder = { + [DecodingDataChangeType.Transfer]: 1, + [DecodingDataChangeType.Listing]: 2, + [DecodingDataChangeType.Approve]: 3, + [DecodingDataChangeType.Revoke]: 4, + [DecodingDataChangeType.Bidding]: 5, + [DecodingDataChangeType.Receive]: 6, +}; + +const getStateChangeLabelMap = ( + changeType: string, + stateChangeType?: StateChangeType, +) => ({ + [DecodingDataChangeType.Transfer]: strings('confirm.simulation.label_change_type_transfer'), + [DecodingDataChangeType.Receive]: + stateChangeType === StateChangeType.NFTListingReceive + ? strings('confirm.simulation.label_change_type_nft_listing') + : strings('confirm.simulation.label_change_type_receive'), + [DecodingDataChangeType.Approve]: strings('confirm.simulation.label_change_type_permit'), + [DecodingDataChangeType.Revoke]: strings('confirm.simulation.label_change_type_permit'), + [DecodingDataChangeType.Bidding]: strings('confirm.simulation.label_change_type_bidding'), + [DecodingDataChangeType.Listing]: strings('confirm.simulation.label_change_type_listing'), + }[changeType]); + +const StateChangeRow = ({ + stateChangeList, + stateChange, + chainId, + shouldDisplayLabel, +}: { + stateChangeList: DecodingDataStateChanges | null; + stateChange: DecodingDataStateChange; + chainId: Hex; + shouldDisplayLabel: boolean; +}) => { + const { assetType, changeType, amount, contractAddress, tokenID } = + stateChange; + const nftTransactionType = getStateChangeType(stateChangeList, stateChange); + const tooltip = shouldDisplayLabel ? getStateChangeToolip(nftTransactionType) : undefined; + + const canDisplayValueAsUnlimited = + assetType === TokenStandard.ERC20 && + (changeType === DecodingDataChangeType.Approve || + changeType === DecodingDataChangeType.Revoke); + + const changeLabel = shouldDisplayLabel + ? getStateChangeLabelMap(changeType, nftTransactionType) + : ''; + + return ( + <InfoRow + label={changeLabel} + tooltip={tooltip} + > + {(assetType === TokenStandard.ERC20 || + assetType === TokenStandard.ERC721 || + assetType === TokenStandard.ERC1155) && ( + <SimulationValueDisplay + labelChangeType={changeType} + tokenContract={contractAddress} + value={amount} + chainId={chainId} + tokenId={tokenID} + credit={ + nftTransactionType !== StateChangeType.NFTListingReceive && + changeType === DecodingDataChangeType.Receive + } + debit={changeType === DecodingDataChangeType.Transfer} + canDisplayValueAsUnlimited={canDisplayValueAsUnlimited} + /> + )} + {assetType === 'NATIVE' && ( + <NativeValueDisplay + value={amount} + chainId={chainId} + credit={ + nftTransactionType !== StateChangeType.NFTListingReceive && + changeType === DecodingDataChangeType.Receive + } + debit={changeType === DecodingDataChangeType.Transfer} + labelChangeType={changeLabel} + /> + )} + </InfoRow> + ); +}; + +const DecodedSimulation: React.FC<object> = () => { + const signatureRequest = useSignatureRequest(); + + const chainId = signatureRequest?.chainId as Hex; + const { decodingLoading, decodingData } = signatureRequest ?? {}; + + const stateChangeFragment = useMemo(() => { + const orderedStateChanges = [...(decodingData?.stateChanges ?? [])].sort((c1, c2) => + stateChangeOrder[c1.changeType] > stateChangeOrder[c2.changeType] + ? 1 + : -1, + ); + const stateChangesGrouped: Record<string, DecodingDataStateChange[]> = ( + orderedStateChanges ?? [] + ).reduce<Record<string, DecodingDataStateChange[]>>( + (result, stateChange) => { + result[stateChange.changeType] = [ + ...(result[stateChange.changeType] ?? []), + stateChange, + ]; + return result; + }, + {}, + ); + + return Object.entries(stateChangesGrouped).flatMap(([_, changeList]) => + changeList.map((change: DecodingDataStateChange, index: number) => ( + <StateChangeRow + key={`${change.changeType}-${index}`} + stateChangeList={decodingData?.stateChanges ?? []} + stateChange={change} + chainId={chainId} + shouldDisplayLabel={index === 0} + /> + )), + ); + }, [chainId, decodingData?.stateChanges]); + + return ( + <StaticSimulation + title={strings('confirm.simulation.title')} + titleTooltip={strings('confirm.simulation.tooltip')} + simulationElements={ + stateChangeFragment.length ? ( + stateChangeFragment + ) : ( + <View style={styles.unavailableContainer}> + <Text>{strings('confirm.simulation.unavailable')}</Text> + </View> + ) + } + isLoading={decodingLoading} + isCollapsed={decodingLoading || !stateChangeFragment.length} + /> + ); +}; + +export default DecodedSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts new file mode 100644 index 00000000000..f4bd7dcc026 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts @@ -0,0 +1 @@ +export { default } from './TypedSignDecoded'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx new file mode 100644 index 00000000000..e7ff8ddf91d --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx @@ -0,0 +1,31 @@ +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/NativeValueDisplay/NativeValueDisplay.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.test.tsx new file mode 100644 index 00000000000..f0ab367e2ee --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import NativeValueDisplay from './NativeValueDisplay'; +import { backgroundState } from '../../../../../../../../../../util/test/initial-root-state'; +import renderWithProvider from '../../../../../../../../../../util/test/renderWithProvider'; +import { fireEvent } from '@testing-library/react-native'; + +const mockInitialState = { + engine: { + backgroundState, + }, +}; + +describe('NativeValueDisplay', () => { + it('renders component correctly', async () => { + const { findByText } = renderWithProvider( + <NativeValueDisplay + labelChangeType={'Spending Cap'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + expect(await findByText('< 0.000001')).toBeDefined(); + expect(await findByText('ETH')).toBeDefined(); + }); + + it('displays modal when clicking on the value', async () => { + const { findByText } = renderWithProvider( + <NativeValueDisplay + labelChangeType={'Spending Cap'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + const button = await findByText('< 0.000001'); + fireEvent.press(button); + + expect(await findByText('Spending Cap')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx new file mode 100644 index 00000000000..54ad3cd28e8 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { Text,TouchableOpacity, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { RootState } from '../../../../../../../../../../reducers'; +import { selectConversionRateByChainId } from '../../../../../../../../../../selectors/currencyRateController'; +import { useTheme } from '../../../../../../../../../../util/theme'; + +import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; +import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; +import ButtonIcon from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon'; +import { IconName , IconColor } from '../../../../../../../../../../component-library/components/Icons/Icon'; + +import AssetPill from '../../../../../../../../../UI/SimulationDetails/AssetPill/AssetPill'; +import { IndividualFiatDisplay } from '../../../../../../../../../UI/SimulationDetails/FiatDisplay/FiatDisplay'; +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../../../../../../UI/SimulationDetails/formatAmount'; +import { AssetType } from '../../../../../../../../../UI/SimulationDetails/types'; +import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { isNumberValue } from '../../../../../../../../../../util/number'; +import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; +import BottomModal from '../../../../../../UI/BottomModal'; + +/** + * Reusing ValueDisplay styles for now. See issue to handle abstracting UI + * @see {@link https://github.com/MetaMask/metamask-mobile/issues/12974} + */ +import styleSheet from '../ValueDisplay/ValueDisplay.styles'; + +const NATIVE_DECIMALS = 18; + +interface PermitSimulationValueDisplayParams { + /** ID of the associated chain. */ + chainId: Hex; + + /** Change type to be displayed in value tooltip */ + labelChangeType: string; + + /** The token amount */ + value: number | string; + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; +} + +const NativeValueDisplay: React.FC<PermitSimulationValueDisplayParams> = ({ + chainId, + credit, + debit, + labelChangeType, + value, +}) => { + const [hasValueModalOpen, setHasValueModalOpen] = useState(false); + + const { colors } = useTheme(); + const styles = styleSheet(colors); + + const conversionRate = useSelector((state: RootState) => + selectConversionRateByChainId(state, chainId), + ); + + const tokenAmount = isNumberValue(value) ? calcTokenAmount(value, NATIVE_DECIMALS) : null; + const isValidTokenAmount = tokenAmount !== null && tokenAmount !== undefined && tokenAmount instanceof BigNumber; + + const fiatValue = isValidTokenAmount && conversionRate + ? tokenAmount.times(String(conversionRate)).toNumber() + : undefined; + + const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; + const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; + + function handlePressTokenValue() { + setHasValueModalOpen(true); + } + + return ( + <View style={styles.wrapper}> + <View 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, + })} + </Text> + </ButtonPill> + <View style={styles.marginStart4}> + <AssetPill asset={{ chainId, type: AssetType.Native }} /> + </View> + </View> + </View> + <View style={styles.fiatDisplay}> + {/** + TODO - add fiat shorten prop after tooltip logic has been updated + {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} + */} + {fiatValue !== undefined && ( + <IndividualFiatDisplay fiatAmount={fiatValue} /> + )} + </View> + {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> + )} + </View> + ); +}; + +export default NativeValueDisplay; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts new file mode 100644 index 00000000000..1e6534cfb1c --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './NativeValueDisplay'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts new file mode 100644 index 00000000000..9a1da0579cc --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts @@ -0,0 +1,90 @@ +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, + }, + fiatDisplay: { + paddingEnd: 8, + }, + flexRowTokenValueAndAddress: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + borderColor: importedColors.transparent, + borderWidth: 0, + padding: 0, + }, + marginStart4: { + marginStart: 4, + }, + tokenValueTooltipContent: { + borderRadius: 12, + paddingHorizontal: 8, + paddingTop: 4, + paddingBottom: 4, + textAlign: 'center', + }, + valueAndAddress: { + paddingVertical: 4, + paddingLeft: 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..8cf784909dc --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx @@ -0,0 +1,185 @@ +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 }, + ); + + expect(await findByText('0.432')).toBeDefined(); + }); + + it('renders "Unlimited" for large values when canDisplayValueAsUnlimited is true', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + const { findByText } = renderWithProvider( + <SimulationValueDisplay + canDisplayValueAsUnlimited + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'1461501637330902918203684832716283019655932542975'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + expect(await findByText('Unlimited')).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 }, + ); + + 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 }, + ); + + 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..44622075f16 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { NetworkClientId } from '@metamask/network-controller'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; +import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; +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 Address from '../../../../../../UI/InfoRow/InfoValue/Address/Address'; + +import { selectContractExchangeRates } from '../../../../../../../../../../selectors/tokenRatesController'; + +import Logger from '../../../../../../../../../../util/Logger'; +import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { isNumberValue } from '../../../../../../../../../../util/number'; +import { useTheme } from '../../../../../../../../../../util/theme'; +import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; + +import useGetTokenStandardAndDetails from '../../../../../../../hooks/useGetTokenStandardAndDetails'; +import useTrackERC20WithoutDecimalInformation from '../../../../../../../hooks/useTrackERC20WithoutDecimalInformation'; +import { TOKEN_VALUE_UNLIMITED_THRESHOLD } from '../../../../../../../utils/confirm'; +import { TokenDetailsERC20 } from '../../../../../../../utils/token'; +import BottomModal from '../../../../../../UI/BottomModal'; + +import styleSheet from './ValueDisplay.styles'; +import { strings } from '../../../../../../../../../../../locales/i18n'; + +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 + + /** Whether a large amount can be substituted by "Unlimited" */ + canDisplayValueAsUnlimited?: boolean; + + /** 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, + canDisplayValueAsUnlimited = false, +}) => { + 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 tokenAmount = isNumberValue(value) && !tokenId ? calcTokenAmount(value, tokenDecimals) : null; + const isValidTokenAmount = tokenAmount !== null && tokenAmount !== undefined && tokenAmount instanceof BigNumber; + + const fiatValue = isValidTokenAmount && exchangeRate && !tokenId + ? tokenAmount.multipliedBy(exchangeRate).toNumber() + : undefined; + + const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; + const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; + + const shouldShowUnlimitedValue = canDisplayValueAsUnlimited && + Number(value) > TOKEN_VALUE_UNLIMITED_THRESHOLD; + + /** 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 ( + <View style={styles.wrapper}> + <View style={styles.flexRowTokenValueAndAddress}> + <View style={styles.valueAndAddress}> + <ButtonPill + isDisabled={!!tokenId || tokenId === '0'} + onPress={handlePressTokenValue} + onPressIn={handlePressTokenValue} + onPressOut={handlePressTokenValue} + style={[credit && styles.valueIsCredit, debit && styles.valueIsDebit]} + > + <Text> + {credit && '+ '} + {debit && '- '} + {shouldShowUnlimitedValue + ? strings('confirm.unlimited') + : tokenValue !== null && + shortenString(tokenValue || '', { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + })} + {tokenId && `#${tokenId}`} + </Text> + </ButtonPill> + <View style={styles.marginStart4}> + <Address address={tokenContract} chainId={chainId} /> + </View> + </View> + </View> + <View style={styles.fiatDisplay}> + {/** + 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*/ />} + </View> + {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> + )} + </View> + ); + }; + +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/Simulation/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts new file mode 100644 index 00000000000..50cee91255f --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts @@ -0,0 +1 @@ +export { default } from './Simulation'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx index 1fd23c2fbeb..2caee661061 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx @@ -1,23 +1,14 @@ import React from 'react'; - -import useApprovalRequest from '../../../../hooks/useApprovalRequest'; import InfoRowOrigin from '../Shared/InfoRowOrigin'; import Message from './Message'; +import TypedSignV3V4Simulation from './Simulation'; -const TypedSignV3V4 = () => { - const { approvalRequest } = useApprovalRequest(); - - if (!approvalRequest) { - return null; - } - - return ( +const TypedSignV3V4 = () => ( <> - {/* SIMULATION TO BE ADDED */} + <TypedSignV3V4Simulation /> <InfoRowOrigin /> <Message /> </> ); -}; export default TypedSignV3V4; diff --git a/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx b/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx index 0f11ada192e..838361aee88 100644 --- a/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx @@ -12,7 +12,7 @@ describe('NoChangeSimulation', () => { expect(getByText('Estimated changes')).toBeDefined(); expect( getByText( - 'You’re signing into a site and there are no predicted changes to your account.', + "You're signing into a site and there are no predicted changes to your account." ), ).toBeDefined(); }); diff --git a/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx b/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx index f610b170a57..9e8bb043755 100644 --- a/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx +++ b/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx @@ -6,7 +6,7 @@ import { useSelector } from 'react-redux'; import setSignatureRequestSecurityAlertResponse from '../../../../../../actions/signatureRequest'; import { store } from '../../../../../../store'; import { useTheme } from '../../../../../../util/theme'; -import useConfirmationRedesignEnabled from '../../../hooks/useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from '../../../hooks/useConfirmationRedesignEnabled'; import PersonalSign from '../../PersonalSign'; import TypedSign from '../../TypedSign'; import { MessageParams } from '../types'; diff --git a/app/components/Views/confirmations/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/ExpandableSection/ExpandableSection.tsx b/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx index 0417a1c9ce2..4c738c73648 100644 --- a/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx +++ b/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx @@ -61,7 +61,7 @@ const ExpandableSection = ({ </View> </TouchableOpacity> {expanded && ( - <BottomModal hideBackground> + <BottomModal onClose={() => setExpanded(false)} canCloseOnBackdropClick> <View style={styles.modalContent}> <View style={styles.modalHeader}> <ButtonIcon 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/useApprovalRequest.ts b/app/components/Views/confirmations/hooks/useApprovalRequest.ts index 0eb31b4a468..61128e7895c 100644 --- a/app/components/Views/confirmations/hooks/useApprovalRequest.ts +++ b/app/components/Views/confirmations/hooks/useApprovalRequest.ts @@ -1,10 +1,10 @@ -import Engine from '../../../../core/Engine'; import { useCallback, useMemo } from 'react'; -import { providerErrors } from '@metamask/rpc-errors'; import { useSelector } from 'react-redux'; -import { selectPendingApprovals } from '../../../../selectors/approvalController'; import { cloneDeep, isEqual } from 'lodash'; import { ApprovalRequest } from '@metamask/approval-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../../../core/Engine'; +import { selectPendingApprovals } from '../../../../selectors/approvalController'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts index ffa03104883..20640e9606b 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts @@ -5,7 +5,7 @@ import { personalSignatureConfirmationState } from '../../../../util/test/confir // eslint-disable-next-line import/no-namespace import * as QRHardwareAwareness from './useQRHardwareAwareness'; -import useConfirmationRedesignEnabled from './useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from './useConfirmationRedesignEnabled'; jest.mock('../../../../core/Engine', () => ({ getTotalFiatAccountBalance: () => ({ tokenFiat: 10 }), diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts index 140c58dcde4..9ca0e9919cc 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts @@ -7,7 +7,7 @@ import { selectRemoteFeatureFlags } from '../../../../selectors/featureFlagContr import useApprovalRequest from './useApprovalRequest'; import useQRHardwareAwareness from './useQRHardwareAwareness'; -const useConfirmationRedesignEnabled = () => { +export const useConfirmationRedesignEnabled = () => { const { approvalRequest } = useApprovalRequest(); const { isSigningQRObject, isSyncingQRHardware } = useQRHardwareAwareness(); const { confirmation_redesign } = useSelector(selectRemoteFeatureFlags); @@ -22,7 +22,6 @@ const useConfirmationRedesignEnabled = () => { const isRedesignedEnabled = useMemo( () => (confirmation_redesign as Record<string, string>)?.signatures && - process.env.REDESIGNED_SIGNATURE_REQUEST === 'true' && // following condition will ensure that user is redirected to old designs is using QR scan aware hardware !isSyncingQRHardware && !isSigningQRObject && @@ -43,5 +42,3 @@ const useConfirmationRedesignEnabled = () => { return { isRedesignedEnabled }; }; - -export default useConfirmationRedesignEnabled; 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/hooks/useTypedSignSimulationEnabled.ts b/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts new file mode 100644 index 00000000000..0553f8fcae2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts @@ -0,0 +1,66 @@ +import { useSelector } from 'react-redux'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import { MessageParamsTyped, SignatureRequest, SignatureRequestType } from '@metamask/signature-controller'; +import { selectUseTransactionSimulations } from '../../../../selectors/preferencesController'; +import { isRecognizedPermit, parseTypedDataMessage } from '../utils/signature'; +import { useSignatureRequest } from './useSignatureRequest'; + +const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ + { + domainName: 'Seaport', + primaryTypeList: ['BulkOrder'], + versionList: ['1.4', '1.5', '1.6'], + }, + { + domainName: 'Seaport', + primaryTypeList: ['OrderComponents'], + }, +]; + +const isNonPermitSupportedByDecodingAPI = ( + signatureRequest: SignatureRequest, +) => { + const data = signatureRequest.messageParams?.data as string; + if (!data) { return false; } + + const { + domain: { name, version }, + primaryType, + } = parseTypedDataMessage(data); + + return NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( + ({ domainName, primaryTypeList, versionList }) => + name === domainName && + primaryTypeList.includes(primaryType) && + (!versionList || versionList.includes(version)), + ); +}; + +export function useTypedSignSimulationEnabled() { + const signatureRequest = useSignatureRequest(); + const useTransactionSimulations = useSelector( + selectUseTransactionSimulations, + ); + + if (!signatureRequest) { + return undefined; + } + + const requestType = signatureRequest.type; + const signatureMethod = (signatureRequest.messageParams as MessageParamsTyped)?.version; + + const isTypedSignV3V4 = requestType === SignatureRequestType.TypedSign && ( + signatureMethod === SignTypedDataVersion.V3 || + signatureMethod === SignTypedDataVersion.V4 + ); + const isPermit = isRecognizedPermit(signatureRequest); + + const nonPermitSupportedByDecodingAPI: boolean = + isTypedSignV3V4 && isNonPermitSupportedByDecodingAPI(signatureRequest); + + return ( + useTransactionSimulations && + isTypedSignV3V4 && + (isPermit || nonPermitSupportedByDecodingAPI) + ); +} diff --git a/app/components/Views/confirmations/utils/confirm.ts b/app/components/Views/confirmations/utils/confirm.ts index 6f9b61bdf5c..f4e414f1583 100644 --- a/app/components/Views/confirmations/utils/confirm.ts +++ b/app/components/Views/confirmations/utils/confirm.ts @@ -1,8 +1,11 @@ import { ApprovalTypes } from '../../../../core/RPCMethods/RPCMethodMiddleware'; +export const TOKEN_VALUE_UNLIMITED_THRESHOLD = 10 ** 15; + export function isSignatureRequest(requestType: string) { return [ ApprovalTypes.PERSONAL_SIGN, ApprovalTypes.ETH_SIGN_TYPED_DATA, ].includes(requestType as ApprovalTypes); } + 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..08472ef3f27 --- /dev/null +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -0,0 +1,72 @@ +import { parseTypedDataMessage, isRecognizedPermit } from './signature'; +import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; +import { SignatureRequest } from '@metamask/signature-controller'; + +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: SignatureRequest = { + messageParams: { + data: JSON.stringify({ + primaryType: PRIMARY_TYPES_PERMIT[0] + }) + } + } as SignatureRequest; + + expect(isRecognizedPermit(mockRequest)).toBe(true); + }); + + it('should return false for unrecognized permit types', () => { + const mockRequest: SignatureRequest = { + messageParams: { + data: JSON.stringify({ + primaryType: 'UnrecognizedType' + }) + } + } as SignatureRequest; + + expect(isRecognizedPermit(mockRequest)).toBe(false); + }); + }); +}); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts new file mode 100644 index 00000000000..9035a2b3087 --- /dev/null +++ b/app/components/Views/confirmations/utils/signature.ts @@ -0,0 +1,61 @@ +import { SignatureRequest } from '@metamask/signature-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 signature request to check + */ +export const isRecognizedPermit = (request: SignatureRequest) => { + if (!request) { + return false; + } + + const data = (request as SignatureRequest).messageParams?.data as string; + + const { primaryType } = parseTypedDataMessage(data); + return PRIMARY_TYPES_PERMIT.includes(primaryType); +}; diff --git a/app/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/core/AppConstants.ts b/app/core/AppConstants.ts index 7d132663576..7f48671fa74 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -137,6 +137,7 @@ export default { 'https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/', STAKING_RISK_DISCLOSURE: 'https://consensys.io/staking-risk-disclosures', }, + DECODING_API_URL: process.env.DECODING_API_URL || 'https://signature-insights.api.cx.metamask.io/v1', ERRORS: { INFURA_BLOCKED_MESSAGE: 'EthQuery - RPC Error - This service is not available in your country', diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index a0fe49693c3..740c50e7b45 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1441,6 +1441,10 @@ export class Engine { }), // This casting expected due to mismatch of browser and react-native version of Sentry traceContext trace: trace as unknown as SignatureControllerOptions['trace'], + decodingApiUrl: AppConstants.DECODING_API_URL, + // TODO: check preferences useExternalServices + isDecodeSignatureRequestEnabled: () => + preferencesController.state.useTransactionSimulations, }), LoggingController: loggingController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) diff --git a/app/reducers/transaction/index.js b/app/reducers/transaction/index.js index 5d1ab2efb94..3b37c620150 100644 --- a/app/reducers/transaction/index.js +++ b/app/reducers/transaction/index.js @@ -110,6 +110,8 @@ const transactionReducer = (state = initialState, action) => { ...getTxData(action.transaction), }, ...txMeta, + // Retain the securityAlertResponses from the old state + securityAlertResponses: state.securityAlertResponses, }; } case 'SET_TOKENS_TRANSACTION': { diff --git a/app/selectors/currencyRateController.test.ts b/app/selectors/currencyRateController.test.ts index e48f6b957a4..61a29d38137 100644 --- a/app/selectors/currencyRateController.test.ts +++ b/app/selectors/currencyRateController.test.ts @@ -2,6 +2,7 @@ import { selectConversionRate, selectCurrentCurrency, selectCurrencyRates, + selectConversionRateByChainId, } from './currencyRateController'; import { isTestNet } from '../../app/util/networks'; import { CurrencyRateState } from '@metamask/assets-controllers'; @@ -64,6 +65,41 @@ describe('CurrencyRateController Selectors', () => { }); }); + describe('selectConversionRateByChainId', () => { + const mockChainId = '1'; + const mockNativeCurrency = 'ETH'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns undefined if on a testnet and fiat is disabled', () => { + (isTestNet as jest.Mock).mockReturnValue(true); + + const result = selectConversionRateByChainId.resultFunc( + mockCurrencyRateState.currencyRates as unknown as CurrencyRateState['currencyRates'], + mockChainId as `0x${string}`, + false, + mockNativeCurrency, + ); + + expect(result).toBeUndefined(); + }); + + it('returns the conversion rate for the native currency of the chain id', () => { + (isTestNet as jest.Mock).mockReturnValue(false); + + const result = selectConversionRateByChainId.resultFunc( + mockCurrencyRateState.currencyRates as unknown as CurrencyRateState['currencyRates'], + mockChainId as `0x${string}`, + true, + mockNativeCurrency, + ); + + expect(result).toBe(3000); + }); + }); + describe('selectCurrentCurrency', () => { it('returns the current currency from the state', () => { const result = selectCurrentCurrency.resultFunc( diff --git a/app/selectors/currencyRateController.ts b/app/selectors/currencyRateController.ts index 715ebeb4e8b..296cf3eedd5 100644 --- a/app/selectors/currencyRateController.ts +++ b/app/selectors/currencyRateController.ts @@ -1,7 +1,11 @@ import { createSelector } from 'reselect'; import { CurrencyRateState } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; -import { selectChainId, selectTicker } from './networkController'; +import { + selectChainId, + selectNativeCurrencyByChainId, + selectTicker, +} from './networkController'; import { isTestNet } from '../../app/util/networks'; const selectCurrencyRateControllerState = (state: RootState) => @@ -56,3 +60,22 @@ export const selectConversionRateFoAllChains = createSelector( (currencyRateControllerState: CurrencyRateState) => currencyRateControllerState?.currencyRates, ); + +export const selectConversionRateByChainId = createSelector( + selectConversionRateFoAllChains, + (_state: RootState, chainId: string) => chainId, + (state: RootState) => state.settings.showFiatOnTestnets, + selectNativeCurrencyByChainId, + ( + currencyRates: CurrencyRateState['currencyRates'], + chainId, + showFiatOnTestnets, + nativeCurrency, + ) => { + if (isTestNet(chainId) && !showFiatOnTestnets) { + return undefined; + } + + return currencyRates?.[nativeCurrency]?.conversionRate; + }, +); diff --git a/app/util/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/number/index.js b/app/util/number/index.js index 894716562aa..05e8cbececd 100644 --- a/app/util/number/index.js +++ b/app/util/number/index.js @@ -349,7 +349,7 @@ export function isBN(value) { /** * Determines if a string is a valid decimal * - * @param {string} value - String to check + * @param {number | string} value - String to check * @returns {boolean} - True if the string is a valid decimal */ export function isDecimal(value) { @@ -380,6 +380,22 @@ export function isNumber(str) { return regex.number.test(str); } +/** + * Determines if a value is a number + * + * @param {number | string | null | undefined} value - Value to check + * @returns {boolean} - True if the value is a valid number + */ +export function isNumberValue(value) { + if (value === null || value === undefined) { return false; } + + if (typeof value === 'number') { + return !Number.isNaN(value) && Number.isFinite(value); + } + + return isDecimal(value); +} + export const dotAndCommaDecimalFormatter = (value) => { const valueStr = String(value); diff --git a/app/util/number/index.test.ts b/app/util/number/index.test.ts index 436f18c0c29..5849713f223 100644 --- a/app/util/number/index.test.ts +++ b/app/util/number/index.test.ts @@ -20,6 +20,7 @@ import { isBN, isDecimal, isNumber, + isNumberValue, isNumberScientificNotationWhenString, isZeroValue, limitToMaximumDecimalPlaces, @@ -925,6 +926,42 @@ describe('Number utils :: isNumber', () => { }); }); +describe('Number utils :: isNumberValue', () => { + it('should return true for valid number types', () => { + expect(isNumberValue(1650.7)).toBe(true); + expect(isNumberValue(1000)).toBe(true); + expect(isNumberValue(0.0001)).toBe(true); + expect(isNumberValue(-0.0001)).toBe(true); + expect(isNumberValue(1)).toBe(true); + expect(isNumberValue(1e-10)).toBe(true); + }); + + it('should be a valid number string types', () => { + expect(isNumberValue('1650.7')).toBe(true); + expect(isNumberValue('1000')).toBe(true); + expect(isNumberValue('.01')).toBe(true); + expect(isNumberValue('0.0001')).toBe(true); + expect(isNumberValue('0001')).toBe(true); + expect(isNumberValue('-0.0001')).toBe(true); + expect(isNumberValue('1')).toBe(true); + expect(isNumberValue('1e-10')).toBe(true); + }); + + it('should not be a valid number ', () => { + expect(isNumberValue('..7')).toBe(false); + expect(isNumberValue('1..1')).toBe(false); + expect(isNumberValue('0..')).toBe(false); + expect(isNumberValue('a.0001')).toBe(false); + expect(isNumberValue('00a01')).toBe(false); + expect(isNumberValue('1,.')).toBe(false); + expect(isNumberValue('1,')).toBe(false); + expect(isNumberValue('.')).toBe(false); + expect(isNumberValue('a¡1')).toBe(false); + expect(isNumberValue(undefined)).toBe(false); + expect(isNumberValue(null)).toBe(false); + }); +}); + describe('Number utils :: dotAndCommaDecimalFormatter', () => { it('should return the number if it does not contain a dot or comma', () => { expect(dotAndCommaDecimalFormatter('1650')).toBe('1650'); diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts index 408f70b8788..485228a065a 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,70 @@ 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', + 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' }, + }, + }, + networkClientId: '1', + status: SignatureRequestStatus.Unapproved, + time: 1733143817088 + }, + }, + }, + }, + }, +}; + export const securityAlertResponse = { block: 21572398, result_type: 'Malicious', diff --git a/bitrise.yml b/bitrise.yml index 80960c25e40..0cfc20b1827 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -160,9 +160,8 @@ stages: - run_tag_smoke_assets_android: {} - run_tag_smoke_confirmations_ios: {} - run_tag_smoke_confirmations_android: {} - - run_tag_smoke_ramps_android: {} - # - run_tag_smoke_swaps_ios: {} - # - run_tag_smoke_swaps_android: {} + - run_tag_smoke_swaps_ios: {} + - run_tag_smoke_swaps_android: {} - run_tag_smoke_core_ios: {} - run_tag_smoke_core_android: {} - run_tag_multichain_permissions_ios: {} diff --git a/e2e/api-mocking/mock-config/mock-events.js b/e2e/api-mocking/mock-config/mock-events.js index 0e3423416b1..755fe4d39da 100644 --- a/e2e/api-mocking/mock-config/mock-events.js +++ b/e2e/api-mocking/mock-config/mock-events.js @@ -38,21 +38,37 @@ export const mockEvents = { securityAlertApiSupportedChains: { urlEndpoint: 'https://security-alerts.api.cx.metamask.io/supportedChains', response: [ - '0xa4b1', - '0xa86a', - '0x2105', - '0x138d5', - '0x38', - '0xe708', - '0x1', - '0x1b6e6', - '0xcc', - '0xa', - '0x89', - '0x82750', - '0xaa36a7', - '0x144' - ], + '0xa4b1', + '0xa86a', + '0x2105', + '0x138d5', + '0x38', + '0xe708', + '0x1', + '0x1b6e6', + '0xcc', + '0xa', + '0x89', + '0x82750', + '0xaa36a7', + '0x144', + ], + responseCode: 200, + }, + + remoteFeatureFlags: { + urlEndpoint: + 'https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=dev', + response: [ + { + mobileMinimumVersions: { + appMinimumBuild: 1243, + appleMinimumOS: 6, + androidMinimumAPIVersion: 21, + }, + }, + { confirmation_redesign: { signatures: false } }, + ], responseCode: 200, }, }, @@ -77,7 +93,8 @@ export const mockEvents = { }, securityAlertApiValidate: { - urlEndpoint: 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', + urlEndpoint: + 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', response: { block: 20733513, result_type: 'Benign', @@ -93,9 +110,9 @@ export const mockEvents = { { from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', to: '0x50587e46c5b96a3f6f9792922ec647f13e6efae4', - value: '0x0' - } - ] + value: '0x0', + }, + ], }, responseCode: 201, }, diff --git a/e2e/api-specs/json-rpc-coverage.js b/e2e/api-specs/json-rpc-coverage.js index ca5b30f828a..bcc75bc049c 100644 --- a/e2e/api-specs/json-rpc-coverage.js +++ b/e2e/api-specs/json-rpc-coverage.js @@ -22,6 +22,7 @@ import ConfirmationsRejectRule from './ConfirmationsRejectionRule'; import { createDriverTransport } from './helpers'; import { BrowserViewSelectorsIDs } from '../selectors/Browser/BrowserView.selectors'; import { getGanachePort } from '../fixtures/utils'; +import { mockEvents } from '../api-mocking/mock-config/mock-events'; const port = getGanachePort(8545, process.pid); const chainId = 1337; @@ -156,6 +157,10 @@ const main = async () => { const server = mockServer(port, openrpcDocument); server.start(); + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + await withFixtures( { dapp: true, @@ -163,6 +168,7 @@ const main = async () => { ganacheOptions: defaultGanacheOptions, disableGanache: true, restartDevice: true, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/pages/Transactions/ActivitiesView.js b/e2e/pages/Transactions/ActivitiesView.js index 089bb7f7839..3e8a0dfc1e7 100644 --- a/e2e/pages/Transactions/ActivitiesView.js +++ b/e2e/pages/Transactions/ActivitiesView.js @@ -1,15 +1,38 @@ -import { ActivitiesViewSelectorsIDs, ActivitiesViewSelectorsText } from '../../selectors/Transactions/ActivitiesView.selectors'; +import { + ActivitiesViewSelectorsIDs, + ActivitiesViewSelectorsText, +} from '../../selectors/Transactions/ActivitiesView.selectors'; import Matchers from '../../utils/Matchers'; import Gestures from '../../utils/Gestures'; +import { CommonSelectorsIDs } from '../../selectors/Common.selectors'; class ActivitiesView { + static FIRST_ROW = 0; + static SECOND_ROW = 1; + get title() { return Matchers.getElementByText(ActivitiesViewSelectorsText.TITLE); } get container() { + return Matchers.getElementByID(ActivitiesViewSelectorsIDs.CONTAINER); + } + + get confirmedLabel() { + return Matchers.getElementByText(ActivitiesViewSelectorsText.CONFIRM_TEXT); + } + + get firstTransactionStatus() { + return Matchers.getElementByID( + CommonSelectorsIDs.TRANSACTION_STATUS, + ActivitiesView.FIRST_ROW, + ); + } + + get secondTransactionStatus() { return Matchers.getElementByID( - ActivitiesViewSelectorsIDs.CONTAINER, + CommonSelectorsIDs.TRANSACTION_STATUS, + ActivitiesView.SECOND_ROW, ); } @@ -20,14 +43,27 @@ class ActivitiesView { return title; } - swapActivity(sourceToken, destinationToken) { + generateApprovedTokenActivityLabel(sourceToken) { + let title = ActivitiesViewSelectorsText.APPROVE; + title = title.replace('{{sourceToken}}', sourceToken); + title = title.replace('{{upTo}}', '.*'); + return new RegExp(`^${title}`); + } + + swapActivityTitle(sourceToken, destinationToken) { return Matchers.getElementByText( this.generateSwapActivityLabel(sourceToken, destinationToken), ); } + tokenApprovalActivity(sourceToken) { + return Matchers.getElementByText( + this.generateApprovedTokenActivityLabel(sourceToken), + ); + } + async tapOnSwapActivity(sourceToken, destinationToken) { - const element = this.swapActivity(sourceToken, destinationToken); + const element = this.swapActivityTitle(sourceToken, destinationToken); await Gestures.waitAndTap(element); } diff --git a/e2e/pages/swaps/QuoteView.js b/e2e/pages/swaps/QuoteView.js index dac368cb41f..a81a3f4fd24 100644 --- a/e2e/pages/swaps/QuoteView.js +++ b/e2e/pages/swaps/QuoteView.js @@ -53,8 +53,8 @@ class QuoteView { await Gestures.typeTextAndHideKeyboard(this.searchToken, symbol); } - async selectToken(symbol) { - const element = Matchers.getElementByText(symbol, 1); + async selectToken(symbol, index = 1) { + const element = Matchers.getElementByText(symbol, index); await Gestures.waitAndTap(element); } diff --git a/e2e/pages/swaps/SwapView.js b/e2e/pages/swaps/SwapView.js index 46abf95cb5b..d780208621d 100644 --- a/e2e/pages/swaps/SwapView.js +++ b/e2e/pages/swaps/SwapView.js @@ -5,6 +5,7 @@ import { import Matchers from '../../utils/Matchers'; import Gestures from '../../utils/Gestures'; +import TestHelpers from '../..//helpers'; class SwapView { get quoteSummary() { @@ -19,8 +20,10 @@ class SwapView { return Matchers.getElementByText(SwapViewSelectorsTexts.FETCHING_QUOTES); } - get swipeToSwapButton() { - return Matchers.getElementByID(SwapsViewSelectors.SWIPE_TO_SWAP_BUTTON); + get swapButton() { + return device.getPlatform() === 'ios' + ? Matchers.getElementByID(SwapsViewSelectors.SWAP_BUTTON) + : Matchers.getElementByLabel(SwapsViewSelectors.SWAP_BUTTON); } get iUnderstandLabel() { @@ -34,25 +37,19 @@ class SwapView { return title; } - async swipeToSwap() { - const percentage = device.getPlatform() === 'ios' ? 0.72 : 0.95; - - // Swipe could happen at the same time when gas fees are falshing - // and that's when the swipe button becomes disabled - // that's the need to retry - await Gestures.swipe(this.swipeToSwapButton, 'right', 'fast', percentage); - await Gestures.swipe(this.swipeToSwapButton, 'right', 'fast', percentage); + // Function to check if the button is enabled + async isButtonEnabled(element) { + const attributes = await element.getAttributes(); + return attributes.enabled === true; // Check if enabled is true } - swapCompleteLabel(sourceTokenSymbol, destTokenSymbol) { - return Matchers.getElementByText( - this.generateSwapCompleteLabel(sourceTokenSymbol, destTokenSymbol), - ); + async tapSwapButton() { + await Gestures.waitAndTap(this.swapButton); } async tapIUnderstandPriceWarning() { try { - await Gestures.waitAndTap(this.iUnderstandLabel, 5000); + await Gestures.waitAndTap(this.iUnderstandLabel, 3000); } catch (e) { // eslint-disable-next-line no-console console.log(`Price warning not displayed: ${e}`); diff --git a/e2e/resources/networks.e2e.js b/e2e/resources/networks.e2e.js index be8c299175a..f8caf3f1627 100644 --- a/e2e/resources/networks.e2e.js +++ b/e2e/resources/networks.e2e.js @@ -103,14 +103,42 @@ const CustomNetworks = { }, }, Tenderly: { - isCustomNetwork: true, - providerConfig: { - type: 'rpc', - chainId: '0x1', - rpcUrl: `https://rpc.tenderly.co/fork/bbfe5a2e-2426-4512-a5f8-46ce85fe9ad6`, - nickname: 'Tenderly', - ticker: 'ETH', + Mainnet: { + providerConfig: { + type: 'rpc', + chainId: toHex('1'), + rpcUrl: 'https://virtual.mainnet.rpc.tenderly.co/3472e4b3-594b-488a-a8b1-93593194615f', + nickname: 'Tenderly - Mainnet', + ticker: 'ETH', + }, + }, + Polygon: { + providerConfig: { + type: 'rpc', + chainId: toHex('137'), + rpcUrl: 'https://virtual.polygon.rpc.tenderly.co/e834a81e-69ba-49e9-a6a5-be5b6eea3cdc', + nickname: 'Polygon', + ticker: 'POL', + }, + }, + Linea: { + providerConfig: { + type: 'rpc', + chainId: toHex('59144'), + rpcUrl: 'https://virtual.linea.rpc.tenderly.co/2c429ceb-43db-45bc-9d84-21a40d21e0d2', + nickname: 'Linea', + ticker: 'ETH', + }, }, + Optimism: { + providerConfig: { + type: 'rpc', + chainId: toHex('10'), + rpcUrl: 'https://virtual.optimism.rpc.tenderly.co/3170a58e-fa67-4ccc-9697-b13aff0f5c1a', + nickname: 'Optimism', + ticker: 'ETH', + }, + } }, Gnosis: { providerConfig: { diff --git a/e2e/selectors/Common.selectors.js b/e2e/selectors/Common.selectors.js index 1e98c4c559e..62f0a69cad7 100644 --- a/e2e/selectors/Common.selectors.js +++ b/e2e/selectors/Common.selectors.js @@ -13,7 +13,7 @@ export const CommonSelectorsIDs = { NAV_IOS_BACK: 'nav-ios-back', FOX_SCREEN: 'fox-screen', NAVBAR_TITLE_NETWORKS_TEXT: 'navbar-title-networks', - STATUS_CONFIRMED: 'status-confirmed-text', + TRANSACTION_STATUS: 'transaction-status', ANDROID_PROGRESS_BAR: 'android.widget.ProgressBar', }; diff --git a/e2e/selectors/Transactions/ActivitiesView.selectors.js b/e2e/selectors/Transactions/ActivitiesView.selectors.js index b7e9c7f1fe5..adb9f23562e 100644 --- a/e2e/selectors/Transactions/ActivitiesView.selectors.js +++ b/e2e/selectors/Transactions/ActivitiesView.selectors.js @@ -17,6 +17,7 @@ export const ActivitiesViewSelectorsText = { SENT_TOKENS_MESSAGE_TEXT: (unit) => getSentUnitMessage(unit), SET_APPROVAL_FOR_ALL_METHOD: enContent.transactions.set_approval_for_all, SWAP: enContent.swaps.transaction_label.swap, + APPROVE: enContent.swaps.transaction_label.approve, TITLE: enContent.transactions_view.title, }; diff --git a/e2e/selectors/swaps/SwapsView.selectors.js b/e2e/selectors/swaps/SwapsView.selectors.js index 257643747fb..d379e8c4a20 100644 --- a/e2e/selectors/swaps/SwapsView.selectors.js +++ b/e2e/selectors/swaps/SwapsView.selectors.js @@ -1,10 +1,9 @@ import enContent from '../../../locales/languages/en.json'; export const SwapsViewSelectors = { - SWIPE_TO_SWAP_BUTTON: 'swipe-to-swap-button', + SWAP_BUTTON: 'swap-button', QUOTE_SUMMARY: 'swap-quote-summary', GAS_FEE: 'swap-gas-fee', - SWAP_BUTTON: 'swap-button', }; export const SwapViewSelectorsTexts = { diff --git a/e2e/specs/confirmations/signatures/ethereum-sign.spec.js b/e2e/specs/confirmations/signatures/ethereum-sign.spec.js index 4be24f172ca..b51be2c81b6 100644 --- a/e2e/specs/confirmations/signatures/ethereum-sign.spec.js +++ b/e2e/specs/confirmations/signatures/ethereum-sign.spec.js @@ -12,6 +12,7 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Ethereum Sign'), () => { beforeAll(async () => { @@ -20,6 +21,10 @@ describe(SmokeConfirmations('Ethereum Sign'), () => { }); it('Sign in with Ethereum', async () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + await withFixtures( { dapp: true, @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Ethereum Sign'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/personal-sign.spec.js b/e2e/specs/confirmations/signatures/personal-sign.spec.js index 0e3acf0579c..4fc37496141 100644 --- a/e2e/specs/confirmations/signatures/personal-sign.spec.js +++ b/e2e/specs/confirmations/signatures/personal-sign.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Personal Sign'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Personal Sign'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js b/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js index ff4a19e0137..77f0f3b3721 100644 --- a/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js +++ b/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js @@ -60,7 +60,10 @@ describe(SmokeConfirmations('Security Alert API - Signature'), () => { it('should sign typed message', async () => { const testSpecificMock = { - GET: [mockEvents.GET.securityAlertApiSupportedChains], + GET: [ + mockEvents.GET.securityAlertApiSupportedChains, + mockEvents.GET.remoteFeatureFlags, + ], POST: [ { ...mockEvents.POST.securityAlertApiValidate, @@ -83,7 +86,10 @@ describe(SmokeConfirmations('Security Alert API - Signature'), () => { it('should show security alert for malicious request', async () => { const testSpecificMock = { - GET: [mockEvents.GET.securityAlertApiSupportedChains], + GET: [ + mockEvents.GET.securityAlertApiSupportedChains, + mockEvents.GET.remoteFeatureFlags, + ], POST: [ { ...mockEvents.POST.securityAlertApiValidate, @@ -108,6 +114,7 @@ describe(SmokeConfirmations('Security Alert API - Signature'), () => { const testSpecificMock = { GET: [ mockEvents.GET.securityAlertApiSupportedChains, + mockEvents.GET.remoteFeatureFlags, { urlEndpoint: 'https://static.cx.metamask.io/api/v1/confirmations/ppom/ppom_version.json', diff --git a/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js b/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js index 86d958aa561..26cf84334ae 100644 --- a/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js +++ b/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Typed Sign V3'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Typed Sign V3'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js b/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js index 4de3dd9b006..c82b6e7a17b 100644 --- a/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js +++ b/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Typed Sign V4'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Typed Sign V4'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/typed-sign.spec.js b/e2e/specs/confirmations/signatures/typed-sign.spec.js index bee5b2a5e8a..c6b9cfed353 100644 --- a/e2e/specs/confirmations/signatures/typed-sign.spec.js +++ b/e2e/specs/confirmations/signatures/typed-sign.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Typed Sign'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Typed Sign'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/quarantine/send-to-contact.failing.js b/e2e/specs/quarantine/send-to-contact.failing.js new file mode 100644 index 00000000000..8e5427cab2e --- /dev/null +++ b/e2e/specs/quarantine/send-to-contact.failing.js @@ -0,0 +1,77 @@ +'use strict'; + +import { SmokeConfirmations } from '../../tags'; + +import AmountView from '../../pages/Send/AmountView'; +import SendView from '../../pages/Send/SendView'; +import TransactionConfirmationView from '../../pages/Send/TransactionConfirmView'; +import { loginToApp } from '../../viewHelper'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; +import enContent from '../../../locales/languages/en.json'; +import FixtureBuilder from '../../fixtures/fixture-builder'; +import { + loadFixture, + startFixtureServer, + stopFixtureServer, +} from '../../fixtures/fixture-helper'; +import { CustomNetworks } from '../../resources/networks.e2e'; +import TestHelpers from '../../helpers'; +import FixtureServer from '../../fixtures/fixture-server'; +import { getFixturesServerPort } from '../../fixtures/utils'; +import Assertions from '../../utils/Assertions'; + +const fixtureServer = new FixtureServer(); + +describe(SmokeConfirmations('Send ETH'), () => { + const TOKEN_NAME = enContent.unit.eth; + const AMOUNT = '0.12345'; + + beforeEach(async () => { + await TestHelpers.reverseServerPort(); + const fixture = new FixtureBuilder() + .withNetworkController(CustomNetworks.Tenderly) + .withAddressBookController({ + addressBook: { + '0x1': { + '0x2f318C334780961FB129D2a6c30D0763d9a5C970': { + address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + chainId: '0x1', + isEns: false, + memo: '', + name: 'Test Name 1', + }, + }, + }, + }) + .build(); + await startFixtureServer(fixtureServer); + await loadFixture(fixtureServer, { fixture }); + await device.launchApp({ + permissions: { notifications: 'YES' }, + launchArgs: { fixtureServerPort: `${getFixturesServerPort()}` }, + }); + await loginToApp(); + }); + + afterAll(async () => { + await stopFixtureServer(fixtureServer); + }); + + it('should send ETH to a contact from inside the wallet', async () => { + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapSendButton(); + await SendView.scrollToSavedAccount(); + + await SendView.tapAccountName('Test Name 1'); + + await SendView.tapNextButton(); + + await AmountView.typeInTransactionAmount(AMOUNT); + await AmountView.tapNextButton(); + await Assertions.checkIfTextIsDisplayed('Test Name 1'); + await TransactionConfirmationView.tapConfirmButton(); + await TabBarComponent.tapActivity(); + await Assertions.checkIfTextIsDisplayed(`${AMOUNT} ${TOKEN_NAME}`); + }); +}); diff --git a/e2e/specs/ramps/offramp.spec.js b/e2e/specs/ramps/offramp.spec.js index 027c6051f29..dab60443aba 100644 --- a/e2e/specs/ramps/offramp.spec.js +++ b/e2e/specs/ramps/offramp.spec.js @@ -38,7 +38,7 @@ describe(SmokeRamps('Off-Ramp'), () => { beforeAll(async () => { await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); diff --git a/e2e/specs/swaps/swap-action-regression.spec.js b/e2e/specs/swaps/swap-action-regression.spec.js index 4f70e048cb6..86b0dd32e64 100644 --- a/e2e/specs/swaps/swap-action-regression.spec.js +++ b/e2e/specs/swaps/swap-action-regression.spec.js @@ -1,11 +1,10 @@ 'use strict'; +import { ethers } from 'ethers'; import { loginToApp } from '../../viewHelper'; -import Onboarding from '../../pages/swaps/OnBoarding'; import QuoteView from '../../pages/swaps/QuoteView'; import SwapView from '../../pages/swaps/SwapView'; import TabBarComponent from '../../pages/wallet/TabBarComponent'; import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import DetailsBottomSheet from '../../pages/Transactions/TransactionDetailsModal'; import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; import WalletView from '../../pages/wallet/WalletView'; import FixtureBuilder from '../../fixtures/fixture-builder'; @@ -15,20 +14,38 @@ import { stopFixtureServer, } from '../../fixtures/fixture-helper'; import { CustomNetworks } from '../../resources/networks.e2e'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; import TestHelpers from '../../helpers'; import FixtureServer from '../../fixtures/fixture-server'; import { getFixturesServerPort } from '../../fixtures/utils'; -import { SmokeSwaps } from '../../tags'; +import { Regression } from '../../tags'; +import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; +import ImportAccountView from '../../pages/importAccount/ImportAccountView'; +import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; import Assertions from '../../utils/Assertions'; +import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; + +import Tenderly from '../../tenderly'; const fixtureServer = new FixtureServer(); +const firstElement = 0; + +describe(Regression('Multiple Swaps from Actions'), () => { + let educationModalTapped = false; + let currentNetwork = CustomNetworks.Tenderly.Mainnet.providerConfig.nickname; + const wallet = ethers.Wallet.createRandom(); -describe(SmokeSwaps('Multiple Swaps from Actions'), () => { - let swapOnboarded = true; // TODO: Set it to false once we show the onboarding page again. beforeAll(async () => { + jest.setTimeout(2500000); + await Tenderly.addFunds( + CustomNetworks.Tenderly.Mainnet.providerConfig.rpcUrl, + wallet.address, + ); + await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); @@ -43,43 +60,65 @@ describe(SmokeSwaps('Multiple Swaps from Actions'), () => { await stopFixtureServer(fixtureServer); }); - beforeEach(async () => { - jest.setTimeout(150000); + it('should be able to import account', async () => { + await WalletView.tapIdenticon(); + await Assertions.checkIfVisible(AccountListBottomSheet.accountList); + await AccountListBottomSheet.tapAddAccountButton(); + await AddAccountBottomSheet.tapImportAccount(); + await Assertions.checkIfVisible(ImportAccountView.container); + await ImportAccountView.enterPrivateKey(wallet.privateKey); + await Assertions.checkIfVisible(SuccessImportAccountView.container); + await SuccessImportAccountView.tapCloseButton(); + await AccountListBottomSheet.swipeToDismissAccountsModal(); + await Assertions.checkIfVisible(WalletView.container); }); + it.each` - quantity | sourceTokenSymbol | destTokenSymbol - ${'1'} | ${'ETH'} | ${'WETH'} - ${'1'} | ${'WETH'} | ${'ETH'} + type | quantity | sourceTokenSymbol | destTokenSymbol | network + ${'native'} | ${'.03'} | ${'ETH'} | ${'DAI'} | ${CustomNetworks.Tenderly.Mainnet} + ${'unapproved'} | ${'3'} | ${'DAI'} | ${'USDC'} | ${CustomNetworks.Tenderly.Mainnet} + ${'erc20'} | ${'10'} | ${'DAI'} | ${'ETH'} | ${CustomNetworks.Tenderly.Mainnet} `( - "should Swap $quantity '$sourceTokenSymbol' to '$destTokenSymbol'", - async ({ quantity, sourceTokenSymbol, destTokenSymbol }) => { + "should swap $type token '$sourceTokenSymbol' to '$destTokenSymbol' on '$network.providerConfig.nickname'", + async ({ type, quantity, sourceTokenSymbol, destTokenSymbol, network }) => { await TabBarComponent.tapWallet(); + + if (network.providerConfig.nickname !== currentNetwork) { + await WalletView.tapNetworksButtonOnNavBar(); + await Assertions.checkIfToggleIsOn(NetworkListModal.testNetToggle); + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, + ); + await NetworkEducationModal.tapGotItButton(); + await TestHelpers.delay(3000); + currentNetwork = network.providerConfig.nickname; + } + await Assertions.checkIfVisible(WalletView.container); await TabBarComponent.tapActions(); await WalletActionsBottomSheet.tapSwapButton(); - if (!swapOnboarded) { - await Onboarding.tapStartSwapping(); - swapOnboarded = true; - } await Assertions.checkIfVisible(QuoteView.getQuotes); - //Select source token, if ETH then can skip because already selected - if (sourceTokenSymbol !== 'ETH') { + //Select source token, if native token can skip because already selected + if (type !== 'native' && type !== 'wrap') { await QuoteView.tapOnSelectSourceToken(); await QuoteView.tapSearchToken(); await QuoteView.typeSearchToken(sourceTokenSymbol); - await TestHelpers.delay(1000); + await QuoteView.selectToken(sourceTokenSymbol); } await QuoteView.enterSwapAmount(quantity); //Select destination token await QuoteView.tapOnSelectDestToken(); - await QuoteView.tapSearchToken(); - await QuoteView.typeSearchToken(destTokenSymbol); - await TestHelpers.delay(1000); - await QuoteView.selectToken(destTokenSymbol); + if (destTokenSymbol !== 'ETH') { + await QuoteView.tapSearchToken(); + await QuoteView.typeSearchToken(destTokenSymbol); + await TestHelpers.delay(2000); + await QuoteView.selectToken(destTokenSymbol); + } else await QuoteView.selectToken(destTokenSymbol, firstElement); //Make sure slippage is zero for wrapped tokens if (sourceTokenSymbol === 'WETH' || destTokenSymbol === 'WETH') { @@ -89,50 +128,59 @@ describe(SmokeSwaps('Multiple Swaps from Actions'), () => { ); } await QuoteView.tapOnGetQuotes(); - await Assertions.checkIfVisible(SwapView.fetchingQuotes); await Assertions.checkIfVisible(SwapView.quoteSummary); await Assertions.checkIfVisible(SwapView.gasFee); await SwapView.tapIUnderstandPriceWarning(); - await SwapView.swipeToSwap(); + await SwapView.tapSwapButton(); + //Wait for Swap to complete try { - await Assertions.checkIfVisible( - SwapView.swapCompleteLabel(sourceTokenSymbol, destTokenSymbol), + await Assertions.checkIfTextIsDisplayed( + SwapView.generateSwapCompleteLabel( + sourceTokenSymbol, + destTokenSymbol, + ), 30000, ); } catch (e) { // eslint-disable-next-line no-console - console.log(`Toast message is slow to appear or did not appear: ${e}`); + console.log(`Swap complete didn't pop up: ${e}`); } await device.enableSynchronization(); - await TestHelpers.delay(5000); + await TestHelpers.delay(10000); + + // Check the swap activity completed await TabBarComponent.tapActivity(); await Assertions.checkIfVisible(ActivitiesView.title); await Assertions.checkIfVisible( - ActivitiesView.swapActivity(sourceTokenSymbol, destTokenSymbol), - ); - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.firstTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); - try { - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } catch (e) { - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + // Check the token approval completed + if (type === 'unapproved') { + await Assertions.checkIfVisible( + ActivitiesView.tokenApprovalActivity(sourceTokenSymbol), ); - await Assertions.checkIfVisible(DetailsBottomSheet.title); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.secondTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); } - await Assertions.checkIfVisible(DetailsBottomSheet.title); - await Assertions.checkIfElementToHaveText( - DetailsBottomSheet.title, - DetailsBottomSheet.generateExpectedTitle(sourceTokenSymbol, destTokenSymbol), + // TODO: The following hack is needed to update the token balance until bug is fixed + await TabBarComponent.tapWallet(); + await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.changeNetworkTo('Localhost', false); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); + } + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, ); - await Assertions.checkIfVisible(DetailsBottomSheet.statusConfirmed); - await DetailsBottomSheet.tapOnCloseIcon(); - await Assertions.checkIfNotVisible(DetailsBottomSheet.title); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); + educationModalTapped = true; + } }, ); }); diff --git a/e2e/specs/swaps/swap-action-smoke.spec.js b/e2e/specs/swaps/swap-action-smoke.spec.js index 7fbb29baba4..db50f431aa7 100644 --- a/e2e/specs/swaps/swap-action-smoke.spec.js +++ b/e2e/specs/swaps/swap-action-smoke.spec.js @@ -1,33 +1,49 @@ 'use strict'; +import { ethers } from 'ethers'; import { loginToApp } from '../../viewHelper'; -import Onboarding from '../../pages/swaps/OnBoarding'; import QuoteView from '../../pages/swaps/QuoteView'; import SwapView from '../../pages/swaps/SwapView'; import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import DetailsBottomSheet from '../../pages/Transactions/TransactionDetailsModal'; +import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; +import WalletView from '../../pages/wallet/WalletView'; import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; import FixtureBuilder from '../../fixtures/fixture-builder'; +import Tenderly from '../../tenderly'; import { loadFixture, startFixtureServer, stopFixtureServer, } from '../../fixtures/fixture-helper'; import { CustomNetworks } from '../../resources/networks.e2e'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; import TestHelpers from '../../helpers'; import FixtureServer from '../../fixtures/fixture-server'; import { getFixturesServerPort } from '../../fixtures/utils'; import { SmokeSwaps } from '../../tags'; +import ImportAccountView from '../../pages/importAccount/ImportAccountView'; +import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; import Assertions from '../../utils/Assertions'; +import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; +import ActivitiesView from '../../pages/Transactions/ActivitiesView'; const fixtureServer = new FixtureServer(); +const firstElement = 0; describe(SmokeSwaps('Swap from Actions'), () => { - let swapOnboarded = true; // TODO: Set it to false once we show the onboarding page again. + let educationModalTapped = false; + let currentNetwork = CustomNetworks.Tenderly.Mainnet.providerConfig.nickname; + const wallet = ethers.Wallet.createRandom(); + beforeAll(async () => { + await Tenderly.addFunds( + CustomNetworks.Tenderly.Mainnet.providerConfig.rpcUrl, + wallet.address, + ); + await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); @@ -43,42 +59,66 @@ describe(SmokeSwaps('Swap from Actions'), () => { }); beforeEach(async () => { - jest.setTimeout(150000); + jest.setTimeout(120000); + }); + + it('should be able to import account', async () => { + await WalletView.tapIdenticon(); + await Assertions.checkIfVisible(AccountListBottomSheet.accountList); + await AccountListBottomSheet.tapAddAccountButton(); + await AddAccountBottomSheet.tapImportAccount(); + await Assertions.checkIfVisible(ImportAccountView.container); + await ImportAccountView.enterPrivateKey(wallet.privateKey); + await Assertions.checkIfVisible(SuccessImportAccountView.container); + await SuccessImportAccountView.tapCloseButton(); + await AccountListBottomSheet.swipeToDismissAccountsModal(); + await Assertions.checkIfVisible(WalletView.container); }); it.each` - quantity | sourceTokenSymbol | destTokenSymbol - ${'.05'} | ${'ETH'} | ${'USDT'} - ${'100'} | ${'USDT'} | ${'ETH'} + type | quantity | sourceTokenSymbol | destTokenSymbol | network + ${'wrap'} | ${'.03'} | ${'ETH'} | ${'WETH'} | ${CustomNetworks.Tenderly.Mainnet} + ${'unwrap'} | ${'.01'} | ${'WETH'} | ${'ETH'} | ${CustomNetworks.Tenderly.Mainnet} `( - "should Swap $quantity '$sourceTokenSymbol' to '$destTokenSymbol'", - async ({ quantity, sourceTokenSymbol, destTokenSymbol }) => { + "should swap $type token '$sourceTokenSymbol' to '$destTokenSymbol' on '$network.providerConfig.nickname'", + async ({ type, quantity, sourceTokenSymbol, destTokenSymbol, network }) => { await TabBarComponent.tapWallet(); - await TabBarComponent.tapActions(); - await WalletActionsBottomSheet.tapSwapButton(); - if (!swapOnboarded) { - await Onboarding.tapStartSwapping(); - swapOnboarded = true; + if (network.providerConfig.nickname !== currentNetwork) { + await WalletView.tapNetworksButtonOnNavBar(); + await Assertions.checkIfToggleIsOn(NetworkListModal.testNetToggle); + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, + ); + await NetworkEducationModal.tapGotItButton(); + await TestHelpers.delay(3000); + currentNetwork = network.providerConfig.nickname; } + + await Assertions.checkIfVisible(WalletView.container); + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapSwapButton(); await Assertions.checkIfVisible(QuoteView.getQuotes); - //Select source token, if ETH then can skip because already selected - if (sourceTokenSymbol !== 'ETH') { + //Select source token, if native tiken can skip because already selected + if (type !== 'native' && type !== 'wrap') { await QuoteView.tapOnSelectSourceToken(); await QuoteView.tapSearchToken(); await QuoteView.typeSearchToken(sourceTokenSymbol); - await TestHelpers.delay(1000); + await QuoteView.selectToken(sourceTokenSymbol); } await QuoteView.enterSwapAmount(quantity); //Select destination token await QuoteView.tapOnSelectDestToken(); - await QuoteView.tapSearchToken(); - await QuoteView.typeSearchToken(destTokenSymbol); - await TestHelpers.delay(1000); - await QuoteView.selectToken(destTokenSymbol); + if (destTokenSymbol !== 'ETH') { + await QuoteView.tapSearchToken(); + await QuoteView.typeSearchToken(destTokenSymbol); + await TestHelpers.delay(2000); + await QuoteView.selectToken(destTokenSymbol); + } else await QuoteView.selectToken(destTokenSymbol, firstElement); //Make sure slippage is zero for wrapped tokens if (sourceTokenSymbol === 'WETH' || destTokenSymbol === 'WETH') { @@ -92,44 +132,58 @@ describe(SmokeSwaps('Swap from Actions'), () => { await Assertions.checkIfVisible(SwapView.quoteSummary); await Assertions.checkIfVisible(SwapView.gasFee); await SwapView.tapIUnderstandPriceWarning(); - await SwapView.swipeToSwap(); + await Assertions.checkIfVisible(SwapView.swapButton); + await TestHelpers.delay(2000); + await SwapView.tapSwapButton(); + //Wait for Swap to complete try { - await Assertions.checkIfVisible( - SwapView.swapCompleteLabel(sourceTokenSymbol, destTokenSymbol), + await Assertions.checkIfTextIsDisplayed( + SwapView.generateSwapCompleteLabel( + sourceTokenSymbol, + destTokenSymbol, + ), 30000, ); } catch (e) { // eslint-disable-next-line no-console - console.log(`Toast message is slow to appear or did not appear: ${e}`); + console.log(`Swap complete didn't pop up: ${e}`); } await device.enableSynchronization(); - await TestHelpers.delay(5000); + await TestHelpers.delay(10000); + + // Check the swap activity completed await TabBarComponent.tapActivity(); await Assertions.checkIfVisible(ActivitiesView.title); await Assertions.checkIfVisible( - ActivitiesView.swapActivity(sourceTokenSymbol, destTokenSymbol), - ); - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.firstTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); - try { - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } catch (e) { - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + // Check the token approval completed + if (type === 'unapproved') { + await Assertions.checkIfVisible( + ActivitiesView.tokenApprovalActivity(sourceTokenSymbol), ); - await Assertions.checkIfVisible(DetailsBottomSheet.title); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.secondTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); + } + + // TODO: The following hack is needed to update the token balance until bug is fixed + await TabBarComponent.tapWallet(); + await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.changeNetworkTo('Localhost', false); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); } - await Assertions.checkIfElementToHaveText( - DetailsBottomSheet.title, - DetailsBottomSheet.generateExpectedTitle(sourceTokenSymbol, destTokenSymbol), + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, ); - await Assertions.checkIfVisible(DetailsBottomSheet.statusConfirmed); - await DetailsBottomSheet.tapOnCloseIcon(); - await Assertions.checkIfNotVisible(DetailsBottomSheet.title); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); + educationModalTapped = true; + } }, ); }); diff --git a/e2e/specs/quarantine/swap-token-chart.failing.js b/e2e/specs/swaps/swap-token-chart.spec.js similarity index 57% rename from e2e/specs/quarantine/swap-token-chart.failing.js rename to e2e/specs/swaps/swap-token-chart.spec.js index 707df3d608b..c3ed9183b9e 100644 --- a/e2e/specs/quarantine/swap-token-chart.failing.js +++ b/e2e/specs/swaps/swap-token-chart.spec.js @@ -1,4 +1,5 @@ 'use strict'; +import { ethers } from 'ethers'; import { loginToApp } from '../../viewHelper'; import Onboarding from '../../pages/swaps/OnBoarding'; import QuoteView from '../../pages/swaps/QuoteView'; @@ -17,20 +18,29 @@ import TestHelpers from '../../helpers'; import FixtureServer from '../../fixtures/fixture-server'; import { getFixturesServerPort } from '../../fixtures/utils'; import { Regression } from '../../tags'; +import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; +import ImportAccountView from '../../pages/importAccount/ImportAccountView'; +import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; import Assertions from '../../utils/Assertions'; +import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import DetailsBottomSheet from '../../pages/Transactions/TransactionDetailsModal'; +import { ActivitiesViewSelectorsText } from '../../selectors/Transactions/ActivitiesView.selectors'; +import Tenderly from '../../tenderly'; const fixtureServer = new FixtureServer(); -const sourceTokenSymbol = 'USDT'; -const destTokenSymbol = 'DAI'; describe(Regression('Swap from Token view'), () => { const swapOnboarded = true; // TODO: Set it to false once we show the onboarding page again. + const wallet = ethers.Wallet.createRandom(); + beforeAll(async () => { + await Tenderly.addFunds( + CustomNetworks.Tenderly.Mainnet.providerConfig.rpcUrl, + wallet.address, + ); await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); @@ -49,7 +59,22 @@ describe(Regression('Swap from Token view'), () => { jest.setTimeout(150000); }); - it('should complete a USDT to ETH swap from the token chart', async () => { + it('should be able to import account', async () => { + await WalletView.tapIdenticon(); + await Assertions.checkIfVisible(AccountListBottomSheet.accountList); + await AccountListBottomSheet.tapAddAccountButton(); + await AddAccountBottomSheet.tapImportAccount(); + await Assertions.checkIfVisible(ImportAccountView.container); + await ImportAccountView.enterPrivateKey(wallet.privateKey); + await Assertions.checkIfVisible(SuccessImportAccountView.container); + await SuccessImportAccountView.tapCloseButton(); + await AccountListBottomSheet.swipeToDismissAccountsModal(); + await Assertions.checkIfVisible(WalletView.container); + }); + + it('should complete a USDC to DAI swap from the token chart', async () => { + const sourceTokenSymbol = 'ETH', + destTokenSymbol = 'DAI'; await TabBarComponent.tapWallet(); await Assertions.checkIfVisible(WalletView.container); await WalletView.tapOnToken('Ethereum'); @@ -58,12 +83,7 @@ describe(Regression('Swap from Token view'), () => { await TokenOverview.tapSwapButton(); if (!swapOnboarded) await Onboarding.tapStartSwapping(); await Assertions.checkIfVisible(QuoteView.getQuotes); - await QuoteView.tapOnSelectSourceToken(); - await QuoteView.tapSearchToken(); - await QuoteView.typeSearchToken(sourceTokenSymbol); - await TestHelpers.delay(1000); - await QuoteView.selectToken(sourceTokenSymbol); - await QuoteView.enterSwapAmount('10'); + await QuoteView.enterSwapAmount('.5'); await QuoteView.tapOnSelectDestToken(); await QuoteView.tapSearchToken(); await QuoteView.typeSearchToken(destTokenSymbol); @@ -74,43 +94,34 @@ describe(Regression('Swap from Token view'), () => { await Assertions.checkIfVisible(SwapView.quoteSummary); await Assertions.checkIfVisible(SwapView.gasFee); await SwapView.tapIUnderstandPriceWarning(); - await SwapView.swipeToSwap(); + await SwapView.tapSwapButton(); + await TestHelpers.delay(2000); + //Wait for Swap to complete try { - await Assertions.checkIfVisible( - SwapView.swapCompleteLabel(sourceTokenSymbol, destTokenSymbol), - 100000, + await Assertions.checkIfTextIsDisplayed( + SwapView.generateSwapCompleteLabel( + sourceTokenSymbol, + destTokenSymbol, + ), + 30000, ); } catch (e) { // eslint-disable-next-line no-console - console.log(`Toast message is slow to appear or did not appear: ${e}`); + console.log(`Swap complete didn't pop up: ${e}`); } await device.enableSynchronization(); - await TestHelpers.delay(5000); - await TokenOverview.tapBackButton(); + await TestHelpers.delay(10000); + + // Check the swap activity completed await TabBarComponent.tapActivity(); await Assertions.checkIfVisible(ActivitiesView.title); await Assertions.checkIfVisible( - ActivitiesView.swapActivity(sourceTokenSymbol, destTokenSymbol), + ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); - await ActivitiesView.tapOnSwapActivity(sourceTokenSymbol, destTokenSymbol); - - try { - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } catch (e) { - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, - ); - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } - - await Assertions.checkIfVisible(DetailsBottomSheet.title); await Assertions.checkIfElementToHaveText( - DetailsBottomSheet.title, - DetailsBottomSheet.generateExpectedTitle(sourceTokenSymbol, destTokenSymbol), + ActivitiesView.firstTransactionStatus, + ActivitiesViewSelectorsText.CONFIRM_TEXT, + 60000, ); - await Assertions.checkIfVisible(DetailsBottomSheet.statusConfirmed); - await DetailsBottomSheet.tapOnCloseIcon(); - await Assertions.checkIfNotVisible(DetailsBottomSheet.title); }); }); diff --git a/e2e/specs/swaps/token-details.spec.js b/e2e/specs/swaps/token-details.spec.js index c40493153fc..4c7407e6a5f 100644 --- a/e2e/specs/swaps/token-details.spec.js +++ b/e2e/specs/swaps/token-details.spec.js @@ -6,10 +6,9 @@ import { importWalletWithRecoveryPhrase, switchToSepoliaNetwork, } from '../../viewHelper'; -import { CustomNetworks } from '../../resources/networks.e2e'; import Assertions from '../../utils/Assertions'; import CommonView from '../../pages/CommonView'; -import TestHelpers from '../../helpers' +import TestHelpers from '../../helpers'; describe(SmokeSwaps('Token Chart Tests'), () => { beforeAll(async () => { @@ -23,7 +22,10 @@ describe(SmokeSwaps('Token Chart Tests'), () => { it('should view the token chart', async () => { await WalletView.tapOnToken(); - await Assertions.checkIfElementNotToHaveText(TokenOverview.tokenPrice, '$0'); + await Assertions.checkIfElementNotToHaveText( + TokenOverview.tokenPrice, + '$0', + ); await TokenOverview.tapChartPeriod1d(); await Assertions.checkIfVisible(TokenOverview.chartPeriod1d); @@ -46,9 +48,10 @@ describe(SmokeSwaps('Token Chart Tests'), () => { }); it('should not display the chart when using Sepolia test network', async () => { + const sepoliaTokenSymbol = 'S'; await switchToSepoliaNetwork(); - await WalletView.tapOnToken(CustomNetworks.Sepolia.providerConfig.ticker); - await Assertions.checkIfNotVisible(TokenOverview.noChartData); + await WalletView.tapOnToken(sepoliaTokenSymbol); + await Assertions.checkIfVisible(TokenOverview.noChartData, 60000); await Assertions.checkIfElementToHaveText(TokenOverview.tokenPrice, '$0'); }); }); diff --git a/e2e/tenderly.js b/e2e/tenderly.js new file mode 100644 index 00000000000..3212112001b --- /dev/null +++ b/e2e/tenderly.js @@ -0,0 +1,27 @@ +import axios from 'axios'; + +export default class Tenderly { + + static async addFunds(rpcURL, account, amount = '0xDE0B6B3A764000000') { + const data = { + jsonrpc: '2.0', + method: 'tenderly_setBalance', + params: [[account], amount], + id: '1234', + }; + + const response = await axios.post(rpcURL, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data.error) { + // eslint-disable-next-line no-console + console.log( + `ERROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`, + ); + return null; + } + } +} diff --git a/jest.config.js b/jest.config.js index bbc092b33f5..d54cc45b1f8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,8 +9,6 @@ process.env.MM_SECURITY_ALERTS_API_ENABLED = 'true'; process.env.PORTFOLIO_VIEW = 'true'; process.env.SECURITY_ALERTS_API_URL = 'https://example.com'; -process.env.REDESIGNED_SIGNATURE_REQUEST = 'true'; - process.env.LAUNCH_DARKLY_URL = 'https://client-config.dev-api.cx.metamask.io/v1'; diff --git a/locales/languages/en.json b/locales/languages/en.json index bb36399ef21..369786e4273 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3611,10 +3611,23 @@ "balance": "Balance", "network": "Network", "simulation": { + "decoded_tooltip_bid_nft": "The NFT will be reflected in your wallet, when the bid is accepted.", + "decoded_tooltip_list_nft": "Expect changes only if someone buys your NFTs.", + "info_permit": "You're giving the spender permission to spend this many tokens from your account.", + "label_change_type_bidding": "You bid", + "label_change_type_listing": "You list", + "label_change_type_nft_listing": "Listing price", + "label_change_type_permit": "Spending cap", + "label_change_type_permit_nft": "Withdraw", + "label_change_type_receive": "You receive", + "label_change_type_revoke": "Revoke", + "label_change_type_transfer": "You send", + "personal_sign_info": "You're signing into a site and there are no predicted changes to your account.", "title": "Estimated changes", - "personal_sign_info": "You’re signing into a site and there are no predicted changes to your account.", - "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee." - } + "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", + "unavailable": "Unavailable" + }, + "unlimited": "Unlimited" }, "change_in_simulation_modal": { "title": "Results have changed",