Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `AccountsController` and `ConnectivityController` as default initialized controllers ([#8924](https://github.com/MetaMask/core/pull/8924))

## [1.0.1]

### Changed
Expand Down
34 changes: 33 additions & 1 deletion packages/wallet/src/Wallet.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller';
import { Messenger } from '@metamask/messenger';
import { Json } from '@metamask/utils';
import { webcrypto } from 'crypto';

import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor';
import * as initializationModule from './initialization/initialization';
import { importSecretRecoveryPhrase } from './utilities';
import {
createSecretRecoveryPhrase,
importSecretRecoveryPhrase,
} from './utilities';
import { Wallet } from './Wallet';

const TEST_SRP = 'test test test test test test test test test test test ball';
Expand Down Expand Up @@ -176,6 +180,34 @@ describe('Wallet', () => {
expect(spy).toHaveBeenCalledTimes(1);
});

describe('AccountsController', () => {
it('tracks accounts created via KeyringController', async () => {
const wallet = new Wallet({});
await createSecretRecoveryPhrase(wallet, TEST_PASSWORD);

const keyringAccounts = await wallet.messenger.call(
'KeyringController:getAccounts',
);
const trackedAddresses = Object.values(
wallet.state.AccountsController.internalAccounts.accounts,
).map((account) => account.address);

expect(trackedAddresses).toStrictEqual(keyringAccounts);
});
});

describe('ConnectivityController', () => {
it('reports online connectivity status', async () => {
const wallet = new Wallet({});

await new Promise<void>((resolve) => process.nextTick(resolve));

expect(wallet.state.ConnectivityController.connectivityStatus).toBe(
CONNECTIVITY_STATUSES.Online,
);
});
});

describe('KeyringController', () => {
it('can unlock and populate accounts', async () => {
const wallet = await setupWallet();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
AccountsController,
AccountsControllerMessenger,
AccountsControllerState,
} from '@metamask/accounts-controller';
import {
Messenger,
MessengerActions,
MessengerEvents,
} from '@metamask/messenger';

import type { DefaultActions, DefaultEvents, RootMessenger } from '../defaults';
import type { InitializationConfiguration } from '../types';

// TODO: AccountsController is deprecated in favour of AccountTreeController
// and MultichainAccountService. Migrate once those controllers are wired into
// the wallet initialization (both still depend on AccountsController at the
// messenger level, so it must remain present in the meantime).
type AllowedActions = MessengerActions<AccountsControllerMessenger>;

type AllowedEvents = MessengerEvents<AccountsControllerMessenger>;

export const accountsController: InitializationConfiguration<
AccountsController,
AccountsControllerMessenger
> = {
name: 'AccountsController',
init: ({ state, messenger }) =>
new AccountsController({
state: (state ?? {}) as AccountsControllerState,
messenger,
}),
getMessenger: (parent: RootMessenger<DefaultActions, DefaultEvents>) => {
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;
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller';
import { Messenger } from '@metamask/messenger';

import {
AlwaysOnlineAdapter,
connectivityController,
} from './connectivity-controller';

describe('AlwaysOnlineAdapter', () => {
it('returns Online from getStatus', async () => {
const adapter = new AlwaysOnlineAdapter();
const status = await adapter.getStatus();

expect(status).toBe(CONNECTIVITY_STATUSES.Online);
});

it('onConnectivityChange is a no-op', () => {
const adapter = new AlwaysOnlineAdapter();
const callback = jest.fn();

adapter.onConnectivityChange(callback);

expect(callback).not.toHaveBeenCalled();
});

it('destroy is a no-op', () => {
const adapter = new AlwaysOnlineAdapter();

expect(() => adapter.destroy()).not.toThrow();
});
});

describe('connectivityController', () => {
it('reports online status after initialization', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent = new Messenger<'Root', any, any>({ namespace: 'Root' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messenger = connectivityController.getMessenger(parent as any);
const controller = connectivityController.init({
messenger,
state: undefined,
options: {},
});

await controller.init();

expect(controller.state.connectivityStatus).toBe(
CONNECTIVITY_STATUSES.Online,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
CONNECTIVITY_STATUSES,
ConnectivityAdapter,
ConnectivityController,
ConnectivityControllerMessenger,
ConnectivityStatus,
} from '@metamask/connectivity-controller';
import { Messenger } from '@metamask/messenger';

import type { DefaultActions, DefaultEvents, RootMessenger } from '../defaults';
import type { InitializationConfiguration } from '../types';

/**
* A connectivity adapter that unconditionally reports the device as online.
*
* This is a temporary placeholder until a real platform-specific adapter
* (one that observes actual network events) is injected by the consumer.
*/
export class AlwaysOnlineAdapter implements ConnectivityAdapter {
/**
* Returns the current connectivity status.
*
* @returns A promise that always resolves to the online status.
*/
async getStatus(): Promise<ConnectivityStatus> {
return CONNECTIVITY_STATUSES.Online;
}

/**
* Registers a callback for connectivity changes.
*
* This adapter never changes status, so the callback is never invoked.
*
* @param _callback - The callback to register.
*/
onConnectivityChange(_callback: (status: ConnectivityStatus) => void): void {
// no-op
}

/**
* Cleans up any resources held by this adapter.
*
* This adapter holds no resources, so this is a no-op.
*/
destroy(): void {
// no-op
}
}

export const connectivityController: InitializationConfiguration<
ConnectivityController,
ConnectivityControllerMessenger
> = {
name: 'ConnectivityController',
init: ({ messenger }) =>
new ConnectivityController({
messenger,
connectivityAdapter: new AlwaysOnlineAdapter(),
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConnectivityController.init() never called during wallet initialization

Medium Severity

The ConnectivityController requires its init() method to be called after construction to fetch the initial connectivity status from the adapter. The unit test in connectivity-controller.test.ts correctly calls await controller.init() after connectivityController.init(...), but the actual wallet initialization in initialization.ts only calls the config's factory function — controller.init() is never invoked. This is currently masked because both getDefaultConnectivityControllerState() and AlwaysOnlineAdapter return Online, but when a real adapter is injected, the initial status will never be queried and the controller will incorrectly report Online regardless of actual connectivity.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e85be4e. Configure here.

getMessenger: (parent: RootMessenger<DefaultActions, DefaultEvents>) =>
new Messenger<'ConnectivityController', never, never, typeof parent>({
namespace: 'ConnectivityController',
parent,
}),
};
2 changes: 2 additions & 0 deletions packages/wallet/src/initialization/instances/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { accountsController } from './accounts-controller';
export { connectivityController } from './connectivity-controller';
export { keyringController } from './keyring-controller';
31 changes: 31 additions & 0 deletions packages/wallet/src/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { webcrypto } from 'crypto';

import { createSecretRecoveryPhrase } from './utilities';
import { Wallet } from './Wallet';

const TEST_PASSWORD = 'testpass';

describe('createSecretRecoveryPhrase', () => {
beforeAll(() => {
// We can remove this once we drop Node 18
// eslint-disable-next-line n/no-unsupported-features/node-builtins
globalThis.crypto ??= webcrypto as typeof globalThis.crypto;

// eslint-disable-next-line no-restricted-syntax
if (!('CryptoKey' in globalThis)) {
Object.defineProperty(globalThis, 'CryptoKey', {
value: webcrypto.CryptoKey,
});
}
});

it('creates a vault and populates accounts', async () => {
const wallet = new Wallet({});

await createSecretRecoveryPhrase(wallet, TEST_PASSWORD);

expect(
await wallet.messenger.call('KeyringController:getAccounts'),
).toHaveLength(1);
});
});
17 changes: 17 additions & 0 deletions packages/wallet/src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,20 @@ export async function importSecretRecoveryPhrase(
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<void> {
// TODO: This should use the new MultichainAccountService.
await wallet.messenger.call(
'KeyringController:createNewVaultAndKeychain',
password,
);
}
2 changes: 2 additions & 0 deletions packages/wallet/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"rootDir": "./src"
},
"references": [
{ "path": "../accounts-controller/tsconfig.build.json" },
{ "path": "../base-controller/tsconfig.build.json" },
{ "path": "../connectivity-controller/tsconfig.build.json" },
{ "path": "../keyring-controller/tsconfig.build.json" },
{ "path": "../messenger/tsconfig.build.json" }
],
Expand Down
Loading