Skip to content
Draft
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,9 @@ linkStyle default opacity:0.5
user_operation_controller --> polling_controller;
user_operation_controller --> transaction_controller;
user_operation_controller --> eth_block_tracker;
wallet --> base_controller;
wallet --> keyring_controller;
wallet --> messenger;
```

<!-- end dependency graph -->
Expand Down
4 changes: 4 additions & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Initial release ([#8838](https://github.com/MetaMask/core/pull/8838))

[Unreleased]: https://github.com/MetaMask/core/
6 changes: 3 additions & 3 deletions packages/wallet/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ module.exports = merge(baseConfig, {
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
functions: 88,
lines: 95.45,
statements: 95.58,
},
},
});
8 changes: 8 additions & 0 deletions packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/base-controller": "^9.1.0",
"@metamask/browser-passworder": "^6.0.0",
"@metamask/keyring-controller": "^25.5.0",
"@metamask/messenger": "^1.2.0",
"@metamask/scure-bip39": "^2.1.1",
"@metamask/utils": "^11.9.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^6.1.0",
"@ts-bridge/cli": "^0.6.4",
Expand Down
211 changes: 211 additions & 0 deletions packages/wallet/src/Wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { KeyringController } from '@metamask/keyring-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 { Wallet } from './Wallet';

const TEST_SRP = 'test test test test test test test test test test test ball';
const TEST_PASSWORD = 'testpass';

async function setupWallet(): Promise<Wallet> {
const wallet = new Wallet({});

await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP);

return wallet;
}

describe('Wallet', () => {
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('exposes state', async () => {
const 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),
});
});

it('exposes instances', async () => {
const wallet = await setupWallet();

expect(wallet.getInstance('KeyringController')?.state).toStrictEqual({
isUnlocked: true,
keyrings: expect.any(Array),
encryptionKey: expect.any(String),
encryptionSalt: expect.any(String),
vault: expect.any(String),
});
});

it('supports passing instance options', async () => {
const wallet = new Wallet({
instanceOptions: {
KeyringController: {
encryptor: new MockEncryptor(),
},
},
});

await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP);

const { state } = wallet;

const vault = JSON.parse(state.KeyringController.vault as string);

expect(vault).toStrictEqual({
data: expect.any(String),
iv: 'iv',
salt: 'salt',
});
});

it('supports passing additional initialization configurations', async () => {
class DummyController {
state = { foo: 'bar' };
}

class DummyService {}

const wallet = new Wallet({
initializationConfigurations: [
{
name: 'KeyringController',
getMessenger: (): Messenger<string> =>
new Messenger({ namespace: 'KeyringController' }),
init: (): DummyController => new DummyController(),
},
{
name: 'TestService',
getMessenger: (): Messenger<string> =>
new Messenger({ namespace: 'TestService' }),
init: (): DummyService => new DummyService(),
},
],
});
const { state } = wallet;

expect(state.KeyringController).toStrictEqual({
foo: 'bar',
});

expect((state as Record<string, Json>).TestService).toBeNull();
});

it('exposes controllerMetadata for each initialized controller', async () => {
const wallet = await setupWallet();

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', async () => {
const fakeMetadata = {
foo: { persist: true, includeInDebugSnapshot: false },
};
jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({
WithMeta: { state: {}, metadata: fakeMetadata },
NoMeta: { state: {} },
} as never);

const wallet = new Wallet({});

expect(wallet.controllerMetadata).toStrictEqual({
WithMeta: fakeMetadata,
});
expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']);
});

describe('lifecycle', () => {
it('publishes Wallet:destroyed exactly once on destroy', async () => {
const wallet = await setupWallet();

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 () => {
const wallet = await setupWallet();

jest
.spyOn(KeyringController.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 () => {
const wallet = await setupWallet();

jest
.spyOn(KeyringController.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);
});
});

describe('KeyringController', () => {
it('can unlock and populate accounts', async () => {
const wallet = await setupWallet();
const { messenger } = wallet;

expect(
await messenger.call('KeyringController:getAccounts'),
).toStrictEqual(['0xc6d5a3c98ec9073b54fa0969957bd582e8d874bf']);
});

it('can lock', async () => {
const wallet = await setupWallet();
const { messenger } = wallet;

await messenger.call('KeyringController:setLocked');

expect(wallet.state.KeyringController).toStrictEqual({
isUnlocked: false,
keyrings: [],
vault: expect.any(String),
});
});
});
});
109 changes: 109 additions & 0 deletions packages/wallet/src/Wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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/defaults';
import { initialize } from './initialization/initialization';
import { WalletOptions } from './types';

export class Wallet {
// TODO: Expand default types when passing additionalConfigurations.
readonly #messenger: RootMessenger<DefaultActions, DefaultEvents>;

readonly #instances: DefaultInstances;

readonly #controllerMetadata: Readonly<
Record<string, Readonly<StateMetadataConstraint>>
>;

#isDestroyed = false;

constructor(options: WalletOptions) {
this.#messenger =
options.messenger ??
new Messenger({
namespace: 'Wallet',
});

this.#instances = initialize({
options,
messenger: this.#messenger,
});

this.#controllerMetadata = Object.fromEntries(
Object.entries(this.#instances)
.filter(([_, instance]) => hasProperty(instance, 'metadata'))
.map(([name, instance]) => [name, instance.metadata]),
);
}

get messenger(): Readonly<RootMessenger<DefaultActions, DefaultEvents>> {
Comment thread
mcmire marked this conversation as resolved.
return this.#messenger;
}

get state(): DefaultState {
return Object.entries(this.#instances).reduce<Record<string, unknown>>(
(totalState, [name, instance]) => {
totalState[name] = instance.state ?? null;
return totalState;
},
{},
) as DefaultState;
}

get controllerMetadata(): Readonly<
Record<string, Readonly<StateMetadataConstraint>>
> {
return this.#controllerMetadata;
}

getInstance<Name extends keyof DefaultInstances>(
name: Name,
): DefaultInstances[Name];

getInstance(
name: string,
): DefaultInstances[keyof DefaultInstances] | undefined;

/**
* Get an instantiated controller or service.
*
* @param name - The name.
* @returns The instance, if it exists.
* @deprecated - Please use the messenger instead of direct access.
*/
getInstance(
name: string,
): DefaultInstances[keyof DefaultInstances] | undefined {
return this.#instances[name as keyof DefaultInstances];
}

async destroy(): Promise<void> {
if (this.#isDestroyed) {
return;
}

this.#isDestroyed = 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.
// eslint-disable-next-line @typescript-eslint/await-thenable
return await instance.destroy();
}
/* istanbul ignore next */
return undefined;
}),
);

this.messenger.publish('Wallet:destroyed');
}
}
9 changes: 0 additions & 9 deletions packages/wallet/src/index.test.ts

This file was deleted.

18 changes: 9 additions & 9 deletions packages/wallet/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* 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,
DefaultInstances,
RootMessenger,
WalletDestroyedEvent,
} from './initialization/defaults';
Loading
Loading