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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
"babel-runtime>core-js": false,
"simple-git-hooks": false,
"tsx>esbuild": false,
"eslint-plugin-import-x>unrs-resolver": false
"eslint-plugin-import-x>unrs-resolver": false,
"better-sqlite3": true
}
}
}
10 changes: 10 additions & 0 deletions packages/wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ or

`npm install @metamask/wallet`

## Troubleshooting

### Rebuilding `better-sqlite3`

This package depends on `better-sqlite3`, which includes a native C addon. The prebuilt binary is downloaded automatically during `yarn install`. If you switch Node versions or branches and the binding is missing, rebuild it with:

```sh
cd node_modules/better-sqlite3 && npx prebuild-install
```

## Contributing

This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
16 changes: 14 additions & 2 deletions packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
"default": "./dist/index.cjs"
}
},
"./persistence": {
"import": {
"types": "./dist/persistence/index.d.mts",
"default": "./dist/persistence/index.mjs"
},
"require": {
"types": "./dist/persistence/index.d.cts",
"default": "./dist/persistence/index.cjs"
}
},
"./package.json": "./package.json"
},
"publishConfig": {
Expand All @@ -47,7 +57,7 @@
"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",
"test:prepare": "./scripts/install-anvil.sh",
"test:prepare": "./scripts/install-binaries.sh",
"test": "yarn test:prepare && 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",
Expand All @@ -65,12 +75,14 @@
"@metamask/remote-feature-flag-controller": "^4.2.0",
"@metamask/scure-bip39": "^2.1.1",
"@metamask/transaction-controller": "^64.3.0",
"@metamask/utils": "^11.9.0"
"@metamask/utils": "^11.9.0",
"better-sqlite3": "^12.9.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^6.1.0",
"@metamask/foundryup": "^1.0.1",
"@ts-bridge/cli": "^0.6.4",
"@types/better-sqlite3": "^7.6.13",
"@types/jest": "^29.5.14",
"deepmerge": "^4.2.2",
"jest": "^29.7.0",
Expand Down
17 changes: 0 additions & 17 deletions packages/wallet/scripts/install-anvil.sh

This file was deleted.

31 changes: 31 additions & 0 deletions packages/wallet/scripts/install-binaries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/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

# Install the better-sqlite3 native addon if missing. Yarn has
# `enableScripts: false` globally, so install scripts never run during
# `yarn install` and the addon may be absent from the filesystem. Invoke the
# prebuild-install binary directly to fetch a matching prebuild for the active
# Node version and platform.
BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3"
if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then
(
cd "${BETTER_SQLITE3_DIR}"
"${MONOREPO_ROOT}/node_modules/.bin/prebuild-install"
)
fi
124 changes: 96 additions & 28 deletions packages/wallet/src/Wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DistributionType,
EnvironmentType,
} from '@metamask/remote-feature-flag-controller';
import { TransactionController } from '@metamask/transaction-controller';
import { enableNetConnect } from 'nock';

import { startAnvil } from '../test/anvil';
Expand All @@ -22,20 +23,18 @@ const TEST_PASSWORD = 'testpass';

async function setupWallet(): Promise<Wallet> {
const wallet = new Wallet({
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',
},
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);
Expand Down Expand Up @@ -134,20 +133,18 @@ describe('Wallet', () => {

it('can create secret recovery phrase', async () => {
wallet = new Wallet({
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',
},
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);
Expand All @@ -169,4 +166,75 @@ describe('Wallet', () => {
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('publishes Root:walletDestroyed exactly once on destroy', async () => {
wallet = new Wallet(options);

const listener = jest.fn();
wallet.messenger.subscribe('Root:walletDestroyed', listener);

await wallet.destroy();
await wallet.destroy();

expect(listener).toHaveBeenCalledTimes(1);
});

it('publishes Root:walletDestroyed 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('Root:walletDestroyed', listener);

await wallet.destroy();

expect(listener).toHaveBeenCalledTimes(1);
});

it('publishes Root:walletDestroyed even if a controller destroy rejects', async () => {
wallet = new Wallet(options);

jest
.spyOn(TransactionController.prototype, 'destroy')
.mockRejectedValue(new Error('async destroy error'));

const listener = jest.fn();
wallet.messenger.subscribe('Root:walletDestroyed', listener);

await wallet.destroy();

expect(listener).toHaveBeenCalledTimes(1);
});
});
});
52 changes: 43 additions & 9 deletions packages/wallet/src/Wallet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { StateMetadataConstraint } from '@metamask/base-controller';
import { Messenger } from '@metamask/messenger';
import type { Json } from '@metamask/utils';

import type {
DefaultActions,
Expand All @@ -11,9 +11,8 @@ import type {
import { initialize } from './initialization';
import type { WalletOptions } from './types';

export type WalletConstructorArgs = {
state?: Record<string, Json>;
options: WalletOptions;
type PersistableController = {
metadata: StateMetadataConstraint;
};

export class Wallet {
Expand All @@ -22,12 +21,34 @@ export class Wallet {

readonly #instances: DefaultInstances;

constructor({ state = {}, options }: WalletConstructorArgs) {
readonly #controllerMetadata: Readonly<
Record<string, Readonly<StateMetadataConstraint>>
>;

#destroyed = false;

constructor({ state, ...options }: WalletOptions) {
this.messenger = new Messenger({
namespace: 'Root',
});

this.#instances = initialize({ state, messenger: this.messenger, options });
this.messenger.registerInitialEventPayload({
eventType: 'Root:walletDestroyed',
getPayload: () => [],
});

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

this.#controllerMetadata = Object.fromEntries(
Object.entries(this.#instances).map(([name, instance]) => [
name,
(instance as unknown as PersistableController).metadata,
]),
);
}

get state(): DefaultState {
Expand All @@ -40,17 +61,30 @@ export class Wallet {
) as DefaultState;
}

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

async destroy(): Promise<void> {
await Promise.all(
if (this.#destroyed) {
return;
}
this.#destroyed = true;

await Promise.allSettled(
Object.values(this.#instances).map((instance) => {
// @ts-expect-error Accessing protected property.
if (typeof instance.destroy === 'function') {
// @ts-expect-error Accessing protected property.
return instance.destroy();
return (async (): Promise<void> => await instance.destroy())();
}
/* istanbul ignore next */
return undefined;
return Promise.resolve();
}),
);

this.messenger.publish('Root:walletDestroyed');
}
}
7 changes: 7 additions & 0 deletions packages/wallet/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export { Wallet } from './Wallet';
export type { WalletOptions } from './types';
export type {
DefaultActions,
DefaultEvents,
RootMessenger,
WalletDestroyedEvent,
} from './initialization';
9 changes: 8 additions & 1 deletion packages/wallet/src/initialization/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ export type DefaultInstances = {

export type DefaultActions = MessengerActions<AllMessengers>;

export type DefaultEvents = MessengerEvents<AllMessengers>;
export type WalletDestroyedEvent = {
type: 'Root:walletDestroyed';
payload: [];
};

export type DefaultEvents =
| MessengerEvents<AllMessengers>
| WalletDestroyedEvent;

export type RootMessenger<
AllowedActions extends ActionConstraint = ActionConstraint,
Expand Down
1 change: 1 addition & 0 deletions packages/wallet/src/initialization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export type {
DefaultInstances,
DefaultState,
RootMessenger,
WalletDestroyedEvent,
} from './defaults';
export { initialize } from './initialization';
Loading
Loading