diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index b518709c7b..b8ff88c4ce 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,4 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `Wallet` class with a bundled controller ensemble (AccountsController, ApprovalController, ConnectivityController, KeyringController, NetworkController, RemoteFeatureFlagController, TransactionController) ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - Pass `state` keyed by controller name to the constructor to hydrate from a stored snapshot. Subscribe to `${ControllerName}:stateChanged` events on `wallet.messenger` to write changes back to your storage backend. See the README for details. + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet/README.md b/packages/wallet/README.md index da275a947d..390ea53c68 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -10,6 +10,39 @@ or `npm install @metamask/wallet` +## Usage + +### Persistence contract + +`@metamask/wallet` has no persistence backend of its own. Clients own persistence entirely: + +**Hydration (boot):** Pass an initial `state` object keyed by controller name to the `Wallet` constructor. + +```ts +const wallet = new Wallet({ + state: { + AccountsController: { ... }, + NetworkController: { ... }, + }, + // ...other options +}); +``` + +The shape matches each controller's own state type. Unknown keys are ignored; missing keys fall back to each controller's default state. + +**Writes (runtime):** Subscribe to each controller's `:stateChanged` event on `wallet.messenger` and persist the relevant fields as reported by `wallet.controllerMetadata`. + +```ts +for (const [name, metadata] of Object.entries(wallet.controllerMetadata)) { + wallet.messenger.subscribe(`${name}:stateChanged`, (state, patches) => { + // Write persist-flagged fields to your storage backend. + // metadata[field].persist === true (or a StateDeriver) means the field should be persisted. + }); +} +``` + +The `patches` argument contains Immer patches identifying exactly which top-level fields changed, so writes can be scoped rather than full-state replacements. + ## 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/wallet/jest.config.js b/packages/wallet/jest.config.js index ca08413339..34d6161cc4 100644 --- a/packages/wallet/jest.config.js +++ b/packages/wallet/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 87.5, + functions: 92.85, + lines: 97.58, + statements: 97.63, }, }, }); diff --git a/packages/wallet/package.json b/packages/wallet/package.json index e067b95767..5f3bdc12a2 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -10,7 +10,7 @@ "bugs": { "url": "https://github.com/MetaMask/core/issues" }, - "license": "(MIT OR Apache-2.0)", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" @@ -44,20 +44,38 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet", - "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", - "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", "since-latest-release": "../../scripts/since-latest-release.sh", + "pretest": "./scripts/install-binaries.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/accounts-controller": "^38.1.1", + "@metamask/approval-controller": "^9.0.1", + "@metamask/base-controller": "^9.1.0", + "@metamask/browser-passworder": "^6.0.0", + "@metamask/connectivity-controller": "^0.2.0", + "@metamask/controller-utils": "^12.1.0", + "@metamask/keyring-controller": "^25.5.0", + "@metamask/messenger": "^1.2.0", + "@metamask/network-controller": "^32.0.0", + "@metamask/remote-feature-flag-controller": "^4.2.1", + "@metamask/scure-bip39": "^2.1.1", + "@metamask/transaction-controller": "^65.4.0", + "@metamask/utils": "^11.9.0" + }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", + "@metamask/foundryup": "^1.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "nock": "^13.3.1", "ts-jest": "^29.2.5", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/wallet/scripts/install-binaries.sh b/packages/wallet/scripts/install-binaries.sh new file mode 100755 index 0000000000..04edf91e45 --- /dev/null +++ b/packages/wallet/scripts/install-binaries.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +# Pin cwd to the package root so all paths are predictable regardless of how +# this script is invoked. Also derive the monorepo root (two levels up). +PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MONOREPO_ROOT="$(cd "${PACKAGE_ROOT}/../.." && pwd)" +cd "${PACKAGE_ROOT}" + +# Run foundryup's TypeScript entry point directly via tsx. This avoids having +# to build @metamask/foundryup first, which matters in CI where workspace deps +# aren't built before tests run. +if ! output=$(yarn tsx ../foundryup/src/cli.ts --binaries anvil 2>&1); then + echo "$output" >&2 + exit 1 +fi diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts new file mode 100644 index 0000000000..d5a8b2201a --- /dev/null +++ b/packages/wallet/src/Wallet.test.ts @@ -0,0 +1,257 @@ +import { RpcEndpointType } from '@metamask/network-controller'; +import { + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; +import { TransactionController } from '@metamask/transaction-controller'; +import { enableNetConnect } from 'nock'; + +import { startAnvil } from '../test/anvil'; +import type { AnvilInstance } from '../test/anvil'; +import * as initializationModule from './initialization'; +import { + createSecretRecoveryPhrase, + importSecretRecoveryPhrase, + sendTransaction, +} from './utilities'; +import { Wallet } from './Wallet'; + +const TEST_PHRASE = + 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'testpass'; + +async function setupWallet(): Promise { + const wallet = new Wallet({ + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }); + + await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_PHRASE); + + return wallet; +} + +describe('Wallet', () => { + let wallet: Wallet; + + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(async () => { + await wallet?.destroy(); + enableNetConnect(); + jest.useRealTimers(); + }); + + it('can unlock and populate accounts', async () => { + wallet = await setupWallet(); + const { messenger } = wallet; + + expect( + messenger + .call('AccountsController:listAccounts') + .map((account) => account.address), + ).toStrictEqual(['0xc6d5a3c98ec9073b54fa0969957bd582e8d874bf']); + }); + + describe('with local chain', () => { + let anvil: AnvilInstance; + + beforeAll(async () => { + anvil = await startAnvil({ mnemonic: TEST_PHRASE }); + }); + + afterAll(async () => { + await anvil?.stop(); + }); + + it('signs transactions', async () => { + enableNetConnect(); + + wallet = await setupWallet(); + + const networkConfig = wallet.messenger.call( + 'NetworkController:addNetwork', + { + chainId: '0x7a69', + name: 'Anvil', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + url: anvil.rpcUrl, + }, + ], + }, + ); + + const { networkClientId } = networkConfig.rpcEndpoints[0]; + + const addresses = wallet.messenger + .call('AccountsController:listAccounts') + .map((account) => account.address); + + const { result, transactionMeta } = await sendTransaction( + wallet, + { from: addresses[0], to: addresses[0] }, + { networkClientId }, + ); + + // Advance timers by an arbitrary value to trigger downstream timer logic. + const hash = await jest + .advanceTimersByTimeAsync(60_000) + .then(() => result); + + expect(hash).toStrictEqual(expect.any(String)); + expect(transactionMeta).toStrictEqual( + expect.objectContaining({ + txParams: expect.objectContaining({ + from: addresses[0], + to: addresses[0], + value: '0x0', + type: '0x2', + }), + }), + ); + }, 15_000); + }); + + it('can create secret recovery phrase', async () => { + wallet = new Wallet({ + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }); + + await createSecretRecoveryPhrase(wallet, TEST_PASSWORD); + + expect( + wallet.messenger.call('AccountsController:listAccounts'), + ).toHaveLength(1); + }); + + it('exposes state', async () => { + wallet = await setupWallet(); + const { state } = wallet; + + expect(state.KeyringController).toStrictEqual({ + isUnlocked: true, + keyrings: expect.any(Array), + encryptionKey: expect.any(String), + encryptionSalt: expect.any(String), + vault: expect.any(String), + }); + }); + + describe('lifecycle', () => { + const options = { + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }; + + it('exposes controllerMetadata for each initialized controller', () => { + wallet = new Wallet(options); + + const names = Object.keys(wallet.controllerMetadata); + expect(names).toStrictEqual(Object.keys(wallet.state)); + for (const name of names) { + expect(wallet.controllerMetadata[name]).toBeDefined(); + } + }); + + it('omits instances without a metadata property from controllerMetadata', () => { + const fakeMetadata = { + foo: { persist: true, includeInDebugSnapshot: false }, + }; + jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({ + WithMeta: { state: {}, metadata: fakeMetadata }, + NoMeta: { state: {} }, + } as never); + + wallet = new Wallet(options); + + expect(wallet.controllerMetadata).toStrictEqual({ + WithMeta: fakeMetadata, + }); + expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']); + }); + + it('publishes Wallet:destroyed exactly once on destroy', async () => { + wallet = new Wallet(options); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Wallet:destroyed even if a controller destroy throws synchronously', async () => { + wallet = new Wallet(options); + + jest + .spyOn(TransactionController.prototype, 'destroy') + .mockImplementation(() => { + throw new Error('sync destroy error'); + }); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Wallet:destroyed even if a controller destroy rejects', async () => { + wallet = new Wallet(options); + + jest + .spyOn(TransactionController.prototype, 'destroy') + .mockRejectedValue(new Error('async destroy error') as never); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts new file mode 100644 index 0000000000..2bb67c3c37 --- /dev/null +++ b/packages/wallet/src/Wallet.ts @@ -0,0 +1,80 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { Messenger } from '@metamask/messenger'; +import { hasProperty } from '@metamask/utils'; + +import type { + DefaultActions, + DefaultEvents, + DefaultInstances, + DefaultState, + RootMessenger, +} from './initialization'; +import { initialize } from './initialization'; +import type { WalletOptions } from './types'; + +export class Wallet { + public readonly messenger: RootMessenger; + + readonly #instances: DefaultInstances; + + readonly #controllerMetadata: Readonly< + Record> + >; + + #destroyed = false; + + constructor({ state, ...options }: WalletOptions) { + this.messenger = new Messenger({ + namespace: 'Wallet', + }); + + this.#instances = initialize({ + state: state ?? {}, + messenger: this.messenger, + options, + }); + + this.#controllerMetadata = Object.fromEntries( + Object.entries(this.#instances) + .filter(([_, instance]) => hasProperty(instance, 'metadata')) + .map(([name, instance]) => [name, instance.metadata]), + ); + } + + get state(): DefaultState { + return Object.entries(this.#instances).reduce>( + (totalState, [name, instance]) => { + totalState[name] = instance.state ?? null; + return totalState; + }, + {}, + ) as DefaultState; + } + + get controllerMetadata(): Readonly< + Record> + > { + return this.#controllerMetadata; + } + + async destroy(): Promise { + if (this.#destroyed) { + return; + } + this.#destroyed = true; + + await Promise.allSettled( + Object.values(this.#instances).map(async (instance) => { + // @ts-expect-error Accessing protected property. + if (typeof instance.destroy === 'function') { + // @ts-expect-error Accessing protected property. + return await instance.destroy(); + } + /* istanbul ignore next */ + return undefined; + }), + ); + + this.messenger.publish('Wallet:destroyed'); + } +} diff --git a/packages/wallet/src/index.test.ts b/packages/wallet/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/wallet/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 6972c11729..5fa0502ec2 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,9 +1,8 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { Wallet } from './Wallet'; +export type { WalletOptions } from './types'; +export type { + DefaultActions, + DefaultEvents, + RootMessenger, + WalletDestroyedEvent, +} from './initialization'; diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts new file mode 100644 index 0000000000..7156aad7a0 --- /dev/null +++ b/packages/wallet/src/initialization/defaults.ts @@ -0,0 +1,57 @@ +import type { + ActionConstraint, + EventConstraint, + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import * as defaultConfigurations from './instances'; +import type { InitializationConfiguration, InstanceState } from './types'; + +export { defaultConfigurations }; + +type ExtractInstance = + Config extends InitializationConfiguration + ? Instance + : never; + +type ExtractInstanceMessenger = + Config extends InitializationConfiguration + ? InferredMessenger + : never; + +type ExtractName = + ExtractInstance extends { name: infer Name extends string } + ? Name + : never; + +type Configs = typeof defaultConfigurations; + +type AllMessengers = ExtractInstanceMessenger; + +export type DefaultInstances = { + [Key in keyof Configs as ExtractName]: ExtractInstance< + Configs[Key] + >; +}; + +export type DefaultActions = MessengerActions; + +export type WalletDestroyedEvent = { + type: 'Wallet:destroyed'; + payload: []; +}; + +export type DefaultEvents = + | MessengerEvents + | WalletDestroyedEvent; + +export type RootMessenger< + AllowedActions extends ActionConstraint = ActionConstraint, + AllowedEvents extends EventConstraint = EventConstraint, +> = Messenger<'Wallet', AllowedActions, AllowedEvents>; + +export type DefaultState = { + [Key in keyof DefaultInstances]: InstanceState; +}; diff --git a/packages/wallet/src/initialization/index.ts b/packages/wallet/src/initialization/index.ts new file mode 100644 index 0000000000..757f51fc01 --- /dev/null +++ b/packages/wallet/src/initialization/index.ts @@ -0,0 +1,9 @@ +export type { + DefaultActions, + DefaultEvents, + DefaultInstances, + DefaultState, + RootMessenger, + WalletDestroyedEvent, +} from './defaults'; +export { initialize } from './initialization'; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts new file mode 100644 index 0000000000..59443b393e --- /dev/null +++ b/packages/wallet/src/initialization/initialization.ts @@ -0,0 +1,38 @@ +import { Json } from '@metamask/utils'; + +import { WalletOptions } from '../types'; +import type { DefaultInstances } from './defaults'; +import { defaultConfigurations, RootMessenger } from './defaults'; +import type { InitializationConfiguration } from './types'; + +export type InitializeArgs = { + state: Record; + messenger: RootMessenger; + options: WalletOptions; +}; + +export function initialize({ + state, + messenger, + options, +}: InitializeArgs): DefaultInstances { + const instances: Record = {}; + + for (const config of Object.values(defaultConfigurations) as InitializationConfiguration[]) { + const { name } = config; + + const instanceState = state[name]; + + const instanceMessenger = config.messenger(messenger); + + const { instance } = config.init({ + state: instanceState, + messenger: instanceMessenger, + options, + }); + + instances[name] = instance as Record; + } + + return instances as DefaultInstances; +} diff --git a/packages/wallet/src/initialization/instances/accounts-controller.ts b/packages/wallet/src/initialization/instances/accounts-controller.ts new file mode 100644 index 0000000000..cceeb1f2bb --- /dev/null +++ b/packages/wallet/src/initialization/instances/accounts-controller.ts @@ -0,0 +1,63 @@ +import { + AccountsController, + AccountsControllerMessenger, +} from '@metamask/accounts-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +export const accountsController: InitializationConfiguration< + AccountsController, + AccountsControllerMessenger +> = { + name: 'AccountsController', + init: ({ state, messenger }) => { + const instance = new AccountsController({ + state, + messenger, + }); + + return { + instance, + }; + }, + messenger: (parent) => { + const accountsControllerMessenger = new Messenger< + 'AccountsController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'AccountsController', + parent, + }); + + parent.delegate({ + messenger: accountsControllerMessenger, + actions: [ + 'KeyringController:getState', + 'KeyringController:getKeyringsByType', + ], + events: [ + // AccountsController subscribes to :stateChange internally; the + // delegation must match until that package migrates to :stateChanged. + // eslint-disable-next-line no-restricted-syntax + 'KeyringController:stateChange', + 'SnapKeyring:accountAssetListUpdated', + 'SnapKeyring:accountBalancesUpdated', + 'SnapKeyring:accountTransactionsUpdated', + 'MultichainNetworkController:networkDidChange', + ], + }); + + return accountsControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/approval-controller.ts b/packages/wallet/src/initialization/instances/approval-controller.ts new file mode 100644 index 0000000000..1e7b7b24ba --- /dev/null +++ b/packages/wallet/src/initialization/instances/approval-controller.ts @@ -0,0 +1,46 @@ +import { + ApprovalController, + ApprovalControllerMessenger, +} from '@metamask/approval-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +export const approvalController: InitializationConfiguration< + ApprovalController, + ApprovalControllerMessenger +> = { + name: 'ApprovalController', + init: ({ state, messenger, options }) => { + const instance = new ApprovalController({ + state, + messenger, + showApprovalRequest: options.showApprovalRequest, + typesExcludedFromRateLimiting: [ + ApprovalType.PersonalSign, + ApprovalType.EthSignTypedData, + ApprovalType.Transaction, + ApprovalType.WatchAsset, + ApprovalType.EthGetEncryptionPublicKey, + ApprovalType.EthDecrypt, + + // Exclude Smart TX Status Page from rate limiting to allow sequential + // transactions. + 'smartTransaction:showSmartTransactionStatusPage', + + // Allow one flavor of snap_dialog to be queued. + 'snap_dialog', + ], + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'ApprovalController', never, never, typeof parent>({ + namespace: 'ApprovalController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/connectivity-controller.ts b/packages/wallet/src/initialization/instances/connectivity-controller.ts new file mode 100644 index 0000000000..98abc2c228 --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller.ts @@ -0,0 +1,47 @@ +import { + CONNECTIVITY_STATUSES, + ConnectivityAdapter, + ConnectivityController, + ConnectivityControllerMessenger, + ConnectivityStatus, +} from '@metamask/connectivity-controller'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +// TODO: For now, we assume we are always online. +class AlwaysOnlineAdapter implements ConnectivityAdapter { + async getStatus(): Promise { + return CONNECTIVITY_STATUSES.Online; + } + + onConnectivityChange(_callback: (status: ConnectivityStatus) => void): void { + // no-op + } + + destroy(): void { + // no-op + } +} + +export const connectivityController: InitializationConfiguration< + ConnectivityController, + ConnectivityControllerMessenger +> = { + name: 'ConnectivityController', + init: ({ messenger }) => { + const instance = new ConnectivityController({ + messenger, + connectivityAdapter: new AlwaysOnlineAdapter(), + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'ConnectivityController', never, never, typeof parent>({ + namespace: 'ConnectivityController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts new file mode 100644 index 0000000000..02f6dabb9a --- /dev/null +++ b/packages/wallet/src/initialization/instances/index.ts @@ -0,0 +1,7 @@ +export { accountsController } from './accounts-controller'; +export { approvalController } from './approval-controller'; +export { connectivityController } from './connectivity-controller'; +export { keyringController } from './keyring-controller'; +export { networkController } from './network-controller'; +export { remoteFeatureFlagController } from './remote-feature-flag-controller'; +export { transactionController } from './transaction-controller'; diff --git a/packages/wallet/src/initialization/instances/keyring-controller.test.ts b/packages/wallet/src/initialization/instances/keyring-controller.test.ts new file mode 100644 index 0000000000..960142bde3 --- /dev/null +++ b/packages/wallet/src/initialization/instances/keyring-controller.test.ts @@ -0,0 +1,69 @@ +import { decrypt, encryptWithDetail, isVaultUpdated } from '@metamask/browser-passworder'; + +import { encryptorFactory } from './keyring-controller'; + +const PASSWORD = 'test-password'; +const DATA = { foo: 'bar' }; +// Use a low iteration count so tests run quickly; we are testing wiring, not +// cryptographic strength. +const ITERATIONS = 1_000; + +describe('encryptorFactory', () => { + describe('encrypt', () => { + it('produces ciphertext that decrypts back to the original data', async () => { + const { encrypt } = encryptorFactory(ITERATIONS); + const ciphertext = await encrypt(PASSWORD, DATA); + expect(await decrypt(PASSWORD, ciphertext)).toStrictEqual(DATA); + }); + + it('embeds the specified PBKDF2 iteration count', async () => { + const { encrypt } = encryptorFactory(ITERATIONS); + const ciphertext = await encrypt(PASSWORD, DATA); + expect( + isVaultUpdated(ciphertext, { + algorithm: 'PBKDF2', + params: { iterations: ITERATIONS }, + }), + ).toBe(true); + }); + }); + + describe('encryptWithDetail', () => { + it('returns a vault that decrypts back to the original data', async () => { + const { encryptWithDetail: encryptWithDetailFn } = + encryptorFactory(ITERATIONS); + const { vault } = await encryptWithDetailFn(PASSWORD, DATA); + expect(await decrypt(PASSWORD, vault)).toStrictEqual(DATA); + }); + + it('embeds the specified PBKDF2 iteration count in the vault', async () => { + const { encryptWithDetail: encryptWithDetailFn } = + encryptorFactory(ITERATIONS); + const { vault } = await encryptWithDetailFn(PASSWORD, DATA); + expect( + isVaultUpdated(vault, { + algorithm: 'PBKDF2', + params: { iterations: ITERATIONS }, + }), + ).toBe(true); + }); + }); + + describe('isVaultUpdated', () => { + it('returns true for a vault encrypted with the matching iteration count', async () => { + const { vault } = await encryptWithDetail(PASSWORD, DATA, undefined, { + algorithm: 'PBKDF2', + params: { iterations: ITERATIONS }, + }); + expect(encryptorFactory(ITERATIONS).isVaultUpdated(vault)).toBe(true); + }); + + it('returns false for a vault encrypted with a different iteration count', async () => { + const { vault } = await encryptWithDetail(PASSWORD, DATA, undefined, { + algorithm: 'PBKDF2', + params: { iterations: ITERATIONS - 1 }, + }); + expect(encryptorFactory(ITERATIONS).isVaultUpdated(vault)).toBe(false); + }); + }); +}); diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts new file mode 100644 index 0000000000..c7ea30b513 --- /dev/null +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -0,0 +1,162 @@ +import type { + DetailedEncryptionResult, + EncryptionKey, + KeyDerivationOptions, +} from '@metamask/browser-passworder'; +import { + encrypt, + encryptWithDetail, + encryptWithKey, + decrypt, + decryptWithKey, + decryptWithDetail, + isVaultUpdated, + keyFromPassword, + importKey, + exportKey, + generateSalt, +} from '@metamask/browser-passworder'; +import type { Encryptor } from '@metamask/keyring-controller'; +import { + KeyringController, + KeyringControllerMessenger, +} from '@metamask/keyring-controller'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +/** + * A factory function for the encrypt method of the browser-passworder library, + * that encrypts with a given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that encrypts with the given number of iterations. + */ +const encryptFactory = + (iterations: number) => + async ( + password: string, + data: unknown, + key?: EncryptionKey | CryptoKey, + salt?: string, + ): Promise => + encrypt(password, data, key, salt, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function for the encryptWithDetail method of the browser-passworder library, + * that encrypts with a given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that encrypts with the given number of iterations. + */ +const encryptWithDetailFactory = + (iterations: number) => + async ( + password: string, + object: unknown, + salt?: string, + ): Promise => + encryptWithDetail(password, object, salt, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function for the keyFromPassword method of the browser-passworder library, + * that generates a key from a password and a salt. + * + * This factory function overrides the default key derivation options with the specified + * number of iterations, unless existing key derivation options are passed in. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that generates a key with a potentially overriden number of iterations. + */ +const keyFromPasswordFactory = + (iterations: number) => + async ( + password: string, + salt: string, + exportable?: boolean, + opts?: KeyDerivationOptions, + ): Promise => + keyFromPassword( + password, + salt, + exportable, + opts ?? { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }, + ); + +/** + * A factory function for the isVaultUpdated method of the browser-passworder library, + * that checks if the given vault was encrypted with the given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that checks if the vault was encrypted with the given number of iterations. + */ +const isVaultUpdatedFactory = + (iterations: number) => + (vault: string): boolean => + isVaultUpdated(vault, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function that returns an encryptor with the given number of iterations. + * + * The returned encryptor is a wrapper around the browser-passworder library, that + * calls the encrypt and encryptWithDetail methods with the given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns An encryptor set with the given number of iterations. + */ +export const encryptorFactory = (iterations: number): Encryptor => ({ + encrypt: encryptFactory(iterations), + encryptWithKey, + encryptWithDetail: encryptWithDetailFactory(iterations), + decrypt, + decryptWithKey, + decryptWithDetail, + keyFromPassword: keyFromPasswordFactory(iterations), + isVaultUpdated: isVaultUpdatedFactory(iterations), + importKey, + exportKey, + generateSalt, +}); + +export const keyringController: InitializationConfiguration< + KeyringController, + KeyringControllerMessenger +> = { + name: 'KeyringController', + init: ({ state, messenger }) => { + const instance = new KeyringController({ + state, + messenger, + encryptor: encryptorFactory(600_000), + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'KeyringController', never, never, typeof parent>({ + namespace: 'KeyringController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/network-controller.ts b/packages/wallet/src/initialization/instances/network-controller.ts new file mode 100644 index 0000000000..669d3d7d66 --- /dev/null +++ b/packages/wallet/src/initialization/instances/network-controller.ts @@ -0,0 +1,92 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { NetworkControllerOptions } from '@metamask/network-controller'; +import { + NetworkController, + NetworkControllerMessenger, +} from '@metamask/network-controller'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import { InitializationConfiguration } from '../types'; + +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +export const networkController: InitializationConfiguration< + NetworkController, + NetworkControllerMessenger +> = { + name: 'NetworkController', + init: ({ state, messenger, options }) => { + const fetchFn = globalThis.fetch.bind(globalThis); + + const getRpcServiceOptions: NetworkControllerOptions['getRpcServiceOptions'] = + () => { + const maxRetries = DEFAULT_MAX_RETRIES; + + const isOffline = (): boolean => { + const connectivityState = messenger.call( + 'ConnectivityController:getState', + ); + return ( + connectivityState.connectivityStatus === + CONNECTIVITY_STATUSES.Offline + ); + }; + + return { + fetch: fetchFn, + btoa: globalThis.btoa.bind(globalThis), + isOffline, + policyOptions: { + // Ensure that the "cooldown" period after breaking the circuit is short. + circuitBreakDuration: inMilliseconds(30, Duration.Second), + maxRetries, + // Ensure that if the endpoint continually responds with errors, we + // break the circuit relatively fast (but not prematurely). + // + // Note that the circuit will break much faster if the errors are + // retriable (e.g. 503) than if not (e.g. 500), so we attempt to strike + // a balance here. + maxConsecutiveFailures: (maxRetries + 1) * 3, + }, + }; + }; + + const instance = new NetworkController({ + state, + messenger, + getRpcServiceOptions, + infuraProjectId: options.infuraProjectId, + }); + + return { + instance, + }; + }, + messenger: (parent) => { + const networkControllerMessenger = new Messenger< + 'NetworkController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'NetworkController', + parent, + }); + + parent.delegate({ + messenger: networkControllerMessenger, + actions: ['ConnectivityController:getState'], + events: [], + }); + + return networkControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts new file mode 100644 index 0000000000..9690397313 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts @@ -0,0 +1,32 @@ +import { Messenger } from '@metamask/messenger'; +import { + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger, +} from '@metamask/remote-feature-flag-controller'; + +import { InitializationConfiguration } from '../types'; + +export const remoteFeatureFlagController: InitializationConfiguration< + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger +> = { + name: 'RemoteFeatureFlagController', + init: ({ state, messenger, options }) => { + const instance = new RemoteFeatureFlagController({ + state, + messenger, + clientVersion: options.clientVersion, + clientConfigApiService: options.clientConfigApiService, + getMetaMetricsId: options.getMetaMetricsId, + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'RemoteFeatureFlagController', never, never, typeof parent>({ + namespace: 'RemoteFeatureFlagController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/transaction-controller.ts b/packages/wallet/src/initialization/instances/transaction-controller.ts new file mode 100644 index 0000000000..a8c5b22565 --- /dev/null +++ b/packages/wallet/src/initialization/instances/transaction-controller.ts @@ -0,0 +1,119 @@ +import type { KeyringControllerSignTransactionAction } from '@metamask/keyring-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { + NetworkControllerGetEIP1559CompatibilityAction, + NetworkControllerGetNetworkClientRegistryAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import type { TransactionControllerOptions } from '@metamask/transaction-controller'; +import { + TransactionController, + TransactionControllerMessenger, +} from '@metamask/transaction-controller'; + +import { bindMessengerAction, InitializationConfiguration } from '../types'; + +type InitActions = + | NetworkControllerGetNetworkClientRegistryAction + | NetworkControllerGetEIP1559CompatibilityAction + | NetworkControllerGetStateAction + | KeyringControllerSignTransactionAction; + +type AllowedActions = + | MessengerActions + | InitActions; + +type AllowedEvents = MessengerEvents; + +type WalletTransactionControllerMessenger = Messenger< + 'TransactionController', + AllowedActions, + AllowedEvents +>; + +export const transactionController: InitializationConfiguration< + TransactionController, + WalletTransactionControllerMessenger +> = { + name: 'TransactionController', + init: ({ state, messenger }) => { + const instance = new TransactionController({ + state, + messenger: messenger as unknown as TransactionControllerMessenger, + disableHistory: true, + disableSendFlowHistory: true, + disableSwaps: false, + hooks: {}, + getNetworkClientRegistry: bindMessengerAction( + messenger, + 'NetworkController:getNetworkClientRegistry', + ), + getCurrentNetworkEIP1559Compatibility: bindMessengerAction( + messenger, + 'NetworkController:getEIP1559Compatibility', + ) as () => Promise, + getNetworkState: bindMessengerAction( + messenger, + 'NetworkController:getState', + ), + // KeyringController.signTransaction is typed as returning + // Promise (a plain data object), but the actual keyring + // implementations return the full TypedTransaction class instance. + // TransactionController expects Promise here. The + // cast bridges a stale return-type declaration in KeyringController, + // not a real runtime mismatch. + sign: bindMessengerAction( + messenger, + 'KeyringController:signTransaction', + ) as unknown as TransactionControllerOptions['sign'], + }); + + return { + instance, + }; + }, + messenger: (parent) => { + const transactionControllerMessenger = new Messenger< + 'TransactionController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'TransactionController', + parent, + }); + + parent.delegate({ + messenger: transactionControllerMessenger, + actions: [ + 'AccountsController:getSelectedAccount', + 'AccountsController:getState', + `ApprovalController:addRequest`, + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'NetworkController:getNetworkClientRegistry', + 'NetworkController:getEIP1559Compatibility', + 'NetworkController:getState', + 'KeyringController:signTransaction', + ], + events: [ + 'AccountActivityService:transactionUpdated', + 'AccountActivityService:statusChanged', + 'AccountsController:selectedAccountChange', + 'BackendWebSocketService:connectionStateChanged', + // TransactionController subscribes to :stateChange internally; the + // delegation must match until that package migrates to :stateChanged. + // eslint-disable-next-line no-restricted-syntax + 'NetworkController:stateChange', + ], + }); + + return transactionControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts new file mode 100644 index 0000000000..b8c1af0954 --- /dev/null +++ b/packages/wallet/src/initialization/types.ts @@ -0,0 +1,58 @@ +import type { + ActionConstraint, + EventConstraint, + ExtractActionParameters, + ExtractActionResponse, + Messenger, + MessengerActions, +} from '@metamask/messenger'; + +import type { WalletOptions } from '../types'; +import type { RootMessenger } from './defaults'; + +export type InstanceState = Instance extends { state: unknown } + ? Instance['state'] + : unknown; + +export type InitFunctionArguments = { + state: InstanceState; + messenger: InstanceMessenger; + options: WalletOptions; +}; + +/** + * Typed wrapper around `messenger.call.bind(messenger, actionType)`. + * + * TypeScript's `Function.prototype.bind` loses generic inference on + * `Messenger.call`, so the bound function's parameters and return type + * collapse to a union of every action. This helper restores the correct + * per-action types via an explicit cast that is safe because `bind` + * preserves the runtime behavior exactly. + * + * @param messenger - The messenger instance. + * @param actionType - The action to bind. + * @returns A function that calls the action with the correct types. + */ +export function bindMessengerAction< + Msgr extends Messenger, + ActionType extends MessengerActions['type'], +>( + messenger: Msgr, + actionType: ActionType, +): ( + ...args: ExtractActionParameters, ActionType> +) => ExtractActionResponse, ActionType> { + return messenger.call.bind(messenger, actionType) as ( + ...args: ExtractActionParameters, ActionType> + ) => ExtractActionResponse, ActionType>; +} + +export type InitializationConfiguration = { + name: string; + // This is a method as opposed to function property in order to collect + // heterogeneous InitializationConfiguration values in a single array. + init(args: InitFunctionArguments): { + instance: Instance; + }; + messenger(parent: RootMessenger): InstanceMessenger; +}; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts new file mode 100644 index 0000000000..865d6241e4 --- /dev/null +++ b/packages/wallet/src/types.ts @@ -0,0 +1,11 @@ +import type { ClientConfigApiService } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; + +export type WalletOptions = { + state?: Record>; + infuraProjectId: string; + clientVersion: string; + showApprovalRequest: () => void; + clientConfigApiService: ClientConfigApiService; + getMetaMetricsId: () => string; +}; diff --git a/packages/wallet/src/utilities.ts b/packages/wallet/src/utilities.ts new file mode 100644 index 0000000000..edd281f968 --- /dev/null +++ b/packages/wallet/src/utilities.ts @@ -0,0 +1,75 @@ +// TODO: Determine if these should be available directly on Wallet. +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; +import type { + AddTransactionOptions, + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; + +import { Wallet } from './Wallet'; + +/** + * Import a secret recovery phrase using the wallet object. + * + * @param wallet - The wallet object. + * @param password - The password to the MetaMask wallet (not the SRP). + * @param phrase - The SRP as a string. + */ +export async function importSecretRecoveryPhrase( + wallet: Wallet, + password: string, + phrase: string, +): Promise { + const indices = phrase.split(' ').map((word) => wordlist.indexOf(word)); + const mnemonic = new Uint8Array(new Uint16Array(indices).buffer); + + // TODO: This should use the new MultichainAccountService. + await wallet.messenger.call( + 'KeyringController:createNewVaultAndRestore', + password, + mnemonic, + ); +} + +/** + * Initialize the wallet object with a randomly generated secret recovery phrase. + * + * @param wallet - The wallet object. + * @param password - The password to the MetaMask wallet (not the SRP). + */ +export async function createSecretRecoveryPhrase( + wallet: Wallet, + password: string, +): Promise { + // TODO: This should use the new MultichainAccountService. + await wallet.messenger.call( + 'KeyringController:createNewVaultAndKeychain', + password, + ); +} + +/** + * Sign a transaction using the wallet and submit it to the blockchain. + * + * @param wallet - The wallet object. + * @param transaction - The transaction. + * @param options - The transaction options (including which network to use). + * @returns The result. + */ +export async function sendTransaction( + wallet: Wallet, + transaction: TransactionParams, + options: AddTransactionOptions, +): Promise<{ transactionMeta: TransactionMeta; result: Promise }> { + const { transactionMeta, result } = await wallet.messenger.call( + 'TransactionController:addTransaction', + transaction, + options, + ); + + const approvalId = transactionMeta.id; + + await wallet.messenger.call('ApprovalController:acceptRequest', approvalId); + + return { transactionMeta, result }; +} diff --git a/packages/wallet/test/anvil.ts b/packages/wallet/test/anvil.ts new file mode 100644 index 0000000000..d871b865fc --- /dev/null +++ b/packages/wallet/test/anvil.ts @@ -0,0 +1,111 @@ +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const ANVIL_STARTUP_TIMEOUT = 15_000; + +export type AnvilInstance = { + port: number; + rpcUrl: string; + stop: () => Promise; +}; + +/** + * Start a local Anvil dev chain instance. + * + * @param options - Options for the Anvil instance. + * @param options.mnemonic - The mnemonic to use for pre-funded accounts. + * @returns An object with the port, RPC URL, and a stop function. + */ +export async function startAnvil(options: { + mnemonic: string; +}): Promise { + const anvilBin = await getAnvilBinaryPath(); + + const proc: ChildProcess = spawn( + anvilBin, + ['--mnemonic', options.mnemonic, '--port', '0'], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + const port = await waitForReady(proc); + const rpcUrl = `http://127.0.0.1:${port}`; + + return { + port, + rpcUrl, + stop: () => stopAnvil(proc), + }; +} + +async function getAnvilBinaryPath(): Promise { + const candidates = [ + resolve(__dirname, '../node_modules/.bin/anvil'), + resolve(__dirname, '../../../node_modules/.bin/anvil'), + ]; + + for (const candidate of candidates) { + try { + await access(candidate); + return candidate; + } catch { + // not found, try next + } + } + + throw new Error( + `Anvil binary not found. Run: yarn workspace @metamask/wallet run test:prepare`, + ); +} + +function waitForReady(proc: ChildProcess): Promise { + return new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error('Anvil failed to start within timeout')); + }, ANVIL_STARTUP_TIMEOUT); + + let output = ''; + proc.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + const match = output.match(/Listening on [^\s:]+:(\d+)/u); + if (match) { + clearTimeout(timeout); + resolvePromise(Number(match[1])); + } + }); + + proc.stderr?.on('data', (data: Buffer) => { + output += data.toString(); + }); + + proc.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + + proc.on('exit', (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Anvil exited with code ${code}:\n${output}`)); + } + }); + }); +} + +function stopAnvil(proc: ChildProcess): Promise { + return new Promise((resolvePromise) => { + if (proc.killed || proc.exitCode !== null) { + resolvePromise(); + return; + } + proc.on('exit', () => resolvePromise()); + proc.kill('SIGTERM'); + setTimeout(() => { + if (!proc.killed) { + proc.kill('SIGKILL'); + } + }, 5000).unref(); + }); +} diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 02a0eea03f..a5e012287d 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -5,6 +5,34 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../approval-controller/tsconfig.build.json" + }, + { + "path": "../connectivity-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + } + ], "include": ["../../types", "./src"] } diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 025ba2ef7f..8f0b0c5788 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -3,6 +3,34 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], - "include": ["../../types", "./src"] + "references": [ + { + "path": "../accounts-controller/tsconfig.json" + }, + { + "path": "../approval-controller/tsconfig.json" + }, + { + "path": "../connectivity-controller/tsconfig.json" + }, + { + "path": "../controller-utils/tsconfig.json" + }, + { + "path": "../keyring-controller/tsconfig.json" + }, + { + "path": "../messenger/tsconfig.json" + }, + { + "path": "../network-controller/tsconfig.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.json" + }, + { + "path": "../transaction-controller/tsconfig.json" + } + ], + "include": ["../../types", "./src", "./test"] }