diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index e25b3dd0..4ea1833e 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `EthKeyringWrapper` abstract class for Ethereum-based `KeyringV2` implementations ([#404](https://github.com/MetaMask/accounts/pull/404)) + - Provides common Ethereum signing method routing (`submitRequest`) for all Ethereum-based keyrings. - Add `KeyringWrapper` base class to adapt legacy keyrings to `KeyringV2` ([#398](https://github.com/MetaMask/accounts/pull/398)) ### Changed diff --git a/packages/keyring-api/package.json b/packages/keyring-api/package.json index 4fd74e54..42bf1907 100644 --- a/packages/keyring-api/package.json +++ b/packages/keyring-api/package.json @@ -46,6 +46,8 @@ "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/tx": "^5.4.0", + "@metamask/eth-sig-util": "^8.2.0", "@metamask/keyring-utils": "workspace:^", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.1.0", diff --git a/packages/keyring-api/src/eth/index.ts b/packages/keyring-api/src/eth/index.ts index 50d26630..48a9212a 100644 --- a/packages/keyring-api/src/eth/index.ts +++ b/packages/keyring-api/src/eth/index.ts @@ -3,3 +3,4 @@ export * from './erc4337'; export * from './rpc'; export * from './types'; export * from './utils'; +export * from './v2'; diff --git a/packages/keyring-api/src/eth/rpc/params.ts b/packages/keyring-api/src/eth/rpc/params.ts index 79c7e85d..2331f381 100644 --- a/packages/keyring-api/src/eth/rpc/params.ts +++ b/packages/keyring-api/src/eth/rpc/params.ts @@ -126,6 +126,14 @@ export type EthEip7702Authorization = Infer< typeof EthEip7702AuthorizationStruct >; +/** + * A struct for getEncryptionPublicKey options. + */ +export const EthGetEncryptionPublicKeyOptionsStruct = record( + string(), + unknown(), +); + // ============================================================================ // RPC Method Parameter Structs // ============================================================================ @@ -214,3 +222,11 @@ export const EthSignEip7702AuthorizationParamsStruct = tuple([ export type EthSignEip7702AuthorizationParams = Infer< typeof EthSignEip7702AuthorizationParamsStruct >; + +/** + * Parameters for `eth_getEncryptionPublicKey`. + */ +export const EthGetEncryptionPublicKeyParamsStruct = tuple([ + EthAddressStruct, // address + optional(EthGetEncryptionPublicKeyOptionsStruct), // options +]); diff --git a/packages/keyring-api/src/eth/v2/eth-keyring-wrapper.test.ts b/packages/keyring-api/src/eth/v2/eth-keyring-wrapper.test.ts new file mode 100644 index 00000000..6e4323f0 --- /dev/null +++ b/packages/keyring-api/src/eth/v2/eth-keyring-wrapper.test.ts @@ -0,0 +1,553 @@ +import type { TypedTxData } from '@ethereumjs/tx'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import type { Keyring } from '@metamask/keyring-utils'; +import type { Hex, Json } from '@metamask/utils'; + +import { EthKeyringMethod, EthKeyringWrapper } from './eth-keyring-wrapper'; +import { EthAccountType } from '../../api/account'; +import type { KeyringAccount } from '../../api/account'; +import type { KeyringRequest } from '../../api/request'; +import type { CreateAccountOptions } from '../../api/v2/create-account'; +import { KeyringType } from '../../api/v2/keyring-type'; +import { EthScope } from '../constants'; +import { EthMethod } from '../types'; + +const MOCK_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Hex; +const MOCK_ACCOUNT_ID = '00000000-0000-0000-0000-000000000001'; + +const TEST_METHODS = [ + EthMethod.SignTransaction, + EthMethod.Sign, + EthMethod.PersonalSign, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + EthKeyringMethod.Decrypt, + EthKeyringMethod.GetEncryptionPublicKey, + EthKeyringMethod.GetAppKeyAddress, + EthKeyringMethod.SignEip7702Authorization, +]; + +/** + * Creates a mock Keyring with configurable method implementations. + * Only implements the required Keyring interface methods. + * + * @param overrides - Optional partial Keyring to override default mock implementations. + * @returns A mock Keyring instance. + */ +function createMockKeyring(overrides: Partial = {}): Keyring { + return { + type: 'Mock Keyring', + getAccounts: jest.fn().mockResolvedValue([MOCK_ADDRESS]), + addAccounts: jest.fn().mockResolvedValue([]), + serialize: jest.fn().mockResolvedValue({}), + deserialize: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +/** + * Minimal concrete implementation of EthKeyringWrapper for testing. + * Only implements abstract methods. + */ +class TestEthKeyringWrapper extends EthKeyringWrapper { + readonly #mockAccount: KeyringAccount = { + id: MOCK_ACCOUNT_ID, + type: EthAccountType.Eoa, + address: MOCK_ADDRESS, + scopes: [EthScope.Eoa], + methods: [...TEST_METHODS], + options: {}, + }; + + constructor(inner: Keyring) { + super({ + type: KeyringType.Hd, + inner, + capabilities: { scopes: [EthScope.Eoa] }, + }); + this.registry.register(MOCK_ADDRESS); + this.registry.set(this.#mockAccount); + } + + async getAccounts(): Promise { + return [this.#mockAccount]; + } + + async createAccounts(_opts: CreateAccountOptions): Promise { + return []; + } + + async deleteAccount(_id: string): Promise { + // noop + } + + // Expose protected method for testing + public testToHexAddress(address: string): Hex { + return this.toHexAddress(address); + } +} + +/** + * Test wrapper with an extra unsupported method in account.methods. + * Used to test the default case in submitRequest switch. + */ +class TestEthKeyringWrapperWithUnsupportedMethod extends TestEthKeyringWrapper { + readonly #mockAccount: KeyringAccount = { + id: MOCK_ACCOUNT_ID, + type: EthAccountType.Eoa, + address: MOCK_ADDRESS, + scopes: [EthScope.Eoa], + methods: [...TEST_METHODS, 'eth_unsupported'], + options: {}, + }; + + constructor(inner: Keyring) { + super(inner); + this.registry.set(this.#mockAccount); + } + + async getAccounts(): Promise { + return [this.#mockAccount]; + } +} + +/** + * Creates a mock KeyringRequest for testing. + * + * @param method - The RPC method name. + * @param params - Optional array of parameters. + * @returns A KeyringRequest object. + */ +function createMockRequest( + method: string, + params: Json[] = [], +): KeyringRequest { + return { + id: '00000000-0000-0000-0000-000000000000', + scope: EthScope.Eoa, + account: MOCK_ACCOUNT_ID, + origin: 'https://example.com', + request: { method, params }, + }; +} + +describe('EthKeyringWrapper', () => { + describe('toHexAddress', () => { + it('adds 0x prefix to address without prefix', () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + expect(wrapper.testToHexAddress('1234')).toBe('0x1234'); + }); + + it('keeps 0x prefix if already present', () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + expect(wrapper.testToHexAddress('0x1234')).toBe('0x1234'); + }); + }); + + describe('submitRequest', () => { + describe('eth_signTransaction', () => { + it('calls inner.signTransaction and returns result', async () => { + const mockSignTransaction = jest + .fn() + .mockResolvedValue({ gasLimit: '0x0' } as TypedTxData); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signTransaction: mockSignTransaction }), + ); + + const result = await wrapper.submitRequest( + createMockRequest(EthMethod.SignTransaction, [{ to: MOCK_ADDRESS }]), + ); + + expect(mockSignTransaction).toHaveBeenCalledWith( + MOCK_ADDRESS, + expect.any(Object), + ); + expect(result).toStrictEqual({ gasLimit: '0x0' }); + }); + + it('throws when keyring does not support signTransaction', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.SignTransaction, [ + { to: MOCK_ADDRESS }, + ]), + ), + ).rejects.toThrow('Keyring does not support signTransaction'); + }); + + it('throws for invalid params', async () => { + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signTransaction: jest.fn() }), + ); + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.SignTransaction, []), + ), + ).rejects.toThrow(expect.any(Error)); + }); + }); + + describe('eth_sign', () => { + it('calls inner.signMessage and returns result', async () => { + const mockSignMessage = jest.fn().mockResolvedValue('0xsig'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signMessage: mockSignMessage }), + ); + // eth_sign requires 32 bytes (64 hex chars) of data + const data = `0x${'1234'.repeat(16)}`; + + const result = await wrapper.submitRequest( + createMockRequest(EthMethod.Sign, [MOCK_ADDRESS, data]), + ); + + expect(mockSignMessage).toHaveBeenCalledWith(MOCK_ADDRESS, data); + expect(result).toBe('0xsig'); + }); + + it('throws when keyring does not support signMessage', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + const data = `0x${'1234'.repeat(16)}`; + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.Sign, [MOCK_ADDRESS, data]), + ), + ).rejects.toThrow('Keyring does not support signMessage'); + }); + }); + + describe('personal_sign', () => { + it('calls inner.signPersonalMessage and returns result', async () => { + const mockSignPersonalMessage = jest.fn().mockResolvedValue('0xsig'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signPersonalMessage: mockSignPersonalMessage }), + ); + const data = '0x68656c6c6f'; // "hello" in hex + + const result = await wrapper.submitRequest( + createMockRequest(EthMethod.PersonalSign, [data]), + ); + + expect(mockSignPersonalMessage).toHaveBeenCalledWith( + MOCK_ADDRESS, + data, + ); + expect(result).toBe('0xsig'); + }); + + it('throws when keyring does not support signPersonalMessage', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + const data = '0x68656c6c6f'; + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.PersonalSign, [data]), + ), + ).rejects.toThrow('Keyring does not support signPersonalMessage'); + }); + }); + + describe('eth_signTypedData_v1', () => { + it('calls inner.signTypedData with V1 and returns result', async () => { + const mockSignTypedData = jest.fn().mockResolvedValue('0xsig'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signTypedData: mockSignTypedData }), + ); + const typedData = [{ type: 'string', name: 'test', value: 'test' }]; + + const result = await wrapper.submitRequest( + createMockRequest(EthMethod.SignTypedDataV1, [ + MOCK_ADDRESS, + typedData, + ]), + ); + + expect(mockSignTypedData).toHaveBeenCalledWith( + MOCK_ADDRESS, + typedData, + { + version: SignTypedDataVersion.V1, + }, + ); + expect(result).toBe('0xsig'); + }); + + it('throws when keyring does not support signTypedData', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.SignTypedDataV1, [MOCK_ADDRESS, []]), + ), + ).rejects.toThrow('Keyring does not support signTypedData'); + }); + }); + + describe('eth_signTypedData_v3', () => { + it('calls inner.signTypedData with V3 and returns result', async () => { + const mockSignTypedData = jest.fn().mockResolvedValue('0xsig'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signTypedData: mockSignTypedData }), + ); + const typedData = { + types: {}, + domain: {}, + primaryType: 'Test', + message: {}, + }; + + const result = await wrapper.submitRequest( + createMockRequest(EthMethod.SignTypedDataV3, [ + MOCK_ADDRESS, + typedData, + ]), + ); + + expect(mockSignTypedData).toHaveBeenCalledWith( + MOCK_ADDRESS, + typedData, + { version: SignTypedDataVersion.V3 }, + ); + expect(result).toBe('0xsig'); + }); + + it('throws when keyring does not support signTypedData', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.SignTypedDataV3, [MOCK_ADDRESS, {}]), + ), + ).rejects.toThrow('Keyring does not support signTypedData'); + }); + }); + + describe('eth_signTypedData_v4', () => { + it('calls inner.signTypedData with V4 and returns result', async () => { + const mockSignTypedData = jest.fn().mockResolvedValue('0xsig'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ signTypedData: mockSignTypedData }), + ); + const typedData = { + types: {}, + domain: {}, + primaryType: 'Test', + message: {}, + }; + + const result = await wrapper.submitRequest( + createMockRequest(EthMethod.SignTypedDataV4, [ + MOCK_ADDRESS, + typedData, + ]), + ); + + expect(mockSignTypedData).toHaveBeenCalledWith( + MOCK_ADDRESS, + typedData, + { version: SignTypedDataVersion.V4 }, + ); + expect(result).toBe('0xsig'); + }); + + it('throws when keyring does not support signTypedData', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthMethod.SignTypedDataV4, [MOCK_ADDRESS, {}]), + ), + ).rejects.toThrow('Keyring does not support signTypedData'); + }); + }); + + describe('eth_decrypt', () => { + it('calls inner.decryptMessage and returns result', async () => { + const mockDecryptMessage = jest.fn().mockResolvedValue('decrypted'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ decryptMessage: mockDecryptMessage }), + ); + const encryptedData = { + version: 'x25519-xsalsa20-poly1305', + nonce: 'n', + ephemPublicKey: 'k', + ciphertext: 'c', + }; + + const result = await wrapper.submitRequest( + createMockRequest(EthKeyringMethod.Decrypt, [encryptedData]), + ); + + expect(mockDecryptMessage).toHaveBeenCalledWith( + MOCK_ADDRESS, + encryptedData, + ); + expect(result).toBe('decrypted'); + }); + + it('throws when keyring does not support decryptMessage', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthKeyringMethod.Decrypt, [ + { + version: 'x', + nonce: 'n', + ephemPublicKey: 'k', + ciphertext: 'c', + }, + ]), + ), + ).rejects.toThrow('Keyring does not support decryptMessage'); + }); + }); + + describe('eth_getEncryptionPublicKey', () => { + it('calls inner.getEncryptionPublicKey and returns result', async () => { + const mockGetEncryptionPublicKey = jest + .fn() + .mockResolvedValue('pubkey'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ + getEncryptionPublicKey: mockGetEncryptionPublicKey, + }), + ); + + const result = await wrapper.submitRequest( + createMockRequest(EthKeyringMethod.GetEncryptionPublicKey, [ + MOCK_ADDRESS, + ]), + ); + + expect(mockGetEncryptionPublicKey).toHaveBeenCalledWith( + MOCK_ADDRESS, + undefined, + ); + expect(result).toBe('pubkey'); + }); + + it('throws when keyring does not support getEncryptionPublicKey', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthKeyringMethod.GetEncryptionPublicKey, []), + ), + ).rejects.toThrow('Keyring does not support getEncryptionPublicKey'); + }); + }); + + describe('eth_getAppKeyAddress', () => { + it('calls inner.getAppKeyAddress and returns result', async () => { + const mockGetAppKeyAddress = jest.fn().mockResolvedValue('0xappkey'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ getAppKeyAddress: mockGetAppKeyAddress }), + ); + + const result = await wrapper.submitRequest( + createMockRequest(EthKeyringMethod.GetAppKeyAddress, [ + 'https://example.com', + ]), + ); + + expect(mockGetAppKeyAddress).toHaveBeenCalledWith( + MOCK_ADDRESS, + 'https://example.com', + ); + expect(result).toBe('0xappkey'); + }); + + it('throws when keyring does not support getAppKeyAddress', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthKeyringMethod.GetAppKeyAddress, [ + 'https://example.com', + ]), + ), + ).rejects.toThrow('Keyring does not support getAppKeyAddress'); + }); + }); + + describe('eth_signEip7702Authorization', () => { + it('calls inner.signEip7702Authorization and returns result', async () => { + const mockSignEip7702Authorization = jest + .fn() + .mockResolvedValue('0xauth'); + const wrapper = new TestEthKeyringWrapper( + createMockKeyring({ + signEip7702Authorization: mockSignEip7702Authorization, + }), + ); + const authorization = [1, MOCK_ADDRESS, 0]; + + const result = await wrapper.submitRequest( + createMockRequest(EthKeyringMethod.SignEip7702Authorization, [ + authorization, + ]), + ); + + expect(mockSignEip7702Authorization).toHaveBeenCalledWith( + MOCK_ADDRESS, + authorization, + ); + expect(result).toBe('0xauth'); + }); + + it('throws when keyring does not support signEip7702Authorization', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest( + createMockRequest(EthKeyringMethod.SignEip7702Authorization, [ + [1, MOCK_ADDRESS, 0], + ]), + ), + ).rejects.toThrow('Keyring does not support signEip7702Authorization'); + }); + }); + + describe('error handling', () => { + it('throws when account cannot handle the method', async () => { + const wrapper = new TestEthKeyringWrapper(createMockKeyring()); + + await expect( + wrapper.submitRequest(createMockRequest('unsupported_method', [])), + ).rejects.toThrow( + `Account ${MOCK_ACCOUNT_ID} cannot handle method: unsupported_method`, + ); + }); + + it('throws for unrecognized method in switch default case', async () => { + const wrapper = new TestEthKeyringWrapperWithUnsupportedMethod( + createMockKeyring(), + ); + + await expect( + wrapper.submitRequest(createMockRequest('eth_unsupported', [])), + ).rejects.toThrow( + 'Unsupported method for EthKeyringWrapper: eth_unsupported', + ); + }); + }); + }); + + describe('EthKeyringMethod enum', () => { + it('has correct values', () => { + expect(EthKeyringMethod.Decrypt).toBe('eth_decrypt'); + expect(EthKeyringMethod.GetEncryptionPublicKey).toBe( + 'eth_getEncryptionPublicKey', + ); + expect(EthKeyringMethod.GetAppKeyAddress).toBe('eth_getAppKeyAddress'); + expect(EthKeyringMethod.SignEip7702Authorization).toBe( + 'eth_signEip7702Authorization', + ); + }); + }); +}); diff --git a/packages/keyring-api/src/eth/v2/eth-keyring-wrapper.ts b/packages/keyring-api/src/eth/v2/eth-keyring-wrapper.ts new file mode 100644 index 00000000..08b72af4 --- /dev/null +++ b/packages/keyring-api/src/eth/v2/eth-keyring-wrapper.ts @@ -0,0 +1,213 @@ +import { TransactionFactory, type TypedTxData } from '@ethereumjs/tx'; +import type { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import type { EthKeyring } from '@metamask/keyring-utils'; +import { assert } from '@metamask/superstruct'; +import { add0x, type Hex, type Json } from '@metamask/utils'; + +import type { KeyringAccount } from '../../api/account'; +import type { KeyringRequest } from '../../api/request'; +import { + KeyringWrapper, + type KeyringWrapperOptions, +} from '../../api/v2/wrapper/keyring-wrapper'; +import { + EthDecryptParamsStruct, + EthGetAppKeyAddressParamsStruct, + EthGetEncryptionPublicKeyParamsStruct, + EthPersonalSignParamsStruct, + EthSignEip7702AuthorizationParamsStruct, + EthSignParamsStruct, + EthSignTransactionParamsStruct, + EthSignTypedDataParamsStruct, + EthSignTypedDataV1ParamsStruct, +} from '../rpc'; +import { EthMethod } from '../types'; + +/** + * Additional Ethereum methods supported by Eth keyrings that are not in the standard EthMethod enum. + * These are primarily encryption and utility methods. + */ +export enum EthKeyringMethod { + Decrypt = 'eth_decrypt', + GetEncryptionPublicKey = 'eth_getEncryptionPublicKey', + GetAppKeyAddress = 'eth_getAppKeyAddress', + SignEip7702Authorization = 'eth_signEip7702Authorization', +} + +/** + * Options for constructing an EthKeyringWrapper. + */ +export type EthKeyringWrapperOptions = + KeyringWrapperOptions; + +/** + * Abstract wrapper for Ethereum-based keyrings that extends KeyringWrapper, that itself implements KeyringV2. + * + * This class provides common functionality for all Ethereum keyrings including: + * - Request handling for standard Ethereum signing methods + * - Helper methods for Hex address conversion + * + * Subclasses must implement: + * - `getAccounts()`: Return all managed accounts + * - `createAccounts()`: Create new accounts based on options + * - `deleteAccount()`: Remove an account from the keyring + * - `exportAccount()` (optional): Export private key in specified format + */ +export abstract class EthKeyringWrapper< + InnerKeyring extends EthKeyring, + KeyringAccountType extends KeyringAccount = KeyringAccount, +> extends KeyringWrapper { + /** + * Helper method to safely cast a KeyringAccount address to Hex type. + * The KeyringAccount.address is typed as string, but for Ethereum accounts + * it should always be a valid Hex address. + * + * @param address - The address from a KeyringAccount. + * @returns The address as Hex type. + */ + protected toHexAddress(address: string): Hex { + return add0x(address); + } + + /** + * Handle an Ethereum signing request. + * + * Routes the request to the appropriate legacy keyring method based on + * the RPC method name. + * + * @param request - The keyring request containing method and params. + * @returns The result of the signing operation. + */ + async submitRequest(request: KeyringRequest): Promise { + const { method, params } = request.request; + + const { address, methods } = await this.getAccount(request.account); + const hexAddress = this.toHexAddress(address); + + // Validate account can handle the method + if (!methods.includes(method)) { + throw new Error( + `Account ${request.account} cannot handle method: ${method}`, + ); + } + + switch (method) { + case `${EthMethod.SignTransaction}`: { + if (!this.inner.signTransaction) { + throw new Error('Keyring does not support signTransaction'); + } + assert(params, EthSignTransactionParamsStruct); + const [txData] = params; + // Convert validated transaction data to TypedTransaction + // TODO: Improve typing to ensure txData matches TypedTxData + const tx = TransactionFactory.fromTxData(txData as TypedTxData); + // Note: Bigints are not directly representable in JSON + return (await this.inner.signTransaction( + hexAddress, + tx, + )) as unknown as Json; // FIXME: Should return type be unknown? + } + + case `${EthMethod.Sign}`: { + if (!this.inner.signMessage) { + throw new Error('Keyring does not support signMessage'); + } + assert(params, EthSignParamsStruct); + const [, data] = params; + return this.inner.signMessage(hexAddress, data); + } + + case `${EthMethod.PersonalSign}`: { + if (!this.inner.signPersonalMessage) { + throw new Error('Keyring does not support signPersonalMessage'); + } + assert(params, EthPersonalSignParamsStruct); + const [data] = params; + return this.inner.signPersonalMessage(hexAddress, data as Hex); + } + + case `${EthMethod.SignTypedDataV1}`: { + if (!this.inner.signTypedData) { + throw new Error('Keyring does not support signTypedData'); + } + assert(params, EthSignTypedDataV1ParamsStruct); + const [, data] = params; + return this.inner.signTypedData(hexAddress, data, { + version: SignTypedDataVersion.V1, + }); + } + + case `${EthMethod.SignTypedDataV3}`: { + if (!this.inner.signTypedData) { + throw new Error('Keyring does not support signTypedData'); + } + assert(params, EthSignTypedDataParamsStruct); + const [, data] = params; + return this.inner.signTypedData( + hexAddress, + // TODO: Improve typing to ensure data matches MessageTypes + data as TypedMessage, + { + version: SignTypedDataVersion.V3, + }, + ); + } + + case `${EthMethod.SignTypedDataV4}`: { + if (!this.inner.signTypedData) { + throw new Error('Keyring does not support signTypedData'); + } + assert(params, EthSignTypedDataParamsStruct); + const [, data] = params; + return this.inner.signTypedData( + hexAddress, + // TODO: Improve typing to ensure data matches MessageTypes + data as TypedMessage, + { + version: SignTypedDataVersion.V4, + }, + ); + } + + case `${EthKeyringMethod.Decrypt}`: { + if (!this.inner.decryptMessage) { + throw new Error('Keyring does not support decryptMessage'); + } + assert(params, EthDecryptParamsStruct); + const [encryptedData] = params; + return this.inner.decryptMessage(hexAddress, encryptedData); + } + + case `${EthKeyringMethod.GetEncryptionPublicKey}`: { + if (!this.inner.getEncryptionPublicKey) { + throw new Error('Keyring does not support getEncryptionPublicKey'); + } + assert(params, EthGetEncryptionPublicKeyParamsStruct); + const [, options] = params; + return this.inner.getEncryptionPublicKey(hexAddress, options); + } + + case `${EthKeyringMethod.GetAppKeyAddress}`: { + if (!this.inner.getAppKeyAddress) { + throw new Error('Keyring does not support getAppKeyAddress'); + } + assert(params, EthGetAppKeyAddressParamsStruct); + const [origin] = params; + return this.inner.getAppKeyAddress(hexAddress, origin); + } + + case `${EthKeyringMethod.SignEip7702Authorization}`: { + if (!this.inner.signEip7702Authorization) { + throw new Error('Keyring does not support signEip7702Authorization'); + } + assert(params, EthSignEip7702AuthorizationParamsStruct); + const [authorization] = params; + return this.inner.signEip7702Authorization(hexAddress, authorization); + } + + default: + throw new Error(`Unsupported method for EthKeyringWrapper: ${method}`); + } + } +} diff --git a/packages/keyring-api/src/eth/v2/index.ts b/packages/keyring-api/src/eth/v2/index.ts new file mode 100644 index 00000000..1fcef8bb --- /dev/null +++ b/packages/keyring-api/src/eth/v2/index.ts @@ -0,0 +1 @@ +export * from './eth-keyring-wrapper'; diff --git a/packages/keyring-eth-hd/CHANGELOG.md b/packages/keyring-eth-hd/CHANGELOG.md index dda11516..50c72485 100644 --- a/packages/keyring-eth-hd/CHANGELOG.md +++ b/packages/keyring-eth-hd/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `HdKeyringV2` class implementing `KeyringV2` interface ([#398](https://github.com/MetaMask/accounts/pull/398)) +- Add `HdKeyringV2` class implementing `KeyringV2` interface ([#398](https://github.com/MetaMask/accounts/pull/398)), ([#404](https://github.com/MetaMask/accounts/pull/404)) - Wraps legacy `HdKeyring` to expose accounts via the unified `KeyringV2` API and the `KeyringAccount` type. + - Extends `EthKeyringWrapper` for common Ethereum logic. ## [13.0.0] diff --git a/packages/keyring-eth-hd/src/hd-keyring-v2.test.ts b/packages/keyring-eth-hd/src/hd-keyring-v2.test.ts index c53dcdce..70d95f65 100644 --- a/packages/keyring-eth-hd/src/hd-keyring-v2.test.ts +++ b/packages/keyring-eth-hd/src/hd-keyring-v2.test.ts @@ -906,7 +906,7 @@ describe('HdKeyringV2', () => { const request = createMockRequest( accountId, 'eth_getEncryptionPublicKey', - [], + ['0x0000000000000000000000000000000000000000'], ); const result = await wrapper.submitRequest(request); @@ -920,7 +920,7 @@ describe('HdKeyringV2', () => { const pubKeyRequest = createMockRequest( accountId, 'eth_getEncryptionPublicKey', - [], + ['0x0000000000000000000000000000000000000000', {}], ); const pubKey = await wrapper.submitRequest(pubKeyRequest); diff --git a/packages/keyring-eth-hd/src/hd-keyring-v2.ts b/packages/keyring-eth-hd/src/hd-keyring-v2.ts index 01d8aba1..e23574dd 100644 --- a/packages/keyring-eth-hd/src/hd-keyring-v2.ts +++ b/packages/keyring-eth-hd/src/hd-keyring-v2.ts @@ -1,10 +1,9 @@ -import { TransactionFactory, type TypedTxData } from '@ethereumjs/tx'; import type { Bip44Account } from '@metamask/account-api'; -import type { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; -import { SignTypedDataVersion } from '@metamask/eth-sig-util'; import { type CreateAccountOptions, EthAccountType, + EthKeyringMethod, + EthKeyringWrapper, EthMethod, EthScope, type ExportAccountOptions, @@ -12,38 +11,33 @@ import { type KeyringAccount, KeyringAccountEntropyTypeOption, type KeyringCapabilities, - type KeyringRequest, - KeyringType, type KeyringV2, - KeyringWrapper, + KeyringType, PrivateKeyEncoding, - EthDecryptParamsStruct, - EthGetAppKeyAddressParamsStruct, - EthPersonalSignParamsStruct, - EthSignEip7702AuthorizationParamsStruct, - EthSignParamsStruct, - EthSignTransactionParamsStruct, - EthSignTypedDataParamsStruct, - EthSignTypedDataV1ParamsStruct, type EntropySourceId, } from '@metamask/keyring-api'; import type { AccountId } from '@metamask/keyring-utils'; -import { assert } from '@metamask/superstruct'; import { add0x, type Hex, type Json } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import type { DeserializableHDKeyringState, HdKeyring } from './hd-keyring'; /** - * Additional Ethereum methods supported by HD keyring that are not in the standard EthMethod enum. - * These are primarily encryption and utility methods. + * Methods supported by HD keyring EOA accounts. + * HD keyrings support all standard signing methods plus encryption and app keys. */ -enum HdKeyringEthMethod { - Decrypt = 'eth_decrypt', - GetEncryptionPublicKey = 'eth_getEncryptionPublicKey', - GetAppKeyAddress = 'eth_getAppKeyAddress', - SignEip7702Authorization = 'eth_signEip7702Authorization', -} +const HD_KEYRING_METHODS = [ + EthMethod.SignTransaction, + EthMethod.Sign, + EthMethod.PersonalSign, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + EthKeyringMethod.Decrypt, + EthKeyringMethod.GetEncryptionPublicKey, + EthKeyringMethod.GetAppKeyAddress, + EthKeyringMethod.SignEip7702Authorization, +]; const hdKeyringV2Capabilities: KeyringCapabilities = { scopes: [EthScope.Eoa], @@ -57,23 +51,6 @@ const hdKeyringV2Capabilities: KeyringCapabilities = { }, }; -/** - * Methods supported by HD keyring EOA accounts. - * Combines standard Ethereum methods with HD keyring-specific methods. - */ -const HD_KEYRING_EOA_METHODS = [ - EthMethod.SignTransaction, - EthMethod.Sign, - EthMethod.PersonalSign, - EthMethod.SignTypedDataV1, - EthMethod.SignTypedDataV3, - EthMethod.SignTypedDataV4, - HdKeyringEthMethod.Decrypt, - HdKeyringEthMethod.GetEncryptionPublicKey, - HdKeyringEthMethod.GetAppKeyAddress, - HdKeyringEthMethod.SignEip7702Authorization, -]; - /** * Concrete {@link KeyringV2} adapter for {@link HdKeyring}. * @@ -86,7 +63,7 @@ export type HdKeyringV2Options = { }; export class HdKeyringV2 - extends KeyringWrapper> + extends EthKeyringWrapper> implements KeyringV2 { protected readonly entropySource: EntropySourceId; @@ -116,18 +93,6 @@ export class HdKeyringV2 return address === lastAddress; } - /** - * Helper method to safely cast a KeyringAccount address to Hex type. - * The KeyringAccount.address is typed as string, but for Ethereum accounts - * it should always be a valid Hex address. - * - * @param address - The address from a KeyringAccount. - * @returns The address as Hex type. - */ - #toHexAddress(address: string): Hex { - return add0x(address); - } - /** * Creates a KeyringAccount object for the given address and index. * @@ -146,7 +111,7 @@ export class HdKeyringV2 type: EthAccountType.Eoa, address, scopes: [...this.capabilities.scopes], - methods: [...HD_KEYRING_EOA_METHODS], + methods: [...HD_KEYRING_METHODS], options: { entropy: { type: KeyringAccountEntropyTypeOption.Mnemonic, @@ -258,7 +223,7 @@ export class HdKeyringV2 await this.#lock.runExclusive(async () => { // Get the account first, before any registry operations const { address } = await this.getAccount(accountId); - const hexAddress = this.#toHexAddress(address); + const hexAddress = this.toHexAddress(address); // Assert that the account to delete is the last one in the inner keyring // We check against the inner keyring directly to avoid stale registry issues @@ -276,6 +241,13 @@ export class HdKeyringV2 }); } + /** + * Export the private key for an account in hexadecimal format. + * + * @param accountId - The ID of the account to export. + * @param options - Export options (only hexadecimal encoding is supported). + * @returns The exported account with private key. + */ async exportAccount( accountId: AccountId, options?: ExportAccountOptions, @@ -294,116 +266,14 @@ export class HdKeyringV2 // The legacy HdKeyring returns a hex string without 0x prefix. const privateKeyWithout0x = await this.inner.exportAccount( - this.#toHexAddress(account.address), + this.toHexAddress(account.address), ); const privateKey = add0x(privateKeyWithout0x); - const exported: ExportedAccount = { + return { type: 'private-key', privateKey, encoding: PrivateKeyEncoding.Hexadecimal, }; - - return exported; - } - - async submitRequest(request: KeyringRequest): Promise { - const { method, params = [] } = request.request; - - const { address, methods } = await this.getAccount(request.account); - const hexAddress = this.#toHexAddress(address); - - // Validate account can handle the method - if (!methods.includes(method)) { - throw new Error( - `Account ${request.account} cannot handle method: ${method}`, - ); - } - - switch (method) { - case `${EthMethod.SignTransaction}`: { - assert(params, EthSignTransactionParamsStruct); - const [txData] = params; - // Convert validated transaction data to TypedTransaction - // TODO: Improve typing to ensure txData matches TypedTxData - const tx = TransactionFactory.fromTxData(txData as TypedTxData); - // Note: Bigints are not directly representable in JSON - return (await this.inner.signTransaction( - hexAddress, - tx, - )) as unknown as Json; // FIXME: Should return type be unknown? - } - - case `${EthMethod.Sign}`: { - assert(params, EthSignParamsStruct); - const [, data] = params; - return this.inner.signMessage(hexAddress, data); - } - - case `${EthMethod.PersonalSign}`: { - assert(params, EthPersonalSignParamsStruct); - const [data] = params; - return this.inner.signPersonalMessage(hexAddress, data); - } - - case `${EthMethod.SignTypedDataV1}`: { - assert(params, EthSignTypedDataV1ParamsStruct); - const [, data] = params; - return this.inner.signTypedData(hexAddress, data, { - version: SignTypedDataVersion.V1, - }); - } - - case `${EthMethod.SignTypedDataV3}`: { - assert(params, EthSignTypedDataParamsStruct); - const [, data] = params; - return this.inner.signTypedData( - hexAddress, - // TODO: Improve typing to ensure data matches MessageTypes - data as TypedMessage, - { - version: SignTypedDataVersion.V3, - }, - ); - } - - case `${EthMethod.SignTypedDataV4}`: { - assert(params, EthSignTypedDataParamsStruct); - const [, data] = params; - return this.inner.signTypedData( - hexAddress, - // TODO: Improve typing to ensure data matches MessageTypes - data as TypedMessage, - { - version: SignTypedDataVersion.V4, - }, - ); - } - - case `${HdKeyringEthMethod.Decrypt}`: { - assert(params, EthDecryptParamsStruct); - const [encryptedData] = params; - return this.inner.decryptMessage(hexAddress, encryptedData); - } - - case `${HdKeyringEthMethod.GetEncryptionPublicKey}`: { - return this.inner.getEncryptionPublicKey(hexAddress); - } - - case `${HdKeyringEthMethod.GetAppKeyAddress}`: { - assert(params, EthGetAppKeyAddressParamsStruct); - const [origin] = params; - return this.inner.getAppKeyAddress(hexAddress, origin); - } - - case `${HdKeyringEthMethod.SignEip7702Authorization}`: { - assert(params, EthSignEip7702AuthorizationParamsStruct); - const [authorization] = params; - return this.inner.signEip7702Authorization(hexAddress, authorization); - } - - default: - throw new Error(`Unsupported method for HdKeyringV2: ${method}`); - } } } diff --git a/packages/keyring-utils/CHANGELOG.md b/packages/keyring-utils/CHANGELOG.md index bb0fbb9b..57e382bb 100644 --- a/packages/keyring-utils/CHANGELOG.md +++ b/packages/keyring-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `EthKeyring` alias export for the legacy `Keyring` type ([#404](https://github.com/MetaMask/accounts/pull/404)) + ## [3.1.0] ### Added diff --git a/packages/keyring-utils/src/keyring.ts b/packages/keyring-utils/src/keyring.ts index da482b71..3b017db2 100644 --- a/packages/keyring-utils/src/keyring.ts +++ b/packages/keyring-utils/src/keyring.ts @@ -279,3 +279,9 @@ export type Keyring = { */ destroy?(): Promise; }; + +/** + * The legacy `Keyring` is Ethereum only, so we can alias it here for better + * clarity. + */ +export type EthKeyring = Keyring; diff --git a/yarn.lock b/yarn.lock index 894217a8..9c4c2a4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1964,9 +1964,11 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/keyring-api@workspace:packages/keyring-api" dependencies: + "@ethereumjs/tx": "npm:^5.4.0" "@lavamoat/allow-scripts": "npm:^3.2.1" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/keyring-utils": "workspace:^" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.1.0"