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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ linkStyle default opacity:0.5
approval_controller --> base_controller;
approval_controller --> messenger;
assets_controller --> account_tree_controller;
assets_controller --> accounts_controller;
assets_controller --> assets_controllers;
assets_controller --> base_controller;
assets_controller --> client_controller;
Expand Down
15 changes: 15 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added `isOnboarded` option to `AssetsControllerOptions` and `RpcDataSourceConfig` ([#8430](https://github.com/MetaMask/core/pull/8430))
- When `isOnboarded` returns `false`, `RpcDataSource` skips `fetch` and `subscribe` calls, preventing on-chain RPC calls before onboarding is complete.
- Defaults to `() => true` so existing consumers are unaffected.

### Changed

- Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.1.0` ([#8432](https://github.com/MetaMask/core/pull/8432))

### Fixed

- `AssetsController` now re-subscribes to all data sources when `AccountTreeController` state changes after initial startup, ensuring snap accounts and their chains are included ([#8430](https://github.com/MetaMask/core/pull/8430))
- Previously, `#start()` would create subscriptions before snap accounts were available, and its idempotency guard prevented re-subscription when the full account list arrived.
- Added `#handleAccountTreeStateChange()` which forces a full re-subscription and re-fetch when the account tree updates, picking up all accounts including snaps.
- Added `AccountsController:getSelectedAccount` as a fallback in `#getSelectedAccounts()` for when the account tree is not yet initialized.
- `TokenDataSource` no longer filters out user-imported custom assets with the `MIN_TOKEN_OCCURRENCES` spam filter or Blockaid bulk scan ([#8430](https://github.com/MetaMask/core/pull/8430))
- Tokens present in `customAssets` state now bypass the EVM occurrence threshold and non-EVM Blockaid scan, ensuring manually imported assets always appear.
- `BackendWebsocketDataSource` now properly releases chains to `AccountsApiDataSource` when the websocket is disconnected or disabled ([#8430](https://github.com/MetaMask/core/pull/8430))
- Previously, `BackendWebsocketDataSource` eagerly claimed all supported chains on initialization regardless of connection state, preventing `AccountsApiDataSource` from polling.
- Chains are now only claimed when the websocket is connected. On disconnect, chains are released so the chain-claiming loop assigns them to `AccountsApiDataSource` for polling fallback. On reconnect, chains are reclaimed.
- `AssetsController` no longer silently skips asset fetching on startup for returning users ([#8412](https://github.com/MetaMask/core/pull/8412))
- Previously, `#start()` was called at keyring unlock before `AccountTreeController.init()` had built the account tree, causing `#selectedAccounts` to return an empty array and all subscriptions and fetches to be skipped. `selectedAccountGroupChange` does not fire when the persisted selected group is unchanged, leaving the controller idle.
- Now subscribes to `AccountTreeController:stateChange` (the base-controller event guaranteed to fire when `init()` calls `this.update()`), so the controller re-evaluates its active state once accounts are available.
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 @@ -53,6 +53,7 @@
"@ethersproject/abi": "^5.7.0",
"@ethersproject/providers": "^5.7.0",
"@metamask/account-tree-controller": "^7.0.0",
"@metamask/accounts-controller": "^37.2.0",
"@metamask/assets-controllers": "^103.1.1",
"@metamask/base-controller": "^9.0.1",
"@metamask/client-controller": "^1.0.1",
Expand Down
9 changes: 9 additions & 0 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ async function withController<ReturnValue>(
namespace: MOCK_ANY_NAMESPACE,
});

// Mock AccountsController
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('AccountsController:getSelectedAccount', () =>
createMockInternalAccount(),
);

// Mock AccountTreeController
messenger.registerActionHandler(
'AccountTreeController:getAccountsFromSelectedAccountGroup',
Expand Down
132 changes: 103 additions & 29 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
AccountTreeControllerSelectedAccountGroupChangeEvent,
AccountTreeControllerStateChangeEvent,
} from '@metamask/account-tree-controller';
import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller';
import { BaseController } from '@metamask/base-controller';
import type {
ControllerGetStateAction,
Expand Down Expand Up @@ -265,6 +266,7 @@ export type AssetsControllerEvents =

type AllowedActions =
// AssetsController
| AccountsControllerGetSelectedAccountAction
| AccountTreeControllerGetAccountsFromSelectedAccountGroupAction
// RpcDataSource
| NetworkControllerGetStateAction
Expand Down Expand Up @@ -355,6 +357,13 @@ export type AssetsControllerOptions = {
priceDataSourceConfig?: PriceDataSourceConfig;
/** Optional configuration for StakedBalanceDataSource. */
stakedBalanceDataSourceConfig?: StakedBalanceDataSourceConfig;
/**
* Function returning whether onboarding is complete. When false,
* RPC and staked balance data sources skip fetch and subscribe
* (no on-chain calls until the user has finished onboarding).
* Defaults to () => true.
*/
isOnboarded?: () => boolean;
};

// ============================================================================
Expand Down Expand Up @@ -620,17 +629,34 @@ export class AssetsController extends BaseController<
/** Currently enabled chains from NetworkEnablementController */
#enabledChains: Set<ChainId> = new Set();

/**
* Snapshot of account IDs that were active when the last subscription/fetch
* cycle ran. Used by #handleAccountTreeStateChange to skip redundant
* re-subscriptions when the account set hasn't actually changed.
*/
#lastKnownAccountIds: ReadonlySet<string> = new Set();

/**
* Get the currently selected accounts from AccountTreeController.
* This includes all accounts in the same group as the selected account
* (EVM, Bitcoin, Solana, Tron, etc. that belong to the same logical account group).
*
* @returns Array of InternalAccount objects from the selected account group.
*/
get #selectedAccounts(): InternalAccount[] {
return this.messenger.call(
#getSelectedAccounts(): InternalAccount[] {
const accounts = this.messenger.call(
'AccountTreeController:getAccountsFromSelectedAccountGroup',
);
if (accounts.length > 0) {
return accounts;
}
const selectedAccount = this.messenger.call(
'AccountsController:getSelectedAccount',
);
if (selectedAccount) {
return [selectedAccount];
}
return [];
}

readonly #backendWebsocketDataSource: BackendWebsocketDataSource;
Expand All @@ -644,8 +670,7 @@ export class AssetsController extends BaseController<
readonly #stakedBalanceDataSource: StakedBalanceDataSource;

/**
* All balance data sources (used for unsubscription in #stop so we can clean up
* regardless of current isBasicFunctionality mode).
* All balance data sources in priority order for chain-claiming and cleanup.
* Note: StakedBalanceDataSource is excluded because it provides supplementary
* data and should not participate in chain-claiming.
*
Expand Down Expand Up @@ -692,6 +717,7 @@ export class AssetsController extends BaseController<
accountsApiDataSourceConfig,
priceDataSourceConfig,
stakedBalanceDataSourceConfig,
isOnboarded,
}: AssetsControllerOptions) {
super({
name: CONTROLLER_NAME,
Expand Down Expand Up @@ -742,6 +768,7 @@ export class AssetsController extends BaseController<
messenger: this.messenger,
onActiveChainsUpdated: this.#onActiveChainsUpdated,
...rpcConfig,
isOnboarded: rpcConfig.isOnboarded ?? isOnboarded,
});
this.#stakedBalanceDataSource = new StakedBalanceDataSource({
messenger: this.messenger,
Expand Down Expand Up @@ -875,7 +902,7 @@ export class AssetsController extends BaseController<
// when init() calls this.update(). #start() is idempotent so
// repeated fires are safe.
this.messenger.subscribe('AccountTreeController:stateChange', () => {
this.#updateActive();
this.#handleAccountTreeStateChange();
});

// Subscribe to network enablement changes (only enabledNetworkMap)
Expand Down Expand Up @@ -920,6 +947,51 @@ export class AssetsController extends BaseController<
}
}

/**
* Handle AccountTreeController state changes.
* If already running, re-subscribe only when the set of selected accounts
* has actually changed (e.g. a snap account was added after initial startup).
* This guards against the many tree mutations that don't affect which
* accounts are selected — without this check every tree update would
* trigger a redundant full re-subscribe + forceUpdate fetch.
* If not running yet, delegate to #start() for the normal start flow.
*/
#handleAccountTreeStateChange(): void {
const shouldRun = this.#uiOpen && this.#keyringUnlocked;
if (!shouldRun) {
return;
}
if (this.#activeSubscriptions.size > 0) {
const accounts = this.#getSelectedAccounts();
const currentIds = new Set(accounts.map((a) => a.id));

const accountsChanged =
currentIds.size !== this.#lastKnownAccountIds.size ||
[...currentIds].some((id) => !this.#lastKnownAccountIds.has(id));

if (!accountsChanged) {
return;
}

log('Account tree changed with new accounts, re-subscribing', {
previousCount: this.#lastKnownAccountIds.size,
currentCount: currentIds.size,
});

this.#lastKnownAccountIds = currentIds;
this.#subscribeAssets();
this.#ensureNativeBalancesDefaultZero();
this.getAssets(accounts, {
chainIds: [...this.#enabledChains],
forceUpdate: true,
}).catch((error) => {
log('Failed to fetch assets after tree change', error);
});
} else {
this.#start();
}
}

#registerActionHandlers(): void {
this.messenger.registerMethodActionHandlers(
this,
Expand Down Expand Up @@ -970,13 +1042,13 @@ export class AssetsController extends BaseController<
}

// If chains were added and we have selected accounts, do one-time fetch
if (addedChains.length > 0 && this.#selectedAccounts.length > 0) {
if (addedChains.length > 0 && this.#getSelectedAccounts().length > 0) {
const addedEnabledChains = addedChains.filter((chain) =>
this.#enabledChains.has(chain),
);
if (addedEnabledChains.length > 0) {
log('Fetching balances for newly added chains', { addedEnabledChains });
this.getAssets(this.#selectedAccounts, {
this.getAssets(this.#getSelectedAccounts(), {
chainIds: addedEnabledChains,
forceUpdate: true,
updateMode: 'merge',
Expand Down Expand Up @@ -1339,7 +1411,7 @@ export class AssetsController extends BaseController<
* @returns Legacy-compatible state for transaction-pay-controller.
*/
getStateForTransactionPay(): TransactionPayLegacyFormat {
const accounts = this.#selectedAccounts;
const accounts = this.#getSelectedAccounts();
const { nativeAssetIdentifiers } = this.messenger.call(
'NetworkEnablementController:getState',
);
Expand Down Expand Up @@ -1429,7 +1501,7 @@ export class AssetsController extends BaseController<
});

// Fetch data for the newly added custom asset (merge to preserve other chains)
const account = this.#selectedAccounts.find((a) => a.id === accountId);
const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
if (account) {
const chainId = extractChainId(normalizedAssetId);
await this.getAssets([account], {
Expand Down Expand Up @@ -1549,7 +1621,7 @@ export class AssetsController extends BaseController<
return;
}

this.getAssets(this.#selectedAccounts, {
this.getAssets(this.#getSelectedAccounts(), {
forceUpdate: true,
dataTypes: ['price'],
assetsForPriceUpdate: Object.values(this.state.assetsBalance).flatMap(
Expand Down Expand Up @@ -1580,10 +1652,11 @@ export class AssetsController extends BaseController<
const existingSubscription = this.#activeSubscriptions.get(subscriptionKey);
const isUpdate = existingSubscription !== undefined;

const request = this.#buildDataRequest(accounts, chainIds, {
dataTypes: ['price'],
});
const subscribeReq: SubscriptionRequest = {
request: this.#buildDataRequest(accounts, chainIds, {
dataTypes: ['price'],
}),
request,
subscriptionId: subscriptionKey,
isUpdate,
onAssetsUpdate: (response) =>
Expand Down Expand Up @@ -1675,7 +1748,7 @@ export class AssetsController extends BaseController<
* Only adds natives for chains that the account supports (correct accountId ↔ chain mapping).
*/
#ensureNativeBalancesDefaultZero(): void {
const accounts = this.#selectedAccounts;
const accounts = this.#getSelectedAccounts();
if (accounts.length === 0) {
return;
}
Expand Down Expand Up @@ -1798,7 +1871,7 @@ export class AssetsController extends BaseController<
})();

// Ensure native tokens have an entry (0 if missing) for chains this account supports
const account = this.#selectedAccounts.find(
const account = this.#getSelectedAccounts().find(
(a) => a.id === accountId,
);
const nativeAssetIdsForAccount = account
Expand Down Expand Up @@ -2064,7 +2137,7 @@ export class AssetsController extends BaseController<
* subscriptions are already active.
*/
#start(): void {
const accounts = this.#selectedAccounts;
const accounts = this.#getSelectedAccounts();
const chainIds = [...this.#enabledChains];

if (accounts.length === 0 || chainIds.length === 0) {
Expand All @@ -2080,6 +2153,7 @@ export class AssetsController extends BaseController<
enabledChainCount: chainIds.length,
});

this.#lastKnownAccountIds = new Set(accounts.map((a) => a.id));
this.#subscribeAssets();
this.#ensureNativeBalancesDefaultZero();
this.getAssets(accounts, {
Expand All @@ -2102,6 +2176,7 @@ export class AssetsController extends BaseController<

this.#firstInitFetchReported = false;
this.#stateSizeReported = false;
this.#lastKnownAccountIds = new Set();

// Stop price subscription first (uses direct messenger call)
this.unsubscribeAssetsPrice();
Expand Down Expand Up @@ -2144,22 +2219,20 @@ export class AssetsController extends BaseController<
* Subscribe to asset updates for all selected accounts.
*/
#subscribeAssets(): void {
if (this.#selectedAccounts.length === 0 || this.#enabledChains.size === 0) {
const accounts = this.#getSelectedAccounts();
const enabledChains = [...this.#enabledChains];
if (accounts.length === 0 || enabledChains.length === 0) {
return;
}

// Subscribe to balance updates (batched by data source)
this.#subscribeAssetsBalance(this.#selectedAccounts, [
...this.#enabledChains,
]);
this.#subscribeAssetsBalance(accounts, enabledChains);

// Subscribe to staked balance updates (separate from regular balance chain-claiming)
this.#subscribeStakedBalance(this.#selectedAccounts, [
...this.#enabledChains,
]);
this.#subscribeStakedBalance(accounts, enabledChains);

// Subscribe to price updates for all assets held by selected accounts
this.subscribeAssetsPrice(this.#selectedAccounts, [...this.#enabledChains]);
this.subscribeAssetsPrice(accounts, enabledChains);
}

/**
Expand All @@ -2185,8 +2258,7 @@ export class AssetsController extends BaseController<
new Set(chainIds),
);
const remainingChains = new Set(chainToAccounts.keys());

// When basic functionality is on (getter true), use all balance data sources; when off (getter false), RPC only.
// When basic functionality is on, use all balance data sources; when off, RPC only.
const balanceDataSources = this.#isBasicFunctionality()
? this.#allBalanceDataSources
: [this.#rpcDataSource];
Expand Down Expand Up @@ -2439,13 +2511,15 @@ export class AssetsController extends BaseController<
// ============================================================================

async #handleAccountGroupChanged(): Promise<void> {
const accounts = this.#selectedAccounts;
const accounts = this.#getSelectedAccounts();

log('Account group changed', {
accountCount: accounts.length,
accountIds: accounts.map((a) => a.id),
});

this.#lastKnownAccountIds = new Set(accounts.map((a) => a.id));

// Subscribe and fetch for the new account group
this.#subscribeAssets();
if (accounts.length > 0) {
Expand Down Expand Up @@ -2495,8 +2569,8 @@ export class AssetsController extends BaseController<
this.#subscribeAssets();

// Do one-time fetch for newly enabled chains; merge so we keep existing chain balances
if (addedChains.length > 0 && this.#selectedAccounts.length > 0) {
await this.getAssets(this.#selectedAccounts, {
if (addedChains.length > 0 && this.#getSelectedAccounts().length > 0) {
await this.getAssets(this.#getSelectedAccounts(), {
chainIds: addedChains,
forceUpdate: true,
updateMode: 'merge',
Expand Down
Loading
Loading