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
5 changes: 5 additions & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
33 changes: 33 additions & 0 deletions packages/wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
8 changes: 4 additions & 4 deletions packages/wallet/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
});
24 changes: 21 additions & 3 deletions packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions packages/wallet/scripts/install-binaries.sh
Original file line number Diff line number Diff line change
@@ -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
257 changes: 257 additions & 0 deletions packages/wallet/src/Wallet.test.ts
Original file line number Diff line number Diff line change
@@ -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<Wallet> {
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);
});
});
});
Loading
Loading