Skip to content
Merged
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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ scripts/coverage
packages/*/*.tsbuildinfo

# AI
.sisyphus/
.sisyphus/

# Wallet
.claude/
.env
!.env.example
2 changes: 2 additions & 0 deletions packages/wallet/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
INFURA_PROJECT_KEY=

3 changes: 3 additions & 0 deletions packages/wallet/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, {
// The display name when running multiple projects
displayName,

// Load dotenv before tests
setupFiles: [path.resolve(__dirname, 'test/setup.ts')],

// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
Expand Down
10 changes: 9 additions & 1 deletion packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,27 @@
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/accounts-controller": "^37.2.0",
"@metamask/approval-controller": "^9.0.1",
"@metamask/browser-passworder": "^6.0.0",
"@metamask/connectivity-controller": "^0.2.0",
"@metamask/controller-utils": "^11.20.0",
"@metamask/keyring-controller": "^25.2.0",
"@metamask/messenger": "^1.1.1",
"@metamask/network-controller": "^30.0.1",
"@metamask/remote-feature-flag-controller": "^4.2.0",
"@metamask/scure-bip39": "^2.1.1",
"@metamask/utils": "^11.11.0"
"@metamask/transaction-controller": "^64.0.0",
"@metamask/utils": "^11.9.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
"@ts-bridge/cli": "^0.6.4",
"@types/jest": "^29.5.14",
"deepmerge": "^4.2.2",
"dotenv": "^16.4.7",
"jest": "^29.7.0",
"nock": "^13.3.1",
"ts-jest": "^29.2.5",
"tsx": "^4.20.5",
"typedoc": "^0.25.13",
Expand Down
52 changes: 44 additions & 8 deletions packages/wallet/src/Wallet.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
ClientConfigApiService,
ClientType,
DistributionType,
EnvironmentType,
} from '@metamask/remote-feature-flag-controller';
import { enableNetConnect } from 'nock';

import { importSecretRecoveryPhrase, sendTransaction } from './utilities';
Expand All @@ -7,12 +13,27 @@ const TEST_PHRASE =
'test test test test test test test test test test test ball';
const TEST_PASSWORD = 'testpass';

async function setupWallet() {
async function setupWallet(): Promise<Wallet> {
if (!process.env.INFURA_PROJECT_KEY) {
throw new Error(
'INFURA_PROJECT_KEY is not set. Copy .env.example to .env and fill in your key.',
);
}

const wallet = new Wallet({
options: {
infuraProjectId: 'infura-project-id',
infuraProjectId: process.env.INFURA_PROJECT_KEY,
clientVersion: '1.0.0',
showApprovalRequest: () => undefined,
showApprovalRequest: (): undefined => undefined,
clientConfigApiService: new ClientConfigApiService({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if we need to pass this entire thing in? Maybe just add client, distribution, environment to WalletOptions and we can construct ClientConfigApiService based on that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think merging the constructor params of ClientConfigApiService into the Wallet constructor params would be more trouble than it's worth. If we ultimately decide that we don't care about all of the options of the former, we can always fold it in later.

fetch: globalThis.fetch,
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
}),
getMetaMetricsId: (): string => 'fake-metrics-id',
},
});

Expand All @@ -22,8 +43,21 @@ async function setupWallet() {
}

describe('Wallet', () => {
let wallet: Wallet;

beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] });
});

afterEach(async () => {
await wallet?.destroy();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Any concern with just doing wallet.destroy() in each test case for now?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As opposed to in afterEach? Why?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I generally find that more readable but it also provides flexibility in the way that we can pass different arguments to setupWallet in each test case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I prefer keeping cleanup in the hooks because customizing it between different test cases has a tendency to cause pernicious bugs in the test suite, IME.

enableNetConnect();
jest.useRealTimers();
});

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

expect(
messenger
Expand All @@ -35,7 +69,7 @@ describe('Wallet', () => {
it('signs transactions', async () => {
enableNetConnect();

const wallet = await setupWallet();
wallet = await setupWallet();

const addresses = wallet.messenger
.call('AccountsController:listAccounts')
Expand All @@ -47,7 +81,8 @@ describe('Wallet', () => {
{ networkClientId: 'sepolia' },
);

const hash = await result;
// 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(
Expand All @@ -61,10 +96,11 @@ describe('Wallet', () => {
}),
}),
);
});
}, 10_000);

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

expect(state.KeyringController).toStrictEqual({
isUnlocked: true,
Expand Down
35 changes: 28 additions & 7 deletions packages/wallet/src/Wallet.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { Messenger } from '@metamask/messenger';
import type { Json } from '@metamask/utils';

import type {
DefaultActions,
DefaultEvents,
DefaultInstances,
DefaultState,
RootMessenger,
} from './initialization';
import { initialize } from './initialization';
import { RootMessenger, WalletOptions } from './types';
import type { WalletOptions } from './types';

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

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

readonly #instances;
readonly #instances: DefaultInstances;

constructor({ state = {}, options }: WalletConstructorArgs) {
this.messenger = new Messenger({
Expand All @@ -22,13 +30,26 @@ export class Wallet {
this.#instances = initialize({ state, messenger: this.messenger, options });
}

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

async destroy(): Promise<void> {
await Promise.all(
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 undefined;
}),
);
}
}
50 changes: 50 additions & 0 deletions packages/wallet/src/initialization/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type {
ActionConstraint,
EventConstraint,
Messenger,
MessengerActions,
MessengerEvents,
} from '@metamask/messenger';

import * as defaultConfigurations from './instances';
import type { InitializationConfiguration, InstanceState } from './types';

export { defaultConfigurations };

type ExtractInstance<Config> =
Config extends InitializationConfiguration<infer Instance, infer _>
? Instance
: never;

type ExtractInstanceMessenger<Config> =
Config extends InitializationConfiguration<infer _, infer InferredMessenger>
? InferredMessenger
: never;

type ExtractName<Config> =
ExtractInstance<Config> extends { name: infer Name extends string }
? Name
: never;

type Configs = typeof defaultConfigurations;

type AllMessengers = ExtractInstanceMessenger<Configs[keyof Configs]>;

export type DefaultInstances = {
[Key in keyof Configs as ExtractName<Configs[Key]>]: ExtractInstance<
Configs[Key]
>;
};

export type DefaultActions = MessengerActions<AllMessengers>;

export type DefaultEvents = MessengerEvents<AllMessengers>;

export type RootMessenger<
AllowedActions extends ActionConstraint = ActionConstraint,
AllowedEvents extends EventConstraint = EventConstraint,
> = Messenger<'Root', AllowedActions, AllowedEvents>;

export type DefaultState = {
[Key in keyof DefaultInstances]: InstanceState<DefaultInstances[Key]>;
};
7 changes: 7 additions & 0 deletions packages/wallet/src/initialization/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export type {
DefaultActions,
DefaultEvents,
DefaultInstances,
DefaultState,
RootMessenger,
} from './defaults';
export { initialize } from './initialization';
13 changes: 7 additions & 6 deletions packages/wallet/src/initialization/initialization.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Json } from '@metamask/utils';

import * as defaultConfigurations from './instances';
import type { DefaultInstances } from './defaults';
import { defaultConfigurations, RootMessenger } from './defaults';
import { InitializationConfiguration } from './types';
import { RootMessenger, WalletOptions } from '../types';
import { WalletOptions } from '../types';

export type InitializeArgs = {
state: Record<string, Json>;
Expand All @@ -19,7 +20,7 @@ export function initialize({
messenger,
initializationConfigurations = [],
options,
}: InitializeArgs) {
}: InitializeArgs): DefaultInstances {
const overriddenConfiguration = initializationConfigurations.map(
(config) => config.name,
);
Expand All @@ -30,7 +31,7 @@ export function initialize({
),
);

const instances = {};
const instances: Record<string, unknown> = {};

for (const config of configurationEntries) {
const { name } = config;
Expand All @@ -45,8 +46,8 @@ export function initialize({
options,
});

instances[name] = instance;
instances[name] = instance as Record<string, unknown>;
}

return instances;
return instances as DefaultInstances;
}
36 changes: 23 additions & 13 deletions packages/wallet/src/initialization/instances/keyring-controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type {
DetailedEncryptionResult,
EncryptionKey,
KeyDerivationOptions,
} from '@metamask/browser-passworder';
import {
encrypt,
encryptWithDetail,
Expand All @@ -10,9 +15,8 @@ import {
importKey,
exportKey,
generateSalt,
EncryptionKey,
KeyDerivationOptions,
} from '@metamask/browser-passworder';
import type { Encryptor } from '@metamask/keyring-controller';
import {
KeyringController,
KeyringControllerMessenger,
Expand All @@ -35,7 +39,7 @@ const encryptFactory =
data: unknown,
key?: EncryptionKey | CryptoKey,
salt?: string,
) =>
): Promise<string> =>
encrypt(password, data, key, salt, {
algorithm: 'PBKDF2',
params: {
Expand All @@ -52,7 +56,11 @@ const encryptFactory =
*/
const encryptWithDetailFactory =
(iterations: number) =>
async (password: string, object: unknown, salt?: string) =>
async (
password: string,
object: unknown,
salt?: string,
): Promise<DetailedEncryptionResult> =>
encryptWithDetail(password, object, salt, {
algorithm: 'PBKDF2',
params: {
Expand All @@ -77,7 +85,7 @@ const keyFromPasswordFactory =
salt: string,
exportable?: boolean,
opts?: KeyDerivationOptions,
) =>
): Promise<EncryptionKey> =>
keyFromPassword(
password,
salt,
Expand All @@ -97,13 +105,15 @@ const keyFromPasswordFactory =
* @param iterations - The number of iterations to use for the PBKDF2 algorithm.
* @returns A function that checks if the vault was encrypted with the given number of iterations.
*/
const isVaultUpdatedFactory = (iterations: number) => (vault: string) =>
isVaultUpdated(vault, {
algorithm: 'PBKDF2',
params: {
iterations,
},
});
const isVaultUpdatedFactory =
(iterations: number) =>
(vault: string): boolean =>
isVaultUpdated(vault, {
algorithm: 'PBKDF2',
params: {
iterations,
},
});

/**
* A factory function that returns an encryptor with the given number of iterations.
Expand All @@ -114,7 +124,7 @@ const isVaultUpdatedFactory = (iterations: number) => (vault: string) =>
* @param iterations - The number of iterations to use for the PBKDF2 algorithm.
* @returns An encryptor set with the given number of iterations.
*/
const encryptorFactory = (iterations: number) => ({
const encryptorFactory = (iterations: number): Encryptor => ({
encrypt: encryptFactory(iterations),
encryptWithKey,
encryptWithDetail: encryptWithDetailFactory(iterations),
Expand Down
Loading
Loading