Skip to content

Commit bba7020

Browse files
committed
fix(assets-controller): handle empty accounts on startup by listening to AccountTreeController state
1 parent 8fda1da commit bba7020

3 files changed

Lines changed: 113 additions & 6 deletions

File tree

eslint-suppressions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@
170170
},
171171
"packages/assets-controller/src/AssetsController.ts": {
172172
"no-restricted-syntax": {
173-
"count": 7
173+
"count": 8
174174
}
175175
},
176176
"packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": {

packages/assets-controller/src/AssetsController.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,4 +1789,85 @@ describe('AssetsController', () => {
17891789
});
17901790
});
17911791
});
1792+
1793+
describe('account tree state change', () => {
1794+
it('triggers start when tree initializes after unlock with empty accounts', async () => {
1795+
const getAccountsMock = jest.fn().mockReturnValue([]);
1796+
1797+
const messenger: RootMessenger = new Messenger({
1798+
namespace: MOCK_ANY_NAMESPACE,
1799+
});
1800+
messenger.registerActionHandler(
1801+
'AccountTreeController:getAccountsFromSelectedAccountGroup',
1802+
getAccountsMock,
1803+
);
1804+
messenger.registerActionHandler(
1805+
'NetworkEnablementController:getState',
1806+
() => ({
1807+
enabledNetworkMap: { eip155: { '1': true } },
1808+
nativeAssetIdentifiers: {
1809+
'eip155:1': 'eip155:1/slip44:60' as `${string}:${string}/slip44:${number}`,
1810+
},
1811+
}),
1812+
);
1813+
(
1814+
messenger as {
1815+
registerActionHandler: (a: string, h: () => unknown) => void;
1816+
}
1817+
).registerActionHandler('NetworkController:getState', () => ({
1818+
networkConfigurationsByChainId: {},
1819+
networksMetadata: {},
1820+
}));
1821+
(
1822+
messenger as {
1823+
registerActionHandler: (a: string, h: () => unknown) => void;
1824+
}
1825+
).registerActionHandler('NetworkController:getNetworkClientById', () => ({
1826+
provider: {},
1827+
}));
1828+
(
1829+
messenger as {
1830+
registerActionHandler: (a: string, h: () => unknown) => void;
1831+
}
1832+
).registerActionHandler('ClientController:getState', () => ({
1833+
isUiOpen: true,
1834+
}));
1835+
1836+
const controller = new AssetsController({
1837+
messenger: messenger as unknown as AssetsControllerMessenger,
1838+
queryApiClient: createMockQueryApiClient(),
1839+
subscribeToBasicFunctionalityChange: (): void => {
1840+
/* no-op */
1841+
},
1842+
});
1843+
1844+
const getAssetsSpy = jest.spyOn(controller, 'getAssets');
1845+
1846+
// Step 1: UI open + unlock — accounts empty, #start() is a no-op
1847+
(
1848+
messenger as unknown as {
1849+
publish: (topic: string, payload?: unknown) => void;
1850+
}
1851+
).publish('ClientController:stateChange', { isUiOpen: true });
1852+
messenger.publish('KeyringController:unlock');
1853+
await new Promise((resolve) => setTimeout(resolve, 100));
1854+
1855+
expect(getAssetsSpy).not.toHaveBeenCalled();
1856+
1857+
// Step 2: AccountTreeController.init() completes — accounts now available
1858+
getAccountsMock.mockReturnValue([createMockInternalAccount()]);
1859+
(messenger.publish as CallableFunction)(
1860+
'AccountTreeController:stateChange',
1861+
{},
1862+
[],
1863+
);
1864+
await new Promise((resolve) => setTimeout(resolve, 100));
1865+
1866+
expect(getAssetsSpy).toHaveBeenCalledTimes(1);
1867+
expect(getAssetsSpy).toHaveBeenCalledWith(
1868+
[expect.objectContaining({ id: MOCK_ACCOUNT_ID })],
1869+
expect.objectContaining({ forceUpdate: true }),
1870+
);
1871+
});
1872+
});
17921873
});

packages/assets-controller/src/AssetsController.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
AccountTreeControllerGetAccountsFromSelectedAccountGroupAction,
33
AccountTreeControllerSelectedAccountGroupChangeEvent,
4+
AccountTreeControllerStateChangeEvent,
45
} from '@metamask/account-tree-controller';
56
import { BaseController } from '@metamask/base-controller';
67
import type {
@@ -282,6 +283,7 @@ type AllowedActions =
282283
type AllowedEvents =
283284
// AssetsController
284285
| AccountTreeControllerSelectedAccountGroupChangeEvent
286+
| AccountTreeControllerStateChangeEvent
285287
| ClientControllerStateChangeEvent
286288
| KeyringControllerLockEvent
287289
| KeyringControllerUnlockEvent
@@ -865,6 +867,18 @@ export class AssetsController extends BaseController<
865867
},
866868
);
867869

870+
// Catch the initial tree build. On returning users,
871+
// `selectedAccountGroupChange` does NOT fire when the persisted group
872+
// is unchanged, and `accountTreeChange` doesn't fire either (init()
873+
// rebuilds from persisted accounts without publishing it).
874+
// The base-controller `:stateChange` event is guaranteed to fire
875+
// when init() calls this.update(). #start() is idempotent so
876+
// repeated fires are safe.
877+
this.messenger.subscribe('AccountTreeController:stateChange', () => {
878+
console.log('AccountTreeController:stateChanged +++++++++++');
879+
this.#updateActive();
880+
});
881+
868882
// Subscribe to network enablement changes (only enabledNetworkMap)
869883
this.messenger.subscribe(
870884
'NetworkEnablementController:stateChange',
@@ -2047,18 +2061,30 @@ export class AssetsController extends BaseController<
20472061

20482062
/**
20492063
* Start asset tracking: subscribe to updates and fetch current balances.
2050-
* Called when app opens, account changes, or keyring unlocks.
2064+
* Idempotent — returns early if accounts/chains are not yet available or
2065+
* subscriptions are already active.
20512066
*/
20522067
#start(): void {
2068+
const accounts = this.#selectedAccounts;
2069+
const chainIds = [...this.#enabledChains];
2070+
2071+
if (accounts.length === 0 || chainIds.length === 0) {
2072+
return;
2073+
}
2074+
2075+
if (this.#activeSubscriptions.size > 0) {
2076+
return;
2077+
}
2078+
20532079
log('Starting asset tracking', {
2054-
selectedAccountCount: this.#selectedAccounts.length,
2055-
enabledChainCount: this.#enabledChains.size,
2080+
selectedAccountCount: accounts.length,
2081+
enabledChainCount: chainIds.length,
20562082
});
20572083

20582084
this.#subscribeAssets();
20592085
this.#ensureNativeBalancesDefaultZero();
2060-
this.getAssets(this.#selectedAccounts, {
2061-
chainIds: [...this.#enabledChains],
2086+
this.getAssets(accounts, {
2087+
chainIds,
20622088
forceUpdate: true,
20632089
}).catch((error) => {
20642090
log('Failed to fetch assets', error);

0 commit comments

Comments
 (0)