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/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"
},
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/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/__mocks__/actions.js b/ui/__mocks__/actions.js
index 56bf28992388..35aa16debe89 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,9 @@ module.exports = {
},
// eslint-disable-next-line no-empty-function
- trackMetaMetricsEvent: () => {},
+ trackMetaMetricsEvent: () => {
+ // Intentionally empty
+ },
decodeTransactionData: async (request) => {
const { contractAddress } = request;
@@ -65,4 +68,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 d5af76004887..f35963a8476b 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,87 @@ import {
} from '../../../../shared/constants/metametrics';
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({ address }) {
+ const t = useI18nContext();
+ const { isUpgraded } = useEIP7702Account();
+
+ const { value: isAccountUpgraded } = useAsyncResult(
+ () => isUpgraded(address),
+ [address],
+ );
+
+ if (!isAccountUpgraded) {
+ return null;
+ }
+
+ return (
+
+
+ {t('confirmAccountTypeSmartContract')}
+
+
+ );
+}
+
+function DowngradeAccountButton({ address, onClose }) {
+ const t = useI18nContext();
+
+ const { downgradeAccount, isUpgraded } = useEIP7702Account({
+ onRedirect: onClose,
+ });
+
+ const { value: isAccountUpgraded } = useAsyncResult(
+ () => isUpgraded(address),
+ [address],
+ );
+
+ const handleClick = useCallback(async () => {
+ await downgradeAccount(address);
+ }, [address, downgradeAccount]);
+
+ if (!isAccountUpgraded) {
+ return null;
+ }
+
+ return (
+
+ {t('accountDetailsRevokeDelegationButton')}
+
+ );
+}
export const AccountDetailsDisplay = ({
accounts,
accountName,
address,
onExportClick,
+ onClose,
}) => {
const dispatch = useDispatch();
const trackEvent = useContext(MetaMetricsContext);
@@ -71,7 +150,9 @@ export const AccountDetailsDisplay = ({
}}
accounts={accounts}
/>
+
+
{exportPrivateKeyFeatureEnabled ? (
({
+ useEIP7702Account: jest.fn(),
+}));
+
+const ADDRESS_MOCK = Object.values(
+ mockState.metamask.internalAccounts.accounts,
+)[0].address;
+
+function renderComponent() {
+ return renderWithProvider(
+ {
+ // Intentionally empty
+ }}
+ onClose={() => {
+ // Intentionally empty
+ }}
+ />,
+ 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);
+ });
+});
diff --git a/ui/components/multichain/account-details/account-details.js b/ui/components/multichain/account-details/account-details.js
index c04070e523e2..a95e9da2ba6e 100644
--- a/ui/components/multichain/account-details/account-details.js
+++ b/ui/components/multichain/account-details/account-details.js
@@ -139,6 +139,7 @@ export const AccountDetails = ({ address }) => {
accountName={name}
address={address}
onExportClick={() => setAttemptingExport(true)}
+ onClose={onClose}
/>
)}
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';
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/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..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
@@ -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/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/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/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/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..1fc259a7932d 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 t('confirmTitleRevokeDelegation');
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 t('confirmTitleDescRevokeDelegation');
case TransactionType.signTypedData:
if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) {
if (tokenStandard === TokenStandard.ERC721) {
diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts
new file mode 100644
index 000000000000..91d6423a5369
--- /dev/null
+++ b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts
@@ -0,0 +1,157 @@
+import { act } from '@testing-library/react';
+import {
+ TransactionEnvelopeType,
+ TransactionType,
+} from '@metamask/transaction-controller';
+import { useDispatch } from 'react-redux';
+import {
+ addTransactionAndRouteToConfirmationPage,
+ getCode,
+} from '../../../store/actions';
+import { renderHookWithProvider } from '../../../../test/lib/render-helpers';
+import { useConfirmationNavigation } from './useConfirmationNavigation';
+import {
+ EIP_7702_REVOKE_ADDRESS,
+ useEIP7702Account,
+} from './useEIP7702Account';
+
+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,
+ },
+ {
+ type: TransactionType.revokeDelegation,
+ },
+ );
+ });
+
+ 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
new file mode 100644
index 000000000000..ac8c7afc53b0
--- /dev/null
+++ b/ui/pages/confirmations/hooks/useEIP7702Account.ts
@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ TransactionEnvelopeType,
+ TransactionMeta,
+ TransactionType,
+} from '@metamask/transaction-controller';
+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';
+
+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,
+ },
+ {
+ type: TransactionType.revokeDelegation,
+ },
+ ),
+ )) 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,
+ ]);
+}