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

## [Unreleased]

### Added

- 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).

### Changed

- Refactor data source tests to use shared `MockAssetControllerMessenger` fixture ([#7958](https://github.com/MetaMask/core/pull/7958))
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@metamask/account-tree-controller": "^4.1.1",
"@metamask/assets-controllers": "^99.4.0",
"@metamask/base-controller": "^9.0.0",
"@metamask/client-controller": "^1.0.0",
"@metamask/controller-utils": "^11.18.0",
"@metamask/core-backend": "^5.1.1",
"@metamask/keyring-api": "^21.5.0",
Expand Down
108 changes: 84 additions & 24 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
ControllerStateChangeEvent,
StateMetadata,
} from '@metamask/base-controller';
import type {

Check failure on line 12 in packages/assets-controller/src/AssetsController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

`@metamask/core-backend` type import should occur after import of `@metamask/client-controller`
ApiPlatformClient,
BackendWebSocketServiceActions,
BackendWebSocketServiceEvents,
} from '@metamask/core-backend';
import type { ClientControllerStateChangeEvent } from '@metamask/client-controller';
import { clientControllerSelectors } from '@metamask/client-controller';
import type {
KeyringControllerLockEvent,
KeyringControllerUnlockEvent,
Expand Down Expand Up @@ -73,9 +75,11 @@
import { TokenDataSource } from './data-sources/TokenDataSource';
import { projectLogger, createModuleLogger } from './logger';
import { DetectionMiddleware } from './middlewares/DetectionMiddleware';
import { createParallelBalanceMiddleware } from './middlewares/parallelBalanceMiddleware';
import type {
AccountId,
AssetPreferences,
AssetsUpdateMode,
ChainId,
Caip19AssetId,
AssetMetadata,
Expand Down Expand Up @@ -225,6 +229,7 @@
type AllowedEvents =
// AssetsController
| AccountTreeControllerSelectedAccountGroupChangeEvent
| ClientControllerStateChangeEvent
| KeyringControllerLockEvent
| KeyringControllerUnlockEvent
| PreferencesControllerStateChangeEvent
Expand Down Expand Up @@ -418,6 +423,10 @@
normalized.errors = { ...response.errors };
}

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

return normalized;
}

Expand All @@ -441,8 +450,10 @@
* 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 +483,12 @@
/** 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 +638,7 @@
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 +739,36 @@
},
);

// 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 @@ -907,10 +951,12 @@
});
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,
Expand All @@ -924,7 +970,7 @@
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 +1245,8 @@
// ============================================================================

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 +1294,35 @@
)) {
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 +1332,7 @@
});
}
}

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

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
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ export class RpcDataSource extends AbstractDataSource<
[result.accountId]: newBalances,
},
assetsInfo,
updateMode: 'full',
};

log('Balance update response', {
Expand Down Expand Up @@ -483,6 +484,7 @@ export class RpcDataSource extends AbstractDataSource<
assetsBalance: {
[result.accountId]: newBalances,
},
updateMode: 'full',
};

for (const subscription of this.#activeSubscriptions.values()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ describe('SnapDataSource', () => {
expect(response).toStrictEqual({
assetsBalance: {},
assetsInfo: {},
updateMode: 'full',
});

cleanup();
Expand Down Expand Up @@ -469,6 +470,7 @@ describe('SnapDataSource', () => {
expect(response).toStrictEqual({
assetsBalance: {},
assetsInfo: {},
updateMode: 'full',
});

cleanup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ export class SnapDataSource extends AbstractDataSource<

// Only report if we have snap-related updates
if (assetsBalance) {
const response: DataResponse = { assetsBalance };
const response: DataResponse = { assetsBalance, updateMode: 'merge' };
for (const subscription of this.activeSubscriptions.values()) {
subscription.onAssetsUpdate(response)?.catch(console.error);
}
Expand Down Expand Up @@ -439,12 +439,13 @@ export class SnapDataSource extends AbstractDataSource<
return {};
}
if (!request?.accountsWithSupportedChains?.length) {
return { assetsBalance: {}, assetsInfo: {} };
return { assetsBalance: {}, assetsInfo: {}, updateMode: 'full' };
}

const results: DataResponse = {
assetsBalance: {},
assetsInfo: {},
updateMode: 'full',
};

// Fetch balances for each account using its snap ID from metadata
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type {
DataType,
DataRequest,
DataResponse,
AssetsUpdateMode,
// Middleware types
Context,
NextFunction,
Expand Down
5 changes: 5 additions & 0 deletions packages/assets-controller/src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { DetectionMiddleware } from './DetectionMiddleware';
export {
createParallelBalanceMiddleware,
mergeDataResponses,
} from './parallelBalanceMiddleware';
export type { BalanceSource } from './parallelBalanceMiddleware';

Check failure on line 6 in packages/assets-controller/src/middlewares/index.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Insert `⏎`
Loading
Loading