diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 331f1487491..5de2cb8dbe6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -116,6 +116,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 +137,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..997e8807406 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,11 @@ 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)) + ### 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/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..fe29e78e0fe --- /dev/null +++ b/packages/money-account-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 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..971b2041805 --- /dev/null +++ b/packages/money-account-service/package.json @@ -0,0 +1,81 @@ +{ + "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", + "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "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/account-tree-controller": "^5.0.1", + "@metamask/accounts-controller": "^37.0.0", + "@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/account-api": "^1.0.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-internal-api": "^10.0.0", + "@metamask/keyring-utils": "^3.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.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..e23c4a8dc0a --- /dev/null +++ b/packages/money-account-service/src/MoneyAccountService-method-action-types.ts @@ -0,0 +1,58 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { MoneyAccountService } from './MoneyAccountService'; + +/** + * Creates a Money keyring derived from the primary HD keyring, and returns + * the associated account. + * + * If a Money keyring already exists, returns undefined. + * + * @returns The account of the newly created Money keyring, or undefined if one already existed. + */ +export type MoneyAccountServiceCreateMoneyAccountAction = { + type: `MoneyAccountService:createMoneyAccount`; + handler: MoneyAccountService['createMoneyAccount']; +}; + +/** + * Returns the account associated with the Money keyring, or undefined if none exists. + * + * @returns The Money keyring account, or undefined if none exists. + */ +export type MoneyAccountServiceGetMoneyAccountAction = { + type: `MoneyAccountService:getMoneyAccount`; + handler: MoneyAccountService['getMoneyAccount']; +}; + +/** + * Returns the account wallet associated with the Money keyring, or undefined if none exists. + * + * @returns The Money keyring account wallet, or undefined if none exists. + */ +export type MoneyAccountServiceGetMoneyAccountWalletAction = { + type: `MoneyAccountService:getMoneyAccountWallet`; + handler: MoneyAccountService['getMoneyAccountWallet']; +}; + +/** + * Returns the account group associated with the Money keyring, or undefined if none exists. + * + * @returns The account group of the Money keyring, or undefined if none exists. + */ +export type MoneyAccountServiceGetMoneyAccountGroupAction = { + type: `MoneyAccountService:getMoneyAccountGroup`; + handler: MoneyAccountService['getMoneyAccountGroup']; +}; + +/** + * Union of all MoneyAccountService action types. + */ +export type MoneyAccountServiceMethodActions = + | MoneyAccountServiceCreateMoneyAccountAction + | MoneyAccountServiceGetMoneyAccountAction + | MoneyAccountServiceGetMoneyAccountWalletAction + | MoneyAccountServiceGetMoneyAccountGroupAction; 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..beb816d8529 --- /dev/null +++ b/packages/money-account-service/src/MoneyAccountService.test.ts @@ -0,0 +1,381 @@ +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 { 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'; +const MOCK_ACCOUNT_ID = 'mock-money-account-id'; + +const MOCK_PRIMARY_HD_KEYRING = { + type: KeyringTypes.hd, + accounts: [], + metadata: { id: MOCK_ENTROPY_SOURCE, name: '' }, +}; + +const MOCK_MONEY_GROUP = { + id: 'mock-group-id', + accounts: [MOCK_ACCOUNT_ID], + metadata: { name: 'Money Account', pinned: false, hidden: false }, +}; + +const MOCK_MONEY_WALLET = { + metadata: { name: '', keyring: { type: KeyringTypes.money } }, + groups: { 'mock-group-id': MOCK_MONEY_GROUP }, +}; + +const MOCK_MONEY_ACCOUNT = { + id: MOCK_ACCOUNT_ID, + address: '0xmockmoneyaddress', +}; + +function setup(): { + service: MoneyAccountService; + rootMessenger: RootMessenger; + mocks: { + withKeyring: jest.Mock; + addNewKeyring: jest.Mock; + getState: jest.Mock; + getAccountWalletObject: jest.Mock; + getAccount: 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', + 'AccountTreeController:getAccountWalletObject', + 'AccountsController:getAccount', + ], + events: [], + }); + + const mocks = { + withKeyring: jest.fn().mockImplementation(async (_selector, operation) => { + 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(undefined), + getState: jest + .fn() + .mockReturnValue({ keyrings: [MOCK_PRIMARY_HD_KEYRING] }), + getAccountWalletObject: jest.fn().mockReturnValue(MOCK_MONEY_WALLET), + getAccount: jest.fn().mockReturnValue(MOCK_MONEY_ACCOUNT), + }; + + rootMessenger.registerActionHandler( + 'KeyringController:withKeyring', + mocks.withKeyring, + ); + rootMessenger.registerActionHandler( + 'KeyringController:addNewKeyring', + mocks.addNewKeyring, + ); + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mocks.getState, + ); + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountWalletObject', + mocks.getAccountWalletObject, + ); + rootMessenger.registerActionHandler( + 'AccountsController:getAccount', + mocks.getAccount, + ); + + const service = new MoneyAccountService({ messenger }); + + return { service, rootMessenger, mocks }; +} + +describe('MoneyAccountService', () => { + describe('createMoneyAccount', () => { + it('creates a Money keyring from the primary HD keyring and returns the account', async () => { + const { service, mocks } = setup(); + + const result = await service.createMoneyAccount(); + + 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(MOCK_MONEY_ACCOUNT); + }); + + it('is callable via the messenger', async () => { + const { rootMessenger } = setup(); + + const result = await rootMessenger.call( + 'MoneyAccountService:createMoneyAccount', + ); + + expect(result).toStrictEqual(MOCK_MONEY_ACCOUNT); + }); + + it('returns the existing account without creating a new keyring if a money keyring already exists', async () => { + const { service, mocks } = setup(); + + mocks.getState.mockReturnValue({ + keyrings: [ + MOCK_PRIMARY_HD_KEYRING, + { + type: KeyringTypes.money, + accounts: [], + metadata: { id: 'money-keyring-id', name: '' }, + }, + ], + }); + + const result = await service.createMoneyAccount(); + + expect(result).toStrictEqual(MOCK_MONEY_ACCOUNT); + expect(mocks.addNewKeyring).not.toHaveBeenCalled(); + }); + + it('throws if no primary HD keyring exists', async () => { + const { service, mocks } = setup(); + + mocks.getState.mockReturnValue({ keyrings: [] }); + + await expect(service.createMoneyAccount()).rejects.toThrow( + 'No primary HD keyring found.', + ); + }); + + 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()).rejects.toThrow( + 'Expected HD keyring, got Simple Key Pair', + ); + }); + + 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) => { + 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; + }); + + await service.createMoneyAccount(); + + 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 Money account could not be retrieved after keyring creation', async () => { + const { service, mocks } = setup(); + + // Wallet exists but group has no accounts, so getMoneyAccount returns undefined. + mocks.getAccountWalletObject.mockReturnValue({ + ...MOCK_MONEY_WALLET, + groups: { 'mock-group-id': { ...MOCK_MONEY_GROUP, accounts: [] } }, + }); + + await expect(service.createMoneyAccount()).rejects.toThrow( + 'Failed to create Money account.', + ); + }); + + 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()).rejects.toThrow( + 'HD keyring does not have a mnemonic for the given entropy source.', + ); + }); + }); + + describe('getMoneyAccountWallet', () => { + it('returns the Money wallet if it exists', () => { + const { service } = setup(); + + const result = service.getMoneyAccountWallet(); + + expect(result).toStrictEqual(MOCK_MONEY_WALLET); + }); + + it('returns undefined if no Money wallet exists', () => { + const { service, mocks } = setup(); + + mocks.getAccountWalletObject.mockReturnValue(undefined); + + const result = service.getMoneyAccountWallet(); + + expect(result).toBeUndefined(); + }); + + it('is callable via the messenger', () => { + const { rootMessenger } = setup(); + + const result = rootMessenger.call( + 'MoneyAccountService:getMoneyAccountWallet', + ); + + expect(result).toStrictEqual(MOCK_MONEY_WALLET); + }); + }); + + describe('getMoneyAccountGroup', () => { + it('returns the group if a Money wallet exists', () => { + const { service } = setup(); + + const result = service.getMoneyAccountGroup(); + + expect(result).toStrictEqual(MOCK_MONEY_GROUP); + }); + + it('returns undefined if no Money wallet exists', () => { + const { service, mocks } = setup(); + + mocks.getAccountWalletObject.mockReturnValue(undefined); + + const result = service.getMoneyAccountGroup(); + + expect(result).toBeUndefined(); + }); + + it('is callable via the messenger', () => { + const { rootMessenger } = setup(); + + const result = rootMessenger.call( + 'MoneyAccountService:getMoneyAccountGroup', + ); + + expect(result).toStrictEqual(MOCK_MONEY_GROUP); + }); + }); + + describe('getMoneyAccount', () => { + it('returns the account if a Money wallet group exists', async () => { + const { service, mocks } = setup(); + + const result = await service.getMoneyAccount(); + + expect(mocks.getAccount).toHaveBeenCalledWith(MOCK_ACCOUNT_ID); + expect(result).toStrictEqual(MOCK_MONEY_ACCOUNT); + }); + + it('returns undefined if no Money wallet exists', async () => { + const { service, mocks } = setup(); + + mocks.getAccountWalletObject.mockReturnValue(undefined); + + const result = await service.getMoneyAccount(); + + expect(result).toBeUndefined(); + expect(mocks.getAccount).not.toHaveBeenCalled(); + }); + + it('returns undefined if the Money wallet group has no accounts', async () => { + const { service, mocks } = setup(); + + mocks.getAccountWalletObject.mockReturnValue({ + ...MOCK_MONEY_WALLET, + groups: { 'mock-group-id': { ...MOCK_MONEY_GROUP, accounts: [] } }, + }); + + const result = await service.getMoneyAccount(); + + expect(result).toBeUndefined(); + expect(mocks.getAccount).not.toHaveBeenCalled(); + }); + + it('is callable via the messenger', async () => { + const { rootMessenger } = setup(); + + const result = await rootMessenger.call( + 'MoneyAccountService:getMoneyAccount', + ); + + expect(result).toStrictEqual(MOCK_MONEY_ACCOUNT); + }); + }); +}); diff --git a/packages/money-account-service/src/MoneyAccountService.ts b/packages/money-account-service/src/MoneyAccountService.ts new file mode 100644 index 00000000000..88af7d8ef58 --- /dev/null +++ b/packages/money-account-service/src/MoneyAccountService.ts @@ -0,0 +1,167 @@ +import { AccountWalletType, toAccountWalletId } from '@metamask/account-api'; +import type { + AccountGroupObject, + AccountWalletObject, +} from '@metamask/account-tree-controller'; +import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { MONEY_DERIVATION_PATH } from '@metamask/eth-money-keyring'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { EthKeyring } from '@metamask/keyring-utils'; + +import type { MoneyAccountServiceMessenger } from './types'; + +export const serviceName = 'MoneyAccountService'; + +const MESSENGER_EXPOSED_METHODS = [ + 'createMoneyAccount', + 'getMoneyAccount', + 'getMoneyAccountWallet', + 'getMoneyAccountGroup', +] as const; + +/** + * Asserts that the given keyring instance is an HD keyring. + * + * @param keyring - The keyring instance to check. + * @throws Will throw an error if the keyring is not an HD keyring. + */ +function assertIsHdKeyring(keyring: EthKeyring): asserts keyring is HdKeyring { + if (keyring.type !== KeyringTypes.hd) { + throw new Error(`Expected HD keyring, got ${keyring.type}`); + } +} + +/** + * Gets the serialized mnemonic from the given HD keyring. + * + * @param hdKeyring - The HD keyring instance to extract the mnemonic from. + * @returns The serialized mnemonic as an array of numbers. + * @throws Will throw an error if the HD keyring does not have a mnemonic. + */ +async function getSerializedMnemonicFromHdKeyring( + hdKeyring: HdKeyring, +): Promise { + if (!hdKeyring.mnemonic) { + throw new Error( + 'HD keyring does not have a mnemonic for the given entropy source.', + ); + } + + const serialized = await hdKeyring.serialize(); + return serialized.mnemonic; +} + +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 primary HD keyring, and returns + * the associated account. + * + * If a Money keyring already exists, returns undefined. + * + * @returns The account of the newly created Money keyring, or undefined if one already existed. + */ + async createMoneyAccount(): Promise { + const { keyrings } = this.#messenger.call('KeyringController:getState'); + + const hasMoneyKeyring = keyrings.some( + (keyring) => keyring.type === KeyringTypes.money, + ); + if (!hasMoneyKeyring) { + // Money keyring re-use the same SRP than the primary HD keyring. + const primaryHdKeyring = keyrings.find( + (keyring) => keyring.type === KeyringTypes.hd, + ); + if (!primaryHdKeyring) { + throw new Error('No primary HD keyring found.'); + } + + // Extract the mnemonic from the primary HD keyring, and use it to + // create the Money keyring. + const mnemonic = await this.#messenger.call( + 'KeyringController:withKeyring', + { id: primaryHdKeyring.metadata.id }, + async ({ keyring }) => { + assertIsHdKeyring(keyring); + return getSerializedMnemonicFromHdKeyring(keyring); + }, + ); + + // This keyring can contain only 1 account, so we can hardcode the number of accounts + // to 1 directly. + await this.#messenger.call( + 'KeyringController:addNewKeyring', + KeyringTypes.money, + { mnemonic, numberOfAccounts: 1, hdPath: MONEY_DERIVATION_PATH }, + ); + } + + // Now, the account should have been created by now. + const account = await this.getMoneyAccount(); + if (!account) { + throw new Error('Failed to create Money account.'); + } + + return account; + } + + /** + * Returns the account associated with the Money keyring, or undefined if none exists. + * + * @returns The Money keyring account, or undefined if none exists. + */ + async getMoneyAccount(): Promise { + const group = this.getMoneyAccountGroup(); + if (!group) { + return undefined; + } + + const [accountId] = group.accounts; + if (!accountId) { + return undefined; + } + + return this.#messenger.call('AccountsController:getAccount', accountId); + } + + /** + * Returns the account wallet associated with the Money keyring, or undefined if none exists. + * + * @returns The Money keyring account wallet, or undefined if none exists. + */ + getMoneyAccountWallet(): AccountWalletObject | undefined { + return this.#messenger.call( + 'AccountTreeController:getAccountWalletObject', + toAccountWalletId(AccountWalletType.Keyring, KeyringTypes.money), + ); + } + + /** + * Returns the account group associated with the Money keyring, or undefined if none exists. + * + * @returns The account group of the Money keyring, or undefined if none exists. + */ + getMoneyAccountGroup(): AccountGroupObject | undefined { + const wallet = this.getMoneyAccountWallet(); + if (!wallet) { + return undefined; + } + + // This wallet should only have 1 group at most. + const [group] = Object.values(wallet.groups); + return group; + } +} 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..776ae1272d2 --- /dev/null +++ b/packages/money-account-service/src/types.ts @@ -0,0 +1,28 @@ +import type { AccountTreeControllerGetAccountWalletObjectAction } from '@metamask/account-tree-controller'; +import type { AccountsControllerGetAccountAction } from '@metamask/accounts-controller'; +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 = + | AccountTreeControllerGetAccountWalletObjectAction + | AccountsControllerGetAccountAction + | 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..c8db87daae6 --- /dev/null +++ b/packages/money-account-service/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../account-tree-controller/tsconfig.build.json" }, + { "path": "../accounts-controller/tsconfig.build.json" }, + { "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..f6e5685b16e --- /dev/null +++ b/packages/money-account-service/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../account-tree-controller" }, + { "path": "../accounts-controller" }, + { "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 c4c6b837525..472890fd33c 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,33 @@ __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/account-api": "npm:^1.0.0" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@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/keyring-internal-api": "npm:^10.0.0" + "@metamask/keyring-utils": "npm:^3.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" + tsx: "npm:^4.20.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"