Skip to content

Commit ddca654

Browse files
feat: Implement wallet initialization library (#8838)
## Explanation This PR implements a narrowly-scoped (as compared to the original feature branch) version of the wallet initialization library that only includes initializing the `KeyringController`. This can eventually be used to demonstrate the integration of the library into the clients and serves as the base for future work. Overall it works in a similarly to the initialization pattern used in extension and mobile today, with some differences: - The entities initialized by the pattern are referred to as "instances", not "messenger clients". - It attempts to be less verbose as the initialization of an instance can be done in a single file exporting a single object, the `InitializationConfiguration`. This object contains both a function to setup the messenger and the instance. - It has no concept of "initialization messengers", the messenger returned from `InitializationConfiguration.messenger` is expected to have access to actions/events necessary to initialize _and_ operate the instance. - ~There is no way to access the instances directly.~ The `Wallet` instance provides access to the instances within using the `messenger` while also exposing a limited set of useful properties like `state` and `controllerMetadata`. ## References https://consensyssoftware.atlassian.net/browse/WPC-999 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Introduces vault encryption, keyring bootstrap, and SRP import paths—security-sensitive wallet/key material handling even though scope is limited to KeyringController. > > **Overview** > Replaces the `@metamask/wallet` placeholder with a **wallet initialization library** centered on a `Wallet` class that wires a root messenger, runs pluggable `InitializationConfiguration` entries, and exposes aggregated `state`, `controllerMetadata`, messenger access, and deprecated `getInstance`. > > The default stack currently boots **`KeyringController` only**, via `initialize()` merging default configs with optional overrides by name, per-instance messengers, hydrated `state`, and `instanceOptions` (custom encryptor / keyring builders). Keyring setup adds a **PBKDF2 (600k iterations) `encryptorFactory`** around `browser-passworder` and exports `importSecretRecoveryPhrase` for SRP restore through messenger actions. > > Package surface: real **runtime dependencies** (`base-controller`, `keyring-controller`, `messenger`, etc.), TS project references, changelog “initial release”, README dependency graph edges, and **CODEOWNERS** for `keyring-controller` initialization. Tests cover wallet lifecycle, immutability guards, vault unlock/lock, encryptor behavior, and config overrides. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 19bfd38. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 79379da commit ddca654

19 files changed

Lines changed: 888 additions & 20 deletions

.github/CODEOWNERS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@
128128
/packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform
129129
/packages/profile-metrics-controller @MetaMask/mobile-platform @MetaMask/extension-platform
130130

131+
## Initialization
132+
/packages/wallet/src/initialization/instances/keyring-controller.ts @MetaMask/accounts-engineers @MetaMask/core-platform
133+
131134
## Package Release related
132135
/packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform
133136
/packages/account-tree-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,9 @@ linkStyle default opacity:0.5
565565
user_operation_controller --> polling_controller;
566566
user_operation_controller --> transaction_controller;
567567
user_operation_controller --> eth_block_tracker;
568+
wallet --> base_controller;
569+
wallet --> keyring_controller;
570+
wallet --> messenger;
568571
```
569572

570573
<!-- end dependency graph -->

packages/wallet/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Initial release ([#8838](https://github.com/MetaMask/core/pull/8838))
13+
1014
[Unreleased]: https://github.com/MetaMask/core/

packages/wallet/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@
5252
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
5353
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
5454
},
55+
"dependencies": {
56+
"@metamask/base-controller": "^9.1.0",
57+
"@metamask/browser-passworder": "^6.0.0",
58+
"@metamask/keyring-controller": "^25.5.0",
59+
"@metamask/messenger": "^1.2.0",
60+
"@metamask/scure-bip39": "^2.1.1",
61+
"@metamask/utils": "^11.9.0"
62+
},
5563
"devDependencies": {
5664
"@metamask/auto-changelog": "^6.1.0",
5765
"@ts-bridge/cli": "^0.6.4",

packages/wallet/src/Wallet.test.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { Messenger } from '@metamask/messenger';
2+
import { Json } from '@metamask/utils';
3+
import { webcrypto } from 'crypto';
4+
5+
import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor';
6+
import * as initializationModule from './initialization/initialization';
7+
import { importSecretRecoveryPhrase } from './utilities';
8+
import { Wallet } from './Wallet';
9+
10+
const TEST_SRP = 'test test test test test test test test test test test ball';
11+
const TEST_PASSWORD = 'testpass';
12+
13+
async function setupWallet(): Promise<Wallet> {
14+
const wallet = new Wallet({});
15+
16+
await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP);
17+
18+
return wallet;
19+
}
20+
21+
describe('Wallet', () => {
22+
beforeAll(() => {
23+
// We can remove this once we drop Node 18
24+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
25+
globalThis.crypto ??= webcrypto as typeof globalThis.crypto;
26+
27+
// eslint-disable-next-line no-restricted-syntax
28+
if (!('CryptoKey' in globalThis)) {
29+
Object.defineProperty(globalThis, 'CryptoKey', {
30+
value: webcrypto.CryptoKey,
31+
});
32+
}
33+
});
34+
35+
it('exposes state', async () => {
36+
const wallet = await setupWallet();
37+
const { state } = wallet;
38+
39+
expect(state.KeyringController).toStrictEqual({
40+
isUnlocked: true,
41+
keyrings: expect.any(Array),
42+
encryptionKey: expect.any(String),
43+
encryptionSalt: expect.any(String),
44+
vault: expect.any(String),
45+
});
46+
});
47+
48+
it('exposes instances', async () => {
49+
const wallet = await setupWallet();
50+
51+
expect(wallet.getInstance('KeyringController').state).toStrictEqual({
52+
isUnlocked: true,
53+
keyrings: expect.any(Array),
54+
encryptionKey: expect.any(String),
55+
encryptionSalt: expect.any(String),
56+
vault: expect.any(String),
57+
});
58+
});
59+
60+
it('supports passing instance options', async () => {
61+
const wallet = new Wallet({
62+
instanceOptions: {
63+
keyringController: {
64+
encryptor: new MockEncryptor(),
65+
},
66+
},
67+
});
68+
69+
await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP);
70+
71+
const { state } = wallet;
72+
73+
const vault = JSON.parse(state.KeyringController.vault as string);
74+
75+
expect(vault).toStrictEqual({
76+
data: expect.any(String),
77+
iv: 'iv',
78+
salt: 'salt',
79+
});
80+
});
81+
82+
it('supports passing additional initialization configurations', async () => {
83+
class DummyController {
84+
state = { foo: 'bar' };
85+
}
86+
87+
class DummyService {}
88+
89+
const wallet = new Wallet({
90+
initializationConfigurations: [
91+
{
92+
name: 'KeyringController',
93+
getMessenger: (): Messenger<string> =>
94+
new Messenger({ namespace: 'KeyringController' }),
95+
init: (): DummyController => new DummyController(),
96+
},
97+
{
98+
name: 'TestService',
99+
getMessenger: (): Messenger<string> =>
100+
new Messenger({ namespace: 'TestService' }),
101+
init: (): DummyService => new DummyService(),
102+
},
103+
],
104+
});
105+
const { state } = wallet;
106+
107+
expect(state.KeyringController).toStrictEqual({
108+
foo: 'bar',
109+
});
110+
111+
expect((state as Record<string, Json>).TestService).toBeUndefined();
112+
});
113+
114+
it('exposes controllerMetadata for each initialized controller', async () => {
115+
const wallet = await setupWallet();
116+
117+
const names = Object.keys(wallet.controllerMetadata);
118+
expect(names).toStrictEqual(Object.keys(wallet.state));
119+
for (const name of names) {
120+
expect(wallet.controllerMetadata[name]).toBeDefined();
121+
}
122+
});
123+
124+
it('omits instances without a metadata property from controllerMetadata', async () => {
125+
const fakeMetadata = {
126+
foo: { persist: true, includeInDebugSnapshot: false },
127+
};
128+
jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({
129+
// @ts-expect-error Mock data.
130+
WithMeta: { state: {}, metadata: fakeMetadata },
131+
NoMeta: { state: {} },
132+
});
133+
134+
const wallet = new Wallet({});
135+
136+
expect(wallet.controllerMetadata).toStrictEqual({
137+
WithMeta: fakeMetadata,
138+
});
139+
expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']);
140+
});
141+
142+
it('disallows modifying the messenger', async () => {
143+
const wallet = await setupWallet();
144+
145+
expect(() => {
146+
wallet.messenger = new Messenger({ namespace: 'Root' });
147+
}).toThrow('The messenger cannot be directly mutated.');
148+
});
149+
150+
it('disallows modifying the state', async () => {
151+
const wallet = await setupWallet();
152+
153+
expect(() => {
154+
wallet.state = { KeyringController: { isUnlocked: false, keyrings: [] } };
155+
}).toThrow('Wallet state cannot be directly mutated.');
156+
});
157+
158+
it('disallows modifying the controller metadata', async () => {
159+
const wallet = await setupWallet();
160+
161+
expect(() => {
162+
wallet.controllerMetadata = {};
163+
}).toThrow('The controller metadata cannot be directly mutated.');
164+
});
165+
166+
it('calls destroy on instances exactly once', async () => {
167+
const wallet = await setupWallet();
168+
169+
const keyringController = wallet.getInstance('KeyringController');
170+
171+
const spy = jest.spyOn(keyringController, 'destroy');
172+
173+
await wallet.destroy();
174+
await wallet.destroy();
175+
176+
expect(spy).toHaveBeenCalledTimes(1);
177+
});
178+
179+
describe('KeyringController', () => {
180+
it('can unlock and populate accounts', async () => {
181+
const wallet = await setupWallet();
182+
const { messenger } = wallet;
183+
184+
expect(
185+
await messenger.call('KeyringController:getAccounts'),
186+
).toStrictEqual(['0xc6d5a3c98ec9073b54fa0969957bd582e8d874bf']);
187+
});
188+
189+
it('can unlock a persisted vault', async () => {
190+
const vault =
191+
'{"data":"iOD5pIcPeRZYQ4WdEMsNYoZ3xBxWBafIU8Cr4nD0X4zBvrOA06tGen3sKQ/ValasXSweLnzH9Fk2frkPYmqeJWBtTNYFwdHPe7P970ThZwreSXN1Sqrx9Ad+YzmIN0y89Yg3KrUodPWaRgIZmgWbfDon6ADPgeEDkX0/GAEYET39O7Rx/gL+rcaTpxnpHPTgHiLbhRHWGsS3z+JVomSqoLAO5XVvrJWenO6R3Nzm62BaJaSPrf/pwstZqhSvxTq8hnQf7aR81hWfwYTxNBVG7TC/dniSQ8K5So6PvUN5nzAqvtzzHT2TagOuxQkX88Zi17P8os21jNmNdA90IGYroD+b/mppyRIgRYWtAUQZH9ji36atEuFupszbg8Qw1iaL3EQyUogC30Cpj9ko5bbqhYgqmFHF0J/kflhPHKuO6d4tgSmhYpTumydQRjxaPnlghIS5YI4W+7p9HVBpb+c6IPUz9y/x3Ngbp+ukJwOnXt2U/eZhXrJzi2z1x/nzPg4fzDJoM7k=","iv":"yrZsyC7dso/q7pQ48YX3vw==","keyMetadata":{"algorithm":"PBKDF2","params":{"iterations":600000}},"salt":"s7nIrMWK1lcZVjfdmES1DBML8Uz4ja2fpm8zUz1lWI0="}';
192+
193+
const wallet = new Wallet({
194+
state: {
195+
KeyringController: {
196+
vault,
197+
},
198+
},
199+
});
200+
201+
await wallet.messenger.call(
202+
'KeyringController:submitPassword',
203+
TEST_PASSWORD,
204+
);
205+
206+
expect(wallet.state.KeyringController).toStrictEqual({
207+
isUnlocked: true,
208+
keyrings: expect.any(Array),
209+
encryptionKey: expect.any(String),
210+
encryptionSalt: expect.any(String),
211+
vault: expect.any(String),
212+
});
213+
});
214+
215+
it('can lock', async () => {
216+
const wallet = await setupWallet();
217+
const { messenger } = wallet;
218+
219+
await messenger.call('KeyringController:setLocked');
220+
221+
expect(wallet.state.KeyringController).toStrictEqual({
222+
isUnlocked: false,
223+
keyrings: [],
224+
vault: expect.any(String),
225+
});
226+
});
227+
});
228+
});

0 commit comments

Comments
 (0)