Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
117ee20
feat: add tracing to multichain account service
montelaidev Oct 28, 2025
af3d908
chore: update changelog
montelaidev Oct 30, 2025
d5c1eb3
fix: lint
montelaidev Oct 30, 2025
3190bca
fix: add test coverage
montelaidev Oct 30, 2025
291b8c6
Merge remote-tracking branch 'origin/main' into fix/mul-1186
montelaidev Oct 31, 2025
5170994
refactor: discover to AccountProviderWrapper
montelaidev Oct 31, 2025
0f351eb
fix: lint
montelaidev Oct 31, 2025
90d5e8c
fix: check provider before determining name
montelaidev Oct 31, 2025
82bf3f7
Merge remote-tracking branch 'origin/main' into fix/mul-1186
montelaidev Oct 31, 2025
a06ed0a
chore: add comment
montelaidev Oct 31, 2025
56a9cc2
refactor: move trace to concrete provider
montelaidev Nov 3, 2025
a38300d
Merge remote-tracking branch 'origin/main' into fix/mul-1186
montelaidev Nov 3, 2025
ccec24f
Merge remote-tracking branch 'origin/main' into fix/mul-1186
montelaidev Nov 4, 2025
019f4f8
fix: trace bind
montelaidev Nov 5, 2025
7683d3b
Merge remote-tracking branch 'origin/main' into fix/mul-1186
montelaidev Nov 5, 2025
553e283
fix: use constants in test
montelaidev Nov 6, 2025
7d6ffe4
fix: create mock provider
montelaidev Nov 6, 2025
d225a86
fix: replace mock with spy
montelaidev Nov 6, 2025
bc61d3a
Merge remote-tracking branch 'origin/main' into fix/mul-1186
montelaidev Nov 7, 2025
24b68c6
fix: lint
montelaidev Nov 7, 2025
3f5ebc4
fix: lint
montelaidev Nov 7, 2025
aebc20c
fix: use constantsfor trace names and data
montelaidev Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/multichain-account-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Add optional tracing configuration ([#7006](https://github.com/MetaMask/core/pull/7006))
- For now, only the account discovery is being traced.
- Limit Bitcoin and Tron providers to 3 concurrent account creations by default when creating multichain account groups ([#7052](https://github.com/MetaMask/core/pull/7052))

## [2.1.0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,12 @@ describe('MultichainAccountService', () => {
expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
providerConfigs?.[EvmAccountProvider.NAME],
expect.any(Function), // TraceCallback
);
expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
providerConfigs?.[SolAccountProvider.NAME],
expect.any(Function), // TraceCallback
);
});

Expand Down Expand Up @@ -254,10 +256,12 @@ describe('MultichainAccountService', () => {
expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
undefined,
expect.any(Function), // TraceCallback
);
expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
providerConfigs?.[SolAccountProvider.NAME],
expect.any(Function), // TraceCallback
);
});
});
Expand Down Expand Up @@ -563,10 +567,20 @@ describe('MultichainAccountService', () => {
);

// Should emit updated event for the existing group
expect(publishSpy).toHaveBeenCalled();
expect(publishSpy).toHaveBeenCalledWith(
'MultichainAccountService:multichainAccountGroupUpdated',
expect.any(Object),
expect.objectContaining({
groupIndex: 0,
}),
);

const emittedGroup = publishSpy.mock.calls[0][1];
expect(emittedGroup).toBeDefined();
expect(emittedGroup).toHaveProperty('groupIndex', 0);
expect(emittedGroup).toHaveProperty('getAccounts');
expect(emittedGroup).toHaveProperty('select');
expect(emittedGroup).toHaveProperty('get');
});

it('creates new detected wallets and update reverse mapping on AccountsController:accountAdded', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api';
import { KeyringTypes } from '@metamask/keyring-controller';
import { areUint8ArraysEqual } from '@metamask/utils';

import { traceFallback } from './analytics';
import { projectLogger as log } from './logger';
import type { MultichainAccountGroup } from './MultichainAccountGroup';
import { MultichainAccountWallet } from './MultichainAccountWallet';
Expand All @@ -28,7 +29,10 @@ import {
SolAccountProvider,
type SolAccountProviderConfig,
} from './providers/SolAccountProvider';
import type { MultichainAccountServiceMessenger } from './types';
import type {
MultichainAccountServiceConfig,
MultichainAccountServiceMessenger,
} from './types';

export const serviceName = 'MultichainAccountService';

Expand All @@ -42,6 +46,7 @@ export type MultichainAccountServiceOptions = {
[EvmAccountProvider.NAME]?: EvmAccountProviderConfig;
[SolAccountProvider.NAME]?: SolAccountProviderConfig;
};
config?: MultichainAccountServiceConfig;
};

