Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
91ae80e
feat(multichain-account-service): basic functionality and alignment m…
fabiobozzo Aug 18, 2025
2f89753
call setDisabled on AccountProvider interface
fabiobozzo Aug 19, 2025
2235983
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 19, 2025
bc04a0a
simplify setBasicFunctionality method and enhance testing
fabiobozzo Aug 19, 2025
6828cad
address some pr remarks
fabiobozzo Aug 19, 2025
220116e
reset SolAccountProvider
fabiobozzo Aug 19, 2025
2ed1305
reset SolAccountProvider
fabiobozzo Aug 19, 2025
407fb56
reset SolAccountProvider
fabiobozzo Aug 19, 2025
76640ca
refactor SnapAccountProvider integration and enhance functionality ch…
fabiobozzo Aug 19, 2025
ee3e69e
added ProviderWrapper to support providers disabled state
fabiobozzo Aug 20, 2025
56a7ba7
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 20, 2025
e73bc0c
update changelog
fabiobozzo Aug 20, 2025
81661f2
update changelog
fabiobozzo Aug 20, 2025
95561be
address cursorbot remarks
fabiobozzo Aug 20, 2025
3cb95a8
improve test coverage
fabiobozzo Aug 20, 2025
c9c0a56
remove unnecessary eslint directive from SnapAccountProvider test file
fabiobozzo Aug 20, 2025
7dfc314
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 20, 2025
d79fc0b
feat: add isAlignmentInProgress prop
fabiobozzo Aug 22, 2025
fcfacab
address pr feedback from @danroc
fabiobozzo Aug 26, 2025
c8bb2d2
remove console logs
fabiobozzo Aug 26, 2025
4538dc6
address pr feedback from @ccharly
fabiobozzo Aug 26, 2025
e255ea1
remove redundant generic
fabiobozzo Aug 26, 2025
372a6ca
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 26, 2025
3eefed3
update changelog
fabiobozzo Aug 26, 2025
6a91767
refactor: rename ProviderWrapper to AccountProviderWrapper and update…
fabiobozzo Aug 26, 2025
544906a
fix import order lint error
fabiobozzo Aug 26, 2025
41f4564
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 27, 2025
68747e9
address pr feedback from @ccharly
fabiobozzo Aug 27, 2025
970969c
fix lint issues
fabiobozzo Aug 27, 2025
ae6e3e0
Update packages/multichain-account-service/CHANGELOG.md
fabiobozzo Aug 27, 2025
4de9cbb
forgot to export MultichainAccountServiceSetBasicFunctionalityAction
fabiobozzo Aug 27, 2025
173e760
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 27, 2025
24dc1eb
Merge branch 'main' into feat/MUL-343-trigger-multichain-alignment
fabiobozzo Aug 27, 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 @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Allow for multichain account group alignment through the `align` method ([#6326](https://github.com/MetaMask/core/pull/6326))
- You can now call alignment from the group, wallet and service levels.
- Add `setBasicFunctionality` method to control providers state and trigger wallets alignment ([#6332](https://github.com/MetaMask/core/pull/6332))
- Add `ProviderWrapper` to handle Snap account providers behavior when disabled

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { KeyringTypes, type KeyringObject } from '@metamask/keyring-controller';

import { MultichainAccountService } from './MultichainAccountService';
import { EvmAccountProvider } from './providers/EvmAccountProvider';
import { ProviderWrapper } from './providers/ProviderWrapper';
import { SolAccountProvider } from './providers/SolAccountProvider';
import type { MockAccountProvider } from './tests';
import {
Expand Down Expand Up @@ -749,5 +750,142 @@ describe('MultichainAccountService', () => {
groupIndex: 0,
});
});

it('sets basic functionality with MultichainAccountService:setBasicFunctionality', async () => {
const { messenger } = setup({ accounts: [MOCK_HD_ACCOUNT_1] });

// This tests the action handler registration
expect(
await messenger.call(
'MultichainAccountService:setBasicFunctionality',
true,
),
).toBeUndefined();
expect(
await messenger.call(
'MultichainAccountService:setBasicFunctionality',
false,
),
).toBeUndefined();
});
});

describe('setBasicFunctionality', () => {
it('accepts a boolean parameter instead of object', async () => {
const { service } = setup({ accounts: [MOCK_HD_ACCOUNT_1] });

// These should not throw errors
expect(await service.setBasicFunctionality(true)).toBeUndefined();
expect(await service.setBasicFunctionality(false)).toBeUndefined();
});

it('can be called with boolean true', async () => {
const { service } = setup({ accounts: [MOCK_HD_ACCOUNT_1] });

// This tests the simplified parameter signature
expect(await service.setBasicFunctionality(true)).toBeUndefined();
});

it('can be called with boolean false', async () => {
const { service } = setup({ accounts: [MOCK_HD_ACCOUNT_1] });

// This tests the simplified parameter signature
expect(await service.setBasicFunctionality(false)).toBeUndefined();
});
});

describe('ProviderWrapper disabled behavior', () => {
let mockProvider: MockAccountProvider;
let wrapper: ProviderWrapper;

beforeEach(() => {
const { mocks } = setup({ accounts: [MOCK_HD_ACCOUNT_1] });
mockProvider = mocks.SolAccountProvider;
wrapper = new ProviderWrapper(mockProvider);
});

it('returns empty array when getAccounts() is disabled', () => {
// Enable first - should work normally
mockProvider.getAccounts.mockReturnValue([MOCK_HD_ACCOUNT_1]);
expect(wrapper.getAccounts()).toStrictEqual([MOCK_HD_ACCOUNT_1]);

// Disable - should return empty array
wrapper.setDisabled(true);
expect(wrapper.getAccounts()).toStrictEqual([]);
});

it('throws error when getAccount() is disabled', () => {
// Enable first - should work normally
mockProvider.getAccount.mockReturnValue(MOCK_HD_ACCOUNT_1);
expect(wrapper.getAccount('test-id')).toStrictEqual(MOCK_HD_ACCOUNT_1);

// Disable - should throw error
wrapper.setDisabled(true);
expect(() => wrapper.getAccount('test-id')).toThrow('is disabled');
});

it('returns empty array when createAccounts() is disabled', async () => {
const options = {
entropySource: MOCK_HD_ACCOUNT_1.options.entropy.id,
groupIndex: 0,
};

// Enable first - should work normally
mockProvider.createAccounts.mockResolvedValue([MOCK_HD_ACCOUNT_1]);
expect(await wrapper.createAccounts(options)).toStrictEqual([
MOCK_HD_ACCOUNT_1,
]);

// Disable - should return empty array and not call underlying provider
wrapper.setDisabled(true);

const result = await wrapper.createAccounts(options);
expect(result).toStrictEqual([]);
});

it('returns empty array when discoverAndCreateAccounts() is disabled', async () => {
const options = {
entropySource: MOCK_HD_ACCOUNT_1.options.entropy.id,
groupIndex: 0,
};

// Enable first - should work normally
mockProvider.discoverAndCreateAccounts.mockResolvedValue([
MOCK_HD_ACCOUNT_1,
]);
expect(await wrapper.discoverAndCreateAccounts(options)).toStrictEqual([
MOCK_HD_ACCOUNT_1,
]);

// Disable - should return empty array
wrapper.setDisabled(true);

const result = await wrapper.discoverAndCreateAccounts(options);
expect(result).toStrictEqual([]);
});

it('proxies isAccountCompatible() correctly', () => {
// Test when provider has the method
const providerWithMethod = mockProvider as MockAccountProvider & {
isAccountCompatible: jest.Mock;
};
jest
.spyOn(providerWithMethod, 'isAccountCompatible')
.mockImplementation()
.mockReturnValue(true);
expect(wrapper.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(true);
expect(providerWithMethod.isAccountCompatible).toHaveBeenCalledWith(
MOCK_HD_ACCOUNT_1,
);

// Test when provider doesn't have the method (fallback to true)
const providerWithoutMethod = { ...mockProvider };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(providerWithoutMethod as any).isAccountCompatible = undefined;
const wrapperWithoutMethod = new ProviderWrapper(providerWithoutMethod);
expect(wrapperWithoutMethod.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(
true,
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import {
import type {
MultichainAccountWalletId,
Bip44Account,
AccountProvider,
} from '@metamask/account-api';
import type { AccountProvider } from '@metamask/account-api';
import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api';
import { KeyringTypes } from '@metamask/keyring-controller';

import type { MultichainAccountGroup } from './MultichainAccountGroup';
import { MultichainAccountWallet } from './MultichainAccountWallet';
import { EvmAccountProvider } from './providers/EvmAccountProvider';
import {
ProviderWrapper,
isProviderWrapper,
} from './providers/ProviderWrapper';
import { SolAccountProvider } from './providers/SolAccountProvider';
import type { MultichainAccountServiceMessenger } from './types';

Expand Down Expand Up @@ -40,7 +44,10 @@ type AccountContext<Account extends Bip44Account<KeyringAccount>> = {
export class MultichainAccountService {
readonly #messenger: MultichainAccountServiceMessenger;

readonly #providers: AccountProvider<Bip44Account<KeyringAccount>>[];
readonly #providers: (
| AccountProvider<Bip44Account<KeyringAccount>>
| ProviderWrapper
)[];

readonly #wallets: Map<
MultichainAccountWalletId,
Expand Down Expand Up @@ -73,10 +80,11 @@ export class MultichainAccountService {
this.#messenger = messenger;
this.#wallets = new Map();
this.#accountIdToContext = new Map();

// TODO: Rely on keyring capabilities once the keyring API is used by all keyrings.
this.#providers = [
new EvmAccountProvider(this.#messenger),
new SolAccountProvider(this.#messenger),
new ProviderWrapper(new SolAccountProvider(this.#messenger)),
// Custom account providers that can be provided by the MetaMask client.
...providers,
];
Expand Down Expand Up @@ -105,6 +113,10 @@ export class MultichainAccountService {
'MultichainAccountService:createMultichainAccountGroup',
(...args) => this.createMultichainAccountGroup(...args),
);
this.#messenger.registerActionHandler(
'MultichainAccountService:setBasicFunctionality',
(...args) => this.setBasicFunctionality(...args),
);
this.#messenger.registerActionHandler(
'MultichainAccountService:alignWallets',
(...args) => this.alignWallets(...args),
Expand Down Expand Up @@ -359,6 +371,34 @@ export class MultichainAccountService {
);
}

/**
* Set basic functionality state and trigger alignment if enabled.
* When basic functionality is disabled, snap-based providers are disabled.
* When enabled, all snap providers are enabled and wallet alignment is triggered.
* EVM providers are never disabled as they're required for basic wallet functionality.
*
* @param enabled - Whether basic functionality is enabled.
*/
async setBasicFunctionality(enabled: boolean): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

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

IDK if we should have this as part of the public API 🤔

My initial idea was to have the "account provider wrapper" to "react" to some events (or maybe a runtime callback) and adapt each of their methods to return all accounts or no accounts (based on the "basic functionality flag").

Though, I haven't checked of this was done on both clients, so maybe it's not that easy to do

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is something to discuss @ccharly . It's an architectural decision, and I chose the simplest path (multichain account service was already available to both places where basicFunctionality is toggled in extension and mobile).

Are you suggesting we use MultichainAccountServiceMessenger to dispatch a MultichainAccountServiceSetBasicFunctionalityAction ? I'm not sure how to get a ref to that messenger in mobile tbh.

Copy link
Contributor

Choose a reason for hiding this comment

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

Well, let's be pragmatic here and keep your implementation for now. It's simple enough and does get the job done, so that's ok! 👍

We can re-visit that later once we have a bit more time.

console.log(
`MultichainAccountService: Setting basic functionality ${enabled ? 'enabled' : 'disabled'}`,
);

// Loop through providers and disable only wrapped ones when basic functionality is disabled
for (const provider of this.#providers) {
if (isProviderWrapper(provider)) {
provider.setDisabled(!enabled);
}
// Regular providers (like EVM) are never disabled for basic functionality
}

// Trigger alignment only when basic functionality is enabled
if (enabled) {
console.log('Triggered wallet alignment...');
await this.alignWallets();
}
}

/**
* Align all multichain account wallets.
*/
Expand Down
129 changes: 129 additions & 0 deletions packages/multichain-account-service/src/providers/ProviderWrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { AccountProvider, Bip44Account } from '@metamask/account-api';
import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api';

/**
* A simple wrapper that adds disable functionality to any AccountProvider.
* When disabled, the provider will not create new accounts and return empty results.
*/
export class ProviderWrapper
implements AccountProvider<Bip44Account<KeyringAccount>>
{
private isDisabled: boolean = false;

private readonly provider: AccountProvider<Bip44Account<KeyringAccount>>;

constructor(provider: AccountProvider<Bip44Account<KeyringAccount>>) {
this.provider = provider;
}

/**
* Set the disabled state for this provider.
*
* @param disabled - Whether the provider should be disabled.
*/
setDisabled(disabled: boolean): void {
this.isDisabled = disabled;
console.log(
`Provider ${this.provider.constructor.name} ${disabled ? 'disabled' : 'enabled'}`,
);
}

/**
* Get accounts, returns empty array when disabled.
*
* @returns Array of accounts, or empty array if disabled.
*/
getAccounts(): Bip44Account<KeyringAccount>[] {
if (this.isDisabled) {
return [];
}
return this.provider.getAccounts();
}

/**
* Get account by ID, throws error when disabled.
*
* @param id - The account ID to retrieve.
* @returns The account with the specified ID, or undefined if not found.
*/
getAccount(
id: Bip44Account<KeyringAccount>['id'],
): Bip44Account<KeyringAccount> | undefined {
if (this.isDisabled) {
throw new Error(`Provider ${this.provider.constructor.name} is disabled`);
}
return this.provider.getAccount(id);
}

/**
* Create accounts, returns empty array when disabled.
*
* @param options - Account creation options.
* @param options.entropySource - The entropy source to use.
* @param options.groupIndex - The group index to use.
* @returns Promise resolving to created accounts, or empty array if disabled.
*/
async createAccounts(options: {
entropySource: EntropySourceId;
groupIndex: number;
}): Promise<Bip44Account<KeyringAccount>[]> {
if (this.isDisabled) {
console.log(
`Provider ${this.provider.constructor.name} is disabled - skipping account creation`,
);
return [];
}
return this.provider.createAccounts(options);
}

/**
* Discover and create accounts, returns empty array when disabled.
*
* @param options - Account discovery options.
* @param options.entropySource - The entropy source to use.
* @param options.groupIndex - The group index to use.
* @returns Promise resolving to discovered accounts, or empty array if disabled.
*/
async discoverAndCreateAccounts(options: {
entropySource: EntropySourceId;
groupIndex: number;
}): Promise<Bip44Account<KeyringAccount>[]> {
if (this.isDisabled) {
console.log(
`Provider ${this.provider.constructor.name} is disabled - skipping account discovery`,
);
return [];
}
return this.provider.discoverAndCreateAccounts(options);
}

/**
* Check if account is compatible.
*
* @param account - The account to check.
* @returns True if the account is compatible.
*/
isAccountCompatible(account: Bip44Account<KeyringAccount>): boolean {
// Check if the provider has the method (from BaseAccountProvider)
if (
'isAccountCompatible' in this.provider &&
typeof this.provider.isAccountCompatible === 'function'
) {
return this.provider.isAccountCompatible(account);
}
// Fallback: return true if the method doesn't exist
return true;
}
}

/**
* Simple type guard to check if a provider is wrapped.
*
* @param provider - The provider to check.
* @returns True if the provider is a ProviderWrapper.
*/
export function isProviderWrapper(
provider: AccountProvider<Bip44Account<KeyringAccount>> | ProviderWrapper,
): provider is ProviderWrapper {
return provider instanceof ProviderWrapper;
}
Loading
Loading