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
2 changes: 2 additions & 0 deletions packages/multichain-account-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevent wallet's lock by-pass when creating non-EVM account asynchronously ([#7801](https://github.com/MetaMask/core/pull/7801))
- The `waitForAllProvidersToFinishCreatingAccounts` option (when set to `false`) was causing account creation to be asynchronous for non-EVM providers, which was potentially creating accounts after the wallet's internal lock was released.
- We now run an internal account alignment operation which locks the wallet properly and runs in the background.
- Wait for Snap keyring in KeyringController before non-EVM account creation ([#8196](https://github.com/MetaMask/core/pull/8196))
- After wallet reset or restore, the Snap keyring is created lazily (e.g. when `getSnapKeyring()` runs). We now wait for it to appear (via `KeyringController:getState` and `KeyringController:stateChange`) with a timeout, avoiding "Keyring not found" error.

## [7.1.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import {
MOCK_HD_KEYRING_1,
MOCK_HD_KEYRING_2,
MOCK_SNAP_KEYRING,
getMultichainAccountServiceMessenger,
getRootMessenger,
makeMockAccountProvider,
Expand Down Expand Up @@ -1075,6 +1076,7 @@ describe('MultichainAccountService', () => {
it('checks for Snap platform readiness with MultichainAccountService:ensureCanUseSnapPlatform', async () => {
const { rootMessenger, spies } = await setup({
accounts: [],
keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2, MOCK_SNAP_KEYRING],
});

await rootMessenger.call(
Expand Down Expand Up @@ -1435,6 +1437,7 @@ describe('MultichainAccountService', () => {
it('delegates Snap platform readiness check to SnapPlatformWatcher (method)', async () => {
const { service, spies } = await setup({
accounts: [],
keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2, MOCK_SNAP_KEYRING],
});

await service.ensureCanUseSnapPlatform();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export class MultichainAccountService {

this.#watcher = new SnapPlatformWatcher(messenger, {
ensureOnboardingComplete,
snapKeyringWaitTimeoutMs: config?.snapWatcher?.timeoutMs,
});

this.#messenger.registerMethodActionHandlers(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-void */
import { KeyringTypes } from '@metamask/keyring-controller';
import { SnapControllerState } from '@metamask/snaps-controllers';
import { createDeferredPromise } from '@metamask/utils';

Expand Down Expand Up @@ -26,13 +27,20 @@ function setup(
SnapController: {
getState: jest.Mock<SnapControllerState>;
};
// eslint-disable-next-line @typescript-eslint/naming-convention
KeyringController: {
getState: jest.Mock<{ keyrings: { type: string }[] }>;
};
};
watcher: SnapPlatformWatcher;
} {
const mocks = {
SnapController: {
getState: jest.fn(),
},
KeyringController: {
getState: jest.fn(),
},
};

rootMessenger.registerActionHandler(
Expand All @@ -41,6 +49,15 @@ function setup(
);
mocks.SnapController.getState.mockReturnValue({ isReady: false });

rootMessenger.registerActionHandler(
'KeyringController:getState',
mocks.KeyringController.getState,
);
// By default, Snap keyring exists so ensureCanUseSnapPlatform can complete (including #waitForSnapKeyring).
mocks.KeyringController.getState.mockReturnValue({
keyrings: [{ type: KeyringTypes.snap }],
});

const messenger = getMultichainAccountServiceMessenger(rootMessenger);

const watcher = new SnapPlatformWatcher(messenger);
Expand Down Expand Up @@ -235,6 +252,148 @@ describe('SnapPlatformWatcher', () => {
expect(watcher.isReady).toBe(true);
});

it('resolves when Snap keyring is available (does not throw)', async () => {
const { rootMessenger, watcher, mocks } = setup();

publishIsReadyState(rootMessenger, true);

expect(await watcher.ensureCanUseSnapPlatform()).toBeUndefined();

// When keyring exists, getState is used to check for Snap keyring, so we return without throwing.
expect(mocks.KeyringController.getState).toHaveBeenCalled();
});

it('resolves when Snap keyring appears via stateChange (listener path)', async () => {
const rootMessenger = getRootMessenger();
const mocks = {
SnapController: { getState: jest.fn() },
KeyringController: { getState: jest.fn() },
};
mocks.SnapController.getState.mockReturnValue({ isReady: true });
mocks.KeyringController.getState.mockReturnValue({ keyrings: [] });
rootMessenger.registerActionHandler(
'SnapController:getState',
mocks.SnapController.getState,
);
rootMessenger.registerActionHandler(
'KeyringController:getState',
mocks.KeyringController.getState,
);
const messenger = getMultichainAccountServiceMessenger(rootMessenger);
const subscribeSpy = jest.spyOn(messenger, 'subscribe');
const watcher = new SnapPlatformWatcher(messenger);

const ensurePromise = watcher.ensureCanUseSnapPlatform();
await Promise.resolve();
await Promise.resolve(); // flush so #waitForSnapKeyring runs and subscribe is called

expect(subscribeSpy.mock.calls.map((call) => call[0])).toContain(
'KeyringController:stateChange',
);
const stateChangeCall = subscribeSpy.mock.calls.find(
(call) => call[0] === 'KeyringController:stateChange',
);
if (stateChangeCall === undefined) {
throw new Error(
'KeyringController:stateChange subscribe call not found',
);
}
const listener = stateChangeCall[1] as (state: {
keyrings: { type: string }[];
}) => void;
listener({ keyrings: [{ type: KeyringTypes.snap }] });

expect(await ensurePromise).toBeUndefined();
});

it('resolves when getState throws but stateChange later delivers Snap keyring (covers #hasSnapKeyring catch path)', async () => {
const rootMessenger = getRootMessenger();
const mocks = {
SnapController: { getState: jest.fn() },
KeyringController: { getState: jest.fn() },
};
mocks.SnapController.getState.mockReturnValue({ isReady: true });
mocks.KeyringController.getState.mockImplementation(() => {
throw new Error('KeyringController locked');
});
rootMessenger.registerActionHandler(
'SnapController:getState',
mocks.SnapController.getState,
);
rootMessenger.registerActionHandler(
'KeyringController:getState',
mocks.KeyringController.getState,
);
const messenger = getMultichainAccountServiceMessenger(rootMessenger);
const subscribeSpy = jest.spyOn(messenger, 'subscribe');
const watcher = new SnapPlatformWatcher(messenger);

const ensurePromise = watcher.ensureCanUseSnapPlatform();
await Promise.resolve();
await Promise.resolve();

expect(subscribeSpy.mock.calls.map((call) => call[0])).toContain(
'KeyringController:stateChange',
);
const stateChangeCall = subscribeSpy.mock.calls.find(
(call) => call[0] === 'KeyringController:stateChange',
);
if (stateChangeCall === undefined) {
throw new Error(
'KeyringController:stateChange subscribe call not found',
);
}
const listener = stateChangeCall[1] as (state: {
keyrings: { type: string }[];
}) => void;
listener({ keyrings: [{ type: KeyringTypes.snap }] });

expect(await ensurePromise).toBeUndefined();
});

it('rejects with explicit error when Snap keyring does not appear within timeout', async () => {
const { rootMessenger, watcher, mocks } = setup();

mocks.KeyringController.getState.mockReturnValue({ keyrings: [] });
publishIsReadyState(rootMessenger, true);

jest.useFakeTimers();
const ensurePromise = watcher.ensureCanUseSnapPlatform();
// Attach rejection handler before advancing timers to avoid unhandled rejection.
// eslint-disable-next-line jest/valid-expect -- assertion is awaited after advancing timers
const expectRejection = expect(ensurePromise).rejects.toThrow(
'Snap platform or keyrings still not ready. Aborting.',
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(5_000);
jest.useRealTimers();

await expectRejection;
});

it('uses custom snapKeyringWaitTimeoutMs when provided', async () => {
const { rootMessenger, messenger, mocks } = setup();
mocks.KeyringController.getState.mockReturnValue({ keyrings: [] });

const watcher = new SnapPlatformWatcher(messenger, {
snapKeyringWaitTimeoutMs: 100,
});
publishIsReadyState(rootMessenger, true);

jest.useFakeTimers();
const ensurePromise = watcher.ensureCanUseSnapPlatform();
// Attach a rejection handler before advancing timers to avoid unhandled rejection.
// eslint-disable-next-line jest/valid-expect -- assertion is awaited after advancing timers
const expectRejection = expect(ensurePromise).rejects.toThrow(
'Snap platform or keyrings still not ready. Aborting.',
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(100);
jest.useRealTimers();

await expectRejection;
});

it('waits for ensureOnboardingComplete first when platform is already ready', async () => {
const { rootMessenger, messenger } = setup();
const { promise: onboardingPromise, resolve: resolveOnboarding } =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
import { KeyringTypes } from '@metamask/keyring-controller';
import { createDeferredPromise, DeferredPromise } from '@metamask/utils';
import { once } from 'lodash';

import { projectLogger as log } from '../logger';
import { projectLogger as log, WARNING_PREFIX } from '../logger';
import { MultichainAccountServiceMessenger } from '../types';

/** Minimal KeyringController state shape needed to detect Snap keyring. */
type KeyringControllerStateSlice = {
keyrings: { type: KeyringTypes | string }[];
};

/** Default wait for Snap keyring to appear before rejecting (ms). */
export const DEFAULT_SNAP_KEYRING_WAIT_TIMEOUT_MS = 5_000;

/** Error message when Snap keyring does not appear within the timeout. */
const SNAP_KEYRING_TIMEOUT_MESSAGE =
'Snap platform or keyrings still not ready. Aborting.';

/**
* Returns true if the given KeyringController state slice contains a Snap keyring.
*
* @param state - KeyringController state.
* @returns True if state.keyrings contains a keyring with type KeyringTypes.snap.
*/
function stateHasSnapKeyring(state: KeyringControllerStateSlice): boolean {
return state.keyrings.some((k) => k.type === KeyringTypes.snap);
}

export type SnapPlatformWatcherOptions = {
/**
* Resolves when onboarding is complete.
*/
ensureOnboardingComplete?: () => Promise<void>;
/**
* How long to wait for the Snap keyring to appear before rejecting (ms).
*
* @default DEFAULT_SNAP_KEYRING_WAIT_TIMEOUT_MS
*/
snapKeyringWaitTimeoutMs?: number;
};

export class SnapPlatformWatcher {
readonly #messenger: MultichainAccountServiceMessenger;

readonly #ensureOnboardingComplete?: () => Promise<void>;

readonly #snapKeyringWaitTimeoutMs: number;

readonly #isReadyOnce: DeferredPromise<void>;

#isReady: boolean;
Expand All @@ -26,6 +57,8 @@ export class SnapPlatformWatcher {
) {
this.#messenger = messenger;
this.#ensureOnboardingComplete = options.ensureOnboardingComplete;
this.#snapKeyringWaitTimeoutMs =
options.snapKeyringWaitTimeoutMs ?? DEFAULT_SNAP_KEYRING_WAIT_TIMEOUT_MS;

this.#isReady = false;
this.#isReadyOnce = createDeferredPromise<void>();
Expand All @@ -47,6 +80,74 @@ export class SnapPlatformWatcher {
if (!this.#isReady) {
throw new Error('Snap platform cannot be used now.');
}

// After a restore/reset, the Snap keyring is created lazily by the client (e.g. when
// getSnapKeyring() is called). Non-EVM account creation needs the keyring to exist, so we
// wait for it here with a timeout to avoid "Keyring not found" errors.
await this.#waitForSnapKeyring();
}

/**
* Waits for KeyringController to have a Snap keyring available.
* Checks once, then subscribes to KeyringController:stateChange until the keyring
* appears or the timeout is reached (then throws).
*/
async #waitForSnapKeyring(): Promise<void> {
if (this.#hasSnapKeyring()) {
return;
}
await this.#waitForSnapKeyringViaStateChange();
}

/**
* Returns true if KeyringController already has a Snap keyring.
* Logs and returns false on error.
*
* @returns True if a Snap keyring exists, false otherwise or on error.
*/
#hasSnapKeyring(): boolean {
try {
const state = this.#messenger.call(
'KeyringController:getState',
) as KeyringControllerStateSlice;
return stateHasSnapKeyring(state);
} catch (error) {
log(
`${WARNING_PREFIX} KeyringController error while waiting for Snap keyring:`,
error,
);
return false;
}
}

/**
* Subscribes to KeyringController:stateChange and resolves when a Snap keyring
* appears in state, or rejects with an error after the timeout.
*/
async #waitForSnapKeyringViaStateChange(): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeoutRef: { id: ReturnType<typeof setTimeout> | undefined } = {
id: undefined,
};

const listener = (state: KeyringControllerStateSlice): void => {
if (stateHasSnapKeyring(state)) {
clearTimeout(timeoutRef.id);
this.#messenger.unsubscribe(
'KeyringController:stateChange',
listener,
);
resolve();
}
};

timeoutRef.id = setTimeout(() => {
this.#messenger.unsubscribe('KeyringController:stateChange', listener);
reject(new Error(SNAP_KEYRING_TIMEOUT_MESSAGE));
}, this.#snapKeyringWaitTimeoutMs);

this.#messenger.subscribe('KeyringController:stateChange', listener);
});
}

#watch(): void {
Expand Down
7 changes: 7 additions & 0 deletions packages/multichain-account-service/src/tests/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export const MOCK_HD_KEYRING_2 = {
accounts: ['0x456'],
};

/** Used when tests need ensureCanUseSnapPlatform to resolve (SnapPlatformWatcher waits for Snap keyring). */
export const MOCK_SNAP_KEYRING = {
type: KeyringTypes.snap,
metadata: { id: 'snap-keyring', name: 'Snap Keyring' },
accounts: [],
};

export const MOCK_HD_ACCOUNT_1: Bip44Account<InternalAccount> = {
id: 'mock-id-1',
address: '0x123',
Expand Down
Loading
Loading