diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 331f1487491..3ba251cca2c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -38,6 +38,7 @@ ## Earn Team /packages/earn-controller @MetaMask/metamask-earn +/packages/money-account-service @MetaMask/metamask-earn ## Social AI Team /packages/ai-controllers @MetaMask/social-ai @@ -116,6 +117,7 @@ /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/money-account-service @MetaMask/accounts-engineers @MetaMask/metamask-earn ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform @@ -136,6 +138,9 @@ /packages/assets-controllers/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform /packages/assets-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform /packages/assets-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform + +/packages/money-account-service/package.json @MetaMask/metamask-earn @MetaMask/core-platform +/packages/money-account-service/CHANGELOG.md @MetaMask/metamask-earn @MetaMask/core-platform /packages/chain-agnostic-permission/package.json @MetaMask/wallet-integrations @MetaMask/core-platform /packages/chain-agnostic-permission/CHANGELOG.md @MetaMask/wallet-integrations @MetaMask/core-platform /packages/config-registry-controller/CHANGELOG.md @MetaMask/networks @MetaMask/core-platform diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 6b7a652a219..3dad8680186 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add support for `KeyringTypes.money` in `keyringTypeToName`, mapping it to `'Money Account'` ([#8204](https://github.com/MetaMask/core/pull/8204)) - Add `:accounts{Added,Removed}` batch events ([#8151](https://github.com/MetaMask/core/pull/8151)) - Those new events can be used instead of single `:accountAdded` and `:accountRemoved` events to reduce the number of events emitted during batch operations (e.g. `KeyringController` state re-synchronization). diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index 2b56924135a..f71452ad9d4 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -2726,6 +2726,7 @@ describe('AccountsController', () => { KeyringTypes.ledger, KeyringTypes.lattice, KeyringTypes.qr, + KeyringTypes.money, ])('should add accounts for %s type', async (keyringType) => { mockUUIDWithNormalAccounts([mockAccount]); diff --git a/packages/accounts-controller/src/utils.ts b/packages/accounts-controller/src/utils.ts index 5620031d37b..fc0ffde28f9 100644 --- a/packages/accounts-controller/src/utils.ts +++ b/packages/accounts-controller/src/utils.ts @@ -39,6 +39,9 @@ export function keyringTypeToName(keyringType: string): string { case KeyringTypes.qr: { return 'QR'; } + case KeyringTypes.money: { + return 'Money Account'; + } case KeyringTypes.snap: { return 'Snap Account'; } diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 073f80166da..5373aacec64 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `KeyringTypes.money` (`'Money Keyring'`) to the `KeyringTypes` enum ([#8204](https://github.com/MetaMask/core/pull/8204)) +- Add `MoneyKeyring` (from `@metamask/eth-money-keyring`) as a built-in default keyring ([#8204](https://github.com/MetaMask/core/pull/8204)) +- Export `KeyringControllerErrorMessage` enum to allow consumers to distinguish specific controller error cases ([#8204](https://github.com/MetaMask/core/pull/8204)) + ### Changed - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.6.0` ([#7857](https://github.com/MetaMask/core/pull/7857), [#8259](https://github.com/MetaMask/core/pull/8259)) diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index 413009e83a7..5cf26876dd0 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -51,6 +51,7 @@ "@metamask/base-controller": "^9.0.0", "@metamask/browser-passworder": "^6.0.0", "@metamask/eth-hd-keyring": "^13.0.0", + "@metamask/eth-money-keyring": "^1.0.0", "@metamask/eth-sig-util": "^8.2.0", "@metamask/eth-simple-keyring": "^11.0.0", "@metamask/keyring-api": "^21.6.0", diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index add11e8183b..50807fa7ba0 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -3,6 +3,7 @@ import { isValidPrivate, getBinarySize } from '@ethereumjs/util'; import { BaseController } from '@metamask/base-controller'; import type * as encryptorUtils from '@metamask/browser-passworder'; import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { MoneyKeyring } from '@metamask/eth-money-keyring'; import { normalize as ethNormalize } from '@metamask/eth-sig-util'; import SimpleKeyring from '@metamask/eth-simple-keyring'; import type { @@ -55,6 +56,7 @@ export enum KeyringTypes { /* eslint-disable @typescript-eslint/naming-convention */ simple = 'Simple Key Pair', hd = 'HD Key Tree', + money = 'Money Keyring', qr = 'QR Hardware Wallet Device', trezor = 'Trezor Hardware', oneKey = 'OneKey Hardware', @@ -558,6 +560,7 @@ const defaultKeyringBuilders = [ // @ts-expect-error keyring types are mismatched keyringBuilderFactory(SimpleKeyring), keyringBuilderFactory(HdKeyring), + keyringBuilderFactory(MoneyKeyring), ]; export const getDefaultKeyringState = (): KeyringControllerState => { diff --git a/packages/keyring-controller/src/index.ts b/packages/keyring-controller/src/index.ts index 341ad18bac3..68f49dad764 100644 --- a/packages/keyring-controller/src/index.ts +++ b/packages/keyring-controller/src/index.ts @@ -1,3 +1,4 @@ export * from './KeyringController'; export type * from './types'; export * from './errors'; +export { KeyringControllerErrorMessage } from './constants'; diff --git a/packages/money-account-service/CHANGELOG.md b/packages/money-account-service/CHANGELOG.md new file mode 100644 index 00000000000..955ecf2e9f9 --- /dev/null +++ b/packages/money-account-service/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release ([#8204](https://github.com/MetaMask/core/pull/8204)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-service/LICENSE b/packages/money-account-service/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/money-account-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-service/README.md b/packages/money-account-service/README.md new file mode 100644 index 00000000000..cab3461be6d --- /dev/null +++ b/packages/money-account-service/README.md @@ -0,0 +1,17 @@ +# `@metamask/money-account-service` + +Money account service. + +This service provides operations for creating and managing Money accounts derived from HD keyrings. + +## Installation + +`yarn add @metamask/money-account-service` + +or + +`npm install @metamask/money-account-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/money-account-service/jest.config.js b/packages/money-account-service/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/money-account-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/money-account-service/package.json b/packages/money-account-service/package.json new file mode 100644 index 00000000000..f65d57ef098 --- /dev/null +++ b/packages/money-account-service/package.json @@ -0,0 +1,74 @@ +{ + "name": "@metamask/money-account-service", + "version": "0.0.0", + "description": "Service to manage money accounts", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-service", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.0.0", + "@metamask/eth-hd-keyring": "^13.0.0", + "@metamask/eth-money-keyring": "^1.0.0", + "@metamask/keyring-controller": "^25.1.0", + "@metamask/messenger": "^0.3.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/money-account-service/src/MoneyAccountService-method-action-types.ts b/packages/money-account-service/src/MoneyAccountService-method-action-types.ts new file mode 100644 index 00000000000..9e08b81937b --- /dev/null +++ b/packages/money-account-service/src/MoneyAccountService-method-action-types.ts @@ -0,0 +1,15 @@ +import type { MoneyAccountService } from './MoneyAccountService'; + +export type MoneyAccountServiceCreateMoneyAccountAction = { + type: `MoneyAccountService:createMoneyAccount`; + handler: MoneyAccountService['createMoneyAccount']; +}; + +export type MoneyAccountServiceGetMoneyAccountAction = { + type: `MoneyAccountService:getMoneyAccount`; + handler: MoneyAccountService['getMoneyAccount']; +}; + +export type MoneyAccountServiceMethodActions = + | MoneyAccountServiceCreateMoneyAccountAction + | MoneyAccountServiceGetMoneyAccountAction; diff --git a/packages/money-account-service/src/MoneyAccountService.test.ts b/packages/money-account-service/src/MoneyAccountService.test.ts new file mode 100644 index 00000000000..b7e3374c7e9 --- /dev/null +++ b/packages/money-account-service/src/MoneyAccountService.test.ts @@ -0,0 +1,301 @@ +import type { HdKeyring } from '@metamask/eth-hd-keyring'; +import { + MoneyKeyring, + MONEY_DERIVATION_PATH, +} from '@metamask/eth-money-keyring'; +import type { MoneyKeyringSerializedState } from '@metamask/eth-money-keyring'; +import { + KeyringControllerError, + KeyringControllerErrorMessage, + KeyringTypes, +} from '@metamask/keyring-controller'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; + +import { MoneyAccountService, serviceName } from './MoneyAccountService'; +import type { MoneyAccountServiceMessenger } from './types'; + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +const MOCK_MNEMONIC = new Uint8Array([ + 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, + 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, + 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, + 116, 32, 116, 101, 115, 116, 32, 106, 117, 110, 107, +]); + +const MOCK_ENTROPY_SOURCE = 'mock-entropy-source-id'; + +function setup(): { + service: MoneyAccountService; + rootMessenger: RootMessenger; + mocks: { + withKeyring: jest.Mock; + addNewKeyring: jest.Mock; + getState: jest.Mock; + }; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + + const messenger: MoneyAccountServiceMessenger = new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'KeyringController:withKeyring', + 'KeyringController:addNewKeyring', + 'KeyringController:getState', + ], + events: [], + }); + + const mocks = { + withKeyring: jest.fn().mockImplementation(async (selector, operation) => { + if ('type' in selector) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + return operation({ + keyring: { + type: 'HD Key Tree', + mnemonic: MOCK_MNEMONIC, + serialize: jest.fn().mockResolvedValue({ mnemonic: MOCK_MNEMONIC }), + } as unknown as HdKeyring, + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, + }); + }), + addNewKeyring: jest.fn().mockResolvedValue({ + id: 'new-money-keyring-id', + name: '', + }), + getState: jest.fn().mockReturnValue({ keyrings: [] }), + }; + + rootMessenger.registerActionHandler( + 'KeyringController:withKeyring', + mocks.withKeyring, + ); + rootMessenger.registerActionHandler( + 'KeyringController:addNewKeyring', + mocks.addNewKeyring, + ); + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mocks.getState, + ); + + const service = new MoneyAccountService({ messenger }); + + return { service, rootMessenger, mocks }; +} + +describe('MoneyAccountService', () => { + describe('createMoneyAccount', () => { + it('creates a Money keyring from the HD keyring mnemonic', async () => { + const { service, mocks } = setup(); + + const result = await service.createMoneyAccount(MOCK_ENTROPY_SOURCE); + + expect(mocks.withKeyring).toHaveBeenCalledWith( + { id: MOCK_ENTROPY_SOURCE }, + expect.any(Function), + ); + expect(mocks.addNewKeyring).toHaveBeenCalledWith(KeyringTypes.money, { + mnemonic: MOCK_MNEMONIC, + numberOfAccounts: 1, + hdPath: MONEY_DERIVATION_PATH, + }); + expect(result).toStrictEqual({ id: 'new-money-keyring-id', name: '' }); + }); + + it('is callable via the messenger', async () => { + const { rootMessenger } = setup(); + + const result = await rootMessenger.call( + 'MoneyAccountService:createMoneyAccount', + MOCK_ENTROPY_SOURCE, + ); + + expect(result).toStrictEqual({ id: 'new-money-keyring-id', name: '' }); + }); + + it('returns null if a money account already exists', async () => { + const { service, mocks } = setup(); + + mocks.getState.mockReturnValue({ + keyrings: [{ type: KeyringTypes.money }], + }); + + const result = await service.createMoneyAccount(MOCK_ENTROPY_SOURCE); + + expect(result).toBeNull(); + expect(mocks.addNewKeyring).not.toHaveBeenCalled(); + }); + + it('throws if the keyring is not an HD keyring', async () => { + const { service, mocks } = setup(); + + mocks.withKeyring.mockImplementation(async (_selector, operation) => { + return operation({ + keyring: { + type: 'Simple Key Pair', + } as unknown as HdKeyring, + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, + }); + }); + + await expect( + service.createMoneyAccount(MOCK_ENTROPY_SOURCE), + ).rejects.toThrow('Got keyring without HD Keyring type'); + }); + + it('passes params to addNewKeyring that produce a correctly initialized MoneyKeyring', async () => { + const { service, mocks } = setup(); + + // The real HdKeyring.serialize() returns mnemonic as number[] (via Array.from), + // not Uint8Array, so we match that format here for MoneyKeyring.deserialize to accept it. + mocks.withKeyring.mockImplementation(async (selector, operation) => { + if ('type' in selector) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + return operation({ + keyring: { + type: 'HD Key Tree', + mnemonic: MOCK_MNEMONIC, + serialize: jest + .fn() + .mockResolvedValue({ mnemonic: Array.from(MOCK_MNEMONIC) }), + } as unknown as HdKeyring, + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, + }); + }); + + let capturedOpts: unknown; + mocks.addNewKeyring.mockImplementation(async (_type, opts) => { + capturedOpts = opts; + return { id: 'new-money-keyring-id', name: '' }; + }); + + await service.createMoneyAccount(MOCK_ENTROPY_SOURCE); + + const moneyKeyring = new MoneyKeyring(); + await moneyKeyring.deserialize( + capturedOpts as MoneyKeyringSerializedState, + ); + + expect(moneyKeyring.hdPath).toBe(MONEY_DERIVATION_PATH); + expect(await moneyKeyring.getAccounts()).toHaveLength(1); + }); + + it('throws if the HD keyring has no mnemonic', async () => { + const { service, mocks } = setup(); + + mocks.withKeyring.mockImplementation(async (_selector, operation) => { + return operation({ + keyring: { + type: 'HD Key Tree', + mnemonic: null, + } as unknown as HdKeyring, + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, + }); + }); + + await expect( + service.createMoneyAccount(MOCK_ENTROPY_SOURCE), + ).rejects.toThrow( + 'HD keyring does not have a mnemonic for the given entropy source.', + ); + }); + }); + + describe('getMoneyAccount', () => { + it('returns the money keyring metadata if one exists', async () => { + const { service, mocks } = setup(); + const MOCK_MONEY_METADATA = { id: 'existing-money-keyring-id', name: '' }; + + mocks.withKeyring.mockImplementation(async (selector, operation) => { + if ('type' in selector && selector.type === KeyringTypes.money) { + return operation({ + keyring: {}, + metadata: MOCK_MONEY_METADATA, + }); + } + return operation({ + keyring: { + type: 'HD Key Tree', + mnemonic: MOCK_MNEMONIC, + } as unknown as HdKeyring, + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, + }); + }); + + const result = await service.getMoneyAccount(); + + expect(result).toStrictEqual(MOCK_MONEY_METADATA); + }); + + it('returns null if no money account exists', async () => { + const { service } = setup(); + + const result = await service.getMoneyAccount(); + + expect(result).toBeNull(); + }); + + it('is callable via the messenger', async () => { + const { rootMessenger, mocks } = setup(); + const MOCK_MONEY_METADATA = { id: 'existing-money-keyring-id', name: '' }; + + mocks.withKeyring.mockImplementation(async (selector, operation) => { + if ('type' in selector && selector.type === KeyringTypes.money) { + return operation({ + keyring: {}, + metadata: MOCK_MONEY_METADATA, + }); + } + return operation({ + keyring: { + type: 'HD Key Tree', + mnemonic: MOCK_MNEMONIC, + } as unknown as HdKeyring, + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, + }); + }); + + const result = await rootMessenger.call( + 'MoneyAccountService:getMoneyAccount', + ); + + expect(result).toStrictEqual(MOCK_MONEY_METADATA); + }); + + it('re-throws errors other than KeyringNotFound', async () => { + const { service, mocks } = setup(); + const unexpectedError = new KeyringControllerError('Unexpected error'); + + mocks.withKeyring.mockImplementation(async (selector) => { + if ('type' in selector && selector.type === KeyringTypes.money) { + throw unexpectedError; + } + }); + + await expect(service.getMoneyAccount()).rejects.toThrow(unexpectedError); + }); + }); +}); diff --git a/packages/money-account-service/src/MoneyAccountService.ts b/packages/money-account-service/src/MoneyAccountService.ts new file mode 100644 index 00000000000..e68fdd7e3fe --- /dev/null +++ b/packages/money-account-service/src/MoneyAccountService.ts @@ -0,0 +1,103 @@ +import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { MONEY_DERIVATION_PATH } from '@metamask/eth-money-keyring'; +import { + KeyringControllerError, + KeyringControllerErrorMessage, + KeyringTypes, +} from '@metamask/keyring-controller'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; + +import type { MoneyAccountServiceMessenger } from './types'; + +export const serviceName = 'MoneyAccountService'; + +const MESSENGER_EXPOSED_METHODS = [ + 'createMoneyAccount', + 'getMoneyAccount', +] as const; + +export class MoneyAccountService { + readonly #messenger: MoneyAccountServiceMessenger; + + name: typeof serviceName = serviceName; + + constructor({ messenger }: { messenger: MoneyAccountServiceMessenger }) { + this.#messenger = messenger; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Creates a Money keyring derived from the HD keyring identified by + * the given entropy source, and returns the new keyring's metadata. + * + * If a keyring already existed, just returns null + * + * @param entropySource - The metadata id of the HD keyring to derive from. + * @returns The metadata of the newly created Money keyring. + */ + async createMoneyAccount( + entropySource: string, + ): Promise { + const mnemonic = (await this.#messenger.call( + 'KeyringController:withKeyring', + { id: entropySource }, + async ({ keyring }) => { + if (keyring.type !== HdKeyring.type) { + throw new Error('Got keyring without HD Keyring type'); + } + const hdKeyring = keyring as HdKeyring; + + if (!hdKeyring.mnemonic) { + throw new Error( + 'HD keyring does not have a mnemonic for the given entropy source.', + ); + } + + return (await hdKeyring.serialize()).mnemonic; + }, + )) as number[]; + + const { keyrings } = this.#messenger.call('KeyringController:getState'); + + const moneyKeyringExists = keyrings.some( + (keyring) => keyring.type === KeyringTypes.money, + ); + + if (moneyKeyringExists) { + return null; + } + + return await this.#messenger.call( + 'KeyringController:addNewKeyring', + KeyringTypes.money, + { mnemonic, numberOfAccounts: 1, hdPath: MONEY_DERIVATION_PATH }, + ); + } + + /** + * Returns the Money keyring metadata if one exists, otherwise null. + * + * @returns The metadata of the Money keyring, or null if none exists. + */ + async getMoneyAccount(): Promise { + return (await this.#messenger + .call( + 'KeyringController:withKeyring', + { type: KeyringTypes.money }, + async ({ metadata }) => metadata, + ) + .catch((error: unknown) => { + if ( + error instanceof KeyringControllerError && + error.message === KeyringControllerErrorMessage.KeyringNotFound + ) { + return null; + } + throw error; + })) as KeyringMetadata | null; + } +} diff --git a/packages/money-account-service/src/index.ts b/packages/money-account-service/src/index.ts new file mode 100644 index 00000000000..02f76ce0381 --- /dev/null +++ b/packages/money-account-service/src/index.ts @@ -0,0 +1,6 @@ +export type { + MoneyAccountServiceActions, + MoneyAccountServiceMessenger, +} from './types'; +export type { MoneyAccountServiceCreateMoneyAccountAction } from './MoneyAccountService-method-action-types'; +export { MoneyAccountService } from './MoneyAccountService'; diff --git a/packages/money-account-service/src/types.ts b/packages/money-account-service/src/types.ts new file mode 100644 index 00000000000..fba68670247 --- /dev/null +++ b/packages/money-account-service/src/types.ts @@ -0,0 +1,24 @@ +import type { + KeyringControllerAddNewKeyringAction, + KeyringControllerWithKeyringAction, + KeyringControllerGetStateAction, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { serviceName } from './MoneyAccountService'; +import type { MoneyAccountServiceMethodActions } from './MoneyAccountService-method-action-types'; + +export type MoneyAccountServiceActions = MoneyAccountServiceMethodActions; + +type AllowedActions = + | KeyringControllerWithKeyringAction + | KeyringControllerAddNewKeyringAction + | KeyringControllerGetStateAction; + +type AllowedEvents = never; + +export type MoneyAccountServiceMessenger = Messenger< + typeof serviceName, + MoneyAccountServiceActions | AllowedActions, + AllowedEvents +>; diff --git a/packages/money-account-service/tsconfig.build.json b/packages/money-account-service/tsconfig.build.json new file mode 100644 index 00000000000..b16ce7cfcda --- /dev/null +++ b/packages/money-account-service/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-service/tsconfig.json b/packages/money-account-service/tsconfig.json new file mode 100644 index 00000000000..775a9ba69b6 --- /dev/null +++ b/packages/money-account-service/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-service/typedoc.json b/packages/money-account-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 4a3f6902d14..b46adc304a1 100644 --- a/teams.json +++ b/teams.json @@ -40,6 +40,7 @@ "metamask/base-controller": "team-core-platform", "metamask/base-data-service": "team-core-platform", "metamask/build-utils": "team-core-platform", + "metamask/money-account-service": "team-accounts-framework,team-earn", "metamask/composable-controller": "team-core-platform", "metamask/connectivity-controller": "team-core-platform", "metamask/geolocation-controller": "team-core-platform", diff --git a/tsconfig.build.json b/tsconfig.build.json index 0b4716238fb..e3894a87f0d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -55,6 +55,9 @@ { "path": "./packages/build-utils/tsconfig.build.json" }, + { + "path": "./packages/money-account-service/tsconfig.build.json" + }, { "path": "./packages/chain-agnostic-permission/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index 529185d3373..da160fb29a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3673,18 +3673,21 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-hd-keyring@npm:^13.0.0": - version: 13.0.0 - resolution: "@metamask/eth-hd-keyring@npm:13.0.0" +"@metamask/eth-hd-keyring@npm:^13.0.0, @metamask/eth-hd-keyring@npm:^13.1.0": + version: 13.1.0 + resolution: "@metamask/eth-hd-keyring@npm:13.1.0" dependencies: + "@ethereumjs/tx": "npm:^5.4.0" "@ethereumjs/util": "npm:^9.1.0" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/key-tree": "npm:^10.0.2" + "@metamask/keyring-api": "npm:^21.3.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.1.0" ethereum-cryptography: "npm:^2.1.2" - checksum: 10/fe955a4e0331090df8110dbd8f46ea6286c2ad20e6677ecf535361ea9d0008194b2043eddd692cd7ceac2e033a54e4e340caa7d302bd5211826cb252b526f6bc + checksum: 10/7d67c29c6387ffe871995e67e4802b9a6f6eb2f14b556e43690509b342ef66b72765477b27e4b669fe8a00606e219e00991f94da3a74fcedcf339ab765215ae6 languageName: node linkType: hard @@ -3780,6 +3783,16 @@ __metadata: languageName: unknown linkType: soft +"@metamask/eth-money-keyring@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/eth-money-keyring@npm:1.0.0" + dependencies: + "@metamask/eth-hd-keyring": "npm:^13.1.0" + "@metamask/superstruct": "npm:^3.1.0" + checksum: 10/244caa4cba12550bf0cadca4923a5540d40391e9dee940d7fe980bf77d4d08d329825745c97f89fe4909763d6978921f38b6c3f925fab9e0acd969165d5da718 + languageName: node + linkType: hard + "@metamask/eth-query@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/eth-query@npm:4.0.0" @@ -4157,7 +4170,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^21.4.0, @metamask/keyring-api@npm:^21.6.0": +"@metamask/keyring-api@npm:^21.3.0, @metamask/keyring-api@npm:^21.4.0, @metamask/keyring-api@npm:^21.6.0": version: 21.6.0 resolution: "@metamask/keyring-api@npm:21.6.0" dependencies: @@ -4187,6 +4200,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/eth-hd-keyring": "npm:^13.0.0" + "@metamask/eth-money-keyring": "npm:^1.0.0" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/eth-simple-keyring": "npm:^11.0.0" "@metamask/keyring-api": "npm:^21.6.0" @@ -4350,6 +4364,27 @@ __metadata: languageName: node linkType: hard +"@metamask/money-account-service@workspace:packages/money-account-service": + version: 0.0.0-use.local + resolution: "@metamask/money-account-service@workspace:packages/money-account-service" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/eth-hd-keyring": "npm:^13.0.0" + "@metamask/eth-money-keyring": "npm:^1.0.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/multichain-account-service@npm:^7.1.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service"