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
6 changes: 6 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `TokensApiClient` (used by `RpcDataSource` / `TokenDetector`) now sets the token-list `occurrenceFloor` query param from the same Token API `GET /v1/suggestedOccurrenceFloors` endpoint (cached 1h), replacing the hardcoded Linea/MegaETH/Tempo special cases. Missing chains or failed fetches fall back to 3 ([#9537](https://github.com/MetaMask/core/pull/9537))
- Bump `@metamask/network-enablement-controller` from `^5.5.0` to `^5.6.0` ([#9520](https://github.com/MetaMask/core/pull/9520))
- Bump `@metamask/phishing-controller` from `^17.2.1` to `^17.3.0` ([#9532](https://github.com/MetaMask/core/pull/9532))
- `AccountActivityDataSource` is now the highest-priority balance data source and participates in chain-claiming: chains it reports as "up" (from `AccountActivityService:statusChanged`) are claimed first so the polling data sources (`AccountsApiDataSource`/`RpcDataSource`) do not also poll them
- `AssetsController` no longer references `BackendWebSocketService` actions/events; real-time balances and chain status are consumed exclusively from `AccountActivityService`

### Removed

- **BREAKING:** Remove `BackendWebsocketDataSource` and its factory/types (`BackendWebsocketDataSource`, `createBackendWebsocketDataSource`, `BackendWebsocketDataSourceOptions`, `BackendWebsocketDataSourceState`). Real-time balance updates and per-chain status are now consumed from `AccountActivityService` via `AccountActivityDataSource`, which manages the WebSocket connection and subscriptions. Consumers no longer need to delegate `BackendWebSocketService` actions/events to the `AssetsController` messenger

## [11.0.0]

Expand Down
114 changes: 102 additions & 12 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ type AllEvents = MessengerEvents<AssetsControllerMessenger>;

type RootMessenger = Messenger<MockAnyNamespace, AllActions, AllEvents>;

/** Mirrors the private `RESUBSCRIBE_DEBOUNCE_MS` in AssetsController.ts. */
const RESUBSCRIBE_DEBOUNCE_MS = 250;

/** Mirrors the private `RESUBSCRIBE_JITTER_MS` in AssetsController.ts. */
const RESUBSCRIBE_JITTER_MS = 5000;

const MOCK_ACCOUNT_ID = 'mock-account-id-1';
const MOCK_ASSET_ID =
'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId;
Expand Down Expand Up @@ -738,7 +744,7 @@ describe('AssetsController', () => {
});
});

it('graduates an EVM custom asset when BackendWebsocketDataSource reports a balance for it', async () => {
it('graduates an EVM custom asset when AccountActivityDataSource reports a balance for it', async () => {
await withController(async ({ controller }) => {
await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID);

Expand All @@ -750,7 +756,7 @@ describe('AssetsController', () => {
},
},
},
'BackendWebsocketDataSource',
'AccountActivityDataSource',
);

expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toBeUndefined();
Expand Down Expand Up @@ -1606,14 +1612,81 @@ describe('AssetsController', () => {
});

describe('handleActiveChainsUpdate', () => {
it('re-subscribes assets when chains are added', async () => {
it('re-subscribes assets when chains are added, debounced and jittered', async () => {
await withController(async ({ controller }) => {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');
jest.useFakeTimers();
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
onActiveChainsUpdated('TestDataSource', ['eip155:1'], []);
const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
onActiveChainsUpdated('TestDataSource', ['eip155:1'], []);

// Re-subscribe is debounced, so it does not run synchronously.
expect(subscribeSpy).not.toHaveBeenCalled();

expect(subscribeSpy).toHaveBeenCalledTimes(1);
// Additions are jittered on top of the base debounce, so nothing runs
// at the base debounce window.
jest.advanceTimersByTime(RESUBSCRIBE_DEBOUNCE_MS);
expect(subscribeSpy).not.toHaveBeenCalled();

// Once the jitter window elapses it runs exactly once.
jest.advanceTimersByTime(RESUBSCRIBE_JITTER_MS);
expect(subscribeSpy).toHaveBeenCalledTimes(1);
} finally {
randomSpy.mockRestore();
jest.useRealTimers();
}
});
});

it('re-subscribes promptly (no jitter) when a chain removal pre-empts a pending jittered addition', async () => {
await withController(async ({ controller }) => {
jest.useFakeTimers();
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.9);
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
// Addition schedules a jittered re-subscribe...
onActiveChainsUpdated('TestDataSource', ['eip155:1'], []);
// ...then a removal arrives; it must pre-empt to the prompt window.
onActiveChainsUpdated('TestDataSource', [], ['eip155:137']);

jest.advanceTimersByTime(RESUBSCRIBE_DEBOUNCE_MS);
expect(subscribeSpy).toHaveBeenCalledTimes(1);
} finally {
randomSpy.mockRestore();
jest.useRealTimers();
}
});
});

it('coalesces a burst of active-chain updates into a single re-subscribe', async () => {
await withController(async ({ controller }) => {
jest.useFakeTimers();
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
// Simulate a burst of chain up/down notifications within the window.
onActiveChainsUpdated('TestDataSource', ['eip155:1'], []);
onActiveChainsUpdated(
'TestDataSource',
['eip155:1', 'eip155:137'],
['eip155:1'],
);
onActiveChainsUpdated(
'TestDataSource',
['eip155:137'],
['eip155:1', 'eip155:137'],
);

jest.advanceTimersByTime(RESUBSCRIBE_DEBOUNCE_MS);
expect(subscribeSpy).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});

Expand Down Expand Up @@ -1642,12 +1715,18 @@ describe('AssetsController', () => {

it('re-subscribes assets when chains are removed', async () => {
await withController(async ({ controller }) => {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');
jest.useFakeTimers();
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
onActiveChainsUpdated('TestDataSource', [], ['eip155:1']);
const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
onActiveChainsUpdated('TestDataSource', [], ['eip155:1']);

expect(subscribeSpy).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(RESUBSCRIBE_DEBOUNCE_MS);
expect(subscribeSpy).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});

Expand Down Expand Up @@ -2233,8 +2312,19 @@ describe('AssetsController', () => {
};

await withController(
{ state: initialState },
{ state: initialState, clientControllerState: { isUiOpen: true } },
async ({ controller, messenger }) => {
// UI must be open and keyring unlocked so AccountActivityDataSource is
// subscribed and can route balanceUpdated events to the account.
(
messenger as unknown as {
publish: (topic: string, payload?: unknown) => void;
}
).publish('ClientController:stateChange', { isUiOpen: true });
messenger.publish('KeyringController:unlock');

await flushPromises();

messenger.publish('AccountActivityService:balanceUpdated', {
address: '0x1234567890123456789012345678901234567890',
chain: 'eip155:42161',
Expand Down
Loading
Loading