diff --git a/README.md b/README.md index 3122d17adc4..c6b05a1cff6 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ linkStyle default opacity:0.5 money_account_upgrade_controller --> chomp_api_service; money_account_upgrade_controller --> keyring_controller; money_account_upgrade_controller --> messenger; + money_account_upgrade_controller --> network_controller; multichain_account_service --> accounts_controller; multichain_account_service --> base_controller; multichain_account_service --> keyring_controller; diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index b7e774317cd..ccd163e8e10 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add EIP-7702 authorization step to the upgrade sequence. ([#8565](https://github.com/MetaMask/core/pull/8565)) + ## [1.0.0] ### Added diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index a865ad58917..06084ba5bd1 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -56,11 +56,12 @@ "@metamask/base-controller": "^9.1.0", "@metamask/chomp-api-service": "^1.0.0", "@metamask/keyring-controller": "^25.2.0", - "@metamask/messenger": "^1.1.1" + "@metamask/messenger": "^1.1.1", + "@metamask/network-controller": "^30.0.1", + "@metamask/utils": "^11.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index b4133ef8318..213fd990e22 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -63,6 +63,11 @@ type Mocks = { getServiceDetails: jest.Mock; signPersonalMessage: jest.Mock; associateAddress: jest.Mock; + createUpgrade: jest.Mock; + signEip7702Authorization: jest.Mock; + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + providerRequest: jest.Mock; }; function setup(): { @@ -71,6 +76,22 @@ function setup(): { messenger: MoneyAccountUpgradeControllerMessenger; mocks: Mocks; } { + // 65-byte signature — r (32 bytes) + s (32 bytes) + v = 0x1c (28). + const signature = `0x${'1'.repeat(64)}${'2'.repeat(64)}1c`; + + // Default provider responses: account is a plain EOA with nonce 0. + const providerRequest = jest + .fn() + .mockImplementation(async ({ method }: { method: string }) => { + if (method === 'eth_getCode') { + return '0x'; + } + if (method === 'eth_getTransactionCount') { + return '0x0'; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + const mocks: Mocks = { getServiceDetails: jest .fn() @@ -81,6 +102,19 @@ function setup(): { address: MOCK_ACCOUNT_ADDRESS, status: 'CREATED', }), + createUpgrade: jest.fn().mockResolvedValue({ + signerAddress: MOCK_ACCOUNT_ADDRESS, + status: 'pending', + createdAt: '2026-04-21T12:00:00.000Z', + }), + signEip7702Authorization: jest.fn().mockResolvedValue(signature), + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('network-client-id'), + getNetworkClientById: jest.fn().mockReturnValue({ + provider: { request: providerRequest }, + }), + providerRequest, }; const rootMessenger = new Messenger({ @@ -99,6 +133,22 @@ function setup(): { 'ChompApiService:associateAddress', mocks.associateAddress, ); + rootMessenger.registerActionHandler( + 'ChompApiService:createUpgrade', + mocks.createUpgrade, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signEip7702Authorization', + mocks.signEip7702Authorization, + ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mocks.findNetworkClientIdByChainId, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mocks.getNetworkClientById, + ); const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ namespace: 'MoneyAccountUpgradeController', @@ -110,6 +160,10 @@ function setup(): { 'ChompApiService:getServiceDetails', 'KeyringController:signPersonalMessage', 'ChompApiService:associateAddress', + 'ChompApiService:createUpgrade', + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', ], events: [], messenger, @@ -242,6 +296,15 @@ describe('MoneyAccountUpgradeController', () => { expect(mocks.associateAddress).toHaveBeenCalledWith( expect.objectContaining({ address: MOCK_ACCOUNT_ADDRESS }), ); + expect(mocks.signEip7702Authorization).toHaveBeenCalledWith( + expect.objectContaining({ + from: MOCK_ACCOUNT_ADDRESS, + contractAddress: MOCK_CONFIG.delegatorImplAddress, + }), + ); + expect(mocks.createUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ address: MOCK_ACCOUNT_ADDRESS }), + ); }); it('is callable via the messenger', async () => { diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 516104b249d..113fca42607 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -6,15 +6,24 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateUpgradeAction, ChompApiServiceGetServiceDetailsAction, } from '@metamask/chomp-api-service'; -import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerSignEip7702AuthorizationAction, + KeyringControllerSignPersonalMessageAction, +} from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, +} from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; -import { associateAddressStep } from './associate-address'; import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountUpgradeController-method-action-types'; -import type { Step } from './step'; +import { associateAddressStep } from './steps/associate-address'; +import { eip7702AuthorizationStep } from './steps/eip-7702-authorization'; +import type { Step } from './steps/step'; import type { InitConfig } from './types'; export const controllerName = 'MoneyAccountUpgradeController'; @@ -38,8 +47,12 @@ export type MoneyAccountUpgradeControllerActions = type AllowedActions = | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateUpgradeAction | ChompApiServiceGetServiceDetailsAction - | KeyringControllerSignPersonalMessageAction; + | KeyringControllerSignEip7702AuthorizationAction + | KeyringControllerSignPersonalMessageAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; export type MoneyAccountUpgradeControllerStateChangedEvent = ControllerStateChangedEvent< @@ -66,9 +79,9 @@ export class MoneyAccountUpgradeController extends BaseController< MoneyAccountUpgradeControllerState, MoneyAccountUpgradeControllerMessenger > { - #initialized: boolean; + #config?: { chainId: Hex; delegatorImplAddress: Hex }; - readonly #steps: Step[] = [associateAddressStep]; + readonly #steps: Step[] = [associateAddressStep, eip7702AuthorizationStep]; /** * Constructor for the MoneyAccountUpgradeController. @@ -88,8 +101,6 @@ export class MoneyAccountUpgradeController extends BaseController< state: {}, }); - this.#initialized = false; - this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -101,9 +112,9 @@ export class MoneyAccountUpgradeController extends BaseController< * given chain. * * @param chainId - The chain to initialize for. - * @param _initConfig - Contract addresses not available from the service details API. + * @param initConfig - Contract addresses not available from the service details API. */ - async init(chainId: Hex, _initConfig: InitConfig): Promise { + async init(chainId: Hex, initConfig: InitConfig): Promise { const response = await this.messenger.call( 'ChompApiService:getServiceDetails', [chainId], @@ -127,7 +138,10 @@ export class MoneyAccountUpgradeController extends BaseController< ); } - this.#initialized = true; + this.#config = { + chainId, + delegatorImplAddress: initConfig.delegatorImplAddress, + }; } /** @@ -139,14 +153,18 @@ export class MoneyAccountUpgradeController extends BaseController< * @param address - The Money Account address to upgrade. */ async upgradeAccount(address: Hex): Promise { - if (!this.#initialized) { + if (!this.#config) { throw new Error( 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', ); } for (const step of this.#steps) { - await step.run({ messenger: this.messenger, address }); + await step.run({ + messenger: this.messenger, + address, + ...this.#config, + }); } } } diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts index 8c3d79a6d18..ffa40fc1019 100644 --- a/packages/money-account-upgrade-controller/src/index.ts +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -1,4 +1,3 @@ -export type { Step, StepResult } from './step'; export type { InitConfig, UpgradeConfig } from './types'; export { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController'; export type { diff --git a/packages/money-account-upgrade-controller/src/associate-address.test.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts similarity index 78% rename from packages/money-account-upgrade-controller/src/associate-address.test.ts rename to packages/money-account-upgrade-controller/src/steps/associate-address.test.ts index 2b58e41a91e..2b779f1b07e 100644 --- a/packages/money-account-upgrade-controller/src/associate-address.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts @@ -6,10 +6,12 @@ import type { } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; import { associateAddressStep } from './associate-address'; -import type { MoneyAccountUpgradeControllerMessenger } from './MoneyAccountUpgradeController'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); @@ -79,7 +81,12 @@ describe('associateAddressStep', () => { it('signs the CHOMP Authentication message with the given address', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ messenger, address: MOCK_ADDRESS }); + await associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + }); expect(mocks.signPersonalMessage).toHaveBeenCalledWith({ data: `CHOMP Authentication ${MOCK_NOW}`, @@ -90,7 +97,12 @@ describe('associateAddressStep', () => { it('submits the signature, timestamp, and address to the CHOMP API', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ messenger, address: MOCK_ADDRESS }); + await associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + }); expect(mocks.associateAddress).toHaveBeenCalledWith({ signature: MOCK_SIGNATURE, @@ -105,6 +117,8 @@ describe('associateAddressStep', () => { const result = await associateAddressStep.run({ messenger, address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, }); expect(result).toBe('completed'); @@ -121,6 +135,8 @@ describe('associateAddressStep', () => { const result = await associateAddressStep.run({ messenger, address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, }); expect(result).toBe('already-done'); @@ -131,7 +147,12 @@ describe('associateAddressStep', () => { mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); await expect( - associateAddressStep.run({ messenger, address: MOCK_ADDRESS }), + associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + }), ).rejects.toThrow('signing failed'); expect(mocks.associateAddress).not.toHaveBeenCalled(); }); @@ -141,7 +162,12 @@ describe('associateAddressStep', () => { mocks.associateAddress.mockRejectedValue(new Error('api failed')); await expect( - associateAddressStep.run({ messenger, address: MOCK_ADDRESS }), + associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + }), ).rejects.toThrow('api failed'); }); }); diff --git a/packages/money-account-upgrade-controller/src/associate-address.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.ts similarity index 100% rename from packages/money-account-upgrade-controller/src/associate-address.ts rename to packages/money-account-upgrade-controller/src/steps/associate-address.ts diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts new file mode 100644 index 00000000000..8bde041b91e --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts @@ -0,0 +1,385 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { eip7702AuthorizationStep } from './eip-7702-authorization'; + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) — non-trivial decimal +const MOCK_CHAIN_ID_DECIMAL = parseInt(MOCK_CHAIN_ID, 16); +const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_THIRD_PARTY_IMPL = + '0x9999999999999999999999999999999999999999' as Hex; +const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; +const MOCK_NONCE_HEX = '0x7'; +const MOCK_NONCE = 7; + +const PLAIN_EOA_CODE = '0x'; +const delegationCode = (impl: Hex): Hex => + `0xef0100${impl.slice(2).toLowerCase()}` as Hex; + +// 65-byte signature: r (32) + s (32) + v (1). v = 28 → yParity = 1. +const MOCK_R = + '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex; +const MOCK_S_NO_PREFIX = + '2222222222222222222222222222222222222222222222222222222222222222'; +const MOCK_S = `0x${MOCK_S_NO_PREFIX}` as Hex; +const MOCK_V_HEX = '1c'; // 28 +const MOCK_SIGNATURE = `${MOCK_R}${MOCK_S_NO_PREFIX}${MOCK_V_HEX}` as Hex; + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type ProviderRequest = (args: { + method: string; + params: unknown[]; +}) => Promise; + +type Mocks = { + createUpgrade: jest.Mock; + signEip7702Authorization: jest.Mock; + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + providerRequest: jest.Mock< + ReturnType, + [Parameters[0]] + >; +}; + +/** + * Configures the provider mock so that `eth_getCode` returns the given code + * and `eth_getTransactionCount` returns `MOCK_NONCE_HEX`. Other methods throw. + * + * @param mocks - The mocks bag from `setup`. + * @param code - The code to return for `eth_getCode`. + */ +function configureProvider(mocks: Mocks, code: Hex = PLAIN_EOA_CODE): void { + mocks.providerRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') { + return code; + } + if (method === 'eth_getTransactionCount') { + return MOCK_NONCE_HEX; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); +} + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const providerRequest = jest.fn() as Mocks['providerRequest']; + + const mocks: Mocks = { + createUpgrade: jest.fn().mockResolvedValue({ + signerAddress: MOCK_ADDRESS, + status: 'pending', + createdAt: '2026-04-21T12:00:00.000Z', + }), + signEip7702Authorization: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue(MOCK_NETWORK_CLIENT_ID), + getNetworkClientById: jest.fn().mockReturnValue({ + provider: { request: providerRequest }, + }), + providerRequest, + }; + + configureProvider(mocks); + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'ChompApiService:createUpgrade', + mocks.createUpgrade, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signEip7702Authorization', + mocks.signEip7702Authorization, + ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mocks.findNetworkClientIdByChainId, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mocks.getNetworkClientById, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'ChompApiService:createUpgrade', + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return eip7702AuthorizationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + }); +} + +describe('eip7702AuthorizationStep', () => { + it('is named "eip-7702-authorization"', () => { + expect(eip7702AuthorizationStep.name).toBe('eip-7702-authorization'); + }); + + describe('when the account is already delegated to the configured impl', () => { + it('returns "already-done" and does not sign or submit', async () => { + const { messenger, mocks } = setup(); + configureProvider(mocks, delegationCode(MOCK_DELEGATOR_IMPL)); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('matches the configured impl case-insensitively', async () => { + const { messenger, mocks } = setup(); + configureProvider( + mocks, + `0xef0100${MOCK_DELEGATOR_IMPL.slice(2).toUpperCase()}` as Hex, + ); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + }); + + describe('when the account is delegated to a different impl', () => { + it('throws and does not sign or submit', async () => { + const { messenger, mocks } = setup(); + configureProvider(mocks, delegationCode(MOCK_THIRD_PARTY_IMPL)); + + await expect(run(messenger)).rejects.toThrow( + `Account ${MOCK_ADDRESS} is already upgraded to another smart account: ${MOCK_THIRD_PARTY_IMPL}.`, + ); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + }); + + describe('when the account has unexpected non-delegation code', () => { + it('throws without signing or submitting', async () => { + const { messenger, mocks } = setup(); + // A regular contract — not a 7702 delegation. + configureProvider(mocks, '0x6080604052' as Hex); + + await expect(run(messenger)).rejects.toThrow( + `Account ${MOCK_ADDRESS} has unexpected on-chain code; expected either no code or an EIP-7702 delegation.`, + ); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('throws when eth_getCode returns a non-hex value', async () => { + const { messenger, mocks } = setup(); + mocks.providerRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') { + return null; + } + return MOCK_NONCE_HEX; + }); + + await expect(run(messenger)).rejects.toThrow( + 'Expected 0x-prefixed hex string from eth_getCode, got null', + ); + }); + }); + + describe('when the account is a plain EOA', () => { + it('resolves the network client for the target chain', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.findNetworkClientIdByChainId).toHaveBeenCalledWith( + MOCK_CHAIN_ID, + ); + expect(mocks.getNetworkClientById).toHaveBeenCalledWith( + MOCK_NETWORK_CLIENT_ID, + ); + }); + + it('reads the on-chain code and nonce for the address', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.providerRequest).toHaveBeenCalledWith({ + method: 'eth_getCode', + params: [MOCK_ADDRESS, 'latest'], + }); + expect(mocks.providerRequest).toHaveBeenCalledWith({ + method: 'eth_getTransactionCount', + params: [MOCK_ADDRESS, 'latest'], + }); + }); + + it('signs the authorization against the configured delegatorImplAddress', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.signEip7702Authorization).toHaveBeenCalledWith({ + chainId: MOCK_CHAIN_ID_DECIMAL, + contractAddress: MOCK_DELEGATOR_IMPL, + nonce: MOCK_NONCE, + from: MOCK_ADDRESS, + }); + }); + + it('submits the split signature and decimal-string chainId/nonce to CHOMP', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.createUpgrade).toHaveBeenCalledWith({ + r: MOCK_R, + s: MOCK_S, + v: 28, + yParity: 1, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID_DECIMAL.toString(), + nonce: MOCK_NONCE.toString(), + }); + }); + + it('returns "completed" on success', async () => { + const { messenger } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + }); + + it('encodes yParity as 0 when v is 27', async () => { + const { messenger, mocks } = setup(); + const sigWithV27 = `${MOCK_R}${MOCK_S_NO_PREFIX}1b` as Hex; + mocks.signEip7702Authorization.mockResolvedValue(sigWithV27); + + await run(messenger); + + expect(mocks.createUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ v: 27, yParity: 0 }), + ); + }); + + it('propagates errors from signing and does not submit to CHOMP', async () => { + const { messenger, mocks } = setup(); + mocks.signEip7702Authorization.mockRejectedValue( + new Error('signing failed'), + ); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('propagates errors from createUpgrade', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue(new Error('api failed')); + + await expect(run(messenger)).rejects.toThrow('api failed'); + }); + + it('throws when eth_getTransactionCount returns a non-hex response', async () => { + const { messenger, mocks } = setup(); + mocks.providerRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') { + return PLAIN_EOA_CODE; + } + return null; + }); + + await expect(run(messenger)).rejects.toThrow( + 'Expected hex string from eth_getTransactionCount, got null', + ); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + }); + + it.each([ + ['a non-hex string', 'not-a-hex-string'], + ['a truncated hex string', `${MOCK_R}${MOCK_S_NO_PREFIX}`], + [ + 'an over-long hex string', + `${MOCK_R}${MOCK_S_NO_PREFIX}${MOCK_V_HEX}00`, + ], + ['null', null], + ])( + 'throws when signEip7702Authorization returns %s', + async (_label, value) => { + const { messenger, mocks } = setup(); + mocks.signEip7702Authorization.mockResolvedValue(value); + + await expect(run(messenger)).rejects.toThrow( + /Expected a 0x-prefixed 65-byte signature from signEip7702Authorization/u, + ); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['0', '00'], + ['1', '01'], + ['26', '1a'], + ['29', '1d'], + ])('throws when v is %s rather than 27 or 28', async (vDecimal, vHex) => { + const { messenger, mocks } = setup(); + mocks.signEip7702Authorization.mockResolvedValue( + `${MOCK_R}${MOCK_S_NO_PREFIX}${vHex}`, + ); + + await expect(run(messenger)).rejects.toThrow( + `Expected v to be 27 or 28 in signEip7702Authorization signature, got ${vDecimal}`, + ); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('accepts an uppercase signature and normalizes it to lowercase', async () => { + const { messenger, mocks } = setup(); + const upperR = MOCK_R.toUpperCase().replace('0X', '0x'); + const upperS = MOCK_S_NO_PREFIX.toUpperCase(); + mocks.signEip7702Authorization.mockResolvedValue( + `${upperR}${upperS}${MOCK_V_HEX.toUpperCase()}`, + ); + + await run(messenger); + + expect(mocks.createUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ + r: MOCK_R, + s: MOCK_S, + v: 28, + yParity: 1, + }), + ); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts new file mode 100644 index 00000000000..bff7624e5bb --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts @@ -0,0 +1,214 @@ +import type { Provider } from '@metamask/network-controller'; +import { add0x, isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { Step, StepContext } from './step'; + +const EIP_7702_DELEGATION_PREFIX = '0xef0100'; +// '0x' (2) + 'ef0100' (6) + 20-byte address (40) = 48 characters. +const EIP_7702_DELEGATED_CODE_LENGTH = 48; + +// 65-byte signature: 32-byte r + 32-byte s + 1-byte v. +// '0x' (2) + 32 bytes (64) + 32 bytes (64) + 1 byte (2) = 132 characters. +const SIGNATURE_HEX_LENGTH = 132; +// '0x' + 32-byte r = 66 characters. +const R_END_INDEX = 66; +// r (66 chars) + 32-byte s (64 chars) = 130 characters. +const S_END_INDEX = 130; +const V_END_INDEX = SIGNATURE_HEX_LENGTH; +// v = 27 means yParity = 0; v = 28 means yParity = 1. +const V_BASE = 27; + +/** + * Submits the EIP-7702 delegation-slot authorization to CHOMP so the Money + * Account can be upgraded to a smart account pointed at the configured + * delegator impl. + * + * The step: + * + * 1. Reads the account's on-chain code. If it is already delegated to the + * configured `delegatorImplAddress`, reports `'already-done'`. If it is + * delegated to a different address, throws rather than silently + * overwriting an existing delegation. + * 2. Fetches the account's current on-chain transaction count — CHOMP + * validates the nonce matches when it applies the authorization. + * 3. Signs the EIP-7702 authorization `{ chainId, delegatorImpl, nonce }` + * with the Money Account's key via the keyring. + * 4. Splits the 65-byte signature into `r`, `s`, `v`, `yParity` and submits + * it to `POST /v1/account-upgrade`. + */ +export const eip7702AuthorizationStep: Step = { + name: 'eip-7702-authorization', + async run({ messenger, address, chainId, delegatorImplAddress }) { + const provider = getProvider(messenger, chainId); + + const existingDelegation = await fetchDelegationAddress(provider, address); + if (existingDelegation !== undefined) { + if (existingDelegation === delegatorImplAddress.toLowerCase()) { + return 'already-done'; + } + throw new Error( + `Account ${address} is already upgraded to another smart account: ${existingDelegation}.`, + ); + } + + const chainIdDecimal = parseInt(chainId, 16); + const nonce = await fetchNonce(provider, address); + + const signature = await messenger.call( + 'KeyringController:signEip7702Authorization', + { + chainId: chainIdDecimal, + contractAddress: delegatorImplAddress, + nonce, + from: address, + }, + ); + + const { r, s, v, yParity } = splitEip7702Signature(signature); + + await messenger.call('ChompApiService:createUpgrade', { + r, + s, + v, + yParity, + address, + chainId: chainIdDecimal.toString(), + nonce: nonce.toString(), + }); + + return 'completed'; + }, +}; + +/** + * Splits a 65-byte ECDSA signature produced by + * `KeyringController:signEip7702Authorization` into its `r`, `s`, `v` + * components and derives `yParity` (`0` for `v = 27`, `1` for `v = 28`). + * + * @param signature - A 0x-prefixed 132-character hex string. Accepted in any + * case; normalized to lowercase before validation. + * @returns The signature components. + */ +function splitEip7702Signature(signature: unknown): { + r: Hex; + s: Hex; + v: number; + yParity: 0 | 1; +} { + const normalized = + typeof signature === 'string' ? signature.toLowerCase() : signature; + + if ( + !isStrictHexString(normalized) || + normalized.length !== SIGNATURE_HEX_LENGTH + ) { + throw new Error( + `Expected a 0x-prefixed 65-byte signature from signEip7702Authorization, got ${JSON.stringify(signature)}`, + ); + } + + // eslint-disable-next-line id-length + const v = parseInt(normalized.slice(S_END_INDEX, V_END_INDEX), 16); + if (v !== 27 && v !== 28) { + throw new Error( + `Expected v to be 27 or 28 in signEip7702Authorization signature, got ${v}`, + ); + } + + return { + r: normalized.slice(0, R_END_INDEX) as Hex, + s: add0x(normalized.slice(R_END_INDEX, S_END_INDEX)), + v, + yParity: v === V_BASE ? 0 : 1, + }; +} + +/** + * Reads the account's on-chain code and, if the account is currently + * delegated via EIP-7702, returns the implementation address the delegation + * points at. Returns `undefined` if the account has no code (a plain EOA). + * Throws if the code is present but not a valid EIP-7702 delegation, since + * that means the address is a regular contract and is not eligible for + * upgrade. + * + * @param provider - JSON-RPC provider for the target chain. + * @param address - The Money Account address. + * @returns The current delegation address, or `undefined` if none. + */ +async function fetchDelegationAddress( + provider: Provider, + address: Hex, +): Promise { + const code = await provider.request({ + method: 'eth_getCode', + params: [address, 'latest'], + }); + + if (typeof code !== 'string' || !code.startsWith('0x')) { + throw new Error( + `Expected 0x-prefixed hex string from eth_getCode, got ${JSON.stringify(code)}`, + ); + } + + const normalized = code.toLowerCase(); + + if (normalized === '0x') { + return undefined; + } + + if ( + normalized.length === EIP_7702_DELEGATED_CODE_LENGTH && + normalized.startsWith(EIP_7702_DELEGATION_PREFIX) + ) { + return add0x(normalized.slice(EIP_7702_DELEGATION_PREFIX.length)); + } + + throw new Error( + `Account ${address} has unexpected on-chain code; expected either no code or an EIP-7702 delegation.`, + ); +} + +/** + * Fetches the current on-chain transaction count for the given address by + * issuing an `eth_getTransactionCount` RPC request. + * + * @param provider - JSON-RPC provider for the target chain. + * @param address - The Money Account address. + * @returns The current nonce as a decimal number. + */ +async function fetchNonce(provider: Provider, address: Hex): Promise { + const nonceHex = await provider.request({ + method: 'eth_getTransactionCount', + params: [address, 'latest'], + }); + + if (!isStrictHexString(nonceHex)) { + throw new Error( + `Expected hex string from eth_getTransactionCount, got ${JSON.stringify(nonceHex)}`, + ); + } + + return parseInt(nonceHex, 16); +} + +/** + * Resolves the JSON-RPC provider for the given chain via NetworkController. + * + * @param messenger - The upgrade controller messenger. + * @param chainId - The chain to query. + * @returns The provider for that chain. + */ +function getProvider( + messenger: StepContext['messenger'], + chainId: Hex, +): Provider { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + return messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).provider; +} diff --git a/packages/money-account-upgrade-controller/src/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts similarity index 86% rename from packages/money-account-upgrade-controller/src/step.ts rename to packages/money-account-upgrade-controller/src/steps/step.ts index 588dbae59c8..fa164d33543 100644 --- a/packages/money-account-upgrade-controller/src/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -1,6 +1,6 @@ import type { Hex } from '@metamask/utils'; -import type { MoneyAccountUpgradeControllerMessenger } from './MoneyAccountUpgradeController'; +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; /** * Context supplied to each step when it is run. @@ -8,6 +8,8 @@ import type { MoneyAccountUpgradeControllerMessenger } from './MoneyAccountUpgra export type StepContext = { messenger: MoneyAccountUpgradeControllerMessenger; address: Hex; + chainId: Hex; + delegatorImplAddress: Hex; }; /** diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json index acb5f8ad014..b69bb81ccab 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -9,7 +9,9 @@ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../chomp-api-service/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, - { "path": "../messenger/tsconfig.build.json" } + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json index 9bd846f6b48..ffcde5ec67f 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -7,7 +7,9 @@ { "path": "../base-controller" }, { "path": "../chomp-api-service" }, { "path": "../keyring-controller" }, - { "path": "../messenger" } + { "path": "../messenger" }, + { "path": "../network-controller" }, + { "path": "../transaction-controller" } ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 12e8fa21d70..46f1c671312 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4524,6 +4524,7 @@ __metadata: "@metamask/chomp-api-service": "npm:^1.0.0" "@metamask/keyring-controller": "npm:^25.2.0" "@metamask/messenger": "npm:^1.1.1" + "@metamask/network-controller": "npm:^30.0.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14"