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

## [Unreleased]

### Added

- Add parallel middlewares in `ParallelMiddleware.ts`: `createParallelBalanceMiddleware` runs balance data sources (Accounts API, Snap, RPC) in parallel with chain partitioning and a fallback round for failed chains; `createParallelMiddleware` runs TokenDataSource and PriceDataSource in parallel (same request, merged response). Both use `mergeDataResponses` and limited concurrency via `p-limit` ([#7950](https://github.com/MetaMask/core/pull/7950))
- Add `@metamask/client-controller` dependency and subscribe to `ClientController:stateChange`. Asset tracking runs only when the UI is open (ClientController) and the keyring is unlocked (KeyringController), and stops when either the UI closes or the keyring locks (Client + Keyring lifecycle) ([#7950](https://github.com/MetaMask/core/pull/7950))
- Add full and merge update modes: `DataResponse.updateMode` and type `AssetsUpdateMode` (`'full'` | `'merge'`). Fetch uses `'full'` (response is authoritative for scope; custom assets not in response are preserved). Subscriptions could use `'merge'` or `'full'` depending on data sources. Default is `'merge'` when omitted ([#7950](https://github.com/MetaMask/core/pull/7950))

### Changed

- Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.18.0` ([#8005](https://github.com/MetaMask/core/pull/8005))
Expand Down
4 changes: 3 additions & 1 deletion packages/assets-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@metamask/account-tree-controller": "^4.1.1",
"@metamask/assets-controllers": "^100.0.2",
"@metamask/base-controller": "^9.0.0",
"@metamask/client-controller": "^1.0.0",
"@metamask/controller-utils": "^11.19.0",
"@metamask/core-backend": "^6.0.0",
"@metamask/keyring-api": "^21.5.0",
Expand All @@ -73,7 +74,8 @@
"@metamask/utils": "^11.9.0",
"async-mutex": "^0.5.0",
"bignumber.js": "^9.1.2",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"p-limit": "^3.1.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
Expand Down
35 changes: 33 additions & 2 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ function createMockInternalAccount(
type WithControllerOptions = {
state?: Partial<AssetsControllerState>;
isBasicFunctionality?: () => boolean;
/**
* When set, registers ClientController:getState so the controller sees this UI state.
* Required for tests that rely on asset tracking running (e.g. trackMetaMetricsEvent on unlock).
*/
clientControllerState?: { isUiOpen: boolean };
/** Extra options passed to AssetsController constructor (e.g. trackMetaMetricsEvent). */
controllerOptions?: Partial<{
trackMetaMetricsEvent: (
Expand Down Expand Up @@ -91,6 +96,7 @@ async function withController<ReturnValue>(
{
state = {},
isBasicFunctionality = (): boolean => true,
clientControllerState,
controllerOptions = {},
},
fn,
Expand Down Expand Up @@ -144,6 +150,17 @@ async function withController<ReturnValue>(
tokensChainsCache: {},
}));

if (clientControllerState !== undefined) {
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler(
'ClientController:getState',
() => clientControllerState,
);
}

const controller = new AssetsController({
messenger: messenger as unknown as AssetsControllerMessenger,
state,
Expand Down Expand Up @@ -814,8 +831,15 @@ describe('AssetsController', () => {
const trackMetaMetricsEvent = jest.fn();

await withController(
{ controllerOptions: { trackMetaMetricsEvent } },
{
clientControllerState: { isUiOpen: true },
controllerOptions: { trackMetaMetricsEvent },
},
async ({ messenger }) => {
// UI must be open and keyring unlocked for asset tracking to run
(
messenger as { publish: (topic: string, payload?: unknown) => void }
).publish('ClientController:stateChange', { isUiOpen: true });
messenger.publish('KeyringController:unlock');

// Allow #start() -> getAssets() to resolve so the callback runs
Expand All @@ -842,8 +866,15 @@ describe('AssetsController', () => {
const trackMetaMetricsEvent = jest.fn();

await withController(
{ controllerOptions: { trackMetaMetricsEvent } },
{
clientControllerState: { isUiOpen: true },
controllerOptions: { trackMetaMetricsEvent },
},
async ({ messenger }) => {
// UI must be open and keyring unlocked for asset tracking to run
(
messenger as { publish: (topic: string, payload?: unknown) => void }
).publish('ClientController:stateChange', { isUiOpen: true });
messenger.publish('KeyringController:unlock');
await new Promise((resolve) => setTimeout(resolve, 100));

Expand Down
133 changes: 104 additions & 29 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
ControllerStateChangeEvent,
StateMetadata,
} from '@metamask/base-controller';
import type { ClientControllerStateChangeEvent } from '@metamask/client-controller';
import { clientControllerSelectors } from '@metamask/client-controller';
import type {
ApiPlatformClient,
BackendWebSocketServiceActions,
Expand Down Expand Up @@ -73,9 +75,14 @@ import { StakedBalanceDataSource } from './data-sources/StakedBalanceDataSource'
import { TokenDataSource } from './data-sources/TokenDataSource';
import { projectLogger, createModuleLogger } from './logger';
import { DetectionMiddleware } from './middlewares/DetectionMiddleware';
import {
createParallelBalanceMiddleware,
createParallelMiddleware,
} from './middlewares/ParallelMiddleware';
import type {
AccountId,
AssetPreferences,
AssetsUpdateMode,
ChainId,
Caip19AssetId,
AssetMetadata,
Expand Down Expand Up @@ -225,6 +232,7 @@ type AllowedActions =
type AllowedEvents =
// AssetsController
| AccountTreeControllerSelectedAccountGroupChangeEvent
| ClientControllerStateChangeEvent
| KeyringControllerLockEvent
| KeyringControllerUnlockEvent
| PreferencesControllerStateChangeEvent
Expand Down Expand Up @@ -418,6 +426,10 @@ function normalizeResponse(response: DataResponse): DataResponse {
normalized.errors = { ...response.errors };
}

if (response.updateMode) {
normalized.updateMode = response.updateMode;
}

return normalized;
}

Expand All @@ -441,8 +453,10 @@ function normalizeResponse(response: DataResponse): DataResponse {
* based on which chains they support. When active chains change, the controller
* dynamically adjusts subscriptions.
*
* 4. **Keyring Lifecycle**: Listens to KeyringController unlock/lock events to
* start/stop subscriptions when the wallet is unlocked or locked.
* 4. **Client + Keyring Lifecycle**: Starts subscriptions only when both the UI is
* open (ClientController) and the wallet is unlocked (KeyringController).
* Stops when either the UI closes or the keyring locks. See client-controller
* README for the combined pattern.
*
* ## Architecture
*
Expand Down Expand Up @@ -472,6 +486,12 @@ export class AssetsController extends BaseController<
/** Whether we have already reported first init fetch for this session (reset on #stop). */
#firstInitFetchReported = false;

/** Whether the client (UI) is open. Combined with #keyringUnlocked for #updateActive. */
#uiOpen = false;

/** Whether the keyring is unlocked. Combined with #uiOpen for #updateActive. */
#keyringUnlocked = false;

readonly #controllerMutex = new Mutex();

/**
Expand Down Expand Up @@ -621,7 +641,7 @@ export class AssetsController extends BaseController<
this.#initializeState();
this.#subscribeToEvents();
this.#registerActionHandlers();
// Subscriptions start only on KeyringController:unlock -> #start(), not here.
// Subscriptions start only when both UI is open and keyring unlocked -> #updateActive().

// Subscribe to basic-functionality changes after construction so a synchronous
// onChange during subscribe cannot run before data sources are initialized.
Expand Down Expand Up @@ -722,9 +742,36 @@ export class AssetsController extends BaseController<
},
);

// Keyring lifecycle: start when unlocked, stop when locked
this.messenger.subscribe('KeyringController:unlock', () => this.#start());
this.messenger.subscribe('KeyringController:lock', () => this.#stop());
// Client + Keyring lifecycle: only run when UI is open AND keyring is unlocked
this.messenger.subscribe(
'ClientController:stateChange',
(isUiOpen: boolean) => {
this.#uiOpen = isUiOpen;
this.#updateActive();
},
clientControllerSelectors.selectIsUiOpen,
);
this.messenger.subscribe('KeyringController:unlock', () => {
this.#keyringUnlocked = true;
this.#updateActive();
});
this.messenger.subscribe('KeyringController:lock', () => {
this.#keyringUnlocked = false;
this.#updateActive();
});
}

/**
* Start or stop asset tracking based on client (UI) open state and keyring
* unlock state. Only runs when both UI is open and keyring is unlocked.
*/
#updateActive(): void {
const shouldRun = this.#uiOpen && this.#keyringUnlocked;
if (shouldRun) {
this.#start();
} else {
this.#stop();
}
}

#registerActionHandlers(): void {
Expand Down Expand Up @@ -890,6 +937,10 @@ export class AssetsController extends BaseController<
const assetTypes = options?.assetTypes ?? ['fungible'];
const dataTypes = options?.dataTypes ?? ['balance', 'metadata', 'price'];

if (accounts.length === 0 || chainIds.length === 0) {
return this.#getAssetsFromState(accounts, chainIds, assetTypes);
}

// Collect custom assets for all requested accounts
const customAssets: Caip19AssetId[] = [];
for (const account of accounts) {
Expand All @@ -907,13 +958,17 @@ export class AssetsController extends BaseController<
});
const sources = this.#isBasicFunctionality()
? [
this.#accountsApiDataSource,
this.#snapDataSource,
this.#rpcDataSource,
this.#stakedBalanceDataSource,
createParallelBalanceMiddleware([
this.#accountsApiDataSource,
this.#snapDataSource,
this.#rpcDataSource,
this.#stakedBalanceDataSource,
]),
this.#detectionMiddleware,
this.#tokenDataSource,
this.#priceDataSource,
createParallelMiddleware([
this.#tokenDataSource,
this.#priceDataSource,
]),
]
: [
this.#rpcDataSource,
Expand All @@ -924,7 +979,7 @@ export class AssetsController extends BaseController<
sources,
request,
);
await this.#updateState(response);
await this.#updateState({ ...response, updateMode: 'full' });
if (this.#trackMetaMetricsEvent && !this.#firstInitFetchReported) {
this.#firstInitFetchReported = true;
const durationMs = Date.now() - startTime;
Expand Down Expand Up @@ -1199,8 +1254,8 @@ export class AssetsController extends BaseController<
// ============================================================================

async #updateState(response: DataResponse): Promise<void> {
// Normalize asset IDs (checksum EVM addresses) before storing in state
const normalizedResponse = normalizeResponse(response);
const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge';

const releaseLock = await this.#controllerMutex.acquire();

Expand Down Expand Up @@ -1248,20 +1303,35 @@ export class AssetsController extends BaseController<
)) {
const previousBalances =
previousState.assetsBalance[accountId] ?? {};

if (!balances[accountId]) {
balances[accountId] = {};
}

for (const [assetId, balance] of Object.entries(accountBalances)) {
const customAssetIds =
(state.customAssets as Record<string, Caip19AssetId[]>)[
accountId
] ?? [];

// Full: response is authoritative; preserve custom assets not in response. Merge: response overlays previous.
const effective: Record<string, AssetBalance> =
mode === 'full'
? ((): Record<string, AssetBalance> => {
const next: Record<string, AssetBalance> = {
...accountBalances,
};
for (const customId of customAssetIds) {
if (!(customId in next)) {
const prev = previousBalances[customId];
next[customId] =
prev ?? ({ amount: '0' } as AssetBalance);
}
}
return next;
})()
: { ...previousBalances, ...accountBalances };

for (const [assetId, balance] of Object.entries(effective)) {
const previousBalance = previousBalances[
assetId as Caip19AssetId
] as { amount: string } | undefined;
const balanceData = balance as { amount: string };
const newAmount = balanceData.amount;
const newAmount = (balance as { amount: string }).amount;
const oldAmount = previousBalance?.amount;

// Track if balance actually changed
if (oldAmount !== newAmount) {
changedBalances.push({
accountId,
Expand All @@ -1271,8 +1341,7 @@ export class AssetsController extends BaseController<
});
}
}

Object.assign(balances[accountId], accountBalances);
balances[accountId] = effective;
}
}

Expand Down Expand Up @@ -1537,7 +1606,7 @@ export class AssetsController extends BaseController<
* Subscribe to asset updates for all selected accounts.
*/
#subscribeAssets(): void {
if (this.#selectedAccounts.length === 0) {
if (this.#selectedAccounts.length === 0 || this.#enabledChains.size === 0) {
Copy link

Choose a reason for hiding this comment

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

handleBasicFunctionalityChange bypasses new UI+keyring lifecycle guard

Low Severity

handleBasicFunctionalityChange calls #stop() then #subscribeAssets() directly, bypassing the new #updateActive() guard that requires both #uiOpen and #keyringUnlocked to be true. This creates subscriptions even when the controller should be inactive according to the new combined lifecycle model. It could call #updateActive() instead to respect the new invariant.

Additional Locations (1)

Fix in Cursor Fix in Web

return;
}

Expand Down Expand Up @@ -1909,10 +1978,16 @@ export class AssetsController extends BaseController<
hasPrice: Boolean(response.assetsPrice),
});

// Run through enrichment middlewares (Event Stack: Detection Token Price)
// Run through enrichment middlewares (Detection, then Token + Price in parallel)
// Include 'metadata' in dataTypes so TokenDataSource runs to enrich detected assets
const { response: enrichedResponse } = await this.#executeMiddlewares(
[this.#detectionMiddleware, this.#tokenDataSource, this.#priceDataSource],
[
this.#detectionMiddleware,
createParallelMiddleware([
this.#tokenDataSource,
this.#priceDataSource,
]),
],
request ?? {
accountsWithSupportedChains: [],
chainIds: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export class AccountsApiDataSource extends AbstractDataSource<
);

response.assetsBalance = assetsBalance;
response.updateMode = 'full';
} catch (error) {
log('Fetch FAILED', { error, chains: chainsToFetch });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ export class BackendWebsocketDataSource extends AbstractDataSource<
};
}

const response: DataResponse = {};
const response: DataResponse = { updateMode: 'merge' };
if (Object.keys(assetsBalance[accountId]).length > 0) {
response.assetsBalance = assetsBalance;
response.assetsInfo = assetsMetadata;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,10 @@ export class PriceDataSource {
fetchResponse.assetsPrice &&
Object.keys(fetchResponse.assetsPrice).length > 0
) {
await subscription.onAssetsUpdate(fetchResponse);
await subscription.onAssetsUpdate({
...fetchResponse,
updateMode: 'merge',
});
}
} catch (error) {
log('Subscription poll failed', { subscriptionId, error });
Expand Down
Loading
Loading