diff --git a/README.md b/README.md index edf5cf71813..707e713f2ea 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,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..57471c8f745 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. + ## [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..c02ee6aac80 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; + getUpgrade: jest.Mock; + createUpgrade: jest.Mock; + signEip7702Authorization: jest.Mock; + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; }; function setup(): { @@ -71,6 +76,9 @@ 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`; + const mocks: Mocks = { getServiceDetails: jest .fn() @@ -81,6 +89,21 @@ function setup(): { address: MOCK_ACCOUNT_ADDRESS, status: 'CREATED', }), + getUpgrade: jest.fn().mockResolvedValue(null), + 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: jest.fn().mockResolvedValue('0x0'), + }, + }), }; const rootMessenger = new Messenger({ @@ -99,6 +122,26 @@ function setup(): { 'ChompApiService:associateAddress', mocks.associateAddress, ); + rootMessenger.registerActionHandler( + 'ChompApiService:getUpgrade', + mocks.getUpgrade, + ); + 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 +153,11 @@ function setup(): { 'ChompApiService:getServiceDetails', 'KeyringController:signPersonalMessage', 'ChompApiService:associateAddress', + 'ChompApiService:getUpgrade', + 'ChompApiService:createUpgrade', + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', ], events: [], messenger, @@ -242,6 +290,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..1fb41e7863e 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -6,15 +6,25 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateUpgradeAction, ChompApiServiceGetServiceDetailsAction, + ChompApiServiceGetUpgradeAction, } 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 +48,13 @@ export type MoneyAccountUpgradeControllerActions = type AllowedActions = | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateUpgradeAction | ChompApiServiceGetServiceDetailsAction - | KeyringControllerSignPersonalMessageAction; + | ChompApiServiceGetUpgradeAction + | KeyringControllerSignEip7702AuthorizationAction + | KeyringControllerSignPersonalMessageAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; export type MoneyAccountUpgradeControllerStateChangedEvent = ControllerStateChangedEvent< @@ -66,9 +81,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 +103,6 @@ export class MoneyAccountUpgradeController extends BaseController< state: {}, }); - this.#initialized = false; - this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -101,9 +114,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 +140,10 @@ export class MoneyAccountUpgradeController extends BaseController< ); } - this.#initialized = true; + this.#config = { + chainId, + delegatorImplAddress: initConfig.delegatorImplAddress, + }; } /** @@ -139,14 +155,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..6e488439795 100644 --- a/packages/money-account-upgrade-controller/src/index.ts +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -1,4 +1,4 @@ -export type { Step, StepResult } from './step'; +export type { Step, StepResult } from './steps/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..e86dcc18ae9 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts @@ -0,0 +1,270 @@ +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_NETWORK_CLIENT_ID = 'network-client-id'; +const MOCK_NONCE_HEX = '0x7'; +const MOCK_NONCE = 7; + +// 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 Mocks = { + getUpgrade: jest.Mock; + createUpgrade: jest.Mock; + signEip7702Authorization: jest.Mock; + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + providerRequest: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const providerRequest = jest.fn().mockResolvedValue(MOCK_NONCE_HEX); + + const mocks: Mocks = { + getUpgrade: jest.fn().mockResolvedValue(null), + 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, + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'ChompApiService:getUpgrade', + mocks.getUpgrade, + ); + 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:getUpgrade', + '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 CHOMP already has an upgrade record for the address', () => { + it('returns "already-done" and does not sign or submit', async () => { + const { messenger, mocks } = setup(); + mocks.getUpgrade.mockResolvedValue({ + signerAddress: MOCK_ADDRESS, + status: 'upgraded', + createdAt: '2026-04-20T12:00:00.000Z', + }); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.providerRequest).not.toHaveBeenCalled(); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('treats "pending" as already-done', async () => { + const { messenger, mocks } = setup(); + mocks.getUpgrade.mockResolvedValue({ + signerAddress: MOCK_ADDRESS, + status: 'pending', + createdAt: '2026-04-20T12:00:00.000Z', + }); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + }); + + describe('when no upgrade record exists', () => { + it('fetches the on-chain nonce for the address on 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, + ); + expect(mocks.providerRequest).toHaveBeenCalledWith({ + method: 'eth_getTransactionCount', + params: [MOCK_ADDRESS, 'latest'], + }); + }); + + it('signs the EIP-7702 authorization with decoded chainId, delegator impl, and nonce', 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.mockResolvedValue(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(); + }, + ); + }); +}); 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..ba3556f9d32 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts @@ -0,0 +1,131 @@ +import { add0x, isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { Step, StepContext } from './step'; + +/** + * Submits the EIP-7702 delegation-slot authorization to CHOMP so the Money + * Account can be upgraded to a smart account pointed at + * `EIP7702StatelessDeleGatorImpl`. + * + * The step: + * + * 1. Asks CHOMP whether an upgrade record already exists for the address — + * any record (including `status: 'pending'`) means the authorization is + * already submitted and CHOMP will apply it before running any intent, so + * the step reports `'already-done'`. + * 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 existing = await messenger.call( + 'ChompApiService:getUpgrade', + address, + ); + if (existing !== null) { + return 'already-done'; + } + + const chainIdDecimal = parseInt(chainId, 16); + const nonce = await fetchNonce(messenger, chainId, 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. + * @returns The signature components. + */ +function splitEip7702Signature(signature: string): { + r: Hex; + s: Hex; + v: number; + yParity: 0 | 1; +} { + if (!isStrictHexString(signature) || signature.length !== 132) { + 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(signature.slice(130, 132), 16); + + return { + r: signature.slice(0, 66) as Hex, + s: add0x(signature.slice(66, 130)), + v, + yParity: v - 27 === 0 ? 0 : 1, + }; +} + +/** + * Fetches the current on-chain transaction count for the given address on the + * given chain by resolving the chain's network client and issuing an + * `eth_getTransactionCount` RPC request. + * + * @param messenger - The upgrade controller messenger. + * @param chainId - The chain to query. + * @param address - The Money Account address. + * @returns The current nonce as a decimal number. + */ +async function fetchNonce( + messenger: StepContext['messenger'], + chainId: Hex, + address: Hex, +): Promise { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + const nonceHex = await networkClient.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); +} 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..18c4df6b2c2 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -9,7 +9,8 @@ { "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" } ], "include": ["../../types", "./src"] } diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json index 9bd846f6b48..668583af548 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -7,7 +7,8 @@ { "path": "../base-controller" }, { "path": "../chomp-api-service" }, { "path": "../keyring-controller" }, - { "path": "../messenger" } + { "path": "../messenger" }, + { "path": "../network-controller" } ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 0115ae2a73f..23a14f7ebcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4516,6 +4516,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"