Skip to content
Draft
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
10 changes: 10 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `webSocketEnabledNamespaces` constructor option to gate non-EVM account-activity balances (e.g. Solana) behind a feature flag ([#9430](https://github.com/MetaMask/core/pull/9430))
- The getter returns the non-EVM CAIP namespaces (e.g. `['solana']`, and later `['solana', 'bip122']` for Tron) served over WebSocket. EVM (`eip155`) is always served over WebSocket.
- For each enabled namespace (while the WebSocket is connected), `BackendWebsocketDataSource` claims that namespace's chains and streams real-time balances for them; disabled namespaces are left unclaimed and unsubscribed so the `SnapDataSource` serves them.
- When the WebSocket is down, those chains are released, so the `SnapDataSource` takes over as a fallback.
- When a chain (or a whole non-EVM namespace, e.g. Solana) is reported down via `system-notifications`, it is excluded from the WebSocket source's claimable chains and automatically falls back to the `SnapDataSource` until it recovers.
- Defaults to none (`() => []`; EVM only).

### Changed

- `AccountsApiDataSource` now documents that it serves EVM chains only; non-EVM chains (e.g. Solana) are served by the WebSocket source when enabled, otherwise by the Snap source ([#9430](https://github.com/MetaMask/core/pull/9430))
- `MulticallClient` memoizes `balanceOf` and `getEthBalance` call encodings per account address when building multicall batches, reducing redundant ABI encoding for wallets with many tokens ([#9425](https://github.com/MetaMask/core/pull/9425))
- Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421))
- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390))
Expand Down
16 changes: 16 additions & 0 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,19 @@ export type AssetsControllerOptions = {
subscribeToBasicFunctionalityChange?: (
onChange: (isBasic: boolean) => void,
) => void | (() => void);
/**
* Getter for the non-EVM CAIP namespaces (e.g. 'solana', and later 'bip122'
* for Tron) whose account activity should be served over WebSocket. EVM
* ('eip155') is always served over WebSocket. For each returned namespace
* (and while the WebSocket is connected), the BackendWebsocketDataSource
* claims that namespace's chains and streams real-time balances for them.
* Namespaces not returned are left unclaimed so the SnapDataSource serves
* them instead. When the WebSocket is down, those chains are released
* regardless, so the SnapDataSource takes over as a fallback. No value is
* stored; the getter is invoked when needed. Defaults to () => [] when not
* provided (EVM only; Snap serves non-EVM).
*/
webSocketEnabledNamespaces?: () => string[];
/**
* API client for balance/price/metadata. The controller instantiates data sources
* and uses them directly when this is provided.
Expand Down Expand Up @@ -849,6 +862,7 @@ export class AssetsController extends BaseController<
isEnabled = (): boolean => true,
isBasicFunctionality,
subscribeToBasicFunctionalityChange,
webSocketEnabledNamespaces,
queryApiClient,
rpcDataSourceConfig,
trace,
Expand Down Expand Up @@ -912,6 +926,8 @@ export class AssetsController extends BaseController<
onActiveChainsUpdated: this.#onActiveChainsUpdated,
getAssetType: (assetId: Caip19AssetId): 'native' | 'erc20' | 'spl' =>
this.#getAssetType(assetId),
getWebSocketEnabledNamespaces:
webSocketEnabledNamespaces ?? ((): string[] => []),
});
this.#accountsApiDataSource = new AccountsApiDataSource({
queryApiClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,13 @@ export class AccountsApiDataSource extends AbstractDataSource<
async #fetchActiveChains(): Promise<ChainId[]> {
const response = await this.#apiClient.accounts.fetchV2SupportedNetworks();

// Use fullSupport networks as active chains
return (
response.fullSupport
.map(decimalToChainId)
// TODO Restore solana when there is a fix for how we handle non-evm chains here
.filter((chainId) => chainId.startsWith('eip155:'))
);
// Use fullSupport networks as active chains. This source serves EVM chains
// only; non-EVM chains (e.g. Solana) are served by the WebSocket source when
// their namespace is enabled, otherwise by the Snap source, so they are
// intentionally excluded from this source's claimable chains.
return response.fullSupport
.map(decimalToChainId)
.filter((chainId) => chainId.startsWith('eip155:'));
}

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,13 @@ function setupController(
options: {
initialActiveChains?: ChainId[];
connectionState?: WebSocketState;
webSocketEnabledNamespaces?: string[];
} = {},
): SetupResult {
const {
initialActiveChains = [],
connectionState = WebSocketState.CONNECTED,
webSocketEnabledNamespaces = [],
} = options;

const rootMessenger = new Messenger<MockAnyNamespace, AllActions, AllEvents>({
Expand Down Expand Up @@ -207,6 +209,7 @@ function setupController(
onActiveChainsUpdated: (dataSourceName, chains, previousChains): void =>
activeChainsUpdateHandler(dataSourceName, chains, previousChains),
getAssetType: getAssetTypeFn,
getWebSocketEnabledNamespaces: (): string[] => webSocketEnabledNamespaces,
state: { activeChains: initialActiveChains },
});

Expand Down Expand Up @@ -402,6 +405,7 @@ describe('BackendWebsocketDataSource', () => {
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId,
],
connectionState: WebSocketState.CONNECTED,
webSocketEnabledNamespaces: ['solana'],
});

await controller.subscribe({
Expand Down Expand Up @@ -446,6 +450,155 @@ describe('BackendWebsocketDataSource', () => {
controller.destroy();
});

describe('Solana account-activity feature flag', () => {
const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId;
const SOLANA_ADDRESS = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU';

it('does not subscribe Solana channels when the flag is disabled', async () => {
const { controller, wsSubscribeMock } = setupController({
initialActiveChains: [CHAIN_MAINNET, SOLANA_MAINNET],
connectionState: WebSocketState.CONNECTED,
webSocketEnabledNamespaces: [],
});

await controller.subscribe({
subscriptionId: 'sub-1',
request: createDataRequest({
chainIds: [CHAIN_MAINNET, SOLANA_MAINNET],
accountsWithSupportedChains: [
{
account: createMockAccount(),
supportedChains: [CHAIN_MAINNET],
},
{
account: createMockAccount({
id: 'solana-account-id',
address: SOLANA_ADDRESS,
type: 'solana:data-account',
scopes: ['solana:0'],
}),
supportedChains: [SOLANA_MAINNET],
},
],
}),
isUpdate: false,
onAssetsUpdate: jest.fn(),
});

const subscribedChannels: string[] = wsSubscribeMock.mock.calls.flatMap(
([arg]) => arg.channels as string[],
);
expect(subscribedChannels).toContain(
`account-activity.v1.eip155:0:${MOCK_ADDRESS.toLowerCase()}`,
);
expect(
subscribedChannels.some((channel) =>
channel.startsWith('account-activity.v1.solana'),
),
).toBe(false);

controller.destroy();
});

it('claims Solana chains on reconnect when the flag is enabled', async () => {
const {
controller,
activeChainsUpdateHandler,
triggerConnectionStateChange,
} = setupController({
initialActiveChains: [CHAIN_MAINNET],
connectionState: WebSocketState.DISCONNECTED,
webSocketEnabledNamespaces: ['solana'],
});

// Let #initializeActiveChains populate #supportedChains from the API.
await new Promise(process.nextTick);

triggerConnectionStateChange(WebSocketState.CONNECTED);
await new Promise(process.nextTick);

const claimedChains: ChainId[] =
activeChainsUpdateHandler.mock.calls.at(-1)?.[1] ?? [];
expect(claimedChains).toContain(SOLANA_MAINNET);

controller.destroy();
});

it('does not claim Solana chains on reconnect when the flag is disabled', async () => {
const {
controller,
activeChainsUpdateHandler,
triggerConnectionStateChange,
} = setupController({
initialActiveChains: [CHAIN_MAINNET],
connectionState: WebSocketState.DISCONNECTED,
webSocketEnabledNamespaces: [],
});

await new Promise(process.nextTick);

triggerConnectionStateChange(WebSocketState.CONNECTED);
await new Promise(process.nextTick);

const claimedChains: ChainId[] =
activeChainsUpdateHandler.mock.calls.at(-1)?.[1] ?? [];
expect(claimedChains).not.toContain(SOLANA_MAINNET);

controller.destroy();
});

it('releases Solana to Snap when reported down via system-notifications, then reclaims when up', async () => {
const {
controller,
activeChainsUpdateHandler,
addChannelCallbackMock,
triggerConnectionStateChange,
} = setupController({
initialActiveChains: [CHAIN_MAINNET],
connectionState: WebSocketState.DISCONNECTED,
webSocketEnabledNamespaces: ['solana'],
});

await new Promise(process.nextTick);
triggerConnectionStateChange(WebSocketState.CONNECTED);
await new Promise(process.nextTick);

// Solana is claimed while healthy.
expect(activeChainsUpdateHandler.mock.calls.at(-1)?.[1]).toContain(
SOLANA_MAINNET,
);

const systemNotificationCallback = addChannelCallbackMock.mock.calls.find(
([arg]) =>
arg.channelName === 'system-notifications.v1.account-activity.v1',
)?.[0]?.callback as (notification: unknown) => void;

// Solana reported down -> released so Snap can claim it.
systemNotificationCallback({
event: 'notification',
channel: 'system-notifications.v1.account-activity.v1',
timestamp: 1,
data: { chainIds: [SOLANA_MAINNET], status: 'down' },
});
expect(activeChainsUpdateHandler.mock.calls.at(-1)?.[1]).not.toContain(
SOLANA_MAINNET,
);

// Solana back up -> reclaimed by the WebSocket source.
systemNotificationCallback({
event: 'notification',
channel: 'system-notifications.v1.account-activity.v1',
timestamp: 2,
data: { chainIds: [SOLANA_MAINNET], status: 'up' },
});
expect(activeChainsUpdateHandler.mock.calls.at(-1)?.[1]).toContain(
SOLANA_MAINNET,
);

controller.destroy();
});
});

it('subscribe update only changes chains if addresses unchanged', async () => {
const { controller, wsSubscribeMock } = setupController({
initialActiveChains: [CHAIN_MAINNET, CHAIN_POLYGON],
Expand Down
Loading