From ecbfa0b33069354171aaa1b1a5ee7fd28b615966 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:04:04 +0100 Subject: [PATCH 01/25] feat(multichain-account-service): add perf local trace wrapper --- .../src/analytics/perf.test.ts | 169 ++++++++++++++++++ .../src/analytics/perf.ts | 72 ++++++++ 2 files changed, 241 insertions(+) create mode 100644 packages/multichain-account-service/src/analytics/perf.test.ts create mode 100644 packages/multichain-account-service/src/analytics/perf.ts diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts new file mode 100644 index 00000000000..6a148715da6 --- /dev/null +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -0,0 +1,169 @@ +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; + +import { projectLogger } from '../logger'; +import { isPerfEnabled, tick, wrapWithLocalPerfTrace } from './perf'; + +jest.mock('../logger', () => ({ + projectLogger: { enabled: false }, + createModuleLogger: jest.fn().mockReturnValue(jest.fn()), +})); + +const mockProjectLogger = projectLogger as { enabled: boolean }; + +describe('perf', () => { + describe('isPerfEnabled', () => { + it('returns false when projectLogger is disabled', () => { + mockProjectLogger.enabled = false; + expect(isPerfEnabled()).toBe(false); + }); + + it('returns true when projectLogger is enabled', () => { + mockProjectLogger.enabled = true; + expect(isPerfEnabled()).toBe(true); + mockProjectLogger.enabled = false; + }); + }); + + describe('tick', () => { + const request: TraceRequest = { name: 'test-operation' }; + + beforeEach(() => { + jest.spyOn(performance, 'now'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockProjectLogger.enabled = false; + }); + + it('returns a no-op when perf is disabled', () => { + mockProjectLogger.enabled = false; + const tock = tick(request); + + expect(performance.now).not.toHaveBeenCalled(); + expect(tock()).toBeUndefined(); + }); + + it('captures start time when perf is enabled', () => { + mockProjectLogger.enabled = true; + jest.mocked(performance.now).mockReturnValueOnce(100); + + tick(request); + + expect(performance.now).toHaveBeenCalledTimes(1); + }); + + it('logs elapsed time when tock is called', () => { + mockProjectLogger.enabled = true; + jest + .mocked(performance.now) + .mockReturnValueOnce(100) + .mockReturnValueOnce(250); + + const tock = tick(request); + tock(); + + expect(performance.now).toHaveBeenCalledTimes(2); + }); + + it('includes JSON-encoded data in the log when request has data', () => { + mockProjectLogger.enabled = true; + const requestWithData: TraceRequest = { + name: 'test-operation', + data: { foo: 'bar' }, + }; + jest + .mocked(performance.now) + .mockReturnValueOnce(0) + .mockReturnValueOnce(42); + + // Should not throw regardless of data shape + const tock = tick(requestWithData); + expect(() => tock()).not.toThrow(); + }); + + it('omits context when request has no data', () => { + mockProjectLogger.enabled = true; + jest + .mocked(performance.now) + .mockReturnValueOnce(0) + .mockReturnValueOnce(10); + + const tock = tick({ name: 'no-data' }); + expect(() => tock()).not.toThrow(); + }); + }); + + describe('wrapWithLocalPerfTrace', () => { + const request: TraceRequest = { name: 'wrapped-op' }; + let mockTrace: jest.MockedFunction; + + beforeEach(() => { + mockTrace = jest.fn(); + jest.spyOn(performance, 'now').mockReturnValue(0); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockProjectLogger.enabled = false; + }); + + it('calls trace directly when perf is disabled', async () => { + mockProjectLogger.enabled = false; + mockTrace.mockResolvedValue('result'); + + const wrapped = wrapWithLocalPerfTrace(mockTrace); + const fn = jest.fn().mockReturnValue('result'); + const result = await wrapped(request, fn); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockTrace).toHaveBeenCalledWith(request, fn); + expect(result).toBe('result'); + expect(performance.now).not.toHaveBeenCalled(); + }); + + it('calls trace and measures timing when perf is enabled', async () => { + mockProjectLogger.enabled = true; + jest + .mocked(performance.now) + .mockReturnValueOnce(0) + .mockReturnValueOnce(100); + mockTrace.mockResolvedValue('result'); + + const wrapped = wrapWithLocalPerfTrace(mockTrace); + const fn = jest.fn().mockReturnValue('result'); + const result = await wrapped(request, fn); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockTrace).toHaveBeenCalledWith(request, fn); + expect(result).toBe('result'); + expect(performance.now).toHaveBeenCalledTimes(2); + }); + + it('still calls tock when trace throws', async () => { + mockProjectLogger.enabled = true; + jest + .mocked(performance.now) + .mockReturnValueOnce(0) + .mockReturnValueOnce(50); + const error = new Error('trace failed'); + mockTrace.mockRejectedValue(error); + + const wrapped = wrapWithLocalPerfTrace(mockTrace); + + await expect(wrapped(request, jest.fn())).rejects.toThrow(error); + // performance.now called once for tick (start) and once for tock (end) + expect(performance.now).toHaveBeenCalledTimes(2); + }); + + it('works without a fn argument', async () => { + mockProjectLogger.enabled = false; + mockTrace.mockResolvedValue(undefined); + + const wrapped = wrapWithLocalPerfTrace(mockTrace); + await wrapped(request); + + expect(mockTrace).toHaveBeenCalledWith(request, undefined); + }); + }); +}); diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts new file mode 100644 index 00000000000..36169fba398 --- /dev/null +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -0,0 +1,72 @@ +import { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; + +import { createModuleLogger, projectLogger } from '../logger'; + +const log = createModuleLogger(projectLogger, 'perf'); + +/** + * Returns true when DEBUG=metamask:multichain-account-service (or a matching glob) is set. + * Re-uses the same enable/disable logic as the rest of the package loggers. + * + * @returns True if performance logging is enabled, false otherwise. + */ +export function isPerfEnabled(): boolean { + return projectLogger.enabled; +} + +/** + * Starts a local performance timer. Returns a `tock` function that, when called, + * logs the elapsed time for `label`. + * + * @example + * ```ts + * const tock = tick(request); + * await createAccounts(...); + * tock(); // logs: "${request.name}: 123.45ms" + * ``` + * + * @param request - A trace request object containing the name and optional data. + * @returns A function that, when called, logs the elapsed time since `tick` was called. + */ +export function tick(request: TraceRequest): () => void { + if (!isPerfEnabled()) { + return () => undefined; + } + + const start = performance.now(); + return function tock(): void { + const duration = performance.now() - start; + + const context = request.data ? ` (${JSON.stringify(request.data)})` : ''; + + log(`${request.name}${context}: ${duration.toFixed(2)}ms`); + }; +} + +/** + * Wraps a trace callback with local performance logging. + * + * @param trace - The original trace callback to wrap. + * @returns A new trace callback that logs the duration of the traced operation. + */ +export function wrapWithLocalPerfTrace(trace: TraceCallback): TraceCallback { + return async ( + request: TraceRequest, + fn?: (context?: TraceContext) => ReturnType, + ): Promise => { + if (!isPerfEnabled()) { + return await trace(request, fn); + } + + const tock = tick(request); + try { + return await trace(request, fn); + } finally { + tock(); + } + }; +} From ad1685f4aafd5d3c28824f53b3c2391cb21b62ca Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:06:16 +0100 Subject: [PATCH 02/25] refactor: move TraceName --- .../src/analytics/traces.test.ts | 12 +----------- .../src/analytics/traces.ts | 8 ++++++++ .../src/constants/traces.ts | 4 ---- .../src/providers/BtcAccountProvider.test.ts | 2 +- .../src/providers/BtcAccountProvider.ts | 2 +- .../src/providers/EvmAccountProvider.test.ts | 2 +- .../src/providers/EvmAccountProvider.ts | 2 +- .../src/providers/SolAccountProvider.test.ts | 2 +- .../src/providers/SolAccountProvider.ts | 2 +- .../src/providers/TrxAccountProvider.test.ts | 2 +- .../src/providers/TrxAccountProvider.ts | 2 +- 11 files changed, 17 insertions(+), 23 deletions(-) delete mode 100644 packages/multichain-account-service/src/constants/traces.ts diff --git a/packages/multichain-account-service/src/analytics/traces.test.ts b/packages/multichain-account-service/src/analytics/traces.test.ts index 9f97d94bb0d..a7333bfbeb3 100644 --- a/packages/multichain-account-service/src/analytics/traces.test.ts +++ b/packages/multichain-account-service/src/analytics/traces.test.ts @@ -1,18 +1,8 @@ import type { TraceRequest } from '@metamask/controller-utils'; -import { traceFallback } from './traces'; -import { TraceName } from '../constants/traces'; +import { traceFallback, TraceName } from './traces'; describe('MultichainAccountService - Traces', () => { - describe('TraceName', () => { - it('contains expected trace names', () => { - expect(TraceName).toStrictEqual({ - SnapDiscoverAccounts: 'Snap Discover Accounts', - EvmDiscoverAccounts: 'EVM Discover Accounts', - }); - }); - }); - describe('traceFallback', () => { let mockTraceRequest: TraceRequest; diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts index 57eee11c914..34d69899cf5 100644 --- a/packages/multichain-account-service/src/analytics/traces.ts +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -23,3 +23,11 @@ export const traceFallback: TraceCallback = async ( } return await Promise.resolve(fn()); }; + +/** + * Trace names. + */ +export enum TraceName { + SnapDiscoverAccounts = 'Snap Discover Accounts', + EvmDiscoverAccounts = 'EVM Discover Accounts', +} diff --git a/packages/multichain-account-service/src/constants/traces.ts b/packages/multichain-account-service/src/constants/traces.ts deleted file mode 100644 index 59d74c0a9da..00000000000 --- a/packages/multichain-account-service/src/constants/traces.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum TraceName { - 'SnapDiscoverAccounts' = 'Snap Discover Accounts', - 'EvmDiscoverAccounts' = 'EVM Discover Accounts', -} diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts index 3c723637b36..74d5ff53ecd 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -16,7 +16,7 @@ import { BtcAccountProvider, } from './BtcAccountProvider'; import type { SnapAccountProviderConfig } from './SnapAccountProvider'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import { getMultichainAccountServiceMessenger, getRootMessenger, diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts index 0c6f1bdb59b..d1dc7b3ab69 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts @@ -20,7 +20,7 @@ import type { } from './SnapAccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import type { MultichainAccountServiceMessenger } from '../types'; export type BtcAccountProviderConfig = SnapAccountProviderConfig; diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts index bd347c5ffe6..5ebaec2599d 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts @@ -21,7 +21,7 @@ import { EvmAccountProviderConfig, } from './EvmAccountProvider'; import { TimeoutError } from './utils'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import { getMultichainAccountServiceMessenger, getRootMessenger, diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts index c247ae812d9..c0ca6f9bc56 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -32,7 +32,7 @@ import { } from './BaseBip44AccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import { projectLogger as log, WARNING_PREFIX } from '../logger'; import type { MultichainAccountServiceMessenger } from '../types'; diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index 0fc981b77b5..8038bd2aef4 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -16,7 +16,7 @@ import { SOL_ACCOUNT_PROVIDER_NAME, SolAccountProvider, } from './SolAccountProvider'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import { getMultichainAccountServiceMessenger, getRootMessenger, diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts index 5c9cc2f9502..abd2c466970 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -23,7 +23,7 @@ import type { } from './SnapAccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import type { MultichainAccountServiceMessenger } from '../types'; export type SolAccountProviderConfig = SnapAccountProviderConfig; diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts index f76cb4b7bc4..f970680fa16 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -16,7 +16,7 @@ import { TRX_ACCOUNT_PROVIDER_NAME, TrxAccountProvider, } from './TrxAccountProvider'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import { getMultichainAccountServiceMessenger, getRootMessenger, diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts index 09bb364ccf9..51d60ba3140 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts @@ -21,7 +21,7 @@ import type { } from './SnapAccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; -import { TraceName } from '../constants/traces'; +import { TraceName } from '../analytics/traces'; import type { MultichainAccountServiceMessenger } from '../types'; export type TrxAccountProviderConfig = SnapAccountProviderConfig; From e79f703c84e6f8dddbbfd80f2b6c5f1b8889b717 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:06:44 +0100 Subject: [PATCH 03/25] test: rework Solana tracing tests --- .../src/providers/SolAccountProvider.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index 8038bd2aef4..f9c5716f872 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -191,11 +191,7 @@ function setup({ keyring.accounts.find((account) => account.address === address), ); - const mockTrace = jest.fn().mockImplementation(async (request, fn) => { - expect(request.name).toBe(TraceName.SnapDiscoverAccounts); - expect(request.data).toStrictEqual({ - provider: SOL_ACCOUNT_PROVIDER_NAME, - }); + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { return await fn(); }); @@ -673,7 +669,13 @@ describe('SolAccountProvider', () => { }); expect(discovered).toHaveLength(1); - expect(mocks.trace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.SnapDiscoverAccounts, + data: { provider: SOL_ACCOUNT_PROVIDER_NAME }, + }), + expect.any(Function), + ); }); it('uses fallback trace when no trace callback is provided', async () => { From ddf487be1376e44f96faaa82122fb6d8a7618c5e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:07:36 +0100 Subject: [PATCH 04/25] feat: wrap trace in service if perf is enabled --- .../src/MultichainAccountService.test.ts | 95 +++++++++++++++++++ .../src/MultichainAccountService.ts | 19 +++- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 47b540dc0ae..e319bee714b 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -12,6 +12,8 @@ import { import type { KeyringObject } from '@metamask/keyring-controller'; import type { EthKeyring } from '@metamask/keyring-internal-api'; +import { traceFallback } from './analytics'; +import { isPerfEnabled, wrapWithLocalPerfTrace } from './analytics/perf'; import type { MultichainAccountServiceOptions } from './MultichainAccountService'; import { MultichainAccountService } from './MultichainAccountService'; import type { Bip44AccountProvider } from './providers'; @@ -46,6 +48,12 @@ import { } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; +// Mock perf helpers so tests can control isPerfEnabled() without setting DEBUG env var. +jest.mock('./analytics/perf', () => ({ + isPerfEnabled: jest.fn().mockReturnValue(false), + wrapWithLocalPerfTrace: jest.fn((trace) => trace), +})); + // Mock providers. jest.mock('./providers/EvmAccountProvider', () => { return { @@ -134,11 +142,13 @@ async function setup({ keyrings = [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], accounts, providerConfigs, + config, }: { rootMessenger?: RootMessenger; keyrings?: KeyringObject[]; accounts?: KeyringAccount[]; providerConfigs?: MultichainAccountServiceOptions['providerConfigs']; + config?: MultichainAccountServiceOptions['config']; } = {}): Promise<{ service: MultichainAccountService; rootMessenger: RootMessenger; @@ -262,6 +272,7 @@ async function setup({ const service = new MultichainAccountService({ messenger, providerConfigs, + config, }); await service.init(); @@ -319,6 +330,90 @@ describe('MultichainAccountService', () => { ); }); + it('passes traceFallback to providers when no config.trace is provided and perf is disabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(false); + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + }); + + expect(wrapWithLocalPerfTrace).not.toHaveBeenCalled(); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + traceFallback, + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + traceFallback, + ); + }); + + it('passes config.trace to providers when provided and perf is disabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(false); + const customTrace = jest.fn(); + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + config: { trace: customTrace }, + }); + + expect(wrapWithLocalPerfTrace).not.toHaveBeenCalled(); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + customTrace, + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + customTrace, + ); + }); + + it('wraps trace with local perf trace and passes it to providers when perf is enabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(true); + const wrappedTrace = jest.fn(); + jest.mocked(wrapWithLocalPerfTrace).mockReturnValue(wrappedTrace); + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + }); + + expect(wrapWithLocalPerfTrace).toHaveBeenCalledTimes(1); + expect(wrapWithLocalPerfTrace).toHaveBeenCalledWith(traceFallback); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + wrappedTrace, + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + wrappedTrace, + ); + }); + + it('wraps config.trace with local perf trace when perf is enabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(true); + const customTrace = jest.fn(); + const wrappedTrace = jest.fn(); + jest.mocked(wrapWithLocalPerfTrace).mockReturnValue(wrappedTrace); + + const { mocks } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + config: { trace: customTrace }, + }); + + expect(wrapWithLocalPerfTrace).toHaveBeenCalledWith(customTrace); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + expect.anything(), + undefined, + wrappedTrace, + ); + }); + it('allows optional configs for some providers', async () => { const providerConfigs: MultichainAccountServiceOptions['providerConfigs'] = { diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 874a9cdf8a0..a81708dd52b 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -6,6 +6,9 @@ import type { MultichainAccountWalletId, Bip44Account, } from '@metamask/account-api'; +import { + TraceCallback, +} from '@metamask/controller-utils'; import type { HdKeyring } from '@metamask/eth-hd-keyring'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; @@ -13,6 +16,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { areUint8ArraysEqual, assert } from '@metamask/utils'; import { traceFallback } from './analytics'; +import { isPerfEnabled, wrapWithLocalPerfTrace } from './analytics/perf'; import { projectLogger as log } from './logger'; import type { MultichainAccountGroup } from './MultichainAccountGroup'; import { MultichainAccountWallet } from './MultichainAccountWallet'; @@ -153,23 +157,28 @@ export class MultichainAccountService { this.#messenger = messenger; this.#wallets = new Map(); - // Pass trace callback directly to preserve original 'this' context - // This avoids binding the callback to the MultichainAccountService instance - const traceCallback = config?.trace ?? traceFallback; + // Pass trace callback directly to preserve original 'this' context. + // This avoids binding the callback to the MultichainAccountService instance. + let trace: TraceCallback = config?.trace ?? traceFallback; + + // Wrap the trace callback with local performance tracing if performance logging is enabled. + if (isPerfEnabled()) { + trace = wrapWithLocalPerfTrace(trace); + } // TODO: Rely on keyring capabilities once the keyring API is used by all keyrings. this.#providers = [ new EvmAccountProvider( this.#messenger, providerConfigs?.[EVM_ACCOUNT_PROVIDER_NAME], - traceCallback, + trace, ), new AccountProviderWrapper( this.#messenger, new SolAccountProvider( this.#messenger, providerConfigs?.[SOL_ACCOUNT_PROVIDER_NAME], - traceCallback, + trace, ), ), // Custom account providers that can be provided by the MetaMask client. From 4e3c2c0c0a8475b0b1a551a1fa8580bc2fcf3e50 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:12:08 +0100 Subject: [PATCH 05/25] chore: lint + add package controller-utils --- packages/multichain-account-service/package.json | 1 + .../src/MultichainAccountService.ts | 4 +--- .../multichain-account-service/src/analytics/perf.test.ts | 2 +- packages/multichain-account-service/src/analytics/perf.ts | 2 +- yarn.lock | 1 + 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index 5d6af4dc0b7..91ab243b341 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -70,6 +70,7 @@ "devDependencies": { "@metamask/account-api": "^1.0.0", "@metamask/auto-changelog": "^3.4.4", + "@metamask/controller-utils": "^11.19.0", "@metamask/eth-hd-keyring": "^13.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index a81708dd52b..98e6a7685b4 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -6,9 +6,7 @@ import type { MultichainAccountWalletId, Bip44Account, } from '@metamask/account-api'; -import { - TraceCallback, -} from '@metamask/controller-utils'; +import type { TraceCallback } from '@metamask/controller-utils'; import type { HdKeyring } from '@metamask/eth-hd-keyring'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts index 6a148715da6..fc81ac9aa0a 100644 --- a/packages/multichain-account-service/src/analytics/perf.test.ts +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -1,7 +1,7 @@ import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; -import { projectLogger } from '../logger'; import { isPerfEnabled, tick, wrapWithLocalPerfTrace } from './perf'; +import { projectLogger } from '../logger'; jest.mock('../logger', () => ({ projectLogger: { enabled: false }, diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts index 36169fba398..84bb7b1efb5 100644 --- a/packages/multichain-account-service/src/analytics/perf.ts +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -1,4 +1,4 @@ -import { +import type { TraceCallback, TraceContext, TraceRequest, diff --git a/yarn.lock b/yarn.lock index 1fbef88f858..82fac26236a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4343,6 +4343,7 @@ __metadata: "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.19.0" "@metamask/eth-hd-keyring": "npm:^13.0.0" "@metamask/eth-snap-keyring": "npm:^19.0.0" "@metamask/key-tree": "npm:^10.1.1" From b2c5d62774ce623a9d65123facc16611ea6aaba9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:21:48 +0100 Subject: [PATCH 06/25] refactor: make withTimeout use a callback --- .../src/providers/BtcAccountProvider.ts | 11 ++++++----- .../src/providers/EvmAccountProvider.ts | 9 +++++---- .../src/providers/SnapAccountProvider.ts | 9 +++++---- .../src/providers/SolAccountProvider.ts | 11 ++++++----- .../src/providers/TrxAccountProvider.ts | 11 ++++++----- .../src/providers/utils.test.ts | 11 ++++++----- .../multichain-account-service/src/providers/utils.ts | 6 +++--- 7 files changed, 37 insertions(+), 31 deletions(-) diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts index d1dc7b3ab69..b8d1b95506e 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts @@ -114,11 +114,12 @@ export class BtcAccountProvider extends SnapAccountProvider { const discoveredAccounts = await withRetry( () => withTimeout( - client.discoverAccounts( - [BtcScope.Mainnet], - entropySource, - groupIndex, - ), + () => + client.discoverAccounts( + [BtcScope.Mainnet], + entropySource, + groupIndex, + ), this.config.discovery.timeoutMs, ), { diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts index c0ca6f9bc56..b0a68945fb5 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -302,10 +302,11 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { const response = await withRetry( () => withTimeout( - provider.request({ - method, - params: [address, 'latest'], - }), + () => + provider.request({ + method, + params: [address, 'latest'], + }), this.#config.discovery.timeoutMs, ), { diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index 58a9e2e4407..52e06281071 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -348,7 +348,7 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { if (batched) { // Batch account creations. snapAccounts = await withTimeout( - keyring.createAccounts(options), + () => keyring.createAccounts(options), this.config.createAccounts.timeoutMs, ); } else { @@ -361,7 +361,8 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { groupIndex++ ) { const snapAccount = await withTimeout( - this.createAccountV1(keyring, { entropySource, groupIndex }), + () => + this.createAccountV1(keyring, { entropySource, groupIndex }), this.config.createAccounts.timeoutMs, ); @@ -375,7 +376,7 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { if (batched) { // Create account using new v2-like flow (no async flow + no Snap keyring events). snapAccounts = await withTimeout( - keyring.createAccounts(options), + () => keyring.createAccounts(options), this.config.createAccounts.timeoutMs, ); } else { @@ -383,7 +384,7 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { // Create account using the existing v1 flow. const snapAccount = await withTimeout( - this.createAccountV1(keyring, { entropySource, groupIndex }), + () => this.createAccountV1(keyring, { entropySource, groupIndex }), this.config.createAccounts.timeoutMs, ); diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts index abd2c466970..cea67f8cd4c 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -139,11 +139,12 @@ export class SolAccountProvider extends SnapAccountProvider { const discoveredAccounts = await withRetry( () => withTimeout( - client.discoverAccounts( - [SolScope.Mainnet], - entropySource, - groupIndex, - ), + () => + client.discoverAccounts( + [SolScope.Mainnet], + entropySource, + groupIndex, + ), this.config.discovery.timeoutMs, ), { diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts index 51d60ba3140..d95776266a0 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts @@ -115,11 +115,12 @@ export class TrxAccountProvider extends SnapAccountProvider { const discoveredAccounts = await withRetry( () => withTimeout( - client.discoverAccounts( - [TrxScope.Mainnet], - entropySource, - groupIndex, - ), + () => + client.discoverAccounts( + [TrxScope.Mainnet], + entropySource, + groupIndex, + ), this.config.discovery.timeoutMs, ), { diff --git a/packages/multichain-account-service/src/providers/utils.test.ts b/packages/multichain-account-service/src/providers/utils.test.ts index 1108ce96d77..b68c19d7599 100644 --- a/packages/multichain-account-service/src/providers/utils.test.ts +++ b/packages/multichain-account-service/src/providers/utils.test.ts @@ -25,11 +25,12 @@ describe('utils', () => { it('throws if the RPC request times out', async () => { await expect( withTimeout( - new Promise((resolve) => { - setTimeout(() => { - resolve(null); - }, 600); - }), + () => + new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 600); + }), ), ).rejects.toThrow(TimeoutError); }); diff --git a/packages/multichain-account-service/src/providers/utils.ts b/packages/multichain-account-service/src/providers/utils.ts index 8671da48b8f..2ab88405059 100644 --- a/packages/multichain-account-service/src/providers/utils.ts +++ b/packages/multichain-account-service/src/providers/utils.ts @@ -44,18 +44,18 @@ export async function withRetry( /** * Execute a promise with a timeout. * - * @param promise - The promise to execute. + * @param fn - A callback that returns the promise to execute. * @param timeoutMs - The timeout in milliseconds. * @returns The result of the promise. */ export async function withTimeout( - promise: Promise, + fn: () => Promise, timeoutMs: number = 500, ): Promise { let timer; try { return await Promise.race([ - promise, + fn(), new Promise((_resolve, reject) => { timer = setTimeout( () => reject(new TimeoutError('Timed out')), From ee36b1133be447c07360f43574fc26d260314c7b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:37:53 +0100 Subject: [PATCH 07/25] test: rework Bitcoin tracing + provider tests --- .../src/providers/BtcAccountProvider.test.ts | 87 +++++-------------- 1 file changed, 23 insertions(+), 64 deletions(-) diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts index 74d5ff53ecd..105de0e9580 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -173,6 +173,7 @@ function setup({ createAccount: jest.Mock; createAccounts: jest.Mock; }; + trace: jest.Mock; }; } { const keyring = new MockBtcKeyring(accounts); @@ -221,8 +222,16 @@ function setup({ }), ); + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const btcProvider = new MockBtcAccountProvider(multichainMessenger, config); + const btcProvider = new MockBtcAccountProvider( + multichainMessenger, + config, + mockTrace, + ); const accountIds = accounts.map((account) => account.id); btcProvider.init(accountIds); const provider = new AccountProviderWrapper(multichainMessenger, btcProvider); @@ -237,6 +246,7 @@ function setup({ createAccount: keyring.createAccount as jest.Mock, createAccounts: keyring.createAccounts as jest.Mock, }, + trace: mockTrace, }, }; } @@ -653,40 +663,26 @@ describe('BtcAccountProvider', () => { describe('trace functionality', () => { it('calls trace callback during account discovery', async () => { - const mockTrace = jest.fn().mockImplementation(async (request, fn) => { - expect(request.name).toBe(TraceName.SnapDiscoverAccounts); - expect(request.data).toStrictEqual({ - provider: BTC_ACCOUNT_PROVIDER_NAME, - }); - return await fn(); - }); - - const { messenger, mocks } = setup({ + const { provider, mocks } = setup({ accounts: [], }); // Simulate one discovered account at the requested index. mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); - const multichainMessenger = - getMultichainAccountServiceMessenger(messenger); - const btcProvider = new MockBtcAccountProvider( - multichainMessenger, - undefined, - mockTrace, - ); - const provider = new AccountProviderWrapper( - multichainMessenger, - btcProvider, - ); - const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, }); expect(discovered).toHaveLength(1); - expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.SnapDiscoverAccounts, + data: { provider: BTC_ACCOUNT_PROVIDER_NAME }, + }), + expect.any(Function), + ); }); it('uses fallback trace when no trace callback is provided', async () => { @@ -702,69 +698,32 @@ describe('BtcAccountProvider', () => { }); expect(discovered).toHaveLength(1); - // No trace errors, fallback trace should be used silently }); it('trace callback is called even when discovery returns empty results', async () => { - const mockTrace = jest.fn().mockImplementation(async (request, fn) => { - expect(request.name).toBe(TraceName.SnapDiscoverAccounts); - expect(request.data).toStrictEqual({ - provider: BTC_ACCOUNT_PROVIDER_NAME, - }); - return await fn(); - }); - - const { messenger, mocks } = setup({ + const { provider, mocks } = setup({ accounts: [], }); mocks.handleRequest.mockReturnValue([]); - const multichainMessenger = - getMultichainAccountServiceMessenger(messenger); - const btcProvider = new MockBtcAccountProvider( - multichainMessenger, - undefined, - mockTrace, - ); - const provider = new AccountProviderWrapper( - multichainMessenger, - btcProvider, - ); - const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, }); expect(discovered).toStrictEqual([]); - expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledTimes(1); }); it('trace callback receives error when discovery fails', async () => { const mockError = new Error('Discovery failed'); - const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { - return await fn(); - }); - - const { messenger, mocks } = setup({ + const { provider, mocks } = setup({ accounts: [], }); mocks.handleRequest.mockRejectedValue(mockError); - const multichainMessenger = - getMultichainAccountServiceMessenger(messenger); - const btcProvider = new MockBtcAccountProvider( - multichainMessenger, - undefined, - mockTrace, - ); - const provider = new AccountProviderWrapper( - multichainMessenger, - btcProvider, - ); - await expect( provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, @@ -772,7 +731,7 @@ describe('BtcAccountProvider', () => { }), ).rejects.toThrow(mockError); - expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledTimes(1); }); }); From 67626ce342cf05dbc7bf92954fe241ff78cc1ea5 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:38:05 +0100 Subject: [PATCH 08/25] test: rework Tron tracing + provider tests --- .../src/providers/TrxAccountProvider.test.ts | 87 +++++-------------- 1 file changed, 23 insertions(+), 64 deletions(-) diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts index f970680fa16..c495c0e87ff 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -147,6 +147,7 @@ function setup({ createAccounts: jest.Mock; discoverAccounts: jest.Mock; }; + trace: jest.Mock; }; } { const keyring = new MockTronKeyring(accounts); @@ -201,8 +202,16 @@ function setup({ }), ); + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + const multichainMessenger = getMultichainAccountServiceMessenger(messenger); - const trxProvider = new MockTrxAccountProvider(multichainMessenger, config); + const trxProvider = new MockTrxAccountProvider( + multichainMessenger, + config, + mockTrace, + ); const accountIds = accounts.map((account) => account.id); trxProvider.init(accountIds); const provider = new AccountProviderWrapper(multichainMessenger, trxProvider); @@ -218,6 +227,7 @@ function setup({ createAccounts: keyring.createAccounts as jest.Mock, discoverAccounts: keyring.discoverAccounts, }, + trace: mockTrace, }, }; } @@ -638,15 +648,7 @@ describe('TrxAccountProvider', () => { describe('trace functionality', () => { it('calls trace callback during account discovery', async () => { - const mockTrace = jest.fn().mockImplementation(async (request, fn) => { - expect(request.name).toBe(TraceName.SnapDiscoverAccounts); - expect(request.data).toStrictEqual({ - provider: TRX_ACCOUNT_PROVIDER_NAME, - }); - return await fn(); - }); - - const { messenger, mocks } = setup({ + const { provider, mocks } = setup({ accounts: [], }); @@ -655,25 +657,19 @@ describe('TrxAccountProvider', () => { MOCK_TRX_DISCOVERED_ACCOUNT_1, ]); - const multichainMessenger = - getMultichainAccountServiceMessenger(messenger); - const trxProvider = new MockTrxAccountProvider( - multichainMessenger, - undefined, - mockTrace, - ); - const provider = new AccountProviderWrapper( - multichainMessenger, - trxProvider, - ); - const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, }); expect(discovered).toHaveLength(1); - expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.SnapDiscoverAccounts, + data: { provider: TRX_ACCOUNT_PROVIDER_NAME }, + }), + expect.any(Function), + ); }); it('uses fallback trace when no trace callback is provided', async () => { @@ -691,69 +687,32 @@ describe('TrxAccountProvider', () => { }); expect(discovered).toHaveLength(1); - // No trace errors, fallback trace should be used silently }); it('trace callback is called even when discovery returns empty results', async () => { - const mockTrace = jest.fn().mockImplementation(async (request, fn) => { - expect(request.name).toBe(TraceName.SnapDiscoverAccounts); - expect(request.data).toStrictEqual({ - provider: TRX_ACCOUNT_PROVIDER_NAME, - }); - return await fn(); - }); - - const { messenger, mocks } = setup({ + const { provider, mocks } = setup({ accounts: [], }); mocks.keyring.discoverAccounts.mockResolvedValue([]); - const multichainMessenger = - getMultichainAccountServiceMessenger(messenger); - const trxProvider = new MockTrxAccountProvider( - multichainMessenger, - undefined, - mockTrace, - ); - const provider = new AccountProviderWrapper( - multichainMessenger, - trxProvider, - ); - const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, }); expect(discovered).toStrictEqual([]); - expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledTimes(1); }); it('trace callback receives error when discovery fails', async () => { const mockError = new Error('Discovery failed'); - const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { - return await fn(); - }); - - const { messenger, mocks } = setup({ + const { provider, mocks } = setup({ accounts: [], }); mocks.keyring.discoverAccounts.mockRejectedValue(mockError); - const multichainMessenger = - getMultichainAccountServiceMessenger(messenger); - const trxProvider = new MockTrxAccountProvider( - multichainMessenger, - undefined, - mockTrace, - ); - const provider = new AccountProviderWrapper( - multichainMessenger, - trxProvider, - ); - await expect( provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, @@ -761,7 +720,7 @@ describe('TrxAccountProvider', () => { }), ).rejects.toThrow(mockError); - expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mocks.trace).toHaveBeenCalledTimes(1); }); }); From 09fd2c12074f29d0505745b0d002df63a6cd305f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:50:07 +0100 Subject: [PATCH 09/25] test: fix default traceBack coverage --- .../src/providers/BtcAccountProvider.test.ts | 13 ++++++++++--- .../src/providers/SolAccountProvider.test.ts | 13 ++++++++++--- .../src/providers/TrxAccountProvider.test.ts | 13 ++++++++++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts index 105de0e9580..8edb7197846 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -686,12 +686,19 @@ describe('BtcAccountProvider', () => { }); it('uses fallback trace when no trace callback is provided', async () => { - const { provider, mocks } = setup({ - accounts: [], - }); + const { messenger, mocks } = setup({ accounts: [] }); mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + // No trace callback (defaults to `traceFallback`). + const btcProvider = new MockBtcAccountProvider(multichainMessenger); + const provider = new AccountProviderWrapper( + multichainMessenger, + btcProvider, + ); + const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index f9c5716f872..7445ccda062 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -679,12 +679,19 @@ describe('SolAccountProvider', () => { }); it('uses fallback trace when no trace callback is provided', async () => { - const { provider, mocks } = setup({ - accounts: [], - }); + const { messenger, mocks } = setup({ accounts: [] }); mocks.handleRequest.mockReturnValue([MOCK_SOL_DISCOVERED_ACCOUNT_1]); + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + // No trace callback (defaults to `traceFallback`). + const solProvider = new MockSolAccountProvider(multichainMessenger); + const provider = new AccountProviderWrapper( + multichainMessenger, + solProvider, + ); + const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts index c495c0e87ff..832b843ceb0 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -673,14 +673,21 @@ describe('TrxAccountProvider', () => { }); it('uses fallback trace when no trace callback is provided', async () => { - const { provider, mocks } = setup({ - accounts: [], - }); + const { messenger, mocks } = setup({ accounts: [] }); mocks.keyring.discoverAccounts.mockResolvedValue([ MOCK_TRX_DISCOVERED_ACCOUNT_1, ]); + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + // No trace callback (defaults to `traceFallback`). + const trxProvider = new MockTrxAccountProvider(multichainMessenger); + const provider = new AccountProviderWrapper( + multichainMessenger, + trxProvider, + ); + const discovered = await provider.discoverAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0, From fd669ea61ed3bc2a6b8135de883a8b4d1ffa650a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 18 Mar 2026 17:50:28 +0100 Subject: [PATCH 10/25] feat: add tracing for v1/v2 createAccount(s) calls --- .../src/analytics/traces.ts | 2 + .../src/providers/SnapAccountProvider.ts | 58 +++++++++++++------ .../src/providers/SolAccountProvider.ts | 21 +++++-- 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts index 34d69899cf5..e5176db2866 100644 --- a/packages/multichain-account-service/src/analytics/traces.ts +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -30,4 +30,6 @@ export const traceFallback: TraceCallback = async ( export enum TraceName { SnapDiscoverAccounts = 'Snap Discover Accounts', EvmDiscoverAccounts = 'EVM Discover Accounts', + ProviderCreateAccountV1 = 'Provider Create Account (v1)', + ProviderCreateAccounts = 'Provider Create Accounts (v2 - batched)', } diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index 52e06281071..bb050476b34 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -23,7 +23,7 @@ import { Semaphore } from 'async-mutex'; import { BaseBip44AccountProvider } from './BaseBip44AccountProvider'; import { withTimeout } from './utils'; -import { traceFallback } from '../analytics'; +import { traceFallback, TraceName } from '../analytics'; import { projectLogger as log, WARNING_PREFIX } from '../logger'; import type { MultichainAccountServiceMessenger } from '../types'; import { createSentryError } from '../utils'; @@ -344,13 +344,45 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { const batched = this.config.createAccounts.batched ?? false; const { entropySource } = options; + const createAccountV1 = async ( + groupIndex: number, + ): Promise => + await withTimeout( + () => + this.trace( + { + name: TraceName.ProviderCreateAccountV1, + data: { + provider: this.getName(), + groupIndex, + }, + }, + () => + this.createAccountV1(keyring, { entropySource, groupIndex }), + ), + this.config.createAccounts.timeoutMs, + ); + const createAccountsV2 = async ( + optionsV2: + | CreateAccountBip44DeriveIndexOptions + | CreateAccountBip44DeriveIndexRangeOptions, + ): Promise => + await withTimeout( + () => + this.trace( + { + name: TraceName.ProviderCreateAccounts, + data: { provider: this.getName() }, + }, + () => keyring.createAccounts(optionsV2), + ), + this.config.createAccounts.timeoutMs, + ); + if (options.type === `${AccountCreationType.Bip44DeriveIndexRange}`) { if (batched) { // Batch account creations. - snapAccounts = await withTimeout( - () => keyring.createAccounts(options), - this.config.createAccounts.timeoutMs, - ); + snapAccounts = await createAccountsV2(options); } else { const { range } = options; @@ -360,11 +392,7 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { groupIndex <= range.to; groupIndex++ ) { - const snapAccount = await withTimeout( - () => - this.createAccountV1(keyring, { entropySource, groupIndex }), - this.config.createAccounts.timeoutMs, - ); + const snapAccount = await createAccountV1(groupIndex); snapAccounts.push(snapAccount); } @@ -375,18 +403,12 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { } else { if (batched) { // Create account using new v2-like flow (no async flow + no Snap keyring events). - snapAccounts = await withTimeout( - () => keyring.createAccounts(options), - this.config.createAccounts.timeoutMs, - ); + snapAccounts = await createAccountsV2(options); } else { const { groupIndex } = options; // Create account using the existing v1 flow. - const snapAccount = await withTimeout( - () => this.createAccountV1(keyring, { entropySource, groupIndex }), - this.config.createAccounts.timeoutMs, - ); + const snapAccount = await createAccountV1(groupIndex); snapAccounts = [snapAccount]; } diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts index cea67f8cd4c..c08fcf57d57 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -83,17 +83,28 @@ export class SolAccountProvider extends SnapAccountProvider { return `m/44'/501'/${groupIndex}'/0'`; } - protected override createAccountV1( + protected override async createAccountV1( keyring: RestrictedSnapKeyring, { entropySource, groupIndex, }: { entropySource: EntropySourceId; groupIndex: number }, ): Promise { - return keyring.createAccount({ - entropySource, - derivationPath: this.#getDerivationPath(groupIndex), - }); + return await super.trace( + { + name: TraceName.ProviderCreateAccountV1, + data: { + provider: this.getName(), + groupIndex, + }, + }, + async () => { + return keyring.createAccount({ + entropySource, + derivationPath: this.#getDerivationPath(groupIndex), + }); + }, + ); } protected override toBip44Account( From 57dceb4a915c1f1f62bbe54a69d28ca66f77c169 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 10:13:36 +0100 Subject: [PATCH 11/25] feat: add more tracing --- .../src/MultichainAccountService.ts | 6 + .../src/MultichainAccountWallet.test.ts | 19 +- .../src/MultichainAccountWallet.ts | 192 ++++++++++++++---- .../src/analytics/traces.ts | 24 +++ 4 files changed, 196 insertions(+), 45 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 98e6a7685b4..4500f3dd572 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -123,6 +123,8 @@ export class MultichainAccountService { readonly #providers: Bip44AccountProvider[]; + readonly #trace: TraceCallback; + readonly #wallets: Map< MultichainAccountWalletId, MultichainAccountWallet> @@ -164,6 +166,9 @@ export class MultichainAccountService { trace = wrapWithLocalPerfTrace(trace); } + // This trace is passed down to wallets and providers to be used for tracing operations within them. + this.#trace = trace; + // TODO: Rely on keyring capabilities once the keyring API is used by all keyrings. this.#providers = [ new EvmAccountProvider( @@ -270,6 +275,7 @@ export class MultichainAccountService { entropySource, providers: this.#providers, messenger: this.#messenger, + trace: this.#trace, }); wallet.init(serviceState[entropySource]); this.#wallets.set(wallet.id, wallet); diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts index ff103d4446e..b6afb021cec 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts @@ -14,6 +14,7 @@ import { AccountCreationType, } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { createDeferredPromise } from '@metamask/utils'; import type { WalletState } from './MultichainAccountWallet'; import { MultichainAccountWallet } from './MultichainAccountWallet'; @@ -519,11 +520,21 @@ describe('MultichainAccountWallet', () => { ]; evmProvider.createAccounts.mockResolvedValueOnce(evmAccounts); - jest.spyOn(wallet, 'alignAccounts').mockResolvedValue(undefined); + // We use a non-resolving mock for SOL provider because we want to verify + // that it's not called during group creation, but rather during the deferred alignment. + const { + promise: mockSolCreateAccountsPromise, + resolve: mockSolCreateAccountsResolve, + } = createDeferredPromise(); + jest + .spyOn(solProvider, 'createAccounts') + .mockImplementationOnce(() => mockSolCreateAccountsPromise); // With wait=false (default), only EVM accounts are created immediately. const groups = await wallet.createMultichainAccountGroups({ to: 2 }); + // At this point, only EVM provider should have been called to create accounts for groups 1 and 2, but + // the SOL provider is has been scheduled, so it shouldn't block. expect(groups).toHaveLength(3); expect(groups[0].groupIndex).toBe(0); // Existing group. expect(groups[1].groupIndex).toBe(1); // New group. @@ -531,7 +542,9 @@ describe('MultichainAccountWallet', () => { expect(wallet.getAccountGroups()).toHaveLength(3); // SOL provider is not called during group creation; it's deferred to alignment. - expect(solProvider.createAccounts).not.toHaveBeenCalled(); + mockSolCreateAccountsResolve(); + await mockSolCreateAccountsPromise; + expect(solProvider.createAccounts).toHaveBeenCalled(); }); it('returns all existing groups when maxGroupIndex is less than nextGroupIndex', async () => { @@ -552,8 +565,6 @@ describe('MultichainAccountWallet', () => { accounts: [[mockEvmAccount0, mockEvmAccount1, mockEvmAccount2]], }); - jest.spyOn(wallet, 'alignAccounts').mockResolvedValue(undefined); - // Request groups 0-1 when groups 0-2 exist. const groups = await wallet.createMultichainAccountGroups({ to: 1 }); diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index d726874c7d7..7765fa651a5 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -12,11 +12,13 @@ import { toDefaultAccountGroupId, toMultichainAccountWalletId, } from '@metamask/account-api'; +import { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import { AccountCreationType } from '@metamask/keyring-api'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { assert } from '@metamask/utils'; import { Mutex } from 'async-mutex'; +import { toProviderDataTraces, traceFallback, TraceName } from './analytics'; import type { Logger } from './logger'; import { createModuleLogger, @@ -75,6 +77,8 @@ export class MultichainAccountWallet< readonly #messenger: MultichainAccountServiceMessenger; + readonly #trace: TraceCallback; + readonly #log: Logger; #initialized = false; @@ -85,16 +89,19 @@ export class MultichainAccountWallet< providers, entropySource, messenger, + trace, }: { providers: Bip44AccountProvider[]; entropySource: EntropySourceId; messenger: MultichainAccountServiceMessenger; + trace?: TraceCallback; }) { this.#id = toMultichainAccountWalletId(entropySource); this.#providers = providers; this.#entropySource = entropySource; this.#messenger = messenger; this.#accountGroups = new Map(); + this.#trace = trace ?? traceFallback; this.#log = createModuleLogger(log, `[${this.#id}]`); @@ -447,29 +454,46 @@ export class MultichainAccountWallet< * @param range.from - Starting group index (inclusive). * @param range.to - Ending group index (inclusive). * @param providers - The providers to align accounts for. + * @param options - Options. + * @param options.trace - Trace options. + * @param options.trace.data - Optional trace data. */ async #alignAccountsForRange( { from, to }: Required, providers: Bip44AccountProvider[], + options: { trace?: { data?: TraceRequest['data'] } } = {}, ): Promise { - const { groupStateByGroupIndex, failures } = - await this.#buildGroupStateForRange(from, to, providers); + await this.#trace( + { + name: TraceName.WalletAlignment, + data: { + from, + to, + ...toProviderDataTraces(providers), + ...options.trace?.data, + }, + }, + async () => { + const { groupStateByGroupIndex, failures } = + await this.#buildGroupStateForRange(from, to, providers); - if (failures.length) { - const error = failures.reduce( - (message, failure) => `${message}\n- ${failure}`, - 'Unable to align some accounts. Providers threw the following errors:', - ); - console.warn(error); - this.#log(`${WARNING_PREFIX} ${error}`); - } + if (failures.length) { + const error = failures.reduce( + (message, failure) => `${message}\n- ${failure}`, + 'Unable to align some accounts. Providers threw the following errors:', + ); + console.warn(error); + this.#log(`${WARNING_PREFIX} ${error}`); + } - for (let groupIndex = from; groupIndex <= to; groupIndex++) { - const groupState = groupStateByGroupIndex.get(groupIndex); - if (groupState) { - this.#createOrUpdateMultichainAccountGroup(groupIndex, groupState); - } - } + for (let groupIndex = from; groupIndex <= to; groupIndex++) { + const groupState = groupStateByGroupIndex.get(groupIndex); + if (groupState) { + this.#createOrUpdateMultichainAccountGroup(groupIndex, groupState); + } + } + }, + ); } /** @@ -561,27 +585,45 @@ export class MultichainAccountWallet< groupIndex: number, options: { waitForAllProvidersToFinishCreatingAccounts?: boolean; - } = { waitForAllProvidersToFinishCreatingAccounts: false }, + } = {}, ): Promise> { - // If the group already exists, return it. - const existingGroup = this.getMultichainAccountGroup(groupIndex); - if (existingGroup) { - this.#log( - `Trying to re-create existing group: [${existingGroup.id}] (idempotent)`, - ); - return existingGroup; - } + // Use this to avoid having it as `boolean | undefined`. + const waitForAllProvidersToFinishCreatingAccounts = + options.waitForAllProvidersToFinishCreatingAccounts ?? false; + + return await this.#trace( + { + name: TraceName.WalletCreateMultichainAccountGroup, + data: { + groupIndex, + waitForAllProvidersToFinishCreatingAccounts, + }, + }, + async () => { + assertGroupIndexIsValid(groupIndex, this.getNextGroupIndex()); + + // If the group already exists, return it. + // If the group already exists, return it. + const existingGroup = this.getMultichainAccountGroup(groupIndex); + if (existingGroup) { + this.#log( + `Trying to re-create existing group: [${existingGroup.id}] (idempotent)`, + ); + return existingGroup; + } - // Create a single group with a range of 1 (so we can reuse the batch creation logic) for the - // given group index. - const groups = await this.createMultichainAccountGroups( - { from: groupIndex, to: groupIndex }, - options, - ); + // Create a single group with a range of 1 (so we can reuse the batch creation logic) for the + // given group index. + const groups = await this.#createMultichainAccountGroups( + { from: groupIndex, to: groupIndex }, + options, + ); - const group = groups[0]; - assert(group, `Expected group at index ${groupIndex} to exist`); - return group; + const group = groups[0]; + assert(group, `Expected group at index ${groupIndex} to exist`); + return group; + }, + ); } /** @@ -604,13 +646,56 @@ export class MultichainAccountWallet< { from = 0, to }: GroupIndexRange, options: { waitForAllProvidersToFinishCreatingAccounts?: boolean; - } = { waitForAllProvidersToFinishCreatingAccounts: false }, + } = {}, + ): Promise[]> { + // Use this to avoid having it as `boolean | undefined`. + const waitForAllProvidersToFinishCreatingAccounts = + options.waitForAllProvidersToFinishCreatingAccounts ?? false; + + return await this.#trace( + { + name: TraceName.WalletCreateMultichainAccountGroups, + data: { + from, + to, + waitForAllProvidersToFinishCreatingAccounts, + }, + }, + async () => + await this.#createMultichainAccountGroups({ from, to }, options), + ); + } + + /** + * Creates multiple multichain account groups up to maxGroupIndex. + * + * NOTE: This operation WILL lock the wallet's mutex. + * + * @param range - The range of group indices to create. + * @param range.from - Starting group index to create (inclusive). + * @param range.to - Maximum group index to create (inclusive). + * @param options - Options to configure the account creation. + * @param options.waitForAllProvidersToFinishCreatingAccounts - Whether to wait for all + * account providers to finish creating their accounts before returning. If `false`, only + * the EVM provider is used and non-EVM account creation is deferred via + * {@link MultichainAccountWallet.alignAccounts}. Defaults to false. + * @throws If range is invalid (e.g. from is greater than to, from or to is negative, etc.). + * @returns Array of created multichain account groups. + */ + async #createMultichainAccountGroups( + { from, to }: Required, + options: { + waitForAllProvidersToFinishCreatingAccounts?: boolean; + }, ): Promise[]> { assertGroupIndexRangeIsValid({ from, to }); assertGroupIndexIsValid(from, this.getNextGroupIndex()); + const waitForAllProvidersToFinishCreatingAccounts = + options.waitForAllProvidersToFinishCreatingAccounts ?? false; + const [evmProvider, ...otherProviders] = this.#getProviders(); - const providers = options.waitForAllProvidersToFinishCreatingAccounts + const providers = waitForAllProvidersToFinishCreatingAccounts ? this.#providers : [evmProvider]; @@ -621,12 +706,18 @@ export class MultichainAccountWallet< // We need to run a post-alignment since non-EVM accounts have not // been created yet. - if (!options.waitForAllProvidersToFinishCreatingAccounts) { + if (!waitForAllProvidersToFinishCreatingAccounts) { const alignOtherAccounts = async (): Promise => { this.#log(`Aligning accounts... (post)`); await this.#withLock('in-progress:alignment', async () => { - await this.#alignAccountsForRange({ from, to }, otherProviders); + await this.#alignAccountsForRange({ from, to }, otherProviders, { + trace: { + data: { + post: true, // Tag to identify post-alignment traces in analytics. + }, + }, + }); }); this.#log('Aligned accounts! (post)'); @@ -670,10 +761,21 @@ export class MultichainAccountWallet< if (nextGroupIndex > 0) { this.#log('Aligning accounts...'); + const from = 0; + const to = nextGroupIndex - 1; + await this.#withLock('in-progress:alignment', async () => { - await this.#alignAccountsForRange( - { from: 0, to: nextGroupIndex - 1 }, - this.#providers, + await this.#trace( + { + name: TraceName.WalletAlignment, + data: { + from, + to, + ...toProviderDataTraces(this.#providers), + }, + }, + async () => + await this.#alignAccountsForRange({ from, to }, this.#providers), ); }); @@ -698,6 +800,7 @@ export class MultichainAccountWallet< await this.#alignAccountsForRange( { from: groupIndex, to: groupIndex }, this.#providers, + { trace: { data: { groupIndex } } }, ); }); @@ -827,6 +930,13 @@ export class MultichainAccountWallet< await this.#alignAccountsForRange( { from: 0, to: nextGroupIndex - 1 }, this.#providers, + { + trace: { + data: { + discovery: true, // Tag to identify discovery-alignment traces in analytics. + }, + }, + }, ); } diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts index e5176db2866..4d054b33696 100644 --- a/packages/multichain-account-service/src/analytics/traces.ts +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -4,6 +4,8 @@ import type { TraceRequest, } from '@metamask/controller-utils'; +import { Bip44AccountProvider } from '../providers'; + /** * Fallback function for tracing. * This function is used when no specific trace function is provided. @@ -24,6 +26,25 @@ export const traceFallback: TraceCallback = async ( return await Promise.resolve(fn()); }; +/** + * Compute trace data for a list of providers. + * + * @param providers Providers to be included in the trace data. + * @returns An object mapping provider names to true, indicating their presence in the trace. + */ +export function toProviderDataTraces( + providers: Bip44AccountProvider[], +): Record { + // We cannot use complex objects within traces, so we just map provider names with true. + return providers.reduce( + (data, provider) => ({ + ...data, + [provider.getName()]: true, + }), + {}, + ); +} + /** * Trace names. */ @@ -32,4 +53,7 @@ export enum TraceName { EvmDiscoverAccounts = 'EVM Discover Accounts', ProviderCreateAccountV1 = 'Provider Create Account (v1)', ProviderCreateAccounts = 'Provider Create Accounts (v2 - batched)', + WalletAlignment = 'Wallet Alignment', + WalletCreateMultichainAccountGroup = 'Wallet Create Multichain Account Group', + WalletCreateMultichainAccountGroups = 'Wallet Create Multichain Account Groups', } From f0db0c124cab9be4e4b7631652e96eaccb14dcd1 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 10:36:25 +0100 Subject: [PATCH 12/25] refactor: wrapWithLocalPerfTrace -> withLocalPerfTrace --- .../src/MultichainAccountService.test.ts | 18 +++++++++--------- .../src/MultichainAccountService.ts | 4 ++-- .../src/analytics/perf.test.ts | 12 ++++++------ .../src/analytics/perf.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index e319bee714b..6124c421feb 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -13,7 +13,7 @@ import type { KeyringObject } from '@metamask/keyring-controller'; import type { EthKeyring } from '@metamask/keyring-internal-api'; import { traceFallback } from './analytics'; -import { isPerfEnabled, wrapWithLocalPerfTrace } from './analytics/perf'; +import { isPerfEnabled, withLocalPerfTrace } from './analytics/perf'; import type { MultichainAccountServiceOptions } from './MultichainAccountService'; import { MultichainAccountService } from './MultichainAccountService'; import type { Bip44AccountProvider } from './providers'; @@ -51,7 +51,7 @@ import type { MultichainAccountServiceMessenger } from './types'; // Mock perf helpers so tests can control isPerfEnabled() without setting DEBUG env var. jest.mock('./analytics/perf', () => ({ isPerfEnabled: jest.fn().mockReturnValue(false), - wrapWithLocalPerfTrace: jest.fn((trace) => trace), + withLocalPerfTrace: jest.fn((trace) => trace), })); // Mock providers. @@ -337,7 +337,7 @@ describe('MultichainAccountService', () => { accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], }); - expect(wrapWithLocalPerfTrace).not.toHaveBeenCalled(); + expect(withLocalPerfTrace).not.toHaveBeenCalled(); expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( messenger, undefined, @@ -359,7 +359,7 @@ describe('MultichainAccountService', () => { config: { trace: customTrace }, }); - expect(wrapWithLocalPerfTrace).not.toHaveBeenCalled(); + expect(withLocalPerfTrace).not.toHaveBeenCalled(); expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( messenger, undefined, @@ -375,14 +375,14 @@ describe('MultichainAccountService', () => { it('wraps trace with local perf trace and passes it to providers when perf is enabled', async () => { jest.mocked(isPerfEnabled).mockReturnValue(true); const wrappedTrace = jest.fn(); - jest.mocked(wrapWithLocalPerfTrace).mockReturnValue(wrappedTrace); + jest.mocked(withLocalPerfTrace).mockReturnValue(wrappedTrace); const { mocks, messenger } = await setup({ accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], }); - expect(wrapWithLocalPerfTrace).toHaveBeenCalledTimes(1); - expect(wrapWithLocalPerfTrace).toHaveBeenCalledWith(traceFallback); + expect(withLocalPerfTrace).toHaveBeenCalledTimes(1); + expect(withLocalPerfTrace).toHaveBeenCalledWith(traceFallback); expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( messenger, undefined, @@ -399,14 +399,14 @@ describe('MultichainAccountService', () => { jest.mocked(isPerfEnabled).mockReturnValue(true); const customTrace = jest.fn(); const wrappedTrace = jest.fn(); - jest.mocked(wrapWithLocalPerfTrace).mockReturnValue(wrappedTrace); + jest.mocked(withLocalPerfTrace).mockReturnValue(wrappedTrace); const { mocks } = await setup({ accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], config: { trace: customTrace }, }); - expect(wrapWithLocalPerfTrace).toHaveBeenCalledWith(customTrace); + expect(withLocalPerfTrace).toHaveBeenCalledWith(customTrace); expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( expect.anything(), undefined, diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 4500f3dd572..16ef36701e4 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -14,7 +14,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { areUint8ArraysEqual, assert } from '@metamask/utils'; import { traceFallback } from './analytics'; -import { isPerfEnabled, wrapWithLocalPerfTrace } from './analytics/perf'; +import { isPerfEnabled, withLocalPerfTrace } from './analytics/perf'; import { projectLogger as log } from './logger'; import type { MultichainAccountGroup } from './MultichainAccountGroup'; import { MultichainAccountWallet } from './MultichainAccountWallet'; @@ -163,7 +163,7 @@ export class MultichainAccountService { // Wrap the trace callback with local performance tracing if performance logging is enabled. if (isPerfEnabled()) { - trace = wrapWithLocalPerfTrace(trace); + trace = withLocalPerfTrace(trace); } // This trace is passed down to wallets and providers to be used for tracing operations within them. diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts index fc81ac9aa0a..7032f294e94 100644 --- a/packages/multichain-account-service/src/analytics/perf.test.ts +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -1,6 +1,6 @@ import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; -import { isPerfEnabled, tick, wrapWithLocalPerfTrace } from './perf'; +import { isPerfEnabled, tick, withLocalPerfTrace } from './perf'; import { projectLogger } from '../logger'; jest.mock('../logger', () => ({ @@ -94,7 +94,7 @@ describe('perf', () => { }); }); - describe('wrapWithLocalPerfTrace', () => { + describe('withLocalPerfTrace', () => { const request: TraceRequest = { name: 'wrapped-op' }; let mockTrace: jest.MockedFunction; @@ -112,7 +112,7 @@ describe('perf', () => { mockProjectLogger.enabled = false; mockTrace.mockResolvedValue('result'); - const wrapped = wrapWithLocalPerfTrace(mockTrace); + const wrapped = withLocalPerfTrace(mockTrace); const fn = jest.fn().mockReturnValue('result'); const result = await wrapped(request, fn); @@ -130,7 +130,7 @@ describe('perf', () => { .mockReturnValueOnce(100); mockTrace.mockResolvedValue('result'); - const wrapped = wrapWithLocalPerfTrace(mockTrace); + const wrapped = withLocalPerfTrace(mockTrace); const fn = jest.fn().mockReturnValue('result'); const result = await wrapped(request, fn); @@ -149,7 +149,7 @@ describe('perf', () => { const error = new Error('trace failed'); mockTrace.mockRejectedValue(error); - const wrapped = wrapWithLocalPerfTrace(mockTrace); + const wrapped = withLocalPerfTrace(mockTrace); await expect(wrapped(request, jest.fn())).rejects.toThrow(error); // performance.now called once for tick (start) and once for tock (end) @@ -160,7 +160,7 @@ describe('perf', () => { mockProjectLogger.enabled = false; mockTrace.mockResolvedValue(undefined); - const wrapped = wrapWithLocalPerfTrace(mockTrace); + const wrapped = withLocalPerfTrace(mockTrace); await wrapped(request); expect(mockTrace).toHaveBeenCalledWith(request, undefined); diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts index 84bb7b1efb5..82ea3d21cef 100644 --- a/packages/multichain-account-service/src/analytics/perf.ts +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -53,7 +53,7 @@ export function tick(request: TraceRequest): () => void { * @param trace - The original trace callback to wrap. * @returns A new trace callback that logs the duration of the traced operation. */ -export function wrapWithLocalPerfTrace(trace: TraceCallback): TraceCallback { +export function withLocalPerfTrace(trace: TraceCallback): TraceCallback { return async ( request: TraceRequest, fn?: (context?: TraceContext) => ReturnType, From 5abaef1580162289b2b71c058deea5212ff345c7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 13:28:21 +0100 Subject: [PATCH 13/25] feat: add missing data traces --- .../src/analytics/traces.test.ts | 66 ++++++++++++++++++- .../src/analytics/traces.ts | 24 +++++++ .../src/providers/SnapAccountProvider.ts | 7 +- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/multichain-account-service/src/analytics/traces.test.ts b/packages/multichain-account-service/src/analytics/traces.test.ts index a7333bfbeb3..b669d8aebbb 100644 --- a/packages/multichain-account-service/src/analytics/traces.test.ts +++ b/packages/multichain-account-service/src/analytics/traces.test.ts @@ -1,6 +1,14 @@ import type { TraceRequest } from '@metamask/controller-utils'; +import { AccountCreationType } from '@metamask/keyring-api'; +import type { CreateAccountOptions } from '@metamask/keyring-api'; -import { traceFallback, TraceName } from './traces'; +import type { Bip44AccountProvider } from '../providers'; +import { + toCreateAccountsV2DataTraces, + toProviderDataTraces, + traceFallback, + TraceName, +} from './traces'; describe('MultichainAccountService - Traces', () => { describe('traceFallback', () => { @@ -63,4 +71,60 @@ describe('MultichainAccountService - Traces', () => { expect(mockFn).toHaveBeenCalledTimes(1); }); }); + + describe('toProviderDataTraces', () => { + const mockProvider = (name: string): Bip44AccountProvider => + ({ getName: () => name }) as unknown as Bip44AccountProvider; + + it('returns an empty object for an empty providers list', () => { + expect(toProviderDataTraces([])).toStrictEqual({}); + }); + + it('returns a single entry for a single provider', () => { + expect(toProviderDataTraces([mockProvider('evm')])).toStrictEqual({ + evm: true, + }); + }); + + it('returns one entry per provider', () => { + expect( + toProviderDataTraces([mockProvider('evm'), mockProvider('btc')]), + ).toStrictEqual({ evm: true, btc: true }); + }); + }); + + describe('toCreateAccountsV2DataTraces', () => { + it('returns groupIndex for bip44:derive-index options', () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Bip44DeriveIndex, + entropySource: 'entropy-source-id', + groupIndex: 3, + }; + + expect(toCreateAccountsV2DataTraces(options)).toStrictEqual({ + groupIndex: 3, + }); + }); + + it('returns range bounds for bip44:derive-index-range options', () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: 'entropy-source-id', + range: { from: 0, to: 5 }, + }; + + expect(toCreateAccountsV2DataTraces(options)).toStrictEqual({ + from: 0, + to: 5, + }); + }); + + it('returns empty options otherwise', () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Custom, + }; + + expect(toCreateAccountsV2DataTraces(options)).toStrictEqual({}); + }); + }); }); diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts index 4d054b33696..ca08f9006e3 100644 --- a/packages/multichain-account-service/src/analytics/traces.ts +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -3,6 +3,8 @@ import type { TraceContext, TraceRequest, } from '@metamask/controller-utils'; +import { CreateAccountOptions } from '@metamask/keyring-api'; +import { group } from 'console'; import { Bip44AccountProvider } from '../providers'; @@ -45,6 +47,28 @@ export function toProviderDataTraces( ); } +/** + * Compute trace data for `createAccounts` options. + * + * @param options The `createAccounts` options. + * @returns An object containing options data depending on its type. + */ +export function toCreateAccountsV2DataTraces( + options: CreateAccountOptions, +): Record { + if (options.type === 'bip44:derive-index') { + return { + groupIndex: options.groupIndex, + }; + } else if (options.type === 'bip44:derive-index-range') { + return { + from: options.range.from, + to: options.range.to, + }; + } + return {}; +} + /** * Trace names. */ diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index bb050476b34..31d4da5200f 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -23,7 +23,7 @@ import { Semaphore } from 'async-mutex'; import { BaseBip44AccountProvider } from './BaseBip44AccountProvider'; import { withTimeout } from './utils'; -import { traceFallback, TraceName } from '../analytics'; +import { toCreateAccountsV2DataTraces, traceFallback, TraceName } from '../analytics'; import { projectLogger as log, WARNING_PREFIX } from '../logger'; import type { MultichainAccountServiceMessenger } from '../types'; import { createSentryError } from '../utils'; @@ -372,7 +372,10 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { this.trace( { name: TraceName.ProviderCreateAccounts, - data: { provider: this.getName() }, + data: { + provider: this.getName(), + ...toCreateAccountsV2DataTraces(optionsV2), + }, }, () => keyring.createAccounts(optionsV2), ), From ea2102394b1e4539b031a88aba98c36ce3ed51cf Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 14:03:55 +0100 Subject: [PATCH 14/25] test: fix invalid performance.now mocking --- .../src/analytics/perf.test.ts | 52 +++++++------------ .../src/analytics/perf.ts | 5 +- .../src/analytics/timer.ts | 10 ++++ .../src/analytics/traces.ts | 1 - 4 files changed, 33 insertions(+), 35 deletions(-) create mode 100644 packages/multichain-account-service/src/analytics/timer.ts diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts index 7032f294e94..93e15a3bc2d 100644 --- a/packages/multichain-account-service/src/analytics/perf.test.ts +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -2,6 +2,11 @@ import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import { isPerfEnabled, tick, withLocalPerfTrace } from './perf'; import { projectLogger } from '../logger'; +import { now } from './timer'; + +jest.mock('./timer', () => ({ + now: jest.fn(), +})); jest.mock('../logger', () => ({ projectLogger: { enabled: false }, @@ -28,11 +33,10 @@ describe('perf', () => { const request: TraceRequest = { name: 'test-operation' }; beforeEach(() => { - jest.spyOn(performance, 'now'); + jest.mocked(now).mockReset(); }); afterEach(() => { - jest.restoreAllMocks(); mockProjectLogger.enabled = false; }); @@ -40,30 +44,27 @@ describe('perf', () => { mockProjectLogger.enabled = false; const tock = tick(request); - expect(performance.now).not.toHaveBeenCalled(); + expect(now).not.toHaveBeenCalled(); expect(tock()).toBeUndefined(); }); it('captures start time when perf is enabled', () => { mockProjectLogger.enabled = true; - jest.mocked(performance.now).mockReturnValueOnce(100); + jest.mocked(now).mockReturnValueOnce(100); tick(request); - expect(performance.now).toHaveBeenCalledTimes(1); + expect(now).toHaveBeenCalledTimes(1); }); it('logs elapsed time when tock is called', () => { mockProjectLogger.enabled = true; - jest - .mocked(performance.now) - .mockReturnValueOnce(100) - .mockReturnValueOnce(250); + jest.mocked(now).mockReturnValueOnce(100).mockReturnValueOnce(250); const tock = tick(request); tock(); - expect(performance.now).toHaveBeenCalledTimes(2); + expect(now).toHaveBeenCalledTimes(2); }); it('includes JSON-encoded data in the log when request has data', () => { @@ -72,10 +73,7 @@ describe('perf', () => { name: 'test-operation', data: { foo: 'bar' }, }; - jest - .mocked(performance.now) - .mockReturnValueOnce(0) - .mockReturnValueOnce(42); + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(42); // Should not throw regardless of data shape const tock = tick(requestWithData); @@ -84,10 +82,7 @@ describe('perf', () => { it('omits context when request has no data', () => { mockProjectLogger.enabled = true; - jest - .mocked(performance.now) - .mockReturnValueOnce(0) - .mockReturnValueOnce(10); + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(10); const tock = tick({ name: 'no-data' }); expect(() => tock()).not.toThrow(); @@ -100,11 +95,10 @@ describe('perf', () => { beforeEach(() => { mockTrace = jest.fn(); - jest.spyOn(performance, 'now').mockReturnValue(0); + jest.mocked(now).mockReset(); }); afterEach(() => { - jest.restoreAllMocks(); mockProjectLogger.enabled = false; }); @@ -119,15 +113,12 @@ describe('perf', () => { expect(mockTrace).toHaveBeenCalledTimes(1); expect(mockTrace).toHaveBeenCalledWith(request, fn); expect(result).toBe('result'); - expect(performance.now).not.toHaveBeenCalled(); + expect(now).not.toHaveBeenCalled(); }); it('calls trace and measures timing when perf is enabled', async () => { mockProjectLogger.enabled = true; - jest - .mocked(performance.now) - .mockReturnValueOnce(0) - .mockReturnValueOnce(100); + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(100); mockTrace.mockResolvedValue('result'); const wrapped = withLocalPerfTrace(mockTrace); @@ -137,23 +128,20 @@ describe('perf', () => { expect(mockTrace).toHaveBeenCalledTimes(1); expect(mockTrace).toHaveBeenCalledWith(request, fn); expect(result).toBe('result'); - expect(performance.now).toHaveBeenCalledTimes(2); + expect(now).toHaveBeenCalledTimes(2); }); it('still calls tock when trace throws', async () => { mockProjectLogger.enabled = true; - jest - .mocked(performance.now) - .mockReturnValueOnce(0) - .mockReturnValueOnce(50); + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(50); const error = new Error('trace failed'); mockTrace.mockRejectedValue(error); const wrapped = withLocalPerfTrace(mockTrace); await expect(wrapped(request, jest.fn())).rejects.toThrow(error); - // performance.now called once for tick (start) and once for tock (end) - expect(performance.now).toHaveBeenCalledTimes(2); + // now called once for tick (start) and once for tock (end) + expect(now).toHaveBeenCalledTimes(2); }); it('works without a fn argument', async () => { diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts index 82ea3d21cef..576ee3b7af5 100644 --- a/packages/multichain-account-service/src/analytics/perf.ts +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -5,6 +5,7 @@ import type { } from '@metamask/controller-utils'; import { createModuleLogger, projectLogger } from '../logger'; +import { now } from './timer'; const log = createModuleLogger(projectLogger, 'perf'); @@ -37,9 +38,9 @@ export function tick(request: TraceRequest): () => void { return () => undefined; } - const start = performance.now(); + const start = now(); return function tock(): void { - const duration = performance.now() - start; + const duration = now() - start; const context = request.data ? ` (${JSON.stringify(request.data)})` : ''; diff --git a/packages/multichain-account-service/src/analytics/timer.ts b/packages/multichain-account-service/src/analytics/timer.ts new file mode 100644 index 00000000000..c4b2093cf8b --- /dev/null +++ b/packages/multichain-account-service/src/analytics/timer.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ // We use this file mainly to ease testing of performance logging, so we don't need to cover it with tests. + +/** + * Returns the current high-resolution timestamp in milliseconds. This is a thin wrapper around `performance.now()`. + * + * @returns The current high-resolution timestamp in milliseconds. + */ +export function now(): number { + return performance.now(); +} diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts index ca08f9006e3..2203f87facf 100644 --- a/packages/multichain-account-service/src/analytics/traces.ts +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -4,7 +4,6 @@ import type { TraceRequest, } from '@metamask/controller-utils'; import { CreateAccountOptions } from '@metamask/keyring-api'; -import { group } from 'console'; import { Bip44AccountProvider } from '../providers'; From 850d35508db3c43afd8ee3f30ada4d0689580d66 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 14:04:09 +0100 Subject: [PATCH 15/25] chore: lint --- .../multichain-account-service/src/analytics/perf.test.ts | 2 +- packages/multichain-account-service/src/analytics/perf.ts | 2 +- .../multichain-account-service/src/analytics/traces.test.ts | 2 +- .../src/providers/SnapAccountProvider.ts | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts index 93e15a3bc2d..a934786625e 100644 --- a/packages/multichain-account-service/src/analytics/perf.test.ts +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -1,8 +1,8 @@ import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import { isPerfEnabled, tick, withLocalPerfTrace } from './perf'; -import { projectLogger } from '../logger'; import { now } from './timer'; +import { projectLogger } from '../logger'; jest.mock('./timer', () => ({ now: jest.fn(), diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts index 576ee3b7af5..612796b686e 100644 --- a/packages/multichain-account-service/src/analytics/perf.ts +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -4,8 +4,8 @@ import type { TraceRequest, } from '@metamask/controller-utils'; -import { createModuleLogger, projectLogger } from '../logger'; import { now } from './timer'; +import { createModuleLogger, projectLogger } from '../logger'; const log = createModuleLogger(projectLogger, 'perf'); diff --git a/packages/multichain-account-service/src/analytics/traces.test.ts b/packages/multichain-account-service/src/analytics/traces.test.ts index b669d8aebbb..2ad41c271de 100644 --- a/packages/multichain-account-service/src/analytics/traces.test.ts +++ b/packages/multichain-account-service/src/analytics/traces.test.ts @@ -2,13 +2,13 @@ import type { TraceRequest } from '@metamask/controller-utils'; import { AccountCreationType } from '@metamask/keyring-api'; import type { CreateAccountOptions } from '@metamask/keyring-api'; -import type { Bip44AccountProvider } from '../providers'; import { toCreateAccountsV2DataTraces, toProviderDataTraces, traceFallback, TraceName, } from './traces'; +import type { Bip44AccountProvider } from '../providers'; describe('MultichainAccountService - Traces', () => { describe('traceFallback', () => { diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index 31d4da5200f..cb6b52d152e 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -23,7 +23,11 @@ import { Semaphore } from 'async-mutex'; import { BaseBip44AccountProvider } from './BaseBip44AccountProvider'; import { withTimeout } from './utils'; -import { toCreateAccountsV2DataTraces, traceFallback, TraceName } from '../analytics'; +import { + toCreateAccountsV2DataTraces, + traceFallback, + TraceName, +} from '../analytics'; import { projectLogger as log, WARNING_PREFIX } from '../logger'; import type { MultichainAccountServiceMessenger } from '../types'; import { createSentryError } from '../utils'; From 7b2d87aa5cddb9c661deb755ca125d842ed6143a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 14:12:15 +0100 Subject: [PATCH 16/25] chore: changelog --- packages/multichain-account-service/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index d45eac14706..31e3aa91345 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new `resyncAccounts.autoRemoveExtraSnapAccounts` configuration on Snap-based providers ([#8200](https://github.com/MetaMask/core/pull/8200)) - When enabled, this will make the `resyncAccounts` method automatically remove any extra accounts that exist on the Snap side but not on MetaMask side. - This behavior was enabled by default and can now be turned off by the clients. +- Add more tracing (alignment, create account v1/v2) ([#8244](https://github.com/MetaMask/core/pull/8244)) +- Add local perf tracing ([#8244](https://github.com/MetaMask/core/pull/8244)) + - Each traces are now automatically wrapped and will log performance timings using the internal logger. + - Only enabled if `metamask:multichain-account-service` is part of `DEBUG` (env var) filters. ### Changed From 188ee01f0d6be7f57424029d29aaca64b75468da Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 15:57:29 +0100 Subject: [PATCH 17/25] fix: also enable for DEBUG=...:perf --- packages/multichain-account-service/src/analytics/perf.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts index 612796b686e..333908b3cb1 100644 --- a/packages/multichain-account-service/src/analytics/perf.ts +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -10,13 +10,14 @@ import { createModuleLogger, projectLogger } from '../logger'; const log = createModuleLogger(projectLogger, 'perf'); /** - * Returns true when DEBUG=metamask:multichain-account-service (or a matching glob) is set. + * Returns true when DEBUG=metamask:multichain-account-service, DEBUG=metamask:multichain-account-service:perf + * or a matching glob is set. * Re-uses the same enable/disable logic as the rest of the package loggers. * * @returns True if performance logging is enabled, false otherwise. */ export function isPerfEnabled(): boolean { - return projectLogger.enabled; + return projectLogger.enabled || log.enabled; } /** From 5c6203a42b9af41717fa322928e0c35382181104 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 23:08:08 +0100 Subject: [PATCH 18/25] fix: remove duplicated trace in sol provider --- .../src/providers/SolAccountProvider.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts index c08fcf57d57..b9bad3cc5cc 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -90,21 +90,10 @@ export class SolAccountProvider extends SnapAccountProvider { groupIndex, }: { entropySource: EntropySourceId; groupIndex: number }, ): Promise { - return await super.trace( - { - name: TraceName.ProviderCreateAccountV1, - data: { - provider: this.getName(), - groupIndex, - }, - }, - async () => { - return keyring.createAccount({ - entropySource, - derivationPath: this.#getDerivationPath(groupIndex), - }); - }, - ); + return keyring.createAccount({ + entropySource, + derivationPath: this.#getDerivationPath(groupIndex), + }); } protected override toBip44Account( From 33659d2a73bb5341ddb459299fc252a9bf4c2c74 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 23:09:46 +0100 Subject: [PATCH 19/25] fix: remove duplicated trace in alignAccounts + cosmetic --- .../src/MultichainAccountWallet.ts | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index 7765fa651a5..ada3afb4a11 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -764,20 +764,11 @@ export class MultichainAccountWallet< const from = 0; const to = nextGroupIndex - 1; - await this.#withLock('in-progress:alignment', async () => { - await this.#trace( - { - name: TraceName.WalletAlignment, - data: { - from, - to, - ...toProviderDataTraces(this.#providers), - }, - }, - async () => - await this.#alignAccountsForRange({ from, to }, this.#providers), - ); - }); + await this.#withLock( + 'in-progress:alignment', + async () => + await this.#alignAccountsForRange({ from, to }, this.#providers), + ); this.#log('Aligned!'); } @@ -796,13 +787,15 @@ export class MultichainAccountWallet< if (group) { this.#log(`Aligning accounts for group "${group.id}"...`); - await this.#withLock('in-progress:alignment', async () => { - await this.#alignAccountsForRange( - { from: groupIndex, to: groupIndex }, - this.#providers, - { trace: { data: { groupIndex } } }, - ); - }); + await this.#withLock( + 'in-progress:alignment', + async () => + await this.#alignAccountsForRange( + { from: groupIndex, to: groupIndex }, + this.#providers, + { trace: { data: { groupIndex } } }, + ), + ); this.#log(`Aligned accounts for group "${group.id}"!`); } From 498b8fbf21225a53e507f0a602d4c92e2991232b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 19 Mar 2026 23:19:31 +0100 Subject: [PATCH 20/25] fix: forward trace to wallet --- .../multichain-account-service/src/MultichainAccountService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 268f234c569..29ef482258a 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -423,6 +423,7 @@ export class MultichainAccountService { providers: this.#providers, entropySource: result.id, messenger: this.#messenger, + trace: this.#trace, }); } @@ -447,6 +448,7 @@ export class MultichainAccountService { providers: this.#providers, entropySource: entropySourceId, messenger: this.#messenger, + trace: this.#trace, }); } @@ -474,6 +476,7 @@ export class MultichainAccountService { providers: this.#providers, entropySource: entropySourceId, messenger: this.#messenger, + trace: this.#trace, }); } From 8aeb58844f779bf3c1679f480f1438060b4644cb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 20 Mar 2026 08:47:22 +0100 Subject: [PATCH 21/25] chore: lint --- .../multichain-account-service/src/MultichainAccountWallet.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index ada3afb4a11..a60c5e3e3e1 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -602,7 +602,6 @@ export class MultichainAccountWallet< async () => { assertGroupIndexIsValid(groupIndex, this.getNextGroupIndex()); - // If the group already exists, return it. // If the group already exists, return it. const existingGroup = this.getMultichainAccountGroup(groupIndex); if (existingGroup) { From 3d11ebc6ab12bb07222a6ae9662c1e19fdb056fd Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 20 Mar 2026 09:48:30 +0100 Subject: [PATCH 22/25] test: fix test by using mocked perf.log --- .../src/analytics/perf.test.ts | 20 +++++++++++++++++-- .../src/analytics/perf.ts | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts index a934786625e..b391f271ddc 100644 --- a/packages/multichain-account-service/src/analytics/perf.test.ts +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -1,6 +1,11 @@ import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; -import { isPerfEnabled, tick, withLocalPerfTrace } from './perf'; +import { + isPerfEnabled, + log as perfLog, + tick, + withLocalPerfTrace, +} from './perf'; import { now } from './timer'; import { projectLogger } from '../logger'; @@ -10,15 +15,19 @@ jest.mock('./timer', () => ({ jest.mock('../logger', () => ({ projectLogger: { enabled: false }, - createModuleLogger: jest.fn().mockReturnValue(jest.fn()), + createModuleLogger: jest + .fn() + .mockReturnValue(Object.assign(jest.fn(), { enabled: false })), })); const mockProjectLogger = projectLogger as { enabled: boolean }; +const mockPerfLog = perfLog as unknown as { enabled: boolean }; describe('perf', () => { describe('isPerfEnabled', () => { it('returns false when projectLogger is disabled', () => { mockProjectLogger.enabled = false; + mockPerfLog.enabled = false; expect(isPerfEnabled()).toBe(false); }); @@ -27,6 +36,12 @@ describe('perf', () => { expect(isPerfEnabled()).toBe(true); mockProjectLogger.enabled = false; }); + + it('returns true when (perf) log is enabled', () => { + mockPerfLog.enabled = true; + expect(isPerfEnabled()).toBe(true); + mockPerfLog.enabled = false; + }); }); describe('tick', () => { @@ -38,6 +53,7 @@ describe('perf', () => { afterEach(() => { mockProjectLogger.enabled = false; + mockPerfLog.enabled = false; }); it('returns a no-op when perf is disabled', () => { diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts index 333908b3cb1..726387d0d9f 100644 --- a/packages/multichain-account-service/src/analytics/perf.ts +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -7,7 +7,7 @@ import type { import { now } from './timer'; import { createModuleLogger, projectLogger } from '../logger'; -const log = createModuleLogger(projectLogger, 'perf'); +export const log = createModuleLogger(projectLogger, 'perf'); /** * Returns true when DEBUG=metamask:multichain-account-service, DEBUG=metamask:multichain-account-service:perf From 5ffe10e3aeee45d9cfd95a2b77571a9d0154b905 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 20 Mar 2026 11:50:45 +0100 Subject: [PATCH 23/25] fix: fix import type --- .../multichain-account-service/src/MultichainAccountWallet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index a60c5e3e3e1..ceeb5ff1cd4 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -12,7 +12,7 @@ import { toDefaultAccountGroupId, toMultichainAccountWalletId, } from '@metamask/account-api'; -import { TraceCallback, TraceRequest } from '@metamask/controller-utils'; +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import { AccountCreationType } from '@metamask/keyring-api'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { assert } from '@metamask/utils'; From a5e4544d0c7c7eb2b4425a1a8bc5227dafc26dbf Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 20 Mar 2026 14:00:36 +0100 Subject: [PATCH 24/25] test: fix after merge --- .../src/providers/utils.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/multichain-account-service/src/providers/utils.test.ts b/packages/multichain-account-service/src/providers/utils.test.ts index 7b011b1bfb3..4c285cb6fc6 100644 --- a/packages/multichain-account-service/src/providers/utils.test.ts +++ b/packages/multichain-account-service/src/providers/utils.test.ts @@ -38,11 +38,12 @@ describe('utils', () => { it('includes the timeout duration in the error message', async () => { await expect( withTimeout( - new Promise((resolve) => { - setTimeout(() => { - resolve(null); - }, 600); - }), + () => + new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 600); + }), 500, ), ).rejects.toThrow('Timed out after: 500ms'); From a5709d7f2280eca4df387e01e983d51c32b60c8c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 20 Mar 2026 14:19:45 +0100 Subject: [PATCH 25/25] fix: fix circular dep --- packages/multichain-account-service/src/analytics/traces.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts index 2203f87facf..003d0005f78 100644 --- a/packages/multichain-account-service/src/analytics/traces.ts +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -5,7 +5,8 @@ import type { } from '@metamask/controller-utils'; import { CreateAccountOptions } from '@metamask/keyring-api'; -import { Bip44AccountProvider } from '../providers'; +// Explicit import to avoid circular dependency between `analytics` and `providers`. +import type { Bip44AccountProvider } from '../providers/BaseBip44AccountProvider'; /** * Fallback function for tracing.