diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59861240bad..e213a4f3a89 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -31,6 +31,7 @@ ## Delegation Team /packages/gator-permissions-controller @MetaMask/delegation +/packages/eip-7702-internal-rpc-middleware @MetaMask/delegation @MetaMask/core-platform ## Earn Team /packages/earn-controller @MetaMask/earn diff --git a/README.md b/README.md index 4eab983a1dc..20d7d9dc806 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/delegation-controller`](packages/delegation-controller) - [`@metamask/earn-controller`](packages/earn-controller) - [`@metamask/eip-5792-middleware`](packages/eip-5792-middleware) +- [`@metamask/eip-7702-internal-rpc-middleware`](packages/eip-7702-internal-rpc-middleware) - [`@metamask/eip1193-permission-middleware`](packages/eip1193-permission-middleware) - [`@metamask/ens-controller`](packages/ens-controller) - [`@metamask/error-reporting-service`](packages/error-reporting-service) @@ -101,6 +102,7 @@ linkStyle default opacity:0.5 delegation_controller(["@metamask/delegation-controller"]); earn_controller(["@metamask/earn-controller"]); eip_5792_middleware(["@metamask/eip-5792-middleware"]); + eip_7702_internal_rpc_middleware(["@metamask/eip-7702-internal-rpc-middleware"]); eip1193_permission_middleware(["@metamask/eip1193-permission-middleware"]); ens_controller(["@metamask/ens-controller"]); error_reporting_service(["@metamask/error-reporting-service"]); @@ -264,6 +266,7 @@ linkStyle default opacity:0.5 permission_log_controller --> json_rpc_engine; phishing_controller --> base_controller; phishing_controller --> controller_utils; + phishing_controller --> transaction_controller; polling_controller --> base_controller; polling_controller --> controller_utils; polling_controller --> network_controller; @@ -292,6 +295,7 @@ linkStyle default opacity:0.5 signature_controller --> controller_utils; signature_controller --> accounts_controller; signature_controller --> approval_controller; + signature_controller --> gator_permissions_controller; signature_controller --> keyring_controller; signature_controller --> logging_controller; signature_controller --> network_controller; diff --git a/packages/eip-7702-internal-rpc-middleware/CHANGELOG.md b/packages/eip-7702-internal-rpc-middleware/CHANGELOG.md new file mode 100644 index 00000000000..50d682b12e6 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of @metamask/eip-7702-internal-rpc-middleware ([#6789](https://github.com/MetaMask/core/pull/6789)) +- `wallet_upgradeAccount` JSON-RPC method for upgrading EOA accounts to smart accounts using EIP-7702 ([#6789](https://github.com/MetaMask/core/pull/6789)) +- `wallet_getAccountUpgradeStatus` JSON-RPC method for checking account upgrade status ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Hook-based architecture with `upgradeAccount` and `getAccountUpgradeStatus` hooks ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Comprehensive TypeScript type definitions ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Full test coverage with Jest ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Documentation and examples ([#6789](https://github.com/MetaMask/core/pull/6789)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/eip-7702-internal-rpc-middleware/LICENSE b/packages/eip-7702-internal-rpc-middleware/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/eip-7702-internal-rpc-middleware/README.md b/packages/eip-7702-internal-rpc-middleware/README.md new file mode 100644 index 00000000000..a2a9bbf2f05 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/README.md @@ -0,0 +1,98 @@ +# `@metamask/eip-7702-internal-rpc-middleware` + +Implements internal JSON-RPC methods that support EIP-7702 account upgrade functionality. These methods are internal to MetaMask and not defined in [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), but provide the necessary infrastructure for EIP-7702 account upgrades. + +## Installation + +`yarn add @metamask/eip-7702-internal-rpc-middleware` + +or + +`npm install @metamask/eip-7702-internal-rpc-middleware` + +## JSON-RPC Methods + +### wallet_upgradeAccount + +Upgrades an EOA account to a smart account using EIP-7702. + +**Parameters:** + +- `account` (string): Address of the EOA to upgrade +- `chainId` (string, optional): Chain ID for the upgrade (defaults to current) + +**Returns:** + +- `transactionHash` (string): Hash of the EIP-7702 authorization transaction +- `upgradedAccount` (string): Address of the upgraded account (same as input) +- `delegatedTo` (string): Address of the contract delegated to + +**Example:** + +```json +{ + "method": "wallet_upgradeAccount", + "params": [ + { + "account": "0x1234567890123456789012345678901234567890", + "chainId": "0x1" + } + ] +} +``` + +### wallet_getAccountUpgradeStatus + +Checks if an account has been upgraded using EIP-7702. + +**Parameters:** + +- `account` (string): Address of the account to check +- `chainId` (string, optional): Chain ID for the check (defaults to current) + +**Returns:** + +- `account` (string): Address of the checked account +- `isUpgraded` (boolean): Whether the account is upgraded +- `upgradedAddress` (string | null): Address to which the account is upgraded (null if not upgraded) +- `chainId` (string): Chain ID where the check was performed + +**Example:** + +```json +{ + "method": "wallet_getAccountUpgradeStatus", + "params": [ + { + "account": "0x1234567890123456789012345678901234567890", + "chainId": "0x1" + } + ] +} +``` + +**Example Response (Upgraded Account):** + +```json +{ + "account": "0x1234567890123456789012345678901234567890", + "isUpgraded": true, + "upgradedAddress": "0xabcdef1234567890abcdef1234567890abcdef12", + "chainId": "0x1" +} +``` + +**Example Response (Non-Upgraded Account):** + +```json +{ + "account": "0x1234567890123456789012345678901234567890", + "isUpgraded": false, + "upgradedAddress": null, + "chainId": "0x1" +} +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/eip-7702-internal-rpc-middleware/jest.config.js b/packages/eip-7702-internal-rpc-middleware/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/eip-7702-internal-rpc-middleware/package.json b/packages/eip-7702-internal-rpc-middleware/package.json new file mode 100644 index 00000000000..445519ed8da --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/eip-7702-internal-rpc-middleware", + "version": "1.0.0", + "description": "Implements internal JSON-RPC methods for EIP-7702 account upgrade functionality", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/eip-7702-internal-rpc-middleware#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eip-7702-internal-rpc-middleware", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eip-7702-internal-rpc-middleware", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/controller-utils": "^11.14.1", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.8.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/eip-7702-internal-rpc-middleware/src/constants.ts b/packages/eip-7702-internal-rpc-middleware/src/constants.ts new file mode 100644 index 00000000000..5ee9aff4c66 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/constants.ts @@ -0,0 +1,7 @@ +// Method names +export const METHOD_NAMES = { + UPGRADE_ACCOUNT: 'wallet_upgradeAccount', + GET_ACCOUNT_UPGRADE_STATUS: 'wallet_getAccountUpgradeStatus', +} as const; + +export const DELEGATION_INDICATOR_PREFIX = '0xef0100'; diff --git a/packages/eip-7702-internal-rpc-middleware/src/index.ts b/packages/eip-7702-internal-rpc-middleware/src/index.ts new file mode 100644 index 00000000000..09ee48299bb --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/index.ts @@ -0,0 +1,17 @@ +// Method handlers +export { walletUpgradeAccount } from './wallet_upgradeAccount'; +export { walletGetAccountUpgradeStatus } from './wallet_getAccountUpgradeStatus'; + +// Utilities +export { validateParams, validateAndNormalizeAddress } from './utils'; + +// Constants +export { METHOD_NAMES } from './constants'; + +// Types +export type { + UpgradeAccountParams, + UpgradeAccountResult, + GetAccountUpgradeStatusParams, + GetAccountUpgradeStatusResult, +} from './types'; diff --git a/packages/eip-7702-internal-rpc-middleware/src/types.ts b/packages/eip-7702-internal-rpc-middleware/src/types.ts new file mode 100644 index 00000000000..6f57f05e3d4 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/types.ts @@ -0,0 +1,36 @@ +import type { Infer } from '@metamask/superstruct'; +import { object, optional } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; + +// Superstruct validation schemas +export const UpgradeAccountParamsStruct = object({ + account: HexChecksumAddressStruct, + chainId: optional(StrictHexStruct), +}); + +export const GetAccountUpgradeStatusParamsStruct = object({ + account: HexChecksumAddressStruct, + chainId: optional(StrictHexStruct), +}); + +// Type definitions derived from schemas +export type UpgradeAccountParams = Infer; + +export type UpgradeAccountResult = { + transactionHash: string; // Hash of the EIP-7702 authorization transaction + upgradedAccount: string; // Address of the upgraded account (same as input) + delegatedTo: string; // Address of the contract delegated to (determined by wallet) +}; + +export type GetAccountUpgradeStatusParams = Infer< + typeof GetAccountUpgradeStatusParamsStruct +>; + +export type GetAccountUpgradeStatusResult = { + account: string; // Address of the checked account + chainId: Hex; // Chain ID where the check was performed + isSupported: boolean; // Whether upgrade to smart account is supported on the chain + isUpgraded: boolean; // Whether the account is upgraded + upgradedAddress: string | null; // Address to which the account is upgraded +}; diff --git a/packages/eip-7702-internal-rpc-middleware/src/utils.test.ts b/packages/eip-7702-internal-rpc-middleware/src/utils.test.ts new file mode 100644 index 00000000000..16a6bb3db53 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/utils.test.ts @@ -0,0 +1,173 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import { object, string, number } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; + +import { validateParams, validateAndNormalizeAddress } from './utils'; + +describe('validateParams', () => { + it('does not throw for valid parameters', () => { + const testStruct = object({ + name: string(), + age: number(), + }); + const validValue = { name: 'John', age: 30 }; + + expect(() => validateParams(validValue, testStruct)).not.toThrow(); + }); + + it('throws RPC error with formatted message for invalid parameters', () => { + const testStruct = object({ + name: string(), + age: number(), + }); + const invalidValue = { name: 123, age: 'invalid' }; + + expect(() => validateParams(invalidValue, testStruct)).toThrow( + 'Invalid parameters', + ); + }); + + it('formats validation errors with field paths', () => { + const testStruct = object({ + name: string(), + age: number(), + }); + const invalidValue = { name: 123, age: 'invalid' }; + + expect(() => validateParams(invalidValue, testStruct)).toThrow( + 'Invalid parameters', + ); + }); + + it('formats validation errors with empty path (root level errors)', () => { + // Test with a struct that expects a string but gets a non-object + const testStruct = string(); + const invalidValue = 123; + + expect(() => validateParams(invalidValue, testStruct)).toThrow( + 'Invalid parameters', + ); + }); +}); + +describe('validateAndNormalizeAddress', () => { + const mockOrigin = 'https://example.com'; + + it('validates and normalizes a valid address', async () => { + const validAddress = '0x1234567890123456789012345678901234567890'; + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue([validAddress]); + + const result = await validateAndNormalizeAddress( + validAddress, + mockOrigin, + getPermittedAccountsForOrigin, + ); + + expect(result).toBe(validAddress.toLowerCase()); + expect(getPermittedAccountsForOrigin).toHaveBeenCalledWith(mockOrigin); + }); + + it('throws error for invalid address format', async () => { + const invalidAddress = '0xinvalid' as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + invalidAddress, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an Ethereum address.', + }), + ); + }); + + it('throws error for unauthorized account access', async () => { + const address = '0x1234567890123456789012345678901234567890'; + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue(['0x9999999999999999999999999999999999999999']); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + 'The requested account and/or method has not been authorized by the user.', + ); + }); + + it('throws error for empty string address', async () => { + const address = '' as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an Ethereum address.', + }), + ); + }); + + it('throws error for non-string address', async () => { + const address = 123 as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an Ethereum address.', + }), + ); + }); + + it('throws error for null address', async () => { + const address = null as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an Ethereum address.', + }), + ); + }); + + it('throws error for undefined address', async () => { + const address = undefined as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an Ethereum address.', + }), + ); + }); +}); diff --git a/packages/eip-7702-internal-rpc-middleware/src/utils.ts b/packages/eip-7702-internal-rpc-middleware/src/utils.ts new file mode 100644 index 00000000000..7af714cc355 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/utils.ts @@ -0,0 +1,85 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { Struct, StructError } from '@metamask/superstruct'; +import { validate } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import { isHexAddress } from '@metamask/utils'; + +/** + * Validates address format, checks user eth_accounts permissions, and normalizes to lowercase. + * + * @param address - The Ethereum address to validate and normalize. + * @param origin - The origin string for permission checking. + * @param getPermittedAccountsForOrigin - Function to retrieve permitted accounts for the origin. + * @returns A normalized (lowercase) hex address if valid and authorized. + * @throws JsonRpcError with unauthorized error if the requester doesn't have permission to access the address. + * @throws JsonRpcError with invalid params if the address format is invalid. + */ +export async function validateAndNormalizeAddress( + address: Hex, + origin: string, + getPermittedAccountsForOrigin: (origin: string) => Promise, +): Promise { + if ( + typeof address === 'string' && + address.length > 0 && + isHexAddress(address) + ) { + // Ensure that an "unauthorized" error is thrown if the requester + // does not have the `eth_accounts` permission. + const accounts = await getPermittedAccountsForOrigin(origin); + + // Validate and convert each account address to normalized Hex + const normalizedAccounts: string[] = accounts.map((accountAddress) => + accountAddress.toLowerCase(), + ); + + const normalizedAddress = address.toLowerCase(); + + if (normalizedAccounts.includes(normalizedAddress)) { + // we know that normalizedAddress is a valid Hex string because of isHexAddress + return normalizedAddress as Hex; + } + + throw providerErrors.unauthorized(); + } + + throw rpcErrors.invalidParams({ + message: `Invalid parameters: must provide an Ethereum address.`, + }); +} + +/** + * Validates parameters against a Superstruct schema and throws an error if validation fails. + * + * @param value - The value to validate against the struct schema. + * @param struct - The Superstruct schema to validate against. + * @throws JsonRpcError with invalid params if the value doesn't match the struct schema. + */ +export function validateParams( + value: unknown | ParamsType, + struct: Struct, +): asserts value is ParamsType { + const [error] = validate(value, struct); + + if (error) { + throw rpcErrors.invalidParams( + formatValidationError(error, 'Invalid parameters'), + ); + } +} + +/** + * Formats a Superstruct validation error into a human-readable string. + * + * @param error - The Superstruct validation error to format. + * @param message - The base error message to prepend to the formatted details. + * @returns A formatted error message string with validation failure details. + */ +function formatValidationError(error: StructError, message: string): string { + return `${message}\n\n${error + .failures() + .map( + (f) => `${f.path.join(' > ')}${f.path.length ? ' - ' : ''}${f.message}`, + ) + .join('\n')}`; +} diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts new file mode 100644 index 00000000000..c88cf34d596 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts @@ -0,0 +1,334 @@ +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Json, +} from '@metamask/utils'; + +import { walletGetAccountUpgradeStatus } from './wallet_getAccountUpgradeStatus'; +import type { WalletGetAccountUpgradeStatusHooks } from './wallet_getAccountUpgradeStatus'; + +type TestHooks = { + [K in keyof WalletGetAccountUpgradeStatusHooks]: jest.MockedFunction< + WalletGetAccountUpgradeStatusHooks[K] + >; +}; + +const TEST_ACCOUNT = '0x1234567890123456789012345678901234567890'; +const NETWORK_CLIENT_ID = 'mainnet'; + +const createTestHooks = (): TestHooks => { + const getCode = jest.fn(); + const getCurrentChainIdForDomain = jest.fn().mockReturnValue('0x1'); + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue([TEST_ACCOUNT]); + const getSelectedNetworkClientIdForChain = jest + .fn() + .mockReturnValue(NETWORK_CLIENT_ID); + const isEip7702Supported = jest.fn().mockResolvedValue({ + isSupported: true, + upgradeContractAddress: '0x1234567890123456789012345678901234567890', + }); + + return { + getCode, + getCurrentChainIdForDomain, + getSelectedNetworkClientIdForChain, + getPermittedAccountsForOrigin, + isEip7702Supported, + }; +}; + +const createTestRequest = ( + params: Json[] = [{ account: TEST_ACCOUNT }], +): JsonRpcRequest & { origin: string } => ({ + id: 1, + method: 'wallet_getAccountUpgradeStatus', + jsonrpc: '2.0' as const, + origin: 'npm:@metamask/gator-permissions-snap', + params, +}); + +const createTestResponse = (): PendingJsonRpcResponse => ({ + result: null, + id: 1, + jsonrpc: '2.0' as const, +}); + +describe('walletGetAccountUpgradeStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns non-upgraded account status with real data flow', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to return empty code (non-upgraded account) + hooks.getCode.mockResolvedValue('0x'); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('returns upgraded account status with real delegation code', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to return valid delegation code (0xef0100 + 40 hex chars for address) + const upgradedAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const delegationCode = `0xef0100${upgradedAddress.slice(2)}`; + hooks.getCode.mockResolvedValue(delegationCode); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: true, + upgradedAddress, + chainId: '0x1', + }); + }); + + it('works with specific chain ID parameter', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([ + { account: TEST_ACCOUNT, chainId: '0xaa36a7' }, + ]); + const res = createTestResponse(); + + // Mock getCode to return non-delegation code + hooks.getCode.mockResolvedValue('0x1234567890abcdef'); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).not.toHaveBeenCalled(); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0xaa36a7', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0xaa36a7', + }); + }); + + it('propagates validation errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([]); // Invalid: empty params + const res = createTestResponse(); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow('Invalid parameters'); + }); + + it('throws error when current chain ID cannot be determined', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); // No chainId provided, should use current + const res = createTestResponse(); + + // Mock getCurrentChainIdForDomain to return null + hooks.getCurrentChainIdForDomain.mockReturnValue(null); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow( + 'Could not determine current chain ID for origin: npm:@metamask/gator-permissions-snap', + ); + }); + + it('calls getSelectedNetworkClientIdForChain with current chain ID when no chainId provided', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); // No chainId provided, should use current + const res = createTestResponse(); + + // Mock getCode to return empty code (non-upgraded account) + hooks.getCode.mockResolvedValue('0x'); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('throws error when network client ID is missing', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([ + { account: TEST_ACCOUNT, chainId: '0x999' }, + ]); + const res = createTestResponse(); + + // Mock getSelectedNetworkClientIdForChain to return null (network not found) + hooks.getSelectedNetworkClientIdForChain.mockReturnValue(null); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow('Network client ID not found for chain ID 0x999'); + }); + + it('returns false for delegation code with wrong length', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to return delegation code with wrong length + const wrongLengthCode = '0xef0100abcdef'; // Too short + hooks.getCode.mockResolvedValue(wrongLengthCode); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('propagates non-RPC errors as internal errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to throw a non-RPC error + hooks.getCode.mockRejectedValue(new Error('Network error')); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow('Failed to get account upgrade status: Network error'); + }); + + it('returns early when EIP-7702 is not supported', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock isEip7702Supported to return false + hooks.isEip7702Supported.mockResolvedValue({ + isSupported: false, + }); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + // Should not call getSelectedNetworkClientIdForChain or getCode when not supported + expect(hooks.getSelectedNetworkClientIdForChain).not.toHaveBeenCalled(); + expect(hooks.getCode).not.toHaveBeenCalled(); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: false, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('returns early when EIP-7702 is not supported with specific chain ID', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([ + { account: TEST_ACCOUNT, chainId: '0xaa36a7' }, + ]); + const res = createTestResponse(); + + // Mock isEip7702Supported to return false + hooks.isEip7702Supported.mockResolvedValue({ + isSupported: false, + }); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).not.toHaveBeenCalled(); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + // Should not call getSelectedNetworkClientIdForChain or getCode when not supported + expect(hooks.getSelectedNetworkClientIdForChain).not.toHaveBeenCalled(); + expect(hooks.getCode).not.toHaveBeenCalled(); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: false, + isUpgraded: false, + upgradedAddress: null, + chainId: '0xaa36a7', + }); + }); + + it('handles isEip7702Supported hook errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock isEip7702Supported to throw an error + hooks.isEip7702Supported.mockRejectedValue( + new Error('EIP-7702 check failed'), + ); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow('EIP-7702 check failed'); + }); +}); diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts new file mode 100644 index 00000000000..1b25bb094ad --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts @@ -0,0 +1,142 @@ +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import { tuple } from '@metamask/superstruct'; +import { + type JsonRpcRequest, + type PendingJsonRpcResponse, + type Json, + type Hex, + getErrorMessage, +} from '@metamask/utils'; + +import { DELEGATION_INDICATOR_PREFIX } from './constants'; +import { GetAccountUpgradeStatusParamsStruct } from './types'; +import { validateParams, validateAndNormalizeAddress } from './utils'; + +export type WalletGetAccountUpgradeStatusHooks = { + getCurrentChainIdForDomain: (origin: string) => Hex | null; + getCode: (address: string, networkClientId: string) => Promise; + getSelectedNetworkClientIdForChain: (chainId: string) => string | null; + getPermittedAccountsForOrigin: (origin: string) => Promise; + isEip7702Supported: (request: { address: string; chainId: Hex }) => Promise<{ + isSupported: boolean; + upgradeContractAddress?: string; + }>; +}; + +const isAccountUpgraded = async ( + address: string, + networkClientId: string, + getCode: (address: string, networkClientId: string) => Promise, +): Promise<{ isUpgraded: boolean; upgradedAddress: Hex | null }> => { + const code = await getCode(address, networkClientId); + if (!code || code === '0x' || code.length <= 2) { + return { isUpgraded: false, upgradedAddress: null }; + } + + if (!code.startsWith(DELEGATION_INDICATOR_PREFIX)) { + return { isUpgraded: false, upgradedAddress: null }; + } + + const expectedLength = DELEGATION_INDICATOR_PREFIX.length + 40; // 0xef0100 + 40 hex chars + if (code.length !== expectedLength) { + return { isUpgraded: false, upgradedAddress: null }; + } + + // Extract the 20-byte address (40 hex characters after the prefix) + const upgradedAddress = `0x${code.slice(8, 48)}` as Hex; + + return { isUpgraded: true, upgradedAddress }; +}; + +/** + * The RPC method handler middleware for `wallet_getAccountUpgradeStatus` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks required for account upgrade status checking. + */ +export async function walletGetAccountUpgradeStatus( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + hooks: WalletGetAccountUpgradeStatusHooks, +): Promise { + const { params, origin } = req; + + // Validate parameters using Superstruct + validateParams(params, tuple([GetAccountUpgradeStatusParamsStruct])); + + const [{ account, chainId }] = params; + + // Validate and normalize the account address with authorization check + const normalizedAccount = await validateAndNormalizeAddress( + account, + origin, + hooks.getPermittedAccountsForOrigin, + ); + + // Use current chain ID if not provided + let targetChainId: Hex; + if (chainId !== undefined) { + targetChainId = chainId; + } else { + const currentChainIdForDomain = hooks.getCurrentChainIdForDomain(origin); + if (!currentChainIdForDomain) { + throw rpcErrors.invalidParams({ + message: `Could not determine current chain ID for origin: ${origin}`, + }); + } + targetChainId = currentChainIdForDomain; + } + + const { isSupported } = await hooks.isEip7702Supported({ + address: normalizedAccount, + chainId: targetChainId, + }); + + if (!isSupported) { + res.result = { + isSupported, + account: normalizedAccount, + isUpgraded: false, + upgradedAddress: null, + chainId: targetChainId, + }; + return; + } + + try { + // Get the network configuration for the target chain + const hexChainId = targetChainId; + const networkClientId = + hooks.getSelectedNetworkClientIdForChain(hexChainId); + + if (!networkClientId) { + throw rpcErrors.invalidParams({ + message: `Network client ID not found for chain ID ${targetChainId}`, + }); + } + + // Check if the account is upgraded using the EIP7702 utils + const { isUpgraded, upgradedAddress } = await isAccountUpgraded( + normalizedAccount, + networkClientId, + hooks.getCode, + ); + + res.result = { + isSupported, + account: normalizedAccount, + isUpgraded, + upgradedAddress, + chainId: targetChainId, + }; + } catch (error) { + // Re-throw RPC errors as-is + if (error instanceof JsonRpcError) { + throw error; + } + throw rpcErrors.internal({ + message: `Failed to get account upgrade status: ${getErrorMessage(error)}`, + }); + } +} diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts new file mode 100644 index 00000000000..ebae507dbc1 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts @@ -0,0 +1,235 @@ +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Json, +} from '@metamask/utils'; + +import { walletUpgradeAccount } from './wallet_upgradeAccount'; +import type { WalletUpgradeAccountHooks } from './wallet_upgradeAccount'; + +type TestHooks = { + [K in keyof WalletUpgradeAccountHooks]: jest.MockedFunction< + WalletUpgradeAccountHooks[K] + >; +}; + +const TEST_ACCOUNT = '0x1234567890123456789012345678901234567890'; +const UPGRADE_CONTRACT = '0x0000000000000000000000000000000000000000'; + +const createTestHooks = (): TestHooks => { + const upgradeAccount = jest.fn(); + const getCurrentChainIdForDomain = jest.fn().mockReturnValue('0x1'); + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue([TEST_ACCOUNT]); + const isEip7702Supported = jest + .fn() + .mockImplementation( + async ({ chainId }: { address: string; chainId: string }) => { + if (chainId === '0x1' || chainId === '0xaa36a7') { + return { + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT, + }; + } + return { + isSupported: false, + }; + }, + ); + + return { + upgradeAccount, + getCurrentChainIdForDomain, + isEip7702Supported, + getPermittedAccountsForOrigin, + }; +}; + +const createTestRequest = ( + params: Json[] = [{ account: TEST_ACCOUNT }], +): JsonRpcRequest & { origin: string } => ({ + id: 1, + method: 'wallet_upgradeAccount', + jsonrpc: '2.0' as const, + origin: 'npm:@metamask/gator-permissions-snap', + params, +}); + +const createTestResponse = (): PendingJsonRpcResponse => ({ + result: null, + id: 1, + jsonrpc: '2.0' as const, +}); + +describe('walletUpgradeAccount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('successfully upgrades account with current chain ID', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock successful upgrade + hooks.upgradeAccount.mockResolvedValue({ + transactionHash: '0xabc123def456789', + delegatedTo: UPGRADE_CONTRACT, + }); + + await walletUpgradeAccount(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.upgradeAccount).toHaveBeenCalledWith( + TEST_ACCOUNT, + UPGRADE_CONTRACT, + '0x1', + ); + expect(res.result).toStrictEqual({ + transactionHash: '0xabc123def456789', + upgradedAccount: TEST_ACCOUNT, + delegatedTo: UPGRADE_CONTRACT, + }); + }); + + it('successfully upgrades account with specific chain ID', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([ + { account: TEST_ACCOUNT, chainId: '0xaa36a7' }, + ]); + const res = createTestResponse(); + + // Mock successful upgrade + hooks.upgradeAccount.mockResolvedValue({ + transactionHash: '0xdef456abc123789', + delegatedTo: UPGRADE_CONTRACT, + }); + + await walletUpgradeAccount(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).not.toHaveBeenCalled(); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + expect(hooks.upgradeAccount).toHaveBeenCalledWith( + TEST_ACCOUNT, + UPGRADE_CONTRACT, + '0xaa36a7', + ); + expect(res.result).toStrictEqual({ + transactionHash: '0xdef456abc123789', + upgradedAccount: TEST_ACCOUNT, + delegatedTo: UPGRADE_CONTRACT, + }); + }); + + it('propagates validation errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([]); // Invalid: empty params + const res = createTestResponse(); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'Invalid parameters', + ); + }); + + it('throws error when EIP-7702 is not supported on the chain', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([ + { account: TEST_ACCOUNT, chainId: '0x999' }, + ]); + const res = createTestResponse(); + + // Mock unsupported chain + hooks.isEip7702Supported.mockImplementation( + async (_: { address: string; chainId: string }) => ({ + isSupported: false, + }), + ); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'Account upgrade not supported on chain ID 0x999', + ); + }); + + it('throws error when no network configuration is found for origin', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock no network configuration found + hooks.getCurrentChainIdForDomain.mockReturnValue(null); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'No network configuration found for origin: npm:@metamask/gator-permissions-snap', + ); + }); + + it('propagates upgrade function errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock upgrade function to throw an error + hooks.upgradeAccount.mockRejectedValue(new Error('Upgrade failed')); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'Failed to upgrade account: Upgrade failed', + ); + }); + + it('throws error when chain has delegation address but is not supported', async () => { + const hooks = createTestHooks(); + const req = createTestRequest([ + { account: TEST_ACCOUNT, chainId: '0x999' }, + ]); + const res = createTestResponse(); + + // Mock chain with delegation address but not supported + hooks.isEip7702Supported.mockImplementation( + async (_: { address: string; chainId: string }) => ({ + isSupported: false, + upgradeContractAddress: UPGRADE_CONTRACT, + }), + ); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'Account upgrade not supported on chain ID 0x999', + ); + }); + + it('handles non-Error objects in error handling', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock upgrade function to throw a non-Error object + hooks.upgradeAccount.mockRejectedValue('String error'); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'Failed to upgrade account: String error', + ); + }); + + it('throws error when upgrade contract address is missing', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock isEip7702Supported to return supported but without upgradeContractAddress + hooks.isEip7702Supported.mockResolvedValue({ + isSupported: true, + // upgradeContractAddress is undefined + }); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'No upgrade contract address available for chain ID 0x1', + ); + }); +}); diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts new file mode 100644 index 00000000000..9177e53c643 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts @@ -0,0 +1,109 @@ +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import { tuple } from '@metamask/superstruct'; +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Json, + Hex, +} from '@metamask/utils'; + +import { UpgradeAccountParamsStruct } from './types'; +import { validateParams, validateAndNormalizeAddress } from './utils'; + +export type WalletUpgradeAccountHooks = { + upgradeAccount: ( + address: string, + upgradeContractAddress: string, + chainId?: Hex, + ) => Promise<{ transactionHash: string; delegatedTo: string }>; + getCurrentChainIdForDomain: (origin: string) => Hex | null; + isEip7702Supported: (request: { address: string; chainId: Hex }) => Promise<{ + isSupported: boolean; + upgradeContractAddress?: string; + }>; + getPermittedAccountsForOrigin: (origin: string) => Promise; +}; + +/** + * The RPC method handler middleware for `wallet_upgradeAccount` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks required for account upgrade functionality. + */ +export async function walletUpgradeAccount( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + hooks: WalletUpgradeAccountHooks, +): Promise { + const { params, origin } = req; + + // Validate parameters using Superstruct + validateParams(params, tuple([UpgradeAccountParamsStruct])); + + const [{ account, chainId }] = params; + + // Validate and normalize the account address with authorization check + const normalizedAccount = await validateAndNormalizeAddress( + account, + origin, + hooks.getPermittedAccountsForOrigin, + ); + + // Use current app selected chain ID if not passed as a param + let targetChainId: Hex; + if (chainId !== undefined) { + targetChainId = chainId; + } else { + const currentChainIdForDomain = hooks.getCurrentChainIdForDomain(origin); + if (!currentChainIdForDomain) { + throw rpcErrors.invalidParams({ + message: `No network configuration found for origin: ${origin}`, + }); + } + targetChainId = currentChainIdForDomain; + } + + try { + // Get the EIP7702 network configuration for the target chain + const hexChainId = targetChainId; + const { isSupported, upgradeContractAddress } = + await hooks.isEip7702Supported({ + address: normalizedAccount, + chainId: hexChainId, + }); + + if (!isSupported) { + throw rpcErrors.invalidParams({ + message: `Account upgrade not supported on chain ID ${targetChainId}`, + }); + } + + if (!upgradeContractAddress) { + throw rpcErrors.invalidParams({ + message: `No upgrade contract address available for chain ID ${targetChainId}`, + }); + } + + // Perform the upgrade using existing EIP-7702 functionality + const result = await hooks.upgradeAccount( + normalizedAccount, + upgradeContractAddress, + targetChainId, + ); + + res.result = { + transactionHash: result.transactionHash, + upgradedAccount: normalizedAccount, + delegatedTo: result.delegatedTo, + }; + } catch (error) { + // Re-throw RPC errors as-is + if (error instanceof JsonRpcError) { + throw error; + } + throw rpcErrors.internal({ + message: `Failed to upgrade account: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} diff --git a/packages/eip-7702-internal-rpc-middleware/tsconfig.build.json b/packages/eip-7702-internal-rpc-middleware/tsconfig.build.json new file mode 100644 index 00000000000..2276f062a38 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../preferences-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip-7702-internal-rpc-middleware/tsconfig.json b/packages/eip-7702-internal-rpc-middleware/tsconfig.json new file mode 100644 index 00000000000..bf46cb203e4 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../.." + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/eip-7702-internal-rpc-middleware/typedoc.json b/packages/eip-7702-internal-rpc-middleware/typedoc.json new file mode 100644 index 00000000000..b867251d8d8 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/typedoc.json @@ -0,0 +1,31 @@ +{ + "entryPoints": ["src/index.ts"], + "out": "docs", + "exclude": ["**/*.test.ts"], + "excludeExternals": true, + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "readme": "README.md", + "name": "@metamask/eip-7702-internal-rpc-middleware", + "includeVersion": true, + "sort": ["source-order"], + "categorizeByGroup": false, + "defaultCategory": "Other", + "categoryOrder": ["Hooks", "Methods", "Types", "Other"], + "kindSortOrder": [ + "Project", + "Module", + "Namespace", + "Enum", + "Class", + "Interface", + "Type alias", + "Constructor", + "Property", + "Variable", + "Function", + "Accessor", + "Method" + ] +} diff --git a/teams.json b/teams.json index 15b053fb6bc..cd958700c01 100644 --- a/teams.json +++ b/teams.json @@ -21,6 +21,7 @@ "metamask/eth-json-rpc-provider": "team-wallet-api-platform,team-wallet-framework", "metamask/gas-fee-controller": "team-confirmations", "metamask/gator-permissions-controller": "team-delegation", + "metamask/eip-7702-internal-rpc-middleware": "team-delegation", "metamask/json-rpc-engine": "team-wallet-api-platform,team-wallet-framework", "metamask/json-rpc-middleware-stream": "team-wallet-api-platform,team-wallet-framework", "metamask/keyring-controller": "team-accounts", diff --git a/tsconfig.build.json b/tsconfig.build.json index 712eded094a..4b077a0c9c3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -18,6 +18,9 @@ { "path": "./packages/delegation-controller/tsconfig.build.json" }, { "path": "./packages/earn-controller/tsconfig.build.json" }, { "path": "./packages/eip-5792-middleware/tsconfig.build.json" }, + { + "path": "./packages/eip-7702-internal-rpc-middleware/tsconfig.build.json" + }, { "path": "./packages/eip1193-permission-middleware/tsconfig.build.json" }, { "path": "./packages/ens-controller/tsconfig.build.json" }, { "path": "./packages/error-reporting-service/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index 43334591a74..b2a137dc54d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3121,6 +3121,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/eip-7702-internal-rpc-middleware@workspace:packages/eip-7702-internal-rpc-middleware": + version: 0.0.0-use.local + resolution: "@metamask/eip-7702-internal-rpc-middleware@workspace:packages/eip-7702-internal-rpc-middleware" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.8.1" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + languageName: unknown + linkType: soft + "@metamask/eip1193-permission-middleware@workspace:packages/eip1193-permission-middleware": version: 0.0.0-use.local resolution: "@metamask/eip1193-permission-middleware@workspace:packages/eip1193-permission-middleware"