Skip to content
Merged
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
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
},
"packages/assets-controller/src/AssetsController.ts": {
"no-restricted-syntax": {
"count": 7
"count": 8
}
},
"packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": {
Expand Down
7 changes: 7 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `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.
- `#start()` is now idempotent: it returns early when accounts or chains are not yet available, and when subscriptions are already active, preventing duplicate fetches from repeated events.

## [5.0.0]

### Changed
Expand Down
82 changes: 82 additions & 0 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1789,4 +1789,86 @@ describe('AssetsController', () => {
});
});
});

describe('account tree state change', () => {
it('triggers start when tree initializes after unlock with empty accounts', async () => {
const getAccountsMock = jest.fn().mockReturnValue([]);

const messenger: RootMessenger = new Messenger({
namespace: MOCK_ANY_NAMESPACE,
});
messenger.registerActionHandler(
'AccountTreeController:getAccountsFromSelectedAccountGroup',
getAccountsMock,
);
messenger.registerActionHandler(
'NetworkEnablementController:getState',
() => ({
enabledNetworkMap: { eip155: { '1': true } },
nativeAssetIdentifiers: {
'eip155:1':
'eip155:1/slip44:60' as `${string}:${string}/slip44:${number}`,
},
}),
);
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('NetworkController:getState', () => ({
networkConfigurationsByChainId: {},
networksMetadata: {},
}));
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('NetworkController:getNetworkClientById', () => ({
provider: {},
}));
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('ClientController:getState', () => ({
isUiOpen: true,
}));

const controller = new AssetsController({
messenger: messenger as unknown as AssetsControllerMessenger,
queryApiClient: createMockQueryApiClient(),
subscribeToBasicFunctionalityChange: (): void => {
/* no-op */
},
});

const getAssetsSpy = jest.spyOn(controller, 'getAssets');

// Step 1: UI open + unlock — accounts empty, #start() is a no-op
(
messenger as unknown as {
publish: (topic: string, payload?: unknown) => void;
}
).publish('ClientController:stateChange', { isUiOpen: true });
messenger.publish('KeyringController:unlock');
await new Promise((resolve) => setTimeout(resolve, 100));

expect(getAssetsSpy).not.toHaveBeenCalled();

// Step 2: AccountTreeController.init() completes — accounts now available
getAccountsMock.mockReturnValue([createMockInternalAccount()]);
(messenger.publish as CallableFunction)(
'AccountTreeController:stateChange',
{},
[],
);
await new Promise((resolve) => setTimeout(resolve, 100));

expect(getAssetsSpy).toHaveBeenCalledTimes(1);
expect(getAssetsSpy).toHaveBeenCalledWith(
[expect.objectContaining({ id: MOCK_ACCOUNT_ID })],
expect.objectContaining({ forceUpdate: true }),
);
});
});
});
35 changes: 30 additions & 5 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AccountTreeControllerGetAccountsFromSelectedAccountGroupAction,
AccountTreeControllerSelectedAccountGroupChangeEvent,
AccountTreeControllerStateChangeEvent,
} from '@metamask/account-tree-controller';
import { BaseController } from '@metamask/base-controller';
import type {
Expand Down Expand Up @@ -282,6 +283,7 @@ type AllowedActions =
type AllowedEvents =
// AssetsController
| AccountTreeControllerSelectedAccountGroupChangeEvent
| AccountTreeControllerStateChangeEvent
| ClientControllerStateChangeEvent
| KeyringControllerLockEvent
| KeyringControllerUnlockEvent
Expand Down Expand Up @@ -865,6 +867,17 @@ export class AssetsController extends BaseController<
},
);

// Catch the initial tree build. On returning users,
// `selectedAccountGroupChange` does NOT fire when the persisted group
// is unchanged, and `accountTreeChange` doesn't fire either (init()
// rebuilds from persisted accounts without publishing it).
// The base-controller `:stateChange` event is guaranteed to fire
// when init() calls this.update(). #start() is idempotent so
// repeated fires are safe.
this.messenger.subscribe('AccountTreeController:stateChange', () => {
this.#updateActive();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we use the state change selector (3rd param of the subscribe func)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not a blocker, we'll receive a new event by accounts team which should remove the need for this state change event

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes it's just a temp solution for now

});

// Subscribe to network enablement changes (only enabledNetworkMap)
this.messenger.subscribe(
'NetworkEnablementController:stateChange',
Expand Down Expand Up @@ -2047,18 +2060,30 @@ export class AssetsController extends BaseController<

/**
* Start asset tracking: subscribe to updates and fetch current balances.
* Called when app opens, account changes, or keyring unlocks.
* Idempotent — returns early if accounts/chains are not yet available or
* subscriptions are already active.
*/
#start(): void {
const accounts = this.#selectedAccounts;
const chainIds = [...this.#enabledChains];

if (accounts.length === 0 || chainIds.length === 0) {
return;
}

if (this.#activeSubscriptions.size > 0) {
return;
}

log('Starting asset tracking', {
selectedAccountCount: this.#selectedAccounts.length,
enabledChainCount: this.#enabledChains.size,
selectedAccountCount: accounts.length,
enabledChainCount: chainIds.length,
});

this.#subscribeAssets();
this.#ensureNativeBalancesDefaultZero();
this.getAssets(this.#selectedAccounts, {
chainIds: [...this.#enabledChains],
this.getAssets(accounts, {
chainIds,
forceUpdate: true,
}).catch((error) => {
log('Failed to fetch assets', error);
Expand Down
Loading