/** Reverse mapping object used to map account IDs and their wallet/multichain account. */
Expand Down Expand Up @@ -81,28 +86,35 @@ export class MultichainAccountService {
* MultichainAccountService.
* @param options.providers - Optional list of account
* @param options.providerConfigs - Optional provider configs
* providers.
* @param options.config - Optional config.
*/
constructor({
messenger,
providers = [],
providerConfigs,
config,
}: MultichainAccountServiceOptions) {
this.#messenger = messenger;
this.#wallets = new Map();
this.#accountIdToContext = 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;

// TODO: Rely on keyring capabilities once the keyring API is used by all keyrings.
this.#providers = [
new EvmAccountProvider(
this.#messenger,
providerConfigs?.[EvmAccountProvider.NAME],
traceCallback,
),
new AccountProviderWrapper(
this.#messenger,
new SolAccountProvider(
this.#messenger,
providerConfigs?.[SolAccountProvider.NAME],
traceCallback,
),
),
// Custom account providers that can be provided by the MetaMask client.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './traces';
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { TraceRequest } from '@metamask/controller-utils';

import { traceFallback } from './traces';
import { TraceName } from '../constants/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;

beforeEach(() => {
mockTraceRequest = {
name: TraceName.SnapDiscoverAccounts,
id: 'trace-id-123',
tags: {},
};
});

it('returns undefined when no function is provided', async () => {
const result = await traceFallback(mockTraceRequest);

expect(result).toBeUndefined();
});

it('executes the provided function and return its result', async () => {
const mockResult = 'test-result';
const mockFn = jest.fn().mockReturnValue(mockResult);

const result = await traceFallback(mockTraceRequest, mockFn);

expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith();
expect(result).toBe(mockResult);
});

it('executes async function and return its result', async () => {
const mockResult = { data: 'async-result' };
const mockAsyncFn = jest.fn().mockResolvedValue(mockResult);

const result = await traceFallback(mockTraceRequest, mockAsyncFn);

expect(mockAsyncFn).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResult);
});

it('handles function that throws an error', async () => {
const mockError = new Error('Test error');
const mockFn = jest.fn().mockImplementation(() => {
throw mockError;
});

await expect(traceFallback(mockTraceRequest, mockFn)).rejects.toThrow(
mockError,
);
expect(mockFn).toHaveBeenCalledTimes(1);
});

it('handles function that returns a rejected promise', async () => {
const mockError = new Error('Async error');
const mockFn = jest.fn().mockRejectedValue(mockError);

await expect(traceFallback(mockTraceRequest, mockFn)).rejects.toThrow(
mockError,
);
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
});
25 changes: 25 additions & 0 deletions packages/multichain-account-service/src/analytics/traces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {
TraceCallback,
TraceContext,
TraceRequest,
} from '@metamask/controller-utils';

/**
* Fallback function for tracing.
* This function is used when no specific trace function is provided.
* It executes the provided function in a trace context if available.
*
* @param _request - The trace request containing additional data and context.
* @param fn - The function to execute within the trace context.
* @returns A promise that resolves to the result of the executed function.
* If no function is provided, it resolves to undefined.
*/
export const traceFallback: TraceCallback = async <ReturnType>(
_request: TraceRequest,
fn?: (context?: TraceContext) => ReturnType,
): Promise<ReturnType> => {
if (!fn) {
return undefined as ReturnType;
}
return await Promise.resolve(fn());
};
4 changes: 4 additions & 0 deletions packages/multichain-account-service/src/constants/traces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum TraceName {
'SnapDiscoverAccounts' = 'Snap Discover Accounts',
'EvmDiscoverAccounts' = 'EVM Discover Accounts',
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import type {
} from '@metamask/keyring-internal-api';

import { AccountProviderWrapper } from './AccountProviderWrapper';
import { BtcAccountProvider } from './BtcAccountProvider';
import {
BTC_ACCOUNT_PROVIDER_NAME,
BtcAccountProvider,
} from './BtcAccountProvider';
import { TraceName } from '../constants/traces';
import {
getMultichainAccountServiceMessenger,
getRootMessenger,
Expand Down Expand Up @@ -324,4 +328,129 @@ describe('BtcAccountProvider', () => {

expect(discovered).toStrictEqual([]);
});

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({
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 BtcAccountProvider(
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);
});

it('uses fallback trace when no trace callback is provided', async () => {
const { provider, mocks } = setup({
accounts: [],
});

mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]);

const discovered = await provider.discoverAccounts({
entropySource: MOCK_HD_KEYRING_1.metadata.id,
groupIndex: 0,
});

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({
accounts: [],
});

mocks.handleRequest.mockReturnValue([]);

const multichainMessenger =
getMultichainAccountServiceMessenger(messenger);
const btcProvider = new BtcAccountProvider(
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);
});

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({
accounts: [],
});

mocks.handleRequest.mockRejectedValue(mockError);

const multichainMessenger =
getMultichainAccountServiceMessenger(messenger);
const btcProvider = new BtcAccountProvider(
multichainMessenger,
undefined,
mockTrace,
);
const provider = new AccountProviderWrapper(
multichainMessenger,
btcProvider,
);

await expect(
provider.discoverAccounts({
entropySource: MOCK_HD_KEYRING_1.metadata.id,
groupIndex: 0,
}),
).rejects.toThrow(mockError);

expect(mockTrace).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading