Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

## [Unreleased]

### Changed

- Add optional tracing configuration ([#7006](https://github.com/MetaMask/core/pull/7006))
- For now, only the account discovery is being traced.

## [2.1.0]

### Added
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 @@ -6,12 +6,14 @@ import type {
MultichainAccountWalletId,
Bip44Account,
} from '@metamask/account-api';
import type { TraceCallback } from '@metamask/controller-utils';
import type { HdKeyring } from '@metamask/eth-hd-keyring';
import { mnemonicPhraseToBytes } from '@metamask/key-tree';
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 +30,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 +47,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 All @@ -68,6 +74,8 @@ export class MultichainAccountService {
AccountContext<Bip44Account<KeyringAccount>>
>;

readonly #trace: TraceCallback;

/**
* The name of the service.
*/
Expand All @@ -81,28 +89,32 @@ 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();
this.#trace = 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],
this.#trace.bind(this),
),
new AccountProviderWrapper(
this.#messenger,
new SolAccountProvider(
this.#messenger,
providerConfigs?.[SolAccountProvider.NAME],
this.#trace.bind(this),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Unbound Trace Callbacks Break Custom Contexts

The trace callback is unnecessarily bound to the MultichainAccountService instance when passed to account providers. This binding can break custom trace functions that rely on their original this context, as trace callbacks typically don't expect a specific this.

Fix in Cursor Fix in Web

),
),
// 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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think Snap* is too generic. Since we're providing the providerName in the trace parameter, maybe we can just use DiscoverAccounts for any providers instead? And we always pass a providerName parameter?

'EvmDiscoverAccounts' = 'EVM Discover Accounts',
}
Original file line number Diff line number Diff line change
Expand Up @@ -324,4 +324,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('Snap Discover Accounts');
expect(request.data).toStrictEqual({
provider: 'Bitcoin',
});
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('Snap Discover Accounts');
expect(request.data).toStrictEqual({
provider: 'Bitcoin',
});
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