diff --git a/app/scripts/controller-init/utils.ts b/app/scripts/controller-init/utils.ts index 8cf0e4834637..79bd1265438f 100644 --- a/app/scripts/controller-init/utils.ts +++ b/app/scripts/controller-init/utils.ts @@ -196,7 +196,9 @@ function getControllerOrThrow( const controller = controllersByName[name]; if (!controller) { - throw new Error(`Controller requested before it was initialized: ${name}`); + throw new Error( + `Controller requested before it was initialized: ${String(name)}`, + ); } return controller; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 79eac0c88f1e..47e1128df259 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -52,6 +52,7 @@ import { ERC1155, ERC20, ERC721, + toHex, } from '@metamask/controller-utils'; import { AccountsController } from '@metamask/accounts-controller'; @@ -69,7 +70,6 @@ import { TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; - import { Interface } from '@ethersproject/abi'; import { abiERC1155, abiERC721 } from '@metamask/metamask-eth-abis'; import { @@ -113,6 +113,11 @@ import { walletSendCalls, } from '@metamask/eip-5792-middleware'; +import { + walletUpgradeAccount, + walletGetAccountUpgradeStatus, +} from '@metamask/eip-7702-internal-rpc-middleware'; + import { Caip25CaveatMutators, Caip25CaveatType, @@ -137,6 +142,7 @@ import { SecretType, RecoveryError, } from '@metamask/seedless-onboarding-controller'; +import { createEIP7702UpgradeTransaction } from '../../shared/lib/eip7702-utils'; import { FEATURE_VERSION_2, isMultichainAccountsFeatureEnabled, @@ -218,6 +224,8 @@ import { isGatorPermissionsFeatureEnabled, } from '../../shared/modules/environment'; import { isSnapPreinstalled } from '../../shared/lib/snaps/snaps'; +// eslint-disable-next-line import/no-restricted-paths +import { isFlask } from '../../ui/helpers/utils/build-types'; import { getShieldGatewayConfig } from '../../shared/modules/shield'; import { HYPERLIQUID_ORIGIN, @@ -1276,6 +1284,35 @@ export default class MetamaskController extends EventEmitter { ), }); + this.eip7702Middleware = createScaffoldMiddleware({ + wallet_upgradeAccount: createAsyncMiddleware(async (req, res) => { + await walletUpgradeAccount(req, res, { + upgradeAccount: this.upgradeAccount.bind(this), + getCurrentChainIdForDomain: + this.getCurrentChainIdForDomain.bind(this), + isAtomicBatchSupported: this.txController.isAtomicBatchSupported.bind( + this.txController, + ), + getAccounts: this.getPermittedAccounts.bind(this, req.origin), + }); + }), + wallet_getAccountUpgradeStatus: createAsyncMiddleware( + async (req, res) => { + await walletGetAccountUpgradeStatus(req, res, { + getCurrentChainIdForDomain: + this.getCurrentChainIdForDomain.bind(this), + getCode: this.getCode.bind(this), + getNetworkConfigurationByChainId: + this.controllerMessenger.call.bind( + this.controllerMessenger, + 'NetworkController:getNetworkConfigurationByChainId', + ), + getAccounts: this.getPermittedAccounts.bind(this, req.origin), + }); + }, + ), + }); + this.metamaskMiddleware = createMetamaskMiddleware({ static: { eth_syncing: false, @@ -1539,6 +1576,22 @@ export default class MetamaskController extends EventEmitter { } } + /** + * Returns the current chainId (hex string) for a given domain/origin. + * Used by EIP-7702 middleware hooks. + * @param {string} domain + * @returns {string | undefined} + */ + getCurrentChainIdForDomain(domain) { + const networkClientId = + this.selectedNetworkController.getNetworkClientIdForDomain(domain); + const { chainId } = + this.networkController.getNetworkConfigurationByNetworkClientId( + networkClientId, + ); + return chainId; + } + // Provides a method for getting feature flags for the multichain // initial rollout, such that we can remotely modify polling interval getInfuraFeatureFlags() { @@ -6990,6 +7043,13 @@ export default class MetamaskController extends EventEmitter { }), ); + if ( + subjectType === SubjectType.Snap && + (isFlask() || isSnapPreinstalled(origin)) + ) { + engine.push(this.eip7702Middleware); + } + if (subjectType !== SubjectType.Internal) { engine.push( this.permissionController.createPermissionMiddleware({ @@ -7445,6 +7505,7 @@ export default class MetamaskController extends EventEmitter { ...this.setupCommonMiddlewareHooks(origin), }), ); + engine.push(this.metamaskMiddleware); engine.push(this.eip5792Middleware); @@ -8869,4 +8930,42 @@ export default class MetamaskController extends EventEmitter { initRequest, }); } + + /** + * Upgrades an account to support EIP-7702 delegation. + * Uses shared EIP-7702 utility to avoid code duplication. + * + * @param {string} address - The account address to upgrade + * @param {string} upgradeContractAddress - The contract address to delegate to + * @param {number} chainId - The chain ID for the upgrade + * @returns {Promise<{transactionHash: string, delegatedTo: string}>} + */ + async upgradeAccount(address, upgradeContractAddress, chainId) { + // Get the network client for the specified chain + const networkClientId = this.networkController.findNetworkClientIdByChainId( + toHex(chainId), + ); + + return createEIP7702UpgradeTransaction( + { + address, + upgradeContractAddress, + networkClientId, + }, + async (transactionParams, options) => { + const transactionMeta = await addTransaction( + this.getAddTransactionRequest({ + transactionParams, + transactionOptions: { + ...options, + origin: 'metamask', + requireApproval: true, + }, + waitForSubmit: true, + }), + ); + return transactionMeta; + }, + ); + } } diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 761a3e8bd1bd..5129e7517e65 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1049,6 +1049,14 @@ "uuid": true } }, + "@metamask/eip-7702-internal-rpc-middleware": { + "packages": { + "@metamask/controller-utils": true, + "@metamask/rpc-errors": true, + "@metamask/superstruct": true, + "@metamask/utils": true + } + }, "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, diff --git a/lavamoat/browserify/experimental/policy.json b/lavamoat/browserify/experimental/policy.json index 761a3e8bd1bd..5129e7517e65 100644 --- a/lavamoat/browserify/experimental/policy.json +++ b/lavamoat/browserify/experimental/policy.json @@ -1049,6 +1049,14 @@ "uuid": true } }, + "@metamask/eip-7702-internal-rpc-middleware": { + "packages": { + "@metamask/controller-utils": true, + "@metamask/rpc-errors": true, + "@metamask/superstruct": true, + "@metamask/utils": true + } + }, "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 761a3e8bd1bd..5129e7517e65 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1049,6 +1049,14 @@ "uuid": true } }, + "@metamask/eip-7702-internal-rpc-middleware": { + "packages": { + "@metamask/controller-utils": true, + "@metamask/rpc-errors": true, + "@metamask/superstruct": true, + "@metamask/utils": true + } + }, "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 761a3e8bd1bd..5129e7517e65 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1049,6 +1049,14 @@ "uuid": true } }, + "@metamask/eip-7702-internal-rpc-middleware": { + "packages": { + "@metamask/controller-utils": true, + "@metamask/rpc-errors": true, + "@metamask/superstruct": true, + "@metamask/utils": true + } + }, "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, diff --git a/package.json b/package.json index 57889b7d6860..2c6f595b3fd5 100644 --- a/package.json +++ b/package.json @@ -286,6 +286,7 @@ "@metamask/design-system-tailwind-preset": "^0.6.1", "@metamask/design-tokens": "^8.1.1", "@metamask/eip-5792-middleware": "^1.1.0", + "@metamask/eip-7702-internal-rpc-middleware": "1.0.0", "@metamask/ens-controller": "^17.0.1", "@metamask/ens-resolver-snap": "^0.1.4", "@metamask/error-reporting-service": "^2.0.0", diff --git a/shared/lib/eip7702-utils.ts b/shared/lib/eip7702-utils.ts new file mode 100644 index 000000000000..a998cada27f4 --- /dev/null +++ b/shared/lib/eip7702-utils.ts @@ -0,0 +1,135 @@ +import { + TransactionEnvelopeType, + TransactionType, + TransactionParams, +} from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; + +export const EIP_7702_REVOKE_ADDRESS = + '0x0000000000000000000000000000000000000000'; + +export type EIP7702TransactionParams = { + address: Hex; + upgradeContractAddress?: Hex; + networkClientId: string; +}; + +export type EIP7702TransactionResult = { + transactionHash: string; + delegatedTo?: string; + transactionId?: string; +}; + +/** + * Creates an EIP-7702 upgrade transaction. + * + * @param params - The transaction parameters + * @param addTransactionAndWaitForPublish - Function to add transaction and wait for publish + * @returns Promise with transaction result + */ +export async function createEIP7702UpgradeTransaction( + params: EIP7702TransactionParams, + addTransactionAndWaitForPublish: ( + transactionParams: TransactionParams, + options: { + networkClientId: string; + requireApproval?: boolean; + type?: TransactionType; + }, + ) => Promise>, +): Promise { + const { address, upgradeContractAddress, networkClientId } = params; + + if (!upgradeContractAddress) { + throw new Error('Upgrade contract address is required'); + } + + const transactionParams: TransactionParams = { + authorizationList: [ + { + address: upgradeContractAddress, + }, + ], + from: address, + to: address, + type: TransactionEnvelopeType.setCode, + }; + + const transactionMeta = await addTransactionAndWaitForPublish( + transactionParams, + { + networkClientId, + requireApproval: true, + type: TransactionType.batch, + }, + ); + + return { + transactionHash: transactionMeta.hash as string, + delegatedTo: upgradeContractAddress, + transactionId: transactionMeta.id as string, + }; +} + +/** + * Creates an EIP-7702 downgrade transaction. + * + * @param params - The transaction parameters + * @param addTransactionAndWaitForPublish - Function to add transaction and wait for publish + * @returns Promise with transaction result + */ +export async function createEIP7702DowngradeTransaction( + params: EIP7702TransactionParams, + addTransactionAndWaitForPublish: ( + transactionParams: TransactionParams, + options: { + networkClientId: string; + requireApproval?: boolean; + type?: TransactionType; + }, + ) => Promise>, +): Promise { + const { address, networkClientId } = params; + + const transactionParams: TransactionParams = { + authorizationList: [ + { + address: EIP_7702_REVOKE_ADDRESS, + }, + ], + from: address, + to: address, + type: TransactionEnvelopeType.setCode, + }; + + const transactionMeta = await addTransactionAndWaitForPublish( + transactionParams, + { + networkClientId, + requireApproval: true, + type: TransactionType.revokeDelegation, + }, + ); + + return { + transactionHash: transactionMeta.hash as string, + transactionId: transactionMeta.id as string, + }; +} + +/** + * Checks if an account is upgraded by checking if it has code. + * + * @param address - The account address to check + * @param networkClientId - The network client ID + * @param getCode - Function to get account code + * @returns Promise with boolean indicating if account is upgraded + */ +export async function isAccountUpgraded( + address: Hex, + networkClientId: string, + getCode: (address: Hex, networkClientId: string) => Promise, +): Promise { + const code = await getCode(address, networkClientId); + return Boolean(code && code.length > 2); +} diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts index a8ec4e46e414..1d28992e3fa9 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.test.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.test.ts @@ -8,12 +8,10 @@ import { addTransactionAndRouteToConfirmationPage, getCode, } from '../../../store/actions'; +import { EIP_7702_REVOKE_ADDRESS } from '../../../../shared/lib/eip7702-utils'; import { renderHookWithProvider } from '../../../../test/lib/render-helpers'; import { useConfirmationNavigation } from './useConfirmationNavigation'; -import { - EIP_7702_REVOKE_ADDRESS, - useEIP7702Account, -} from './useEIP7702Account'; +import { useEIP7702Account } from './useEIP7702Account'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -69,18 +67,17 @@ describe('useEIP7702Account', () => { beforeEach(() => { jest.resetAllMocks(); - addTransactionAndRouteToConfirmationPageMock.mockReturnValue({ + const mockDispatch = jest.fn().mockResolvedValue({ + hash: TRANSACTION_ID_MOCK, + id: TRANSACTION_ID_MOCK, type: 'MockAction', - } as unknown as ReturnType< - typeof addTransactionAndRouteToConfirmationPageMock - >); + }); + useDispatchMock.mockReturnValue(mockDispatch); useConfirmationNavigationMock.mockReturnValue({ confirmations: [], navigateToId: jest.fn(), } as unknown as ReturnType); - - useDispatchMock.mockReturnValue(jest.fn()); }); describe('isUpgraded', () => { @@ -120,6 +117,7 @@ describe('useEIP7702Account', () => { }, { networkClientId: 'sepolia', + requireApproval: true, type: TransactionType.revokeDelegation, }, ); @@ -192,6 +190,7 @@ describe('useEIP7702Account', () => { }, { networkClientId: 'sepolia', + requireApproval: true, type: TransactionType.batch, }, ); diff --git a/ui/pages/confirmations/hooks/useEIP7702Account.ts b/ui/pages/confirmations/hooks/useEIP7702Account.ts index ee6c24bf988c..4621f1254435 100644 --- a/ui/pages/confirmations/hooks/useEIP7702Account.ts +++ b/ui/pages/confirmations/hooks/useEIP7702Account.ts @@ -1,11 +1,13 @@ import { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { - TransactionEnvelopeType, - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { + createEIP7702UpgradeTransaction, + createEIP7702DowngradeTransaction, + isAccountUpgraded, + EIP_7702_REVOKE_ADDRESS, +} from '../../../../shared/lib/eip7702-utils'; import { addTransactionAndRouteToConfirmationPage, getCode, @@ -13,9 +15,6 @@ import { import { selectDefaultRpcEndpointByChainId } from '../../../selectors'; import { useConfirmationNavigation } from './useConfirmationNavigation'; -export const EIP_7702_REVOKE_ADDRESS = - '0x0000000000000000000000000000000000000000'; - export function useEIP7702Account( { chainId, onRedirect }: { chainId: Hex; onRedirect?: () => void } = { chainId: '0x', @@ -35,60 +34,54 @@ 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, - }, - { - networkClientId, - type: TransactionType.revokeDelegation, - }, - ), - )) as unknown as TransactionMeta; + const result = await createEIP7702DowngradeTransaction( + { + address, + networkClientId, + }, + async (transactionParams, options) => { + const transactionMeta = (await dispatch( + addTransactionAndRouteToConfirmationPage( + transactionParams, + options, + ), + )) as unknown as TransactionMeta; + return transactionMeta; + }, + ); - setTransactionId(transactionMeta?.id); + setTransactionId(result.transactionId); }, [dispatch, networkClientId], ); const upgradeAccount = useCallback( async (address: Hex, upgradeContractAddress: Hex) => { - const transactionMeta = (await dispatch( - addTransactionAndRouteToConfirmationPage( - { - authorizationList: [ - { - address: upgradeContractAddress, - }, - ], - from: address, - to: address, - type: TransactionEnvelopeType.setCode, - }, - { - networkClientId, - type: TransactionType.batch, - }, - ), - )) as unknown as TransactionMeta; + const result = await createEIP7702UpgradeTransaction( + { + address, + upgradeContractAddress, + networkClientId, + }, + async (transactionParams, options) => { + const transactionMeta = (await dispatch( + addTransactionAndRouteToConfirmationPage( + transactionParams, + options, + ), + )) as unknown as TransactionMeta; + return transactionMeta; + }, + ); - setTransactionId(transactionMeta?.id); + setTransactionId(result.transactionId); }, [dispatch, networkClientId], ); const isUpgraded = useCallback( async (address: Hex) => { - const code = await getCode(address, networkClientId); - return code?.length > 2; + return isAccountUpgraded(address, networkClientId, getCode); }, [networkClientId], ); @@ -102,3 +95,5 @@ export function useEIP7702Account( return { isUpgraded, downgradeAccount, upgradeAccount }; } + +export { EIP_7702_REVOKE_ADDRESS }; diff --git a/yarn.lock b/yarn.lock index b12237deacf7..d8ea472bbd43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5929,6 +5929,18 @@ __metadata: languageName: node linkType: hard +"@metamask/eip-7702-internal-rpc-middleware@file:/Users/tadejvengust/Documents/metamask/core/packages/eip-7702-internal-rpc-middleware::locator=metamask-crx%40workspace%3A.": + version: 1.0.0 + resolution: "@metamask/eip-7702-internal-rpc-middleware@file:/Users/tadejvengust/Documents/metamask/core/packages/eip-7702-internal-rpc-middleware#/Users/tadejvengust/Documents/metamask/core/packages/eip-7702-internal-rpc-middleware::hash=eece7d&locator=metamask-crx%40workspace%3A." + dependencies: + "@metamask/controller-utils": "npm:^11.14.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.8.1" + checksum: 10/fb350b55fa82dd2a3486d0e3ca134f9e7a9e3b163cfa645c440f19efeeb21c1253a8607099166fbcb748cf3dcc5ad21937d49865b8f2b0a132f31c4b8cbc8448 + languageName: node + linkType: hard + "@metamask/ens-controller@npm:^17.0.1": version: 17.0.1 resolution: "@metamask/ens-controller@npm:17.0.1" @@ -31421,6 +31433,7 @@ __metadata: "@metamask/design-system-tailwind-preset": "npm:^0.6.1" "@metamask/design-tokens": "npm:^8.1.1" "@metamask/eip-5792-middleware": "npm:^1.1.0" + "@metamask/eip-7702-internal-rpc-middleware": "npm:1.0.0" "@metamask/ens-controller": "npm:^17.0.1" "@metamask/ens-resolver-snap": "npm:^0.1.4" "@metamask/error-reporting-service": "npm:^2.0.0"