diff --git a/packages/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md index 4047659eaea..9e63be67aa9 100644 --- a/packages/money-account-controller/CHANGELOG.md +++ b/packages/money-account-controller/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `addMoneyAccount` to register an already-built `KeyringAccount` (for example from an MPC or Money keyring). +- Add `setDefaultMoneyAccount` and persisted `defaultMoneyAccountId` state so callers can choose which account `getMoneyAccount()` returns. +- Support creating a money account from an existing MPC keyring via `createMoneyAccount({ keyringType: 'MPC Keyring', keyringId? })`. + - Reuses the keyring's first account when present, otherwise calls `addAccounts(1)`. + - Does not construct MPC keyrings; the client must already have registered one. +- Export `MPC_KEYRING_TYPE`, `isMpcKeyring`, and `CreateMoneyAccountParams`. + +### Changed + +- **BREAKING:** `MoneyAccount` is now an alias of `KeyringAccount`, so accounts are no longer required to use mnemonic entropy options. +- **BREAKING:** `createMoneyAccount` now takes a source object instead of an entropy source id: + - Money Keyring: `{ keyringType: 'Money Keyring', entropySource }` + - MPC Keyring: `{ keyringType: 'MPC Keyring', keyringId? }` +- **BREAKING:** `getMoneyAccount()` with no selector returns the default account (`defaultMoneyAccountId`), not the account for the primary HD entropy source. + - Lookup by `{ entropySource }` still works for mnemonic Money Keyring accounts. + - Lookup by `{ id }` is also supported. + ## [2.0.0] ### Changed diff --git a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts index a0e8d1fb519..287fcba2507 100644 --- a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts +++ b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts @@ -15,10 +15,11 @@ export type MoneyAccountControllerInitAction = { }; /** - * Creates a money account for the given entropy source. If an account - * already exists for that entropy source, it is returned as-is (idempotent). + * Creates a money account from a Money Keyring (entropy source) or an + * existing MPC Keyring. If an account already exists for that source, it is + * returned as-is (idempotent). * - * @param entropySource - The entropy source ID to create the money account for. + * @param params - The keyring source to create the money account from. * @returns The money account. */ export type MoneyAccountControllerCreateMoneyAccountAction = { @@ -27,12 +28,37 @@ export type MoneyAccountControllerCreateMoneyAccountAction = { }; /** - * Gets a money account by its associated entropy source ID. If no ID is - * provided, the primary entropy source will be used. + * Registers an already-built money account. If an account with the same id + * is already in state, it is returned as-is (idempotent). + * + * If no default account is set, the added account becomes the default. + * + * @param account - The account to register. + * @returns The registered money account. + */ +export type MoneyAccountControllerAddMoneyAccountAction = { + type: `MoneyAccountController:addMoneyAccount`; + handler: MoneyAccountController['addMoneyAccount']; +}; + +/** + * Sets the default money account. + * + * @param id - The id of the money account to use as the default. + */ +export type MoneyAccountControllerSetDefaultMoneyAccountAction = { + type: `MoneyAccountController:setDefaultMoneyAccount`; + handler: MoneyAccountController['setDefaultMoneyAccount']; +}; + +/** + * Gets a money account. With no selector, returns the default account. * * @param selector - Selector options for getting the money account. - * @param selector.entropySource - The entropy source ID to get the money account for. If not provided, the primary entropy source will be used. - * @returns The money account, or `undefined` if no account exists for the given entropy source. + * @param selector.id - The account id to look up. + * @param selector.entropySource - The entropy source ID of a Money Keyring + * account. Ignored when `id` is provided. + * @returns The money account, or `undefined` if none matches. */ export type MoneyAccountControllerGetMoneyAccountAction = { type: `MoneyAccountController:getMoneyAccount`; @@ -57,5 +83,7 @@ export type MoneyAccountControllerClearStateAction = { export type MoneyAccountControllerMethodActions = | MoneyAccountControllerInitAction | MoneyAccountControllerCreateMoneyAccountAction + | MoneyAccountControllerAddMoneyAccountAction + | MoneyAccountControllerSetDefaultMoneyAccountAction | MoneyAccountControllerGetMoneyAccountAction | MoneyAccountControllerClearStateAction; diff --git a/packages/money-account-controller/src/MoneyAccountController.test.ts b/packages/money-account-controller/src/MoneyAccountController.test.ts index 60b56cb69a2..44bb9f0e3a2 100644 --- a/packages/money-account-controller/src/MoneyAccountController.test.ts +++ b/packages/money-account-controller/src/MoneyAccountController.test.ts @@ -1,6 +1,7 @@ import { KeyringControllerError, KeyringControllerErrorMessage, + KeyringTypes, } from '@metamask/keyring-controller'; import { EthKeyring } from '@metamask/keyring-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; @@ -14,11 +15,20 @@ import type { MoneyAccount, MoneyAccountControllerMessenger } from './index.js'; import { MoneyAccountController, getDefaultMoneyAccountControllerState, + MPC_KEYRING_TYPE, } from './index.js'; const MOCK_ENTROPY_SOURCE_ID = 'entropy-source-1'; const MOCK_OTHER_ENTROPY_SOURCE_ID = 'entropy-source-2'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12'; +const MOCK_MPC_ADDRESS = '0x2222222222222222222222222222222222222222'; +const MOCK_MPC_KEYRING_ID = 'mpc-keyring-1'; +const MOCK_MPC_KEYRING_ID_2 = 'mpc-keyring-2'; + +const CREATE_MONEY_PARAMS = { + keyringType: KeyringTypes.money, + entropySource: MOCK_ENTROPY_SOURCE_ID, +} as const; const MOCK_HD_KEYRING = { type: 'HD Key Tree', @@ -26,6 +36,12 @@ const MOCK_HD_KEYRING = { metadata: { id: MOCK_ENTROPY_SOURCE_ID, name: 'HD Key Tree' }, }; +const MOCK_MPC_KEYRING_STATE = { + type: MPC_KEYRING_TYPE, + accounts: [MOCK_MPC_ADDRESS], + metadata: { id: MOCK_MPC_KEYRING_ID, name: 'MPC Keyring' }, +}; + const MOCK_MONEY_ACCOUNT: MoneyAccount = { id: 'e9b8f87e-f08d-4e98-a3e4-3c2d3a4e5b6f', type: 'eip155:eoa', @@ -70,6 +86,26 @@ const MOCK_MONEY_ACCOUNT_2: MoneyAccount = { ], }; +const MOCK_MPC_ACCOUNT: MoneyAccount = { + id: 'c0ffee00-0000-4000-8000-000000000001', + type: 'eip155:eoa', + address: MOCK_MPC_ADDRESS, + scopes: ['eip155:0'], + options: { + entropy: { + type: 'custom', + }, + exportable: false, + keyringId: MOCK_MPC_KEYRING_ID, + }, + methods: [ + 'personal_sign', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], +}; + type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, @@ -107,8 +143,28 @@ class MockMoneyKeyring { } } +class MockMpcKeyring { + readonly type = MPC_KEYRING_TYPE; + + readonly #accounts: string[]; + + constructor({ accounts = [MOCK_MPC_ADDRESS] }: { accounts?: string[] } = {}) { + this.#accounts = [...accounts]; + } + + async getAccounts(): Promise { + return [...this.#accounts]; + } + + async addAccounts(_n: number): Promise { + this.#accounts.push(MOCK_MPC_ADDRESS); + return [MOCK_MPC_ADDRESS]; + } +} + type SetupOptions = { accounts?: MoneyAccount[]; + defaultMoneyAccountId?: string | null; isUnlocked?: boolean; keyrings?: { type: string; @@ -125,6 +181,7 @@ type AllMoneyAccountControllerEvents = function setup({ accounts = [], + defaultMoneyAccountId, isUnlocked = true, keyrings = [MOCK_HD_KEYRING], }: SetupOptions = {}): { @@ -209,9 +266,17 @@ function setup({ accounts.map((account) => [account.id, account]), ); + const resolvedDefaultMoneyAccountId = + defaultMoneyAccountId === undefined + ? (accounts[0]?.id ?? null) + : defaultMoneyAccountId; + const controller = new MoneyAccountController({ messenger, - state: { moneyAccounts }, + state: { + moneyAccounts, + defaultMoneyAccountId: resolvedDefaultMoneyAccountId, + }, }); return { @@ -222,6 +287,22 @@ function setup({ }; } +function mockMpcWithKeyring( + mocks: ReturnType['mocks'], + keyring: MockMpcKeyring = new MockMpcKeyring(), + metadata: { id: string; name: string } = MOCK_MPC_KEYRING_STATE.metadata, +): void { + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring(keyring), + metadata, + }); + }, + ); +} + describe('MoneyAccountController', () => { describe('constructor', () => { it('initializes with default state when no state is provided', () => { @@ -238,6 +319,9 @@ describe('MoneyAccountController', () => { expect(controller.state.moneyAccounts).toStrictEqual({ [MOCK_MONEY_ACCOUNT.id]: MOCK_MONEY_ACCOUNT, }); + expect(controller.state.defaultMoneyAccountId).toBe( + MOCK_MONEY_ACCOUNT.id, + ); }); }); @@ -251,6 +335,7 @@ describe('MoneyAccountController', () => { address: MOCK_ADDRESS, options: { entropy: { id: MOCK_ENTROPY_SOURCE_ID } }, }); + expect(controller.state.defaultMoneyAccountId).toBe(account?.id); }); it('does nothing when no HD keyring exists', async () => { @@ -258,6 +343,7 @@ describe('MoneyAccountController', () => { await controller.init(); expect(controller.state.moneyAccounts).toStrictEqual({}); + expect(controller.state.defaultMoneyAccountId).toBeNull(); }); it('is idempotent — calling init twice does not create duplicate accounts', async () => { @@ -267,6 +353,17 @@ describe('MoneyAccountController', () => { expect(Object.keys(controller.state.moneyAccounts)).toHaveLength(1); }); + it('does not override an existing default account', async () => { + const { controller } = setup({ + accounts: [MOCK_MPC_ACCOUNT], + defaultMoneyAccountId: MOCK_MPC_ACCOUNT.id, + }); + await controller.init(); + + expect(controller.state.defaultMoneyAccountId).toBe(MOCK_MPC_ACCOUNT.id); + expect(Object.keys(controller.state.moneyAccounts)).toHaveLength(2); + }); + it('throws when the keyring is locked', async () => { const { controller } = setup({ isUnlocked: false }); await expect(controller.init()).rejects.toThrow( @@ -276,226 +373,543 @@ describe('MoneyAccountController', () => { }); describe('createMoneyAccount', () => { - it('creates a new money account with the correct shape', async () => { - const { controller } = setup(); - const account = await controller.createMoneyAccount( - MOCK_ENTROPY_SOURCE_ID, - ); - expect(account).toMatchObject({ - address: MOCK_ADDRESS, - type: 'eip155:eoa', - scopes: ['eip155:0'], - options: { - entropy: { - type: 'mnemonic', - id: MOCK_ENTROPY_SOURCE_ID, - groupIndex: 0, - derivationPath: "m/44'/4392018'/0'/0", + describe('Money Keyring', () => { + it('creates a new money account with the correct shape', async () => { + const { controller } = setup(); + const account = + await controller.createMoneyAccount(CREATE_MONEY_PARAMS); + expect(account).toMatchObject({ + address: MOCK_ADDRESS, + type: 'eip155:eoa', + scopes: ['eip155:0'], + options: { + entropy: { + type: 'mnemonic', + id: MOCK_ENTROPY_SOURCE_ID, + groupIndex: 0, + derivationPath: "m/44'/4392018'/0'/0", + }, }, - }, - methods: expect.arrayContaining(['personal_sign']), + methods: expect.arrayContaining(['personal_sign']), + }); + expect(typeof account.id).toBe('string'); }); - expect(typeof account.id).toBe('string'); - }); - it('persists the created account to state', async () => { - const { controller } = setup(); - const account = await controller.createMoneyAccount( - MOCK_ENTROPY_SOURCE_ID, - ); - expect(controller.state.moneyAccounts[account.id]).toStrictEqual(account); + it('persists the created account to state and sets it as the default', async () => { + const { controller } = setup(); + const account = + await controller.createMoneyAccount(CREATE_MONEY_PARAMS); + expect(controller.state.moneyAccounts[account.id]).toStrictEqual( + account, + ); + expect(controller.state.defaultMoneyAccountId).toBe(account.id); + }); + + it('does not override an existing default when creating another account', async () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT], + defaultMoneyAccountId: MOCK_MONEY_ACCOUNT.id, + }); + const account = await controller.createMoneyAccount({ + keyringType: KeyringTypes.money, + entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, + }); + expect(account.id).not.toBe(MOCK_MONEY_ACCOUNT.id); + expect(controller.state.defaultMoneyAccountId).toBe( + MOCK_MONEY_ACCOUNT.id, + ); + }); + + it('returns the existing account without calling withKeyring (idempotent)', async () => { + const { controller, mocks } = setup({ + accounts: [MOCK_MONEY_ACCOUNT], + }); + const account = + await controller.createMoneyAccount(CREATE_MONEY_PARAMS); + expect(account).toStrictEqual(MOCK_MONEY_ACCOUNT); + expect(mocks.KeyringController.withKeyring).not.toHaveBeenCalled(); + }); + + it('reuses the keyring address when keyring has an account but state does not', async () => { + const EXISTING_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + const { controller, mocks } = setup(); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring( + new MockMoneyKeyring({ accounts: [EXISTING_ADDRESS] }), + ), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + const account = + await controller.createMoneyAccount(CREATE_MONEY_PARAMS); + expect(account.address).toBe(EXISTING_ADDRESS); + }); + + it('adds an account when the money keyring exists but has no accounts', async () => { + const { controller, mocks } = setup(); + const mockKeyring = new MockMoneyKeyring({ accounts: [] }); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring(mockKeyring), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + const account = + await controller.createMoneyAccount(CREATE_MONEY_PARAMS); + expect(account.address).toBe(MOCK_ADDRESS); + }); + + it('does not create duplicate keyrings when called concurrently for the same entropy source', async () => { + const { controller, mocks } = setup(); + + let keyringCreated = false; + + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + // Yield to the event loop so concurrent calls can interleave at this + // point — simulating real async I/O latency. + await Promise.resolve(); + if (!keyringCreated) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + return callback({ + keyring: asKeyring(new MockMoneyKeyring()), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + + mocks.KeyringController.addNewKeyring.mockReset(); + mocks.KeyringController.addNewKeyring.mockImplementation(async () => { + keyringCreated = true; + return { id: 'mock-keyring-id', name: 'Money Keyring' }; + }); + + await Promise.all([ + controller.createMoneyAccount(CREATE_MONEY_PARAMS), + controller.createMoneyAccount(CREATE_MONEY_PARAMS), + ]); + + // The mutex in #withMoneyKeyring serializes the two calls, so only the first + // one creates the keyring; the second finds it already created. + expect(mocks.KeyringController.addNewKeyring).toHaveBeenCalledTimes(1); + }); + + it('rethrows unexpected errors from withKeyring', async () => { + const { controller, mocks } = setup(); + + const unexpectedError = new Error('Unexpected keyring error'); + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockRejectedValueOnce( + unexpectedError, + ); + + await expect( + controller.createMoneyAccount(CREATE_MONEY_PARAMS), + ).rejects.toThrow('Unexpected keyring error'); + }); + + it('throws when the keyring is locked', async () => { + const { controller } = setup({ isUnlocked: false }); + + await expect( + controller.createMoneyAccount(CREATE_MONEY_PARAMS), + ).rejects.toThrow( + 'Cannot create a money account while the keyring is locked', + ); + }); + + it('passes only the matching MoneyKeyring to the withKeyring callback', async () => { + const { controller, mocks } = setup(); + // Reset clears the "once" reject queue from setup() so the first (and only) + // call goes through this implementation directly (no create-keyring retry). + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockImplementation( + async ( + selector: { filter: (k: EthKeyring) => boolean }, + callback: Parameters[1], + ) => { + const { filter } = selector; + // Non-MoneyKeyring keyrings should not match. + expect(filter(asKeyring({ type: 'HD Key Tree' }))).toBe(false); + // A MoneyKeyring for a different entropy source should not match. + expect( + filter( + asKeyring( + new MockMoneyKeyring({ + entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, + }), + ), + ), + ).toBe(false); + // A MoneyKeyring for the correct entropy source should match. + const mockKeyring = new MockMoneyKeyring(); + expect(filter(asKeyring(mockKeyring))).toBe(true); + return callback({ + keyring: asKeyring(mockKeyring), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + await controller.createMoneyAccount(CREATE_MONEY_PARAMS); + }); + + it('uses the explicitly provided entropy source', async () => { + const { controller, mocks } = setup({ + keyrings: [ + MOCK_HD_KEYRING, + { + type: 'HD Key Tree', + accounts: ['0x2222222222222222222222222222222222222222'], + metadata: { + id: MOCK_OTHER_ENTROPY_SOURCE_ID, + name: 'HD Key Tree', + }, + }, + ], + }); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring( + new MockMoneyKeyring({ + entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, + }), + ), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + + const account = await controller.createMoneyAccount({ + keyringType: KeyringTypes.money, + entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, + }); + expect( + account.options.entropy?.type === 'mnemonic' && + account.options.entropy.id, + ).toBe(MOCK_OTHER_ENTROPY_SOURCE_ID); + }); + + it('is callable via the messenger', async () => { + const { rootMessenger } = setup(); + + const account = await rootMessenger.call( + 'MoneyAccountController:createMoneyAccount', + CREATE_MONEY_PARAMS, + ); + expect(account).toMatchObject({ + address: MOCK_ADDRESS, + options: { entropy: { id: MOCK_ENTROPY_SOURCE_ID } }, + }); + }); }); - it('returns the existing account without calling withKeyring (idempotent)', async () => { - const { controller, mocks } = setup({ - accounts: [MOCK_MONEY_ACCOUNT], + describe('MPC Keyring', () => { + const createMpcParams = { + keyringType: MPC_KEYRING_TYPE, + } as const; + + it('creates an account from an existing MPC keyring', async () => { + const { controller, mocks } = setup({ + keyrings: [MOCK_HD_KEYRING, MOCK_MPC_KEYRING_STATE], + }); + mockMpcWithKeyring(mocks); + + const account = await controller.createMoneyAccount(createMpcParams); + + expect(account).toMatchObject({ + address: MOCK_MPC_ADDRESS, + type: 'eip155:eoa', + options: { + entropy: { type: 'custom' }, + exportable: false, + keyringId: MOCK_MPC_KEYRING_ID, + }, + }); + expect(controller.state.moneyAccounts[account.id]).toStrictEqual( + account, + ); + expect(controller.state.defaultMoneyAccountId).toBe(account.id); + }); + + it('adds an account when the MPC keyring has none', async () => { + const { controller, mocks } = setup({ + keyrings: [MOCK_HD_KEYRING, MOCK_MPC_KEYRING_STATE], + }); + const mockKeyring = new MockMpcKeyring({ accounts: [] }); + mockMpcWithKeyring(mocks, mockKeyring); + + const account = await controller.createMoneyAccount(createMpcParams); + + expect(account.address).toBe(MOCK_MPC_ADDRESS); + }); + + it('returns the existing account without calling withKeyring (idempotent)', async () => { + const { controller, mocks } = setup({ + accounts: [MOCK_MPC_ACCOUNT], + keyrings: [MOCK_HD_KEYRING, MOCK_MPC_KEYRING_STATE], + }); + + const account = await controller.createMoneyAccount({ + keyringType: MPC_KEYRING_TYPE, + keyringId: MOCK_MPC_KEYRING_ID, + }); + + expect(account).toStrictEqual(MOCK_MPC_ACCOUNT); + expect(mocks.KeyringController.withKeyring).not.toHaveBeenCalled(); + }); + + it('throws when no MPC keyring exists', async () => { + const { controller, mocks } = setup(); + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockRejectedValue( + new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ), + ); + + await expect( + controller.createMoneyAccount(createMpcParams), + ).rejects.toThrow('No MPC keyring found'); + expect(mocks.KeyringController.addNewKeyring).not.toHaveBeenCalled(); + }); + + it('throws when the given MPC keyring id is not found', async () => { + const { controller, mocks } = setup(); + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockRejectedValue( + new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ), + ); + + await expect( + controller.createMoneyAccount({ + keyringType: MPC_KEYRING_TYPE, + keyringId: 'missing-mpc', + }), + ).rejects.toThrow('No MPC keyring found'); + }); + + it('rethrows unexpected errors from withKeyring', async () => { + const { controller, mocks } = setup(); + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockRejectedValue( + new Error('Unexpected MPC keyring error'), + ); + + await expect( + controller.createMoneyAccount({ + keyringType: MPC_KEYRING_TYPE, + keyringId: MOCK_MPC_KEYRING_ID, + }), + ).rejects.toThrow('Unexpected MPC keyring error'); + }); + + it('throws when multiple MPC keyrings exist and keyringId is omitted', async () => { + const { controller } = setup({ + keyrings: [ + MOCK_MPC_KEYRING_STATE, + { + type: MPC_KEYRING_TYPE, + accounts: ['0x3333333333333333333333333333333333333333'], + metadata: { id: MOCK_MPC_KEYRING_ID_2, name: 'MPC Keyring' }, + }, + ], + }); + + await expect( + controller.createMoneyAccount(createMpcParams), + ).rejects.toThrow('Multiple MPC keyrings found; provide keyringId'); + }); + + it('uses the provided keyringId when multiple MPC keyrings exist', async () => { + const { controller, mocks } = setup({ + keyrings: [ + MOCK_MPC_KEYRING_STATE, + { + type: MPC_KEYRING_TYPE, + accounts: ['0x3333333333333333333333333333333333333333'], + metadata: { id: MOCK_MPC_KEYRING_ID_2, name: 'MPC Keyring' }, + }, + ], + }); + mockMpcWithKeyring(mocks, new MockMpcKeyring(), { + id: MOCK_MPC_KEYRING_ID_2, + name: 'MPC Keyring', + }); + + const account = await controller.createMoneyAccount({ + keyringType: MPC_KEYRING_TYPE, + keyringId: MOCK_MPC_KEYRING_ID_2, + }); + + expect(account.options.keyringId).toBe(MOCK_MPC_KEYRING_ID_2); + expect(mocks.KeyringController.withKeyring).toHaveBeenCalledWith( + { id: MOCK_MPC_KEYRING_ID_2 }, + expect.any(Function), + ); + }); + + it('throws when the selected keyring is not an MPC keyring', async () => { + const { controller, mocks } = setup(); + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring(new MockMoneyKeyring()), + metadata: { id: 'not-mpc', name: 'Money Keyring' }, + }); + }, + ); + + await expect( + controller.createMoneyAccount({ + keyringType: MPC_KEYRING_TYPE, + keyringId: 'not-mpc', + }), + ).rejects.toThrow('Keyring not-mpc is not an MPC Keyring'); + }); + + it('throws when the keyring is locked', async () => { + const { controller } = setup({ + isUnlocked: false, + keyrings: [MOCK_MPC_KEYRING_STATE], + }); + + await expect( + controller.createMoneyAccount(createMpcParams), + ).rejects.toThrow( + 'Cannot create a money account while the keyring is locked', + ); }); - const account = await controller.createMoneyAccount( - MOCK_ENTROPY_SOURCE_ID, - ); - expect(account).toStrictEqual(MOCK_MONEY_ACCOUNT); - expect(mocks.KeyringController.withKeyring).not.toHaveBeenCalled(); }); + }); - it('reuses the keyring address when keyring has an account but state does not', async () => { - const EXISTING_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - const { controller, mocks } = setup(); - mocks.KeyringController.withKeyring.mockImplementation( - async (_selector, callback) => { - return callback({ - keyring: asKeyring( - new MockMoneyKeyring({ accounts: [EXISTING_ADDRESS] }), - ), - metadata: MOCK_HD_KEYRING.metadata, - }); - }, - ); - const account = await controller.createMoneyAccount( - MOCK_ENTROPY_SOURCE_ID, + describe('addMoneyAccount', () => { + it('registers an arbitrary account and sets it as the default', () => { + const { controller } = setup(); + + const account = controller.addMoneyAccount(MOCK_MPC_ACCOUNT); + + expect(account).toStrictEqual(MOCK_MPC_ACCOUNT); + expect(controller.state.moneyAccounts[MOCK_MPC_ACCOUNT.id]).toStrictEqual( + MOCK_MPC_ACCOUNT, ); - expect(account.address).toBe(EXISTING_ADDRESS); + expect(controller.state.defaultMoneyAccountId).toBe(MOCK_MPC_ACCOUNT.id); }); - it('adds an account when the money keyring exists but has no accounts', async () => { - const { controller, mocks } = setup(); - const mockKeyring = new MockMoneyKeyring({ accounts: [] }); - mocks.KeyringController.withKeyring.mockImplementation( - async (_selector, callback) => { - return callback({ - keyring: asKeyring(mockKeyring), - metadata: MOCK_HD_KEYRING.metadata, - }); - }, - ); - const account = await controller.createMoneyAccount( - MOCK_ENTROPY_SOURCE_ID, + it('returns the existing account without replacing it (idempotent)', () => { + const { controller } = setup({ accounts: [MOCK_MPC_ACCOUNT] }); + const mutated: MoneyAccount = { + ...MOCK_MPC_ACCOUNT, + address: '0x9999999999999999999999999999999999999999', + }; + + const account = controller.addMoneyAccount(mutated); + + expect(account).toStrictEqual(MOCK_MPC_ACCOUNT); + expect(controller.state.moneyAccounts[MOCK_MPC_ACCOUNT.id]).toStrictEqual( + MOCK_MPC_ACCOUNT, ); - expect(account.address).toBe(MOCK_ADDRESS); }); - it('does not create duplicate keyrings when called concurrently for the same entropy source', async () => { - const { controller, mocks } = setup(); - - let keyringCreated = false; - - mocks.KeyringController.withKeyring.mockReset(); - mocks.KeyringController.withKeyring.mockImplementation( - async (_selector, callback) => { - // Yield to the event loop so concurrent calls can interleave at this - // point — simulating real async I/O latency. - await Promise.resolve(); - if (!keyringCreated) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - return callback({ - keyring: asKeyring(new MockMoneyKeyring()), - metadata: MOCK_HD_KEYRING.metadata, - }); - }, + it('does not override an existing default', () => { + const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT] }); + + controller.addMoneyAccount(MOCK_MPC_ACCOUNT); + + expect(controller.state.defaultMoneyAccountId).toBe( + MOCK_MONEY_ACCOUNT.id, ); + }); - mocks.KeyringController.addNewKeyring.mockReset(); - mocks.KeyringController.addNewKeyring.mockImplementation(async () => { - keyringCreated = true; - return { id: 'mock-keyring-id', name: 'Money Keyring' }; - }); + it('is callable via the messenger', () => { + const { rootMessenger } = setup(); - await Promise.all([ - controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), - controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), - ]); + const account = rootMessenger.call( + 'MoneyAccountController:addMoneyAccount', + MOCK_MPC_ACCOUNT, + ); - // The mutex in #withKeyring serializes the two calls, so only the first - // one creates the keyring; the second finds it already created. - expect(mocks.KeyringController.addNewKeyring).toHaveBeenCalledTimes(1); + expect(account).toStrictEqual(MOCK_MPC_ACCOUNT); }); + }); - it('rethrows unexpected errors from withKeyring', async () => { - const { controller, mocks } = setup(); + describe('setDefaultMoneyAccount', () => { + it('sets the default account', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MPC_ACCOUNT], + defaultMoneyAccountId: MOCK_MONEY_ACCOUNT.id, + }); - const unexpectedError = new Error('Unexpected keyring error'); - mocks.KeyringController.withKeyring.mockReset(); - mocks.KeyringController.withKeyring.mockRejectedValueOnce( - unexpectedError, - ); + controller.setDefaultMoneyAccount(MOCK_MPC_ACCOUNT.id); - await expect( - controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), - ).rejects.toThrow('Unexpected keyring error'); + expect(controller.state.defaultMoneyAccountId).toBe(MOCK_MPC_ACCOUNT.id); + expect(controller.getMoneyAccount()).toStrictEqual(MOCK_MPC_ACCOUNT); }); - it('throws when the keyring is locked', async () => { - const { controller } = setup({ isUnlocked: false }); + it('throws for an unknown account id', () => { + const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT] }); - await expect( - controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), - ).rejects.toThrow( - 'Cannot create a money account while the keyring is locked', + expect(() => controller.setDefaultMoneyAccount('unknown-id')).toThrow( + 'Unknown money account: unknown-id', ); }); - it('passes only the matching MoneyKeyring to the withKeyring callback', async () => { - const { controller, mocks } = setup(); - // Reset clears the "once" reject queue from setup() so the first (and only) - // call goes through this implementation directly (no create-keyring retry). - mocks.KeyringController.withKeyring.mockReset(); - mocks.KeyringController.withKeyring.mockImplementation( - async ( - selector: { filter: (k: EthKeyring) => boolean }, - callback: Parameters[1], - ) => { - const { filter } = selector; - // Non-MoneyKeyring keyrings should not match. - expect(filter(asKeyring({ type: 'HD Key Tree' }))).toBe(false); - // A MoneyKeyring for a different entropy source should not match. - expect( - filter( - asKeyring( - new MockMoneyKeyring({ - entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, - }), - ), - ), - ).toBe(false); - // A MoneyKeyring for the correct entropy source should match. - const mockKeyring = new MockMoneyKeyring(); - expect(filter(asKeyring(mockKeyring))).toBe(true); - return callback({ - keyring: asKeyring(mockKeyring), - metadata: MOCK_HD_KEYRING.metadata, - }); - }, + it('is callable via the messenger', () => { + const { controller, rootMessenger } = setup({ + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MPC_ACCOUNT], + defaultMoneyAccountId: MOCK_MONEY_ACCOUNT.id, + }); + + rootMessenger.call( + 'MoneyAccountController:setDefaultMoneyAccount', + MOCK_MPC_ACCOUNT.id, ); - await controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID); + + expect(controller.state.defaultMoneyAccountId).toBe(MOCK_MPC_ACCOUNT.id); }); + }); - it('uses the explicitly provided entropy source', async () => { - const { controller, mocks } = setup({ - keyrings: [ - MOCK_HD_KEYRING, - { - type: 'HD Key Tree', - accounts: ['0x2222222222222222222222222222222222222222'], - metadata: { id: MOCK_OTHER_ENTROPY_SOURCE_ID, name: 'HD Key Tree' }, - }, - ], - }); - mocks.KeyringController.withKeyring.mockImplementation( - async (_selector, callback) => { - return callback({ - keyring: asKeyring( - new MockMoneyKeyring({ - entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, - }), - ), - metadata: MOCK_HD_KEYRING.metadata, - }); - }, - ); + describe('getMoneyAccount', () => { + it('returns the default account when no selector is provided', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MONEY_ACCOUNT_2], + defaultMoneyAccountId: MOCK_MONEY_ACCOUNT_2.id, + }); - const account = await controller.createMoneyAccount( - MOCK_OTHER_ENTROPY_SOURCE_ID, - ); - expect(account.options.entropy.id).toBe(MOCK_OTHER_ENTROPY_SOURCE_ID); + expect(controller.getMoneyAccount()).toStrictEqual(MOCK_MONEY_ACCOUNT_2); }); - it('is callable via the messenger', async () => { - const { rootMessenger } = setup(); + it('returns undefined when no default is set', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT], + defaultMoneyAccountId: null, + }); - const account = await rootMessenger.call( - 'MoneyAccountController:createMoneyAccount', - MOCK_ENTROPY_SOURCE_ID, - ); - expect(account).toMatchObject({ - address: MOCK_ADDRESS, - options: { entropy: { id: MOCK_ENTROPY_SOURCE_ID } }, + expect(controller.getMoneyAccount()).toBeUndefined(); + }); + + it('returns the account for the given id', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MPC_ACCOUNT], }); + + expect( + controller.getMoneyAccount({ id: MOCK_MPC_ACCOUNT.id }), + ).toStrictEqual(MOCK_MPC_ACCOUNT); }); - }); - describe('getMoneyAccount', () => { it('returns the account for the given entropy source', () => { const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT, MOCK_MONEY_ACCOUNT_2], @@ -514,19 +928,17 @@ describe('MoneyAccountController', () => { ).toBeUndefined(); }); - it('falls back to the primary entropy source when none is provided', () => { - const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT] }); - - expect(controller.getMoneyAccount()).toStrictEqual(MOCK_MONEY_ACCOUNT); - }); - - it('returns undefined when no entropy source is provided and no HD keyring exists', () => { + it('prefers id over entropySource when both are provided', () => { const { controller } = setup({ - accounts: [MOCK_MONEY_ACCOUNT], - keyrings: [], + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MONEY_ACCOUNT_2], }); - expect(controller.getMoneyAccount()).toBeUndefined(); + expect( + controller.getMoneyAccount({ + id: MOCK_MONEY_ACCOUNT_2.id, + entropySource: MOCK_ENTROPY_SOURCE_ID, + }), + ).toStrictEqual(MOCK_MONEY_ACCOUNT_2); }); it('is callable via the messenger', () => { @@ -541,7 +953,7 @@ describe('MoneyAccountController', () => { }); describe('clearState', () => { - it('resets moneyAccounts to an empty object', () => { + it('resets moneyAccounts and the default account', () => { const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT, MOCK_MONEY_ACCOUNT_2], }); @@ -550,7 +962,9 @@ describe('MoneyAccountController', () => { controller.clearState(); - expect(controller.state.moneyAccounts).toStrictEqual({}); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountControllerState(), + ); }); it('is a no-op when state is already empty', () => { @@ -558,7 +972,9 @@ describe('MoneyAccountController', () => { controller.clearState(); - expect(controller.state.moneyAccounts).toStrictEqual({}); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountControllerState(), + ); }); it('is callable via the messenger', () => { @@ -568,7 +984,9 @@ describe('MoneyAccountController', () => { rootMessenger.call('MoneyAccountController:clearState'); - expect(controller.state.moneyAccounts).toStrictEqual({}); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountControllerState(), + ); }); }); }); diff --git a/packages/money-account-controller/src/MoneyAccountController.ts b/packages/money-account-controller/src/MoneyAccountController.ts index 5e6d259adfa..54d091a740a 100644 --- a/packages/money-account-controller/src/MoneyAccountController.ts +++ b/packages/money-account-controller/src/MoneyAccountController.ts @@ -30,14 +30,19 @@ import { Mutex } from 'async-mutex'; import { projectLogger as log } from './logger.js'; import type { MoneyAccountControllerMethodActions } from './MoneyAccountController-method-action-types.js'; import type { MoneyAccount } from './types.js'; -import { isMoneyKeyring } from './utils.js'; +import { isMoneyKeyring, isMpcKeyring, MPC_KEYRING_TYPE } from './utils.js'; export const controllerName = 'MoneyAccountController'; +export type CreateMoneyAccountParams = + | { keyringType: typeof KeyringTypes.money; entropySource: EntropySourceId } + | { keyringType: typeof MPC_KEYRING_TYPE; keyringId?: string }; + export type MoneyAccountControllerState = { moneyAccounts: { [id: MoneyAccount['id']]: MoneyAccount; }; + defaultMoneyAccountId: MoneyAccount['id'] | null; }; const moneyAccountControllerMetadata = { @@ -47,16 +52,25 @@ const moneyAccountControllerMetadata = { persist: true, usedInUi: true, }, + defaultMoneyAccountId: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, } satisfies StateMetadata; export function getDefaultMoneyAccountControllerState(): MoneyAccountControllerState { return { moneyAccounts: {}, + defaultMoneyAccountId: null, }; } const MESSENGER_EXPOSED_METHODS = [ 'createMoneyAccount', + 'addMoneyAccount', + 'setDefaultMoneyAccount', 'getMoneyAccount', 'clearState', 'init', @@ -92,6 +106,14 @@ export type MoneyAccountControllerMessenger = Messenger< MoneyAccountControllerEvents | AllowedEvents >; +const MONEY_ACCOUNT_METHODS = [ + EthMethod.PersonalSign, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + // TODO: Update this once the `keyring-api` package supports `SignEip7702Authorization` method. +]; + /** * Controller for managing money accounts. */ @@ -143,8 +165,10 @@ export class MoneyAccountController extends BaseController< const primaryEntropySource = this.#getPrimaryEntropySource(); if (primaryEntropySource) { - const { id, address } = - await this.createMoneyAccount(primaryEntropySource); + const { id, address } = await this.createMoneyAccount({ + keyringType: KeyringTypes.money, + entropySource: primaryEntropySource, + }); log( `Money keyring (entropy:${primaryEntropySource} - primary) account is: ${address} (${id})`, ); @@ -158,40 +182,139 @@ export class MoneyAccountController extends BaseController< } /** - * Creates a money account for the given entropy source. If an account - * already exists for that entropy source, it is returned as-is (idempotent). + * Creates a money account from a Money Keyring (entropy source) or an + * existing MPC Keyring. If an account already exists for that source, it is + * returned as-is (idempotent). * - * @param entropySource - The entropy source ID to create the money account for. + * @param params - The keyring source to create the money account from. * @returns The money account. */ async createMoneyAccount( - entropySource: EntropySourceId, + params: CreateMoneyAccountParams, ): Promise { this.#assertIsUnlocked(); - // Idempotent: return existing account if already in state. - const existingAccount = this.getMoneyAccount({ entropySource }); + if (params.keyringType === KeyringTypes.money) { + return await this.#createMoneyKeyringAccount(params.entropySource); + } + + return await this.#createMpcKeyringAccount(params.keyringId); + } + + /** + * Registers an already-built money account. If an account with the same id + * is already in state, it is returned as-is (idempotent). + * + * If no default account is set, the added account becomes the default. + * + * @param account - The account to register. + * @returns The registered money account. + */ + addMoneyAccount(account: MoneyAccount): MoneyAccount { + const existingAccount = this.state.moneyAccounts[account.id]; if (existingAccount) { return existingAccount; } - const address = await this.#withKeyring(entropySource, async (keyring) => { - // We're adding this logic to be defensive against the possibility of a money keyring - // existing without any accounts, which shouldn't normally happen but we want to be - // sure we can handle it if it does. - // If there are no accounts, we'll add one and then get the address. - const accounts = await keyring.getAccounts(); - if (accounts.length > 0) { - const [moneyAddress] = accounts; - return moneyAddress; - } + this.#persistAccount(account); + log(`Money account added: ${account.address} (${account.id})`); + return account; + } - log( - `Money keyring (entropy:${entropySource}) has no accounts, creating one...`, - ); - const [moneyAddress] = await keyring.addAccounts(1); - return moneyAddress; + /** + * Sets the default money account. + * + * @param id - The id of the money account to use as the default. + */ + setDefaultMoneyAccount(id: MoneyAccount['id']): void { + if (this.state.moneyAccounts[id] === undefined) { + throw new Error(`Unknown money account: ${id}`); + } + + this.update((state) => { + state.defaultMoneyAccountId = id; + }); + } + + /** + * Gets a money account. With no selector, returns the default account. + * + * @param selector - Selector options for getting the money account. + * @param selector.id - The account id to look up. + * @param selector.entropySource - The entropy source ID of a Money Keyring + * account. Ignored when `id` is provided. + * @returns The money account, or `undefined` if none matches. + */ + getMoneyAccount( + selector: { + id?: MoneyAccount['id']; + entropySource?: EntropySourceId; + } = {}, + ): MoneyAccount | undefined { + if (selector.id !== undefined) { + return this.state.moneyAccounts[selector.id]; + } + + if (selector.entropySource !== undefined) { + return this.#getMoneyAccountByEntropySource(selector.entropySource); + } + + const { defaultMoneyAccountId } = this.state; + if (defaultMoneyAccountId === null) { + return undefined; + } + + return this.state.moneyAccounts[defaultMoneyAccountId]; + } + + /** + * Resets the controller state to its default, removing all money accounts. + * + * Intended for use during a full app reset (e.g. when the user wipes all + * wallet data). Does not interact with the keyring — the caller is + * responsible for ensuring the associated keyring state is also cleared. + */ + clearState(): void { + this.update((state) => { + state.moneyAccounts = {}; + state.defaultMoneyAccountId = null; }); + } + + /** + * Creates or reuses a Money Keyring account for the given entropy source. + * + * @param entropySource - The entropy source ID to create the money account for. + * @returns The money account. + */ + async #createMoneyKeyringAccount( + entropySource: EntropySourceId, + ): Promise { + const existingAccount = this.#getMoneyAccountByEntropySource(entropySource); + if (existingAccount) { + return existingAccount; + } + + const address = await this.#withMoneyKeyring( + entropySource, + async (keyring) => { + // We're adding this logic to be defensive against the possibility of a money keyring + // existing without any accounts, which shouldn't normally happen but we want to be + // sure we can handle it if it does. + // If there are no accounts, we'll add one and then get the address. + const accounts = await keyring.getAccounts(); + if (accounts.length > 0) { + const [moneyAddress] = accounts; + return moneyAddress; + } + + log( + `Money keyring (entropy:${entropySource}) has no accounts, creating one...`, + ); + const [moneyAddress] = await keyring.addAccounts(1); + return moneyAddress; + }, + ); const account: MoneyAccount = { // This is an EVM account, so let's re-use the deterministic ID generation logic of @@ -209,63 +332,136 @@ export class MoneyAccountController extends BaseController< }, exportable: false, }, - methods: [ - EthMethod.PersonalSign, - EthMethod.SignTypedDataV1, - EthMethod.SignTypedDataV3, - EthMethod.SignTypedDataV4, - // TODO: Update this once the `keyring-api` package supports `SignEip7702Authorization` method. - ], + methods: [...MONEY_ACCOUNT_METHODS], }; - // Store the account in state. - this.update((state) => { - state.moneyAccounts[account.id] = account; - }); - + this.#persistAccount(account); log( - `Money keyring (entropy:${account.options.entropy.id}) account created: ${account.address} (${account.id})`, + `Money keyring (entropy:${entropySource}) account created: ${account.address} (${account.id})`, ); return account; } /** - * Gets a money account by its associated entropy source ID. If no ID is - * provided, the primary entropy source will be used. + * Creates or reuses an account on an existing MPC keyring. * - * @param selector - Selector options for getting the money account. - * @param selector.entropySource - The entropy source ID to get the money account for. If not provided, the primary entropy source will be used. - * @returns The money account, or `undefined` if no account exists for the given entropy source. + * @param keyringId - Optional MPC keyring id. Required when more than one + * MPC keyring exists. + * @returns The money account. */ - getMoneyAccount( - selector: { entropySource?: EntropySourceId } = {}, - ): MoneyAccount | undefined { - const entropySource = - selector.entropySource ?? this.#getPrimaryEntropySource(); - if (entropySource === undefined) { - return undefined; + async #createMpcKeyringAccount(keyringId?: string): Promise { + const resolvedKeyringId = this.#resolveMpcKeyringId(keyringId); + + const existingAccount = this.#getMoneyAccountByKeyringId(resolvedKeyringId); + if (existingAccount) { + return existingAccount; } - // We should never have more than one money account per entropy source, but if we - // do, just return the first one we find. - return Object.values(this.state.moneyAccounts).find( - (account) => account.options.entropy.id === entropySource, + const { address, id: metadataId } = await this.#withMpcKeyring( + resolvedKeyringId, + async (keyring, metadata) => { + const accounts = await keyring.getAccounts(); + if (accounts.length > 0) { + const [mpcAddress] = accounts; + return { address: mpcAddress, id: metadata.id }; + } + + log(`MPC keyring (${metadata.id}) has no accounts, creating one...`); + const [mpcAddress] = await keyring.addAccounts(1); + return { address: mpcAddress, id: metadata.id }; + }, ); + + const account: MoneyAccount = { + id: getUUIDFromAddressOfNormalAccount(address), + type: EthAccountType.Eoa, + address, + scopes: [EthScope.Eoa], + options: { + entropy: { + type: 'custom', + }, + exportable: false, + keyringId: metadataId, + }, + methods: [...MONEY_ACCOUNT_METHODS], + }; + + this.#persistAccount(account); + log( + `MPC keyring (${metadataId}) account created: ${account.address} (${account.id})`, + ); + return account; } /** - * Resets the controller state to its default, removing all money accounts. + * Stores the account in state. If no default is set, this account becomes + * the default. * - * Intended for use during a full app reset (e.g. when the user wipes all - * wallet data). Does not interact with the keyring — the caller is - * responsible for ensuring the associated keyring state is also cleared. + * @param account - The account to persist. */ - clearState(): void { + #persistAccount(account: MoneyAccount): void { this.update((state) => { - state.moneyAccounts = {}; + state.moneyAccounts[account.id] = account; + state.defaultMoneyAccountId ??= account.id; + }); + } + + /** + * Gets a Money Keyring account by entropy source id. + * + * @param entropySource - The entropy source ID. + * @returns The matching account, if any. + */ + #getMoneyAccountByEntropySource( + entropySource: EntropySourceId, + ): MoneyAccount | undefined { + return Object.values(this.state.moneyAccounts).find((account) => { + const { entropy } = account.options; + return entropy?.type === 'mnemonic' && entropy.id === entropySource; }); } + /** + * Gets an account previously created from a specific keyring id. + * + * @param keyringId - The keyring metadata id. + * @returns The matching account, if any. + */ + #getMoneyAccountByKeyringId(keyringId: string): MoneyAccount | undefined { + return Object.values(this.state.moneyAccounts).find( + (account) => account.options.keyringId === keyringId, + ); + } + + /** + * Resolves which MPC keyring to use. + * + * @param keyringId - Optional explicit keyring id. + * @returns The MPC keyring metadata id. + */ + #resolveMpcKeyringId(keyringId?: string): string { + if (keyringId !== undefined) { + return keyringId; + } + + const { keyrings } = this.messenger.call('KeyringController:getState'); + const mpcKeyrings = keyrings.filter( + (keyring) => keyring.type === MPC_KEYRING_TYPE, + ); + + if (mpcKeyrings.length === 0) { + throw new Error('No MPC keyring found'); + } + + if (mpcKeyrings.length > 1) { + throw new Error('Multiple MPC keyrings found; provide keyringId'); + } + + const [mpcKeyring] = mpcKeyrings; + return mpcKeyring.metadata.id; + } + /** * Calls `KeyringController:withKeyring` for the `MoneyKeyring` associated with the * given entropy source, creating one first if it does not yet exist. @@ -274,7 +470,7 @@ export class MoneyAccountController extends BaseController< * @param operation - Callback invoked with the resolved `MoneyKeyring`. * @returns The value returned by `operation`. */ - async #withKeyring( + async #withMoneyKeyring( entropySource: EntropySourceId, operation: (keyring: MoneyKeyring) => Promise, ): Promise { @@ -297,7 +493,7 @@ export class MoneyAccountController extends BaseController< ) as Promise; // We have an extra lock here to avoid a race-condition where 2 calls to - // `#withKeyring` for the same entropy source happen at the same time, and + // `#withMoneyKeyring` for the same entropy source happen at the same time, and // both don't find an existing keyring, so they both try to create a new // one, which creates multiple keyrings for the same entropy source. // NOTE: We cannot use `createIfMissing` here either, since it's only supported @@ -332,6 +528,39 @@ export class MoneyAccountController extends BaseController< }); } + /** + * Calls `KeyringController:withKeyring` for an existing MPC keyring. + * + * @param keyringId - The MPC keyring metadata id. + * @param operation - Callback invoked with the resolved keyring and metadata. + * @returns The value returned by `operation`. + */ + async #withMpcKeyring( + keyringId: string, + operation: ( + keyring: EthKeyring, + metadata: KeyringMetadata, + ) => Promise, + ): Promise { + try { + return (await this.messenger.call( + 'KeyringController:withKeyring', + { id: keyringId }, + async ({ keyring, metadata }) => { + if (!isMpcKeyring(keyring)) { + throw new Error(`Keyring ${keyringId} is not an MPC Keyring`); + } + return operation(keyring, metadata); + }, + )) as Result; + } catch (error) { + if (isKeyringNotFoundError(error)) { + throw new Error('No MPC keyring found'); + } + throw error; + } + } + /** * Adds a new money keyring for the given entropy source and returns its metadata. * diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index d8ab43a19a9..bb3a88c2b3d 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -1,11 +1,12 @@ export type { MoneyAccount } from './types.js'; -export { isMoneyKeyring } from './utils.js'; +export { isMoneyKeyring, isMpcKeyring, MPC_KEYRING_TYPE } from './utils.js'; export { MoneyAccountController, controllerName, getDefaultMoneyAccountControllerState, } from './MoneyAccountController.js'; export type { + CreateMoneyAccountParams, MoneyAccountControllerState, MoneyAccountControllerGetStateAction, MoneyAccountControllerActions, @@ -14,8 +15,10 @@ export type { MoneyAccountControllerMessenger, } from './MoneyAccountController.js'; export type { + MoneyAccountControllerAddMoneyAccountAction, MoneyAccountControllerClearStateAction, MoneyAccountControllerCreateMoneyAccountAction, MoneyAccountControllerGetMoneyAccountAction, MoneyAccountControllerInitAction, + MoneyAccountControllerSetDefaultMoneyAccountAction, } from './MoneyAccountController-method-action-types.js'; diff --git a/packages/money-account-controller/src/types.ts b/packages/money-account-controller/src/types.ts index ca5fdc4a596..f59c37a7f07 100644 --- a/packages/money-account-controller/src/types.ts +++ b/packages/money-account-controller/src/types.ts @@ -1,14 +1,4 @@ -import type { - KeyringAccount, - KeyringAccountEntropyMnemonicOptions, -} from '@metamask/keyring-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; /** A money account represents an account managed by the MoneyAccountController. */ -export type MoneyAccount = Omit & { - // We use stricter options for money accounts. They can be seen as BIP-44 accounts - // and we make them non-exportable too. - options: { - entropy: KeyringAccountEntropyMnemonicOptions; - exportable: false; - }; -}; +export type MoneyAccount = KeyringAccount; diff --git a/packages/money-account-controller/src/utils.test.ts b/packages/money-account-controller/src/utils.test.ts index b3fa2459282..0dfd0d5e3e8 100644 --- a/packages/money-account-controller/src/utils.test.ts +++ b/packages/money-account-controller/src/utils.test.ts @@ -1,7 +1,7 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { EthKeyring } from '@metamask/keyring-utils'; -import { isMoneyKeyring } from './utils.js'; +import { isMpcKeyring, isMoneyKeyring, MPC_KEYRING_TYPE } from './utils.js'; describe('isMoneyKeyring', () => { it('returns true for a Money Keyring', () => { @@ -18,3 +18,17 @@ describe('isMoneyKeyring', () => { ).toBe(false); }); }); + +describe('isMpcKeyring', () => { + it('returns true for an MPC Keyring', () => { + expect( + isMpcKeyring({ type: MPC_KEYRING_TYPE } as unknown as EthKeyring), + ).toBe(true); + }); + + it('returns false for a non-MPC Keyring', () => { + expect( + isMpcKeyring({ type: KeyringTypes.money } as unknown as EthKeyring), + ).toBe(false); + }); +}); diff --git a/packages/money-account-controller/src/utils.ts b/packages/money-account-controller/src/utils.ts index af40858f12d..c13e87ed807 100644 --- a/packages/money-account-controller/src/utils.ts +++ b/packages/money-account-controller/src/utils.ts @@ -2,6 +2,8 @@ import type { MoneyKeyring } from '@metamask/eth-money-keyring'; import { KeyringTypes } from '@metamask/keyring-controller'; import { EthKeyring } from '@metamask/keyring-utils'; +export const MPC_KEYRING_TYPE = 'MPC Keyring'; + /** * Returns `true` if the given keyring is a {@link MoneyKeyring}. * @@ -11,3 +13,13 @@ import { EthKeyring } from '@metamask/keyring-utils'; export function isMoneyKeyring(keyring: EthKeyring): keyring is MoneyKeyring { return keyring.type === KeyringTypes.money; } + +/** + * Returns `true` if the given keyring is an MPC keyring. + * + * @param keyring - The keyring to check. + * @returns Whether the keyring is an MPC keyring. + */ +export function isMpcKeyring(keyring: EthKeyring): boolean { + return keyring.type === MPC_KEYRING_TYPE; +}