diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index c11bf2d68bf..fda8c117f1c 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Wait for Snap platform to be ready before any wallet/group operations ([#7266](https://github.com/MetaMask/core/pull/7266)) + ### Changed +- **BREAKING:** Abstract method `SnapAccountProvider.createAccounts` has been renamed `runCreateAccounts` ([#7266](https://github.com/MetaMask/core/pull/7266)) + - `SnapAccountProvider.createAccounts` is now implemented by `SnapAccountProvider` directly and automatically wait for the Snap platform to be ready before calling `runCreateAccounts`. +- **BREAKING:** Abstract method `SnapAccountProvider.discoverAccounts` has been renamed `runDiscoverAccounts` ([#7266](https://github.com/MetaMask/core/pull/7266)) + - `SnapAccountProvider.discoverAccounts` is now implemented by `SnapAccountProvider` directly and automatically wait for the Snap platform to be ready before calling `runDiscoverAccounts`. - Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) - The dependencies moved are: - `@metamask/accounts-controller` (^35.0.0) diff --git a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts index 67eddd59709..a99ddc2ab4e 100644 --- a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts @@ -62,6 +62,23 @@ export abstract class BaseBip44AccountProvider implements Bip44AccountProvider { abstract getName(): string; + protected getAnyAccount(): Bip44Account | undefined { + const anyAccount = this.messenger + .call( + // NOTE: Even though the name is misleading, this only fetches all internal + // accounts, including EVM and non-EVM. We might wanna change this action + // name once we fully support multichain accounts. + 'AccountsController:listMultichainAccounts', + ) + .find((account) => { + return isBip44Account(account) && this.isAccountCompatible(account); + }); + + // We cast here since `.find` will return a `InternalAccount | undefined` + // despite having the `isBip44Account` check. + return anyAccount as Bip44Account | undefined; + } + #getAccounts( filter: (account: KeyringAccount) => boolean = () => true, ): Bip44Account[] { diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts index d61b7362146..626d4424d3c 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -96,6 +96,11 @@ class MockBtcKeyring { return account; }); } +class MockBtcAccountProvider extends BtcAccountProvider { + override async ensureSnapPlatformIsReady(): Promise { + // Override to avoid waiting during tests. + } +} /** * Sets up a BtcAccountProvider for testing. @@ -153,7 +158,7 @@ function setup({ const multichainMessenger = getMultichainAccountServiceMessenger(messenger); const provider = new AccountProviderWrapper( multichainMessenger, - new BtcAccountProvider(multichainMessenger), + new MockBtcAccountProvider(multichainMessenger), ); return { @@ -348,7 +353,7 @@ describe('BtcAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const btcProvider = new BtcAccountProvider( + const btcProvider = new MockBtcAccountProvider( multichainMessenger, undefined, mockTrace, @@ -400,7 +405,7 @@ describe('BtcAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const btcProvider = new BtcAccountProvider( + const btcProvider = new MockBtcAccountProvider( multichainMessenger, undefined, mockTrace, @@ -433,7 +438,7 @@ describe('BtcAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const btcProvider = new BtcAccountProvider( + const btcProvider = new MockBtcAccountProvider( multichainMessenger, undefined, mockTrace, diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts index 1065737e64d..2f45c52173c 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts @@ -52,7 +52,7 @@ export class BtcAccountProvider extends SnapAccountProvider { ); } - async createAccounts({ + async runCreateAccounts({ entropySource, groupIndex: index, }: { @@ -77,7 +77,7 @@ export class BtcAccountProvider extends SnapAccountProvider { }); } - async discoverAccounts({ + async runDiscoverAccounts({ entropySource, groupIndex, }: { diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts index c47beeaee8c..9a2f06edcfd 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts @@ -1,10 +1,16 @@ import { isBip44Account, type Bip44Account } from '@metamask/account-api'; import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; -import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import type { GetAccountRequest } from '@metamask/keyring-api'; +import { + KeyringRpcMethod, + type EntropySourceId, + type KeyringAccount, +} from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import type { SnapId } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; import { BtcAccountProvider } from './BtcAccountProvider'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider'; import { isSnapAccountProvider, SnapAccountProvider, @@ -35,28 +41,64 @@ const THROTTLED_OPERATION_DELAY_MS = 10; const TEST_SNAP_ID = 'npm:@metamask/test-snap' as SnapId; const TEST_ENTROPY_SOURCE = 'test-entropy-source' as EntropySourceId; -// Helper to create a test provider that exposes protected trace method -class TestSnapAccountProvider extends SnapAccountProvider { +class MockSnapAccountProvider extends SnapAccountProvider { + readonly tracker: { + startLog: number[]; + endLog: number[]; + activeCount: number; + maxActiveCount: number; + }; + + constructor( + snapId: SnapId, + messenger: MultichainAccountServiceMessenger, + config: SnapAccountProviderConfig, + /* istanbul ignore next */ + trace: TraceCallback = traceFallback, + ) { + super(snapId, messenger, config, trace); + + // Tracker to monitor concurrent executions. + this.tracker = { + startLog: [], + endLog: [], + activeCount: 0, + maxActiveCount: 0, + }; + } + getName(): string { return 'Test Provider'; } - isAccountCompatible(_account: Bip44Account): boolean { + isAccountCompatible(): boolean { return true; } - async discoverAccounts(_options: { - entropySource: EntropySourceId; - groupIndex: number; - }): Promise[]> { + async runDiscoverAccounts(): Promise[]> { return []; } - async createAccounts(_options: { + async runCreateAccounts(options: { entropySource: EntropySourceId; groupIndex: number; }): Promise[]> { - return []; + const { tracker } = this; + + return this.withMaxConcurrency(async () => { + tracker.startLog.push(options.groupIndex); + tracker.activeCount += 1; + tracker.maxActiveCount = Math.max( + tracker.maxActiveCount, + tracker.activeCount, + ); + await new Promise((resolve) => + setTimeout(resolve, THROTTLED_OPERATION_DELAY_MS), + ); + tracker.activeCount -= 1; + tracker.endLog.push(options.groupIndex); + return []; + }); } // Expose protected trace method as public for testing @@ -66,58 +108,72 @@ class TestSnapAccountProvider extends SnapAccountProvider { ): Promise { return super.trace(request, fn); } + + static resetEnsureSnapPlatformIsReady(): void { + SnapAccountProvider.ensureSnapPlatformIsReadyPromise = null; + } } // Helper to create a tracked provider that monitors concurrent execution const setup = ({ maxConcurrency, messenger = getRootMessenger(), -}: { maxConcurrency?: number; messenger?: RootMessenger } = {}) => { - const tracker: { - startLog: number[]; - endLog: number[]; - activeCount: number; - maxActiveCount: number; - } = { - startLog: [], - endLog: [], - activeCount: 0, - maxActiveCount: 0, + accounts = [], +}: { + maxConcurrency?: number; + messenger?: RootMessenger; + accounts?: InternalAccount[]; +} = {}) => { + const mocks = { + AccountsController: { + listMultichainAccounts: jest.fn(), + }, + ErrorReportingService: { + captureException: jest.fn(), + }, + SnapController: { + handleKeyringRequest: { + getAccount: jest.fn(), + listAccounts: jest.fn(), + }, + handleRequest: jest.fn(), + }, }; - class MockSnapAccountProvider extends SnapAccountProvider { - getName(): string { - return 'Test Provider'; - } + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mocks.AccountsController.listMultichainAccounts, + ); + mocks.AccountsController.listMultichainAccounts.mockReturnValue(accounts); - isAccountCompatible(): boolean { - return true; - } + messenger.registerActionHandler( + 'ErrorReportingService:captureException', + mocks.ErrorReportingService.captureException, + ); - async discoverAccounts(): Promise[]> { - return []; - } - - async createAccounts(options: { - entropySource: EntropySourceId; - groupIndex: number; - }): Promise[]> { - return this.withMaxConcurrency(async () => { - tracker.startLog.push(options.groupIndex); - tracker.activeCount += 1; - tracker.maxActiveCount = Math.max( - tracker.maxActiveCount, - tracker.activeCount, - ); - await new Promise((resolve) => - setTimeout(resolve, THROTTLED_OPERATION_DELAY_MS), + messenger.registerActionHandler( + 'SnapController:handleRequest', + mocks.SnapController.handleRequest, + ); + mocks.SnapController.handleRequest.mockImplementation( + async ({ request }: { request: JsonRpcRequest }) => { + if (request.method === String(KeyringRpcMethod.GetAccount)) { + return await mocks.SnapController.handleKeyringRequest.getAccount( + (request as GetAccountRequest).params.id, ); - tracker.activeCount -= 1; - tracker.endLog.push(options.groupIndex); - return []; - }); - } - } + } else if (request.method === String(KeyringRpcMethod.ListAccounts)) { + return await mocks.SnapController.handleKeyringRequest.listAccounts(); + } + throw new Error(`Unhandled method: ${request.method}`); + }, + ); + mocks.SnapController.handleKeyringRequest.getAccount.mockImplementation( + async (id) => + accounts.map(asKeyringAccount).find((account) => account.id === id), + ); + mocks.SnapController.handleKeyringRequest.listAccounts.mockImplementation( + async () => accounts.map(asKeyringAccount), + ); const keyring = { createAccount: jest.fn(), @@ -133,6 +189,10 @@ const setup = ({ ), ); + // We reset this state, since it's a static field, so each tests would start + // with a fresh state. + MockSnapAccountProvider.resetEnsureSnapPlatformIsReady(); + const serviceMessenger = getMultichainAccountServiceMessenger(messenger); const config = { ...(maxConcurrency !== undefined && { maxConcurrency }), @@ -151,7 +211,7 @@ const setup = ({ config, ); - return { messenger, provider, tracker, keyring }; + return { messenger, provider, tracker: provider.tracker, keyring, mocks }; }; describe('SnapAccountProvider', () => { @@ -172,18 +232,24 @@ describe('SnapAccountProvider', () => { }); it('creates SolAccountProvider with default trace using 1 parameter', () => { + setup(); + const provider = new SolAccountProvider(mockMessenger); expect(provider).toBeDefined(); expect(provider.snapId).toBe(SolAccountProvider.SOLANA_SNAP_ID); }); it('creates SolAccountProvider with default trace using 2 parameters', () => { + setup(); + const provider = new SolAccountProvider(mockMessenger, undefined); expect(provider).toBeDefined(); expect(provider.snapId).toBe(SolAccountProvider.SOLANA_SNAP_ID); }); it('creates SolAccountProvider with custom trace using 3 parameters', () => { + setup(); + const customTrace = jest.fn(); const provider = new SolAccountProvider( mockMessenger, @@ -195,6 +261,8 @@ describe('SnapAccountProvider', () => { }); it('creates SolAccountProvider with custom config and default trace', () => { + setup(); + const customConfig = { discovery: { timeoutMs: 3000, @@ -211,6 +279,8 @@ describe('SnapAccountProvider', () => { }); it('creates BtcAccountProvider with default trace', () => { + setup(); + // Test other subclasses to ensure branch coverage const btcProvider = new BtcAccountProvider(mockMessenger); @@ -219,6 +289,8 @@ describe('SnapAccountProvider', () => { }); it('creates TrxAccountProvider with custom trace', () => { + setup(); + const customTrace = jest.fn(); // Explicitly test with all three parameters @@ -233,6 +305,8 @@ describe('SnapAccountProvider', () => { }); it('creates provider without trace parameter', () => { + setup(); + // Test creating provider without passing trace parameter const provider = new SolAccountProvider(mockMessenger, undefined); @@ -240,6 +314,8 @@ describe('SnapAccountProvider', () => { }); it('tests parameter spreading to trigger branch coverage', () => { + setup(); + type SolConfig = ConstructorParameters[1]; type ProviderArgs = [ MultichainAccountServiceMessenger, @@ -286,6 +362,8 @@ describe('SnapAccountProvider', () => { }); it('returns true for actual SnapAccountProvider instance', () => { + setup(); + // Create a mock messenger with required methods const mockMessenger = { call: jest.fn(), @@ -325,6 +403,8 @@ describe('SnapAccountProvider', () => { }); it('uses default trace parameter when only messenger is provided', async () => { + setup(); + traceFallbackMock.mockImplementation(async (_request, fn) => fn?.()); // Test with default config and trace @@ -338,7 +418,7 @@ describe('SnapAccountProvider', () => { timeoutMs: 3000, }, }; - const testProvider = new TestSnapAccountProvider( + const testProvider = new MockSnapAccountProvider( TEST_SNAP_ID, mockMessenger, defaultConfig, @@ -357,12 +437,14 @@ describe('SnapAccountProvider', () => { }); it('uses custom trace when explicitly provided with all parameters', async () => { + setup(); + const customTrace = jest.fn().mockImplementation(async (_request, fn) => { return await fn(); }); // Test with all parameters including custom trace - const testProvider = new TestSnapAccountProvider( + const testProvider = new MockSnapAccountProvider( TEST_SNAP_ID, mockMessenger, { @@ -389,6 +471,8 @@ describe('SnapAccountProvider', () => { }); it('calls trace callback with the correct arguments', async () => { + setup(); + const mockTrace = jest.fn().mockImplementation(async (request, fn) => { expect(request).toStrictEqual({ name: 'Test Request', @@ -407,7 +491,7 @@ describe('SnapAccountProvider', () => { timeoutMs: 3000, }, }; - const testProvider = new TestSnapAccountProvider( + const testProvider = new MockSnapAccountProvider( TEST_SNAP_ID, mockMessenger, defaultConfig, @@ -424,6 +508,8 @@ describe('SnapAccountProvider', () => { }); it('propagates errors through trace callback', async () => { + setup(); + const mockError = new Error('Test error'); const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { return await fn(); @@ -439,7 +525,7 @@ describe('SnapAccountProvider', () => { timeoutMs: 3000, }, }; - const testProvider = new TestSnapAccountProvider( + const testProvider = new MockSnapAccountProvider( TEST_SNAP_ID, mockMessenger, defaultConfig, @@ -455,6 +541,8 @@ describe('SnapAccountProvider', () => { }); it('handles trace callback returning undefined', async () => { + setup(); + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { return await fn(); }); @@ -469,7 +557,7 @@ describe('SnapAccountProvider', () => { timeoutMs: 3000, }, }; - const testProvider = new TestSnapAccountProvider( + const testProvider = new MockSnapAccountProvider( TEST_SNAP_ID, mockMessenger, defaultConfig, @@ -591,12 +679,7 @@ describe('SnapAccountProvider', () => { ].filter(isBip44Account); it('does not create any accounts if already in-sync', async () => { - const { provider, messenger } = setup(); - - messenger.registerActionHandler( - 'SnapController:handleRequest', - jest.fn().mockResolvedValue(mockAccounts.map(asKeyringAccount)), - ); + const { provider } = setup({ accounts: mockAccounts }); const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); @@ -606,24 +689,15 @@ describe('SnapAccountProvider', () => { }); it('creates new accounts if de-synced', async () => { - const { provider, messenger } = setup(); - - messenger.registerActionHandler( - 'SnapController:handleRequest', - jest.fn().mockResolvedValue([mockAccounts[0]].map(asKeyringAccount)), - ); - - const mockCaptureException = jest.fn(); - messenger.registerActionHandler( - 'ErrorReportingService:captureException', - mockCaptureException, - ); + const { provider, mocks } = setup({ + accounts: [mockAccounts[0]], + }); const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); await provider.resyncAccounts(mockAccounts); - expect(mockCaptureException).toHaveBeenCalledWith( + expect(mocks.ErrorReportingService.captureException).toHaveBeenCalledWith( new Error( `Snap "${TEST_SNAP_ID}" has de-synced accounts, we'll attempt to re-sync them...`, ), @@ -637,22 +711,11 @@ describe('SnapAccountProvider', () => { }); it('reports an error if a Snap has more accounts than MetaMask', async () => { - const { provider, messenger } = setup(); - - messenger.registerActionHandler( - 'SnapController:handleRequest', - jest.fn().mockResolvedValue(mockAccounts.map(asKeyringAccount)), - ); - - const mockCaptureException = jest.fn(); - messenger.registerActionHandler( - 'ErrorReportingService:captureException', - mockCaptureException, - ); + const { provider, mocks } = setup({ accounts: mockAccounts }); await provider.resyncAccounts([mockAccounts[0]]); // Less accounts than the Snap - expect(mockCaptureException).toHaveBeenCalledWith( + expect(mocks.ErrorReportingService.captureException).toHaveBeenCalledWith( new Error( `Snap "${TEST_SNAP_ID}" has de-synced accounts, Snap has more accounts than MetaMask!`, ), @@ -660,18 +723,7 @@ describe('SnapAccountProvider', () => { }); it('does not throw errors if any provider is not able to re-sync', async () => { - const { provider, messenger } = setup(); - - messenger.registerActionHandler( - 'SnapController:handleRequest', - jest.fn().mockResolvedValue([mockAccounts[0]].map(asKeyringAccount)), - ); - - const mockCaptureException = jest.fn(); - messenger.registerActionHandler( - 'ErrorReportingService:captureException', - mockCaptureException, - ); + const { provider, mocks } = setup({ accounts: [mockAccounts[0]] }); const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); @@ -682,6 +734,7 @@ describe('SnapAccountProvider', () => { expect(createAccountsSpy).toHaveBeenCalled(); + const mockCaptureException = mocks.ErrorReportingService.captureException; expect(mockCaptureException).toHaveBeenNthCalledWith( 1, new Error( @@ -698,4 +751,68 @@ describe('SnapAccountProvider', () => { ); }); }); + + describe('ensureSnapPlatformIsReady', () => { + it('waits for Snap platform to be ready when there is no associated Snap accounts', async () => { + const { provider, mocks } = setup({ accounts: [] }); + const { listAccounts, getAccount } = + mocks.SnapController.handleKeyringRequest; + + await provider.ensureSnapPlatformIsReady(); + + expect(listAccounts).toHaveBeenCalled(); + expect(getAccount).not.toHaveBeenCalled(); + }); + + it('waits for Snap platform to be ready when there is some associated Snap accounts', async () => { + const account = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withUuid() + .withSnapId(TEST_SNAP_ID) + .get(); + + const { provider, mocks } = setup({ accounts: [account] }); + const { listAccounts, getAccount } = + mocks.SnapController.handleKeyringRequest; + + await provider.ensureSnapPlatformIsReady(); + + expect(getAccount).toHaveBeenCalledWith(account.id); + expect(listAccounts).not.toHaveBeenCalled(); + }); + + it('does not wait again if Snap platform is already ready', async () => { + const { provider, mocks } = setup(); + const { listAccounts, getAccount } = + mocks.SnapController.handleKeyringRequest; + + await provider.ensureSnapPlatformIsReady(); + + // Clear previous calls. + listAccounts.mockClear(); + getAccount.mockClear(); + + await provider.ensureSnapPlatformIsReady(); + + // No additional calls should be made since it's already ready. + expect(listAccounts).not.toHaveBeenCalled(); + expect(getAccount).not.toHaveBeenCalled(); + }); + + it('resolves even if messenger handleRequest fails', async () => { + const { provider, mocks } = setup(); + const { listAccounts, getAccount } = + mocks.SnapController.handleKeyringRequest; + + // This will be called when pinging the Snap provider. + listAccounts.mockRejectedValue(new Error('Snap request failed')); + + // Platform should still be considered ready even if the ping fails. + await provider.ensureSnapPlatformIsReady(); + + expect(listAccounts).toHaveBeenCalled(); + expect(getAccount).not.toHaveBeenCalled(); + }); + + // Removed logSpy test as requested + }); }); diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index 28b0ac97fd3..377ed089216 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -1,17 +1,23 @@ import { type Bip44Account } from '@metamask/account-api'; import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import type { SnapKeyring } from '@metamask/eth-snap-keyring'; -import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { + KeyringRpcMethod, + type EntropySourceId, + type KeyringAccount, +} from '@metamask/keyring-api'; import type { KeyringMetadata } from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringClient } from '@metamask/keyring-snap-client'; import type { Json, JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; import { HandlerType } from '@metamask/snaps-utils'; +import { createDeferredPromise } from '@metamask/utils'; import { Semaphore } from 'async-mutex'; import { BaseBip44AccountProvider } from './BaseBip44AccountProvider'; import { traceFallback } from '../analytics'; +import { projectLogger as log } from '../logger'; import type { MultichainAccountServiceMessenger } from '../types'; import { createSentryError } from '../utils'; @@ -42,6 +48,9 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { readonly #trace: TraceCallback; + protected static ensureSnapPlatformIsReadyPromise: Promise | null = + null; + constructor( snapId: SnapId, messenger: MultichainAccountServiceMessenger, @@ -54,6 +63,11 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { this.snapId = snapId; this.client = this.#getKeyringClientFromSnapId(snapId); + // All Snap requests are queued until the Snap platform is ready, so we use a basic "get" + // request to detect that and make sure any request to the client will wait for that first. + // eslint-disable-next-line no-void + void this.ensureSnapPlatformIsReady(); + const maxConcurrency = config.maxConcurrency ?? Infinity; this.config = { ...config, @@ -68,6 +82,50 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { this.#trace = trace; } + async ensureSnapPlatformIsReady(): Promise { + // Use a static property to ensure we only create one promise for all instances of any + // Snap providers. + if (!SnapAccountProvider.ensureSnapPlatformIsReadyPromise) { + // We create the deferred promise here to ensure that any request to the Snap platform + // will go through once it's ready. The platform is considered ready when the onboarding + // is complete. + const ensureSnapPlatformIsReadyDeferred = createDeferredPromise(); + SnapAccountProvider.ensureSnapPlatformIsReadyPromise = + ensureSnapPlatformIsReadyDeferred.promise; + + log('Waiting for Snap platform to be ready...'); + + // We just need to make a simple request to ensure the Snap platform is ready. + // eslint-disable-next-line no-void + void this.#ping().finally(() => { + log('Snap platform is ready!'); + // No matter if the request succeeded or failed, we consider the Snap platform + // is ready to process requests. + ensureSnapPlatformIsReadyDeferred.resolve(); + }); + } + + return SnapAccountProvider.ensureSnapPlatformIsReadyPromise; + } + + async #ping(): Promise { + // Can be used to ping and check if the Snap is responsive. + // NOTE: We're trying to do this the fastest way possible, so we check for 1 account + // if any exists, or just list accounts (which would be 0 in that case). + const account = this.getAnyAccount(); + if (account) { + log( + `Ping (used "${KeyringRpcMethod.GetAccount}" with "${account.id}") with Snap: ${this.snapId}`, + ); + await this.client.getAccount(account.id); + } else { + log( + `Ping (used "${KeyringRpcMethod.ListAccounts}") with Snap: ${this.snapId}`, + ); + await this.client.listAccounts(); + } + } + /** * Wraps an async operation with concurrency limiting based on maxConcurrency config. * If maxConcurrency is Infinity (the default), the operation runs immediately without throttling. @@ -131,6 +189,8 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { async resyncAccounts( accounts: Bip44Account[], ): Promise { + await this.ensureSnapPlatformIsReady(); + const localSnapAccounts = accounts.filter( (account) => account.metadata.snap && account.metadata.snap.id === this.snapId, @@ -214,6 +274,8 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { metadata: KeyringMetadata; }) => Promise, ): Promise { + await this.ensureSnapPlatformIsReady(); + return this.withKeyring( { type: KeyringTypes.snap }, (args) => { @@ -222,14 +284,32 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { ); } + async createAccounts(options: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + await this.ensureSnapPlatformIsReady(); + + return await this.runCreateAccounts(options); + } + + async discoverAccounts(options: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + await this.ensureSnapPlatformIsReady(); + + return await this.runDiscoverAccounts(options); + } + abstract isAccountCompatible(account: Bip44Account): boolean; - abstract createAccounts(options: { + abstract runCreateAccounts(options: { entropySource: EntropySourceId; groupIndex: number; }): Promise[]>; - abstract discoverAccounts(options: { + abstract runDiscoverAccounts(options: { entropySource: EntropySourceId; groupIndex: number; }): Promise[]>; diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index 68645fa31d9..8260fe97059 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -80,6 +80,12 @@ class MockSolanaKeyring { }); } +class MockSolAccountProvider extends SolAccountProvider { + override async ensureSnapPlatformIsReady(): Promise { + // Override to avoid waiting during tests. + } +} + /** * Sets up a SolAccountProvider for testing. * @@ -146,7 +152,7 @@ function setup({ const multichainMessenger = getMultichainAccountServiceMessenger(messenger); const provider = new AccountProviderWrapper( multichainMessenger, - new SolAccountProvider(multichainMessenger, undefined, mockTrace), + new MockSolAccountProvider(multichainMessenger, undefined, mockTrace), ); return { @@ -333,7 +339,7 @@ describe('SolAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const solProvider = new SolAccountProvider( + const solProvider = new MockSolAccountProvider( multichainMessenger, undefined, mocks.trace, @@ -376,7 +382,7 @@ describe('SolAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const solProvider = new SolAccountProvider( + const solProvider = new MockSolAccountProvider( multichainMessenger, undefined, mocks.trace, @@ -405,7 +411,7 @@ describe('SolAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const solProvider = new SolAccountProvider( + const solProvider = new MockSolAccountProvider( multichainMessenger, undefined, mocks.trace, diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts index 98cf2db6d5a..65e0f87ae07 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -84,7 +84,7 @@ export class SolAccountProvider extends SnapAccountProvider { return account; } - async createAccounts({ + async runCreateAccounts({ entropySource, groupIndex, }: { @@ -103,7 +103,7 @@ export class SolAccountProvider extends SnapAccountProvider { }); } - async discoverAccounts({ + async runDiscoverAccounts({ entropySource, groupIndex, }: { diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts index fc0ca2e8c73..012bb50f5b2 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -68,6 +68,11 @@ class MockTronKeyring { // Add discoverAccounts method to match the provider's usage discoverAccounts = jest.fn().mockResolvedValue([]); } +class MockTrxAccountProvider extends TrxAccountProvider { + override async ensureSnapPlatformIsReady(): Promise { + // Override to avoid waiting during tests. + } +} /** * Sets up a TrxAccountProvider for testing. @@ -132,7 +137,7 @@ function setup({ const multichainMessenger = getMultichainAccountServiceMessenger(messenger); const provider = new AccountProviderWrapper( multichainMessenger, - new TrxAccountProvider(multichainMessenger), + new MockTrxAccountProvider(multichainMessenger), ); return { @@ -334,7 +339,7 @@ describe('TrxAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const trxProvider = new TrxAccountProvider( + const trxProvider = new MockTrxAccountProvider( multichainMessenger, undefined, mockTrace, @@ -388,7 +393,7 @@ describe('TrxAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const trxProvider = new TrxAccountProvider( + const trxProvider = new MockTrxAccountProvider( multichainMessenger, undefined, mockTrace, @@ -421,7 +426,7 @@ describe('TrxAccountProvider', () => { const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const trxProvider = new TrxAccountProvider( + const trxProvider = new MockTrxAccountProvider( multichainMessenger, undefined, mockTrace, diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts index f436e47ce84..29f903b42b4 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts @@ -53,7 +53,7 @@ export class TrxAccountProvider extends SnapAccountProvider { ); } - async createAccounts({ + async runCreateAccounts({ entropySource, groupIndex: index, }: { @@ -78,7 +78,7 @@ export class TrxAccountProvider extends SnapAccountProvider { }); } - async discoverAccounts({ + async runDiscoverAccounts({ entropySource, groupIndex, }: {