From 420f851bdeb7b160c8c1c43e9e1d9bdf4ff2275e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 12 Mar 2025 15:27:55 +0000 Subject: [PATCH 01/10] Add downgrade button in account details --- app/scripts/metamask-controller.js | 11 +++ .../account-details-display.js | 84 ++++++++++++++++++- .../account-details/account-details.js | 1 + .../transaction-account-details.tsx | 26 ++++-- .../info/hooks/useIsUpgradeTransaction.ts | 23 ++++- .../confirmations/hooks/useEIP7702Account.ts | 65 ++++++++++++++ ui/store/actions.ts | 7 ++ 7 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 ui/pages/confirmations/hooks/useEIP7702Account.ts diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 832ba64cf1cb..ff92e2afab26 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3438,6 +3438,7 @@ export default class MetamaskController extends EventEmitter { getOpenMetamaskTabsIds: this.getOpenMetamaskTabsIds, markNotificationPopupAsAutomaticallyClosed: () => this.notificationManager.markAsAutomaticallyClosed(), + getCode: this.getCode.bind(this), // primary keyring management addNewAccount: this.addNewAccount.bind(this), @@ -7619,6 +7620,16 @@ export default class MetamaskController extends EventEmitter { }); } + async getCode(address, networkClientId) { + const { provider } = + this.networkController.getNetworkClientById(networkClientId); + + return await provider.request({ + method: 'eth_getCode', + params: [address], + }); + } + async _onAccountChange(newAddress) { const permittedAccountsMap = getPermittedAccountsByOrigin( this.permissionController.state, diff --git a/ui/components/multichain/account-details/account-details-display.js b/ui/components/multichain/account-details/account-details-display.js index d5af76004887..d6b35a583819 100644 --- a/ui/components/multichain/account-details/account-details-display.js +++ b/ui/components/multichain/account-details/account-details-display.js @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; @@ -15,11 +15,15 @@ import { Box, ButtonSecondary, ButtonSecondarySize, + Text, } from '../../component-library'; import { AlignItems, + BackgroundColor, + BorderRadius, Display, FlexDirection, + TextColor, TextVariant, } from '../../../helpers/constants/design-system'; import { MetaMetricsContext } from '../../../contexts/metametrics'; @@ -30,12 +34,73 @@ import { } from '../../../../shared/constants/metametrics'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; +import { useEIP7702Account } from '../../../pages/confirmations/hooks/useEIP7702Account'; + +function SmartAccountPill() { + const { isUpgraded } = useEIP7702Account(); + + if (!isUpgraded) { + return null; + } + + return ( + + + Smart account + + + ); +} + +function DowngradeAccountButton({ address, onClose }) { + const { downgradeAccount, isUpgraded } = useEIP7702Account({ + onRedirect: onClose, + }); + + const handleClick = useCallback(() => { + downgradeAccount(address); + }, [address, downgradeAccount]); + + if (!isUpgraded) { + return null; + } + + return ( + + Switch back to regular account + + ); +} export const AccountDetailsDisplay = ({ accounts, accountName, address, onExportClick, + onClose, }) => { const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); @@ -71,7 +136,9 @@ export const AccountDetailsDisplay = ({ }} accounts={accounts} /> + + {exportPrivateKeyFeatureEnabled ? ( { accountName={name} address={address} onExportClick={() => setAttemptingExport(true)} + onClose={onClose} /> )} diff --git a/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx b/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx index 3e1764873599..d80c15a510c2 100644 --- a/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx @@ -7,17 +7,21 @@ import { } from '../../../../../../../components/app/confirm/info/row'; import { useConfirmContext } from '../../../../../context/confirm'; import { ConfirmInfoSection } from '../../../../../../../components/app/confirm/info/row/section'; -import { useIsUpgradeTransaction } from '../../hooks/useIsUpgradeTransaction'; +import { + useIsDowngradeTransaction, + useIsUpgradeTransaction, +} from '../../hooks/useIsUpgradeTransaction'; import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; export function TransactionAccountDetails() { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); const isUpgrade = useIsUpgradeTransaction(); + const isDowngrade = useIsDowngradeTransaction(); const { chainId, txParams } = currentConfirmation; const { from } = txParams; - if (!isUpgrade) { + if (!isUpgrade && !isDowngrade) { return null; } @@ -26,9 +30,21 @@ export function TransactionAccountDetails() { - - - + {isUpgrade && ( + + + + )} + {isDowngrade && ( + <> + + + + + + + + )} ); } diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.ts b/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.ts index 5271e0c41aab..7ec5ad218a26 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.ts @@ -1,10 +1,31 @@ import { TransactionMeta } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; import { useConfirmContext } from '../../../../context/confirm'; +import { EIP_7702_REVOKE_ADDRESS } from '../../../../hooks/useEIP7702Account'; export function useIsUpgradeTransaction(): boolean { + const authorizationAddress = useTransactionAuthorizationAddress(); + + return ( + Boolean(authorizationAddress) && + authorizationAddress !== EIP_7702_REVOKE_ADDRESS + ); +} + +export function useIsDowngradeTransaction(): boolean { + const authorizationAddress = useTransactionAuthorizationAddress(); + + return ( + Boolean(authorizationAddress) && + authorizationAddress === EIP_7702_REVOKE_ADDRESS + ); +} + +function useTransactionAuthorizationAddress(): Hex | undefined { const { currentConfirmation } = useConfirmContext(); const { txParams } = currentConfirmation ?? {}; const { authorizationList } = txParams ?? {}; + const authorization = authorizationList?.[0]; - return Boolean(authorizationList?.length); + return authorization?.address; } diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.ts b/ui/pages/confirmations/hooks/useEIP7702Account.ts new file mode 100644 index 000000000000..8e3a057e7084 --- /dev/null +++ b/ui/pages/confirmations/hooks/useEIP7702Account.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + addTransactionAndRouteToConfirmationPage, + getCode, +} from '../../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; +import { + TransactionEnvelopeType, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { useConfirmationNavigation } from './useConfirmationNavigation'; +import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; +import { Hex } from '@metamask/utils'; + +export const EIP_7702_REVOKE_ADDRESS = '0x0000000000000000000000000000000000000000'; + +export function useEIP7702Account({ + onRedirect, +}: { onRedirect?: () => void } = {}) { + const dispatch = useDispatch(); + const [transactionId, setTransactionId] = useState(); + const { confirmations, navigateToId } = useConfirmationNavigation(); + const globalNetworkClientId = useSelector(getSelectedNetworkClientId); + + const isRedirectPending = confirmations.some( + (conf) => conf.id === transactionId, + ); + + const downgradeAccount = useCallback( + async (address: Hex) => { + const transactionMeta = (await dispatch( + addTransactionAndRouteToConfirmationPage({ + authorizationList: [ + { + address: EIP_7702_REVOKE_ADDRESS, + }, + ], + from: address, + to: address, + type: TransactionEnvelopeType.setCode, + }), + )) as unknown as TransactionMeta; + + setTransactionId(transactionMeta?.id); + }, + [dispatch], + ); + + const isUpgraded = useCallback( + async (address: Hex) => { + const code = await getCode(address, globalNetworkClientId); + return code?.length > 2; + }, + [globalNetworkClientId], + ); + + useEffect(() => { + if (isRedirectPending) { + navigateToId(transactionId); + onRedirect?.(); + } + }, [isRedirectPending, navigateToId, transactionId, onRedirect]); + + return { isUpgraded, downgradeAccount }; +} diff --git a/ui/store/actions.ts b/ui/store/actions.ts index da93f38461d5..38e708f247b3 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -6084,3 +6084,10 @@ export async function disableAccountUpgradeForChain(chainId: string) { chainId, ]); } + +export async function getCode(address: Hex, networkClientId: string) { + return await submitRequestToBackground('getCode', [ + address, + networkClientId, + ]); +} From efb0dd05cf77dbcb43f2e0c609b04a40d95de367 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 12 Mar 2025 23:37:41 +0000 Subject: [PATCH 02/10] Add unit tests for hooks --- .../hooks/useIsUpgradeTransaction.test.ts | 57 ++++++- .../hooks/useEIP7702Account.test.ts | 157 ++++++++++++++++++ .../confirmations/hooks/useEIP7702Account.ts | 3 +- 3 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 ui/pages/confirmations/hooks/useEIP7702Account.test.ts diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.test.ts b/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.test.ts index efbdbb82469f..544e1c411918 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.test.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useIsUpgradeTransaction.test.ts @@ -2,9 +2,13 @@ import { AuthorizationList } from '@metamask/transaction-controller'; import { genUnapprovedContractInteractionConfirmation } from '../../../../../../../test/data/confirmations/contract-interaction'; import { getMockConfirmStateForTransaction } from '../../../../../../../test/data/confirmations/helper'; import { renderHookWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; -import { useIsUpgradeTransaction } from './useIsUpgradeTransaction'; +import { EIP_7702_REVOKE_ADDRESS } from '../../../../hooks/useEIP7702Account'; +import { + useIsDowngradeTransaction, + useIsUpgradeTransaction, +} from './useIsUpgradeTransaction'; -function runHook(authorizationList?: AuthorizationList) { +function runUpgradeHook(authorizationList?: AuthorizationList) { const transaction = genUnapprovedContractInteractionConfirmation({ authorizationList, }); @@ -19,18 +23,59 @@ function runHook(authorizationList?: AuthorizationList) { return result.current as boolean; } +function runDowngradeHook(authorizationList?: AuthorizationList) { + const transaction = genUnapprovedContractInteractionConfirmation({ + authorizationList, + }); + + const state = getMockConfirmStateForTransaction(transaction); + + const { result } = renderHookWithConfirmContextProvider( + useIsDowngradeTransaction, + state, + ); + + return result.current as boolean; +} + describe('useIsUpgradeTransaction', () => { - it('returns true if authorizationList is not empty', async () => { - const result = runHook([{ address: '0x123' }]); + it('returns true if authorization address is not empty', async () => { + const result = runUpgradeHook([{ address: '0x123' }]); + expect(result).toBe(true); + }); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([undefined, null, []] as const)( + 'returns false if authorization address is %s', + async (authorizationList: never) => { + const result = runUpgradeHook(authorizationList); + expect(result).toBe(false); + }, + ); + + it('returns false if authorization address is zero address', async () => { + const result = runUpgradeHook([{ address: EIP_7702_REVOKE_ADDRESS }]); + expect(result).toBe(false); + }); +}); + +describe('useIsDowngradeTransaction', () => { + it('returns true if authorization address is zero address', async () => { + const result = runDowngradeHook([{ address: EIP_7702_REVOKE_ADDRESS }]); expect(result).toBe(true); }); // @ts-expect-error This is missing from the Mocha type definitions it.each([undefined, null, []] as const)( - 'returns false if authorizationList is %s', + 'returns false if authorization address is %s', async (authorizationList: never) => { - const result = runHook(authorizationList); + const result = runDowngradeHook(authorizationList); expect(result).toBe(false); }, ); + + it('returns false if authorization address is other address', async () => { + const result = runDowngradeHook([{ address: '0x123' }]); + expect(result).toBe(false); + }); }); diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts new file mode 100644 index 000000000000..a389905c425a --- /dev/null +++ b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts @@ -0,0 +1,157 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { genUnapprovedContractInteractionConfirmation } from '../../../../test/data/confirmations/contract-interaction'; +import { getMockConfirmStateForTransaction } from '../../../../test/data/confirmations/helper'; +import { + EIP_7702_REVOKE_ADDRESS, + useEIP7702Account, +} from './useEIP7702Account'; +import { + addTransactionAndRouteToConfirmationPage, + getCode, +} from '../../../store/actions'; +import { act } from '@testing-library/react'; +import { renderHookWithProvider } from '../../../../test/lib/render-helpers'; +import { TransactionEnvelopeType } from '@metamask/transaction-controller'; +import { ThunkAction } from 'redux-thunk'; +import { useConfirmationNavigation } from './useConfirmationNavigation'; +import { ApprovalRequest } from '@metamask/approval-controller'; +import { flushPromises } from '../../../../test/lib/timer-helpers'; +import { useDispatch } from 'react-redux'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +jest.mock('../../../store/actions', () => ({ + ...jest.requireActual('../../../store/actions'), + addTransactionAndRouteToConfirmationPage: jest.fn(), + getCode: jest.fn(), +})); + +jest.mock('./useConfirmationNavigation', () => ({ + useConfirmationNavigation: jest.fn(), +})); + +const ADDRESS_MOCK = '0x1234'; +const CODE_MOCK = '0xabcd'; +const TRANSACTION_ID_MOCK = '1234-5678'; + +function runHook({ onRedirect }: { onRedirect?: () => void } = {}) { + const { result } = renderHookWithProvider( + () => useEIP7702Account({ onRedirect }), + {}, + ); + return result.current; +} + +describe('useEIP7702Account', () => { + const addTransactionAndRouteToConfirmationPageMock = jest.mocked( + addTransactionAndRouteToConfirmationPage, + ); + + const useDispatchMock = jest.mocked(useDispatch); + const getCodeMock = jest.mocked(getCode); + const useConfirmationNavigationMock = jest.mocked(useConfirmationNavigation); + + beforeEach(() => { + jest.resetAllMocks(); + + addTransactionAndRouteToConfirmationPageMock.mockReturnValue({ + type: 'MockAction', + } as unknown as ReturnType); + + useConfirmationNavigationMock.mockReturnValue({ + confirmations: [], + navigateToId: jest.fn(), + } as unknown as ReturnType); + + useDispatchMock.mockReturnValue(jest.fn()); + }); + + describe('isUpgraded', () => { + it('returns true if account has code', async () => { + getCodeMock.mockResolvedValue(CODE_MOCK); + const result = await runHook().isUpgraded(ADDRESS_MOCK); + expect(result).toBe(true); + }); + + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([undefined, '', '0x'])( + 'returns false if code is %s', + async (code: string) => { + getCodeMock.mockResolvedValue(code); + const result = await runHook().isUpgraded(ADDRESS_MOCK); + expect(result).toBe(false); + }, + ); + }); + + describe('downgradeAccount', () => { + it('adds transaction', async () => { + const { downgradeAccount } = runHook(); + + await downgradeAccount(ADDRESS_MOCK); + + expect(addTransactionAndRouteToConfirmationPageMock).toHaveBeenCalledWith( + { + authorizationList: [ + { + address: EIP_7702_REVOKE_ADDRESS, + }, + ], + from: ADDRESS_MOCK, + to: ADDRESS_MOCK, + type: TransactionEnvelopeType.setCode, + }, + ); + }); + + it('navigates to confirmation', async () => { + const navigateToIdMock = jest.fn(); + + useConfirmationNavigationMock.mockReturnValue({ + confirmations: [{ id: TRANSACTION_ID_MOCK }], + navigateToId: navigateToIdMock, + } as unknown as ReturnType); + + useDispatchMock.mockReturnValue( + jest.fn().mockResolvedValue({ + id: TRANSACTION_ID_MOCK, + }), + ); + + const { downgradeAccount } = runHook(); + + await act(async () => { + await downgradeAccount(ADDRESS_MOCK); + }); + + expect(navigateToIdMock).toHaveBeenCalledTimes(1); + expect(navigateToIdMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); + }); + + it('calls onRedirect', async () => { + const onRedirect = jest.fn(); + + useConfirmationNavigationMock.mockReturnValue({ + confirmations: [{ id: TRANSACTION_ID_MOCK }], + navigateToId: jest.fn(), + } as unknown as ReturnType); + + useDispatchMock.mockReturnValue( + jest.fn().mockResolvedValue({ + id: TRANSACTION_ID_MOCK, + }), + ); + + const { downgradeAccount } = runHook({ onRedirect }); + + await act(async () => { + await downgradeAccount(ADDRESS_MOCK); + }); + + expect(onRedirect).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.ts b/ui/pages/confirmations/hooks/useEIP7702Account.ts index 8e3a057e7084..8d0bae250a85 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.ts @@ -12,7 +12,8 @@ import { useConfirmationNavigation } from './useConfirmationNavigation'; import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { Hex } from '@metamask/utils'; -export const EIP_7702_REVOKE_ADDRESS = '0x0000000000000000000000000000000000000000'; +export const EIP_7702_REVOKE_ADDRESS = + '0x0000000000000000000000000000000000000000'; export function useEIP7702Account({ onRedirect, From 35e15fa9d6133326dbdb077e7c2e446f339c03ff Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Mar 2025 00:31:48 +0000 Subject: [PATCH 03/10] Add unit tests for pill and button --- .../account-details-display.js | 28 +++++- .../account-details-display.test.js | 96 +++++++++++++++++++ 2 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 ui/components/multichain/account-details/account-details-display.test.js diff --git a/ui/components/multichain/account-details/account-details-display.js b/ui/components/multichain/account-details/account-details-display.js index d6b35a583819..da92fd4e4c20 100644 --- a/ui/components/multichain/account-details/account-details-display.js +++ b/ui/components/multichain/account-details/account-details-display.js @@ -35,11 +35,17 @@ import { import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { useEIP7702Account } from '../../../pages/confirmations/hooks/useEIP7702Account'; +import { useAsyncResult } from '../../../hooks/useAsyncResult'; -function SmartAccountPill() { +function SmartAccountPill({ address }) { const { isUpgraded } = useEIP7702Account(); - if (!isUpgraded) { + const { value: isAccountUpgraded } = useAsyncResult( + () => isUpgraded(address), + [address], + ); + + if (!isAccountUpgraded) { return null; } @@ -74,11 +80,16 @@ function DowngradeAccountButton({ address, onClose }) { onRedirect: onClose, }); - const handleClick = useCallback(() => { - downgradeAccount(address); + const { value: isAccountUpgraded } = useAsyncResult( + () => isUpgraded(address), + [address], + ); + + const handleClick = useCallback(async () => { + await downgradeAccount(address); }, [address, downgradeAccount]); - if (!isUpgraded) { + if (!isAccountUpgraded) { return null; } @@ -186,6 +197,13 @@ AccountDetailsDisplay.propTypes = { onClose: PropTypes.func.isRequired, }; +SmartAccountPill.propTypes = { + /** + * Current address + */ + address: PropTypes.string.isRequired, +}; + DowngradeAccountButton.propTypes = { /** * Current address diff --git a/ui/components/multichain/account-details/account-details-display.test.js b/ui/components/multichain/account-details/account-details-display.test.js new file mode 100644 index 000000000000..780d34b23570 --- /dev/null +++ b/ui/components/multichain/account-details/account-details-display.test.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { act } from '@testing-library/react'; +import { useEIP7702Account } from '../../../pages/confirmations/hooks/useEIP7702Account'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import configureStore from '../../../store/store'; +import mockState from '../../../../test/data/mock-state.json'; +import { AccountDetailsDisplay } from './account-details-display'; + +jest.mock('../../../pages/confirmations/hooks/useEIP7702Account', () => ({ + useEIP7702Account: jest.fn(), +})); + +const ADDRESS_MOCK = Object.values( + mockState.metamask.internalAccounts.accounts, +)[0].address; + +function renderComponent() { + return renderWithProvider( + , + configureStore(mockState), + ); +} + +describe('AccountDetailsDisplay', () => { + const useEIP7702AccountMock = jest.mocked(useEIP7702Account); + const isUpgradedMock = jest.fn(); + const downgradeAccountMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + useEIP7702AccountMock.mockReturnValue({ + isUpgraded: isUpgradedMock, + downgradeAccount: downgradeAccountMock, + }); + }); + + it('renders smart account pill if account is upgraded', async () => { + isUpgradedMock.mockResolvedValue(true); + const { getByText } = renderComponent(); + + await act(async () => { + // Intentionally empty + }); + + expect(getByText('Smart account')).toBeInTheDocument(); + }); + + it('does not render smart account pill if account is not upgraded', async () => { + isUpgradedMock.mockResolvedValue(false); + const { queryByText } = renderComponent(); + + await act(async () => { + // Intentionally empty + }); + + expect(queryByText('Smart account')).toBeNull(); + }); + + it('renders downgrade button if account is upgraded', async () => { + isUpgradedMock.mockResolvedValue(true); + const { getByText } = renderComponent(); + + await act(async () => { + // Intentionally empty + }); + + expect(getByText('Switch back to regular account')).toBeInTheDocument(); + }); + + it('does not render downgrade button if account is not upgraded', async () => { + isUpgradedMock.mockResolvedValue(false); + const { queryByText } = renderComponent(); + + await act(async () => { + // Intentionally empty + }); + + expect(queryByText('Switch back to regular account')).toBeNull(); + }); + + it('adds transaction on downgrade button click', async () => { + isUpgradedMock.mockResolvedValue(true); + const { queryByText } = renderComponent(); + + await act(async () => { + // Intentionally empty + }); + + await act(async () => { + queryByText('Switch back to regular account').click(); + }); + + expect(downgradeAccountMock).toHaveBeenCalledTimes(1); + expect(downgradeAccountMock).toHaveBeenCalledWith(ADDRESS_MOCK); + }); +}); From 12930eb1e9d83d5d714c6bcd54b51c76ad51243a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Mar 2025 01:05:37 +0000 Subject: [PATCH 04/10] Update storybook --- ui/__mocks__/actions.js | 11 ++++++++++- .../account-details/account-details-display.js | 2 +- .../account-details/account-details.stories.js | 10 ++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/ui/__mocks__/actions.js b/ui/__mocks__/actions.js index 56bf28992388..5f55b01cbc04 100644 --- a/ui/__mocks__/actions.js +++ b/ui/__mocks__/actions.js @@ -10,6 +10,7 @@ const { const ERC20_TOKEN_1_MOCK = '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599'; // WBTC const ERC20_TOKEN_2_MOCK = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; // USDC +const UPGRADED_ACCOUNT_MOCK = '0x9d0ba4ddac06032527b140912ec808ab9451b788'; const ERC721_TOKEN_MOCK = '0x06012c8cf97bead5deae237070f9587f8e7a266d'; // CryptoKitties const TOKEN_DETAILS_MOCK = { @@ -50,7 +51,7 @@ module.exports = { }, // eslint-disable-next-line no-empty-function - trackMetaMetricsEvent: () => {}, + trackMetaMetricsEvent: () => { }, decodeTransactionData: async (request) => { const { contractAddress } = request; @@ -65,4 +66,12 @@ module.exports = { return undefined; }, + + getCode: async (address) => { + if (address === UPGRADED_ACCOUNT_MOCK) { + return '0x1234'; + } + + return '0x'; + }, }; diff --git a/ui/components/multichain/account-details/account-details-display.js b/ui/components/multichain/account-details/account-details-display.js index da92fd4e4c20..c998c049fb11 100644 --- a/ui/components/multichain/account-details/account-details-display.js +++ b/ui/components/multichain/account-details/account-details-display.js @@ -147,7 +147,7 @@ export const AccountDetailsDisplay = ({ }} accounts={accounts} /> - + {exportPrivateKeyFeatureEnabled ? ( diff --git a/ui/components/multichain/account-details/account-details.stories.js b/ui/components/multichain/account-details/account-details.stories.js index 9c5ae2026324..86daf6542deb 100644 --- a/ui/components/multichain/account-details/account-details.stories.js +++ b/ui/components/multichain/account-details/account-details.stories.js @@ -2,6 +2,8 @@ import React from 'react'; import testData from '../../../../.storybook/test-data'; import { AccountDetails } from '.'; +const UPGRADED_ACCOUNT_MOCK = '0x9d0ba4ddac06032527b140912ec808ab9451b788'; + const { address } = Object.values( testData.metamask.internalAccounts.accounts, )[1]; @@ -20,3 +22,11 @@ export default { }; export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +export const UpgradedAccountStory = (args) => ( + +); + +UpgradedAccountStory.storyName = 'Upgraded Account'; From e6801e68f500bd876f801801731e27e83d4763c0 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Mar 2025 07:20:11 +0000 Subject: [PATCH 05/10] Support revert transaction type --- shared/lib/confirmation.utils.ts | 1 + ui/helpers/utils/transactions.util.js | 3 ++- ui/hooks/useTransactionDisplayData.js | 3 ++- .../components/confirm/info/info.tsx | 1 + .../components/confirm/title/title.tsx | 4 +++ .../confirmations/hooks/useEIP7702Account.ts | 26 ++++++++++++------- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/shared/lib/confirmation.utils.ts b/shared/lib/confirmation.utils.ts index 80ba5ca6a585..9dfe26eb04a1 100644 --- a/shared/lib/confirmation.utils.ts +++ b/shared/lib/confirmation.utils.ts @@ -14,6 +14,7 @@ const REDESIGN_USER_TRANSACTION_TYPES = [ TransactionType.batch, TransactionType.contractInteraction, TransactionType.deployContract, + TransactionType.revokeDelegation, TransactionType.tokenMethodApprove, TransactionType.tokenMethodIncreaseAllowance, TransactionType.tokenMethodSetApprovalForAll, diff --git a/ui/helpers/utils/transactions.util.js b/ui/helpers/utils/transactions.util.js index 79cc1c4395b7..0b5489402c4f 100644 --- a/ui/helpers/utils/transactions.util.js +++ b/ui/helpers/utils/transactions.util.js @@ -135,7 +135,8 @@ export function getTransactionTypeTitle(t, type, nativeCurrency = 'ETH') { return t('sendingNativeAsset', [nativeCurrency]); } case TransactionType.contractInteraction: - case TransactionType.batch: { + case TransactionType.batch: + case TransactionType.revokeDelegation: { return t('contractInteraction'); } case TransactionType.deployContract: { diff --git a/ui/hooks/useTransactionDisplayData.js b/ui/hooks/useTransactionDisplayData.js index 4eff10b40374..ea19b897bb33 100644 --- a/ui/hooks/useTransactionDisplayData.js +++ b/ui/hooks/useTransactionDisplayData.js @@ -345,7 +345,8 @@ export function useTransactionDisplayData(transactionGroup) { subtitleContainsOrigin = true; } else if ( type === TransactionType.contractInteraction || - type === TransactionType.batch + type === TransactionType.batch || + type === TransactionType.revokeDelegation ) { category = TransactionGroupCategory.interaction; const transactionTypeTitle = getTransactionTypeTitle(t, type); diff --git a/ui/pages/confirmations/components/confirm/info/info.tsx b/ui/pages/confirmations/components/confirm/info/info.tsx index e329ec7a8f76..39621ec1293b 100644 --- a/ui/pages/confirmations/components/confirm/info/info.tsx +++ b/ui/pages/confirmations/components/confirm/info/info.tsx @@ -25,6 +25,7 @@ const Info = () => { [TransactionType.contractInteraction]: () => BaseTransactionInfo, [TransactionType.deployContract]: () => BaseTransactionInfo, [TransactionType.personalSign]: () => PersonalSignInfo, + [TransactionType.revokeDelegation]: () => BaseTransactionInfo, [TransactionType.simpleSend]: () => NativeTransferInfo, [TransactionType.signTypedData]: () => { const { version } = diff --git a/ui/pages/confirmations/components/confirm/title/title.tsx b/ui/pages/confirmations/components/confirm/title/title.tsx index a544e6580437..96eace246498 100644 --- a/ui/pages/confirmations/components/confirm/title/title.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.tsx @@ -95,6 +95,8 @@ const getTitle = ( return t('confirmTitleSIWESignature'); } return t('confirmTitleSignature'); + case TransactionType.revokeDelegation: + return 'Revert'; case TransactionType.signTypedData: if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { if (tokenStandard === TokenStandard.ERC721) { @@ -156,6 +158,8 @@ const getDescription = ( return t('confirmTitleDescSIWESignature'); } return t('confirmTitleDescSign'); + case TransactionType.revokeDelegation: + return 'Test'; case TransactionType.signTypedData: if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { if (tokenStandard === TokenStandard.ERC721) { diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.ts b/ui/pages/confirmations/hooks/useEIP7702Account.ts index 8d0bae250a85..2ebf05d318a0 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.ts @@ -7,6 +7,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { TransactionEnvelopeType, TransactionMeta, + TransactionType, } from '@metamask/transaction-controller'; import { useConfirmationNavigation } from './useConfirmationNavigation'; import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; @@ -30,16 +31,21 @@ export function useEIP7702Account({ const downgradeAccount = useCallback( async (address: Hex) => { const transactionMeta = (await dispatch( - addTransactionAndRouteToConfirmationPage({ - authorizationList: [ - { - address: EIP_7702_REVOKE_ADDRESS, - }, - ], - from: address, - to: address, - type: TransactionEnvelopeType.setCode, - }), + addTransactionAndRouteToConfirmationPage( + { + authorizationList: [ + { + address: EIP_7702_REVOKE_ADDRESS, + }, + ], + from: address, + to: address, + type: TransactionEnvelopeType.setCode, + }, + { + type: TransactionType.revokeDelegation, + }, + ), )) as unknown as TransactionMeta; setTransactionId(transactionMeta?.id); From d8e0dc10cb3fc6f8c7967f1841360931ff324a16 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Mar 2025 13:22:59 +0000 Subject: [PATCH 06/10] Add translations --- app/_locales/en/messages.json | 14 +++++++++++++- .../account-details/account-details-display.js | 7 +++++-- .../transaction-account-details.tsx | 2 +- .../transasction-account-details.test.tsx | 4 ++-- .../components/confirm/title/title.tsx | 4 ++-- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a24e770a8aba..048372280c2e 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -90,6 +90,9 @@ "accountDetails": { "message": "Account details" }, + "accountDetailsRevokeDelegationButton": { + "message": " Switch back to regular account" + }, "accountIdenticon": { "message": "Account identicon" }, @@ -1033,7 +1036,10 @@ "message": "Type" }, "confirmAccountTypeSmartContract": { - "message": "Smart contract account" + "message": "Smart account" + }, + "confirmAccountTypeStandard": { + "message": "Standard account" }, "confirmAlertModalAcknowledgeMultiple": { "message": "I have acknowledged the alerts and still want to proceed" @@ -1077,6 +1083,9 @@ "confirmTitleDescPermitSignature": { "message": "This site wants permission to spend your tokens." }, + "confirmTitleDescRevokeDelegation": { + "message": "This site is requesting to switch back to a standard account." + }, "confirmTitleDescSIWESignature": { "message": "A site wants you to sign in to prove you own this account." }, @@ -1089,6 +1098,9 @@ "confirmTitleRevokeApproveTransaction": { "message": "Remove permission" }, + "confirmTitleRevokeDelegation": { + "message": "Reset account" + }, "confirmTitleSIWESignature": { "message": "Sign-in request" }, diff --git a/ui/components/multichain/account-details/account-details-display.js b/ui/components/multichain/account-details/account-details-display.js index c998c049fb11..f35963a8476b 100644 --- a/ui/components/multichain/account-details/account-details-display.js +++ b/ui/components/multichain/account-details/account-details-display.js @@ -38,6 +38,7 @@ import { useEIP7702Account } from '../../../pages/confirmations/hooks/useEIP7702 import { useAsyncResult } from '../../../hooks/useAsyncResult'; function SmartAccountPill({ address }) { + const t = useI18nContext(); const { isUpgraded } = useEIP7702Account(); const { value: isAccountUpgraded } = useAsyncResult( @@ -69,13 +70,15 @@ function SmartAccountPill({ address }) { variant={TextVariant.bodyMd} color={TextColor.textAlternativeSoft} > - Smart account + {t('confirmAccountTypeSmartContract')} ); } function DowngradeAccountButton({ address, onClose }) { + const t = useI18nContext(); + const { downgradeAccount, isUpgraded } = useEIP7702Account({ onRedirect: onClose, }); @@ -101,7 +104,7 @@ function DowngradeAccountButton({ address, onClose }) { marginBottom={4} onClick={handleClick} > - Switch back to regular account + {t('accountDetailsRevokeDelegationButton')} ); } diff --git a/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx b/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx index d80c15a510c2..62e9692152e5 100644 --- a/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transaction-account-details.tsx @@ -41,7 +41,7 @@ export function TransactionAccountDetails() { - + )} diff --git a/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transasction-account-details.test.tsx b/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transasction-account-details.test.tsx index 365380b6be61..22ff705dfe6f 100644 --- a/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transasction-account-details.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/batch/transaction-account-details/transasction-account-details.test.tsx @@ -40,13 +40,13 @@ describe('TransactionAccountDetails', () => { authorizationList: [{ address: DELEGATION_MOCK }], }); - expect(getByText('Smart contract account')).toBeInTheDocument(); + expect(getByText('Smart account')).toBeInTheDocument(); }); it('does not render if no authorization list', () => { const { queryByText } = render({}); expect(queryByText('0x12345...67890')).toBeNull(); - expect(queryByText('Smart contract account')).toBeNull(); + expect(queryByText('Smart account')).toBeNull(); }); }); diff --git a/ui/pages/confirmations/components/confirm/title/title.tsx b/ui/pages/confirmations/components/confirm/title/title.tsx index 96eace246498..1fc259a7932d 100644 --- a/ui/pages/confirmations/components/confirm/title/title.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.tsx @@ -96,7 +96,7 @@ const getTitle = ( } return t('confirmTitleSignature'); case TransactionType.revokeDelegation: - return 'Revert'; + return t('confirmTitleRevokeDelegation'); case TransactionType.signTypedData: if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { if (tokenStandard === TokenStandard.ERC721) { @@ -159,7 +159,7 @@ const getDescription = ( } return t('confirmTitleDescSign'); case TransactionType.revokeDelegation: - return 'Test'; + return t('confirmTitleDescRevokeDelegation'); case TransactionType.signTypedData: if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { if (tokenStandard === TokenStandard.ERC721) { From 0643a1c7695124bed820192fc541d279cb76e721 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Mar 2025 13:24:31 +0000 Subject: [PATCH 07/10] Fix locales --- app/_locales/en_GB/messages.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 132e013c86dd..86164456eaf9 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -90,6 +90,9 @@ "accountDetails": { "message": "Account details" }, + "accountDetailsRevokeDelegationButton": { + "message": " Switch back to regular account" + }, "accountIdenticon": { "message": "Account identicon" }, @@ -1033,7 +1036,10 @@ "message": "Type" }, "confirmAccountTypeSmartContract": { - "message": "Smart contract account" + "message": "Smart account" + }, + "confirmAccountTypeStandard": { + "message": "Standard account" }, "confirmAlertModalAcknowledgeMultiple": { "message": "I have acknowledged the alerts and still want to proceed" @@ -1077,6 +1083,9 @@ "confirmTitleDescPermitSignature": { "message": "This site wants permission to spend your tokens." }, + "confirmTitleDescRevokeDelegation": { + "message": "This site is requesting to switch back to a standard account." + }, "confirmTitleDescSIWESignature": { "message": "A site wants you to sign in to prove you own this account." }, @@ -1089,6 +1098,9 @@ "confirmTitleRevokeApproveTransaction": { "message": "Remove permission" }, + "confirmTitleRevokeDelegation": { + "message": "Reset account" + }, "confirmTitleSIWESignature": { "message": "Sign-in request" }, From 1118353d4e9fa2bc1398366a199f7db03312297e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Mar 2025 16:08:59 +0000 Subject: [PATCH 08/10] Fix linting --- ui/__mocks__/actions.js | 4 +++- .../hooks/useEIP7702Account.test.ts | 20 +++++++------------ .../confirmations/hooks/useEIP7702Account.ts | 12 +++++------ 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/ui/__mocks__/actions.js b/ui/__mocks__/actions.js index 5f55b01cbc04..35aa16debe89 100644 --- a/ui/__mocks__/actions.js +++ b/ui/__mocks__/actions.js @@ -51,7 +51,9 @@ module.exports = { }, // eslint-disable-next-line no-empty-function - trackMetaMetricsEvent: () => { }, + trackMetaMetricsEvent: () => { + // Intentionally empty + }, decodeTransactionData: async (request) => { const { contractAddress } = request; diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts index a389905c425a..953c047c2e65 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts @@ -1,22 +1,16 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { genUnapprovedContractInteractionConfirmation } from '../../../../test/data/confirmations/contract-interaction'; -import { getMockConfirmStateForTransaction } from '../../../../test/data/confirmations/helper'; -import { - EIP_7702_REVOKE_ADDRESS, - useEIP7702Account, -} from './useEIP7702Account'; +import { act } from '@testing-library/react'; +import { TransactionEnvelopeType } from '@metamask/transaction-controller'; +import { useDispatch } from 'react-redux'; import { addTransactionAndRouteToConfirmationPage, getCode, } from '../../../store/actions'; -import { act } from '@testing-library/react'; import { renderHookWithProvider } from '../../../../test/lib/render-helpers'; -import { TransactionEnvelopeType } from '@metamask/transaction-controller'; -import { ThunkAction } from 'redux-thunk'; import { useConfirmationNavigation } from './useConfirmationNavigation'; -import { ApprovalRequest } from '@metamask/approval-controller'; -import { flushPromises } from '../../../../test/lib/timer-helpers'; -import { useDispatch } from 'react-redux'; +import { + EIP_7702_REVOKE_ADDRESS, + useEIP7702Account, +} from './useEIP7702Account'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.ts b/ui/pages/confirmations/hooks/useEIP7702Account.ts index 2ebf05d318a0..ac8c7afc53b0 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.ts @@ -1,17 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; -import { - addTransactionAndRouteToConfirmationPage, - getCode, -} from '../../../store/actions'; import { useDispatch, useSelector } from 'react-redux'; import { TransactionEnvelopeType, TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; -import { useConfirmationNavigation } from './useConfirmationNavigation'; -import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { Hex } from '@metamask/utils'; +import { + addTransactionAndRouteToConfirmationPage, + getCode, +} from '../../../store/actions'; +import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; +import { useConfirmationNavigation } from './useConfirmationNavigation'; export const EIP_7702_REVOKE_ADDRESS = '0x0000000000000000000000000000000000000000'; From 14f7f67a4bb8ee68a4c5850dfb07ba9de4318ed7 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 14 Mar 2025 10:47:58 +0000 Subject: [PATCH 09/10] Fix fitness --- ...ay.test.js => account-details-display.test.tsx} | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) rename ui/components/multichain/account-details/{account-details-display.test.js => account-details-display.test.tsx} (90%) diff --git a/ui/components/multichain/account-details/account-details-display.test.js b/ui/components/multichain/account-details/account-details-display.test.tsx similarity index 90% rename from ui/components/multichain/account-details/account-details-display.test.js rename to ui/components/multichain/account-details/account-details-display.test.tsx index 780d34b23570..35f367e46d4c 100644 --- a/ui/components/multichain/account-details/account-details-display.test.js +++ b/ui/components/multichain/account-details/account-details-display.test.tsx @@ -16,7 +16,17 @@ const ADDRESS_MOCK = Object.values( function renderComponent() { return renderWithProvider( - , + { + // Intentionally empty + }} + onClose={() => { + // Intentionally empty + }} + />, configureStore(mockState), ); } @@ -87,7 +97,7 @@ describe('AccountDetailsDisplay', () => { }); await act(async () => { - queryByText('Switch back to regular account').click(); + queryByText('Switch back to regular account')?.click(); }); expect(downgradeAccountMock).toHaveBeenCalledTimes(1); From f40fb6bb8278f0be93ee2d7911c87353a87d1ab0 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 14 Mar 2025 10:58:09 +0000 Subject: [PATCH 10/10] Fix unit test --- ui/pages/confirmations/hooks/useEIP7702Account.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts index 953c047c2e65..91d6423a5369 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts @@ -1,5 +1,8 @@ import { act } from '@testing-library/react'; -import { TransactionEnvelopeType } from '@metamask/transaction-controller'; +import { + TransactionEnvelopeType, + TransactionType, +} from '@metamask/transaction-controller'; import { useDispatch } from 'react-redux'; import { addTransactionAndRouteToConfirmationPage, @@ -98,6 +101,9 @@ describe('useEIP7702Account', () => { to: ADDRESS_MOCK, type: TransactionEnvelopeType.setCode, }, + { + type: TransactionType.revokeDelegation, + }, ); });