-
-
Notifications
You must be signed in to change notification settings - Fork 279
feat: Implement wallet initialization library #8838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
FrederikBolding
wants to merge
18
commits into
main
Choose a base branch
from
fb/wallet-lib-v1
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
c25f679
feat: Implement wallet initialization library
FrederikBolding 71ab81f
Add KeyringController initialization
FrederikBolding 7564ea8
Update README
FrederikBolding 98fdd94
Add more tests
FrederikBolding a3f1856
Fix Yarn constraints
FrederikBolding 905c208
Add instance specific options
FrederikBolding 7b624cd
Return instances directly
FrederikBolding 43ed9a1
Add CHANGELOG
FrederikBolding aa4796a
Fix tests and lint
FrederikBolding 598dbe6
Fix Node 18 tests
FrederikBolding ea54cec
Expose instances for now
FrederikBolding b8c3fff
Allow passing messenger
FrederikBolding 48f8c56
Address a couple comments
FrederikBolding a04182d
Address more PR comments
FrederikBolding 24ed61b
Fix import
FrederikBolding b6651ed
Tweak state types
FrederikBolding db7c180
Export DefaultInstances
FrederikBolding 9c4f0b9
Add overloads for getInstance
FrederikBolding File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>> { | ||
| 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'); | ||
| } | ||
| } | ||
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.