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

## [Unreleased]

### Changed
- `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

### Fixed

- The coalesced re-subscribe scheduled by `AssetsController` now re-checks the run guards (UI open, keyring unlocked, and `isEnabled`) when its debounce/jitter timer fires. Previously a re-subscribe scheduled while tracking was allowed could still run after the controller was disabled or tracking was stopped (e.g. UI closed or keyring locked), inappropriately restarting polling subscriptions

## [11.1.1]

### Changed
Expand Down Expand Up @@ -38,11 +50,6 @@ 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))
- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.1.0` ([#9568](https://github.com/MetaMask/core/pull/9568))

### Fixed

- `TokenDataSource` now also fetches token metadata for assets present in `assetsBalance` that are missing `assetsInfo` (previously only detected assets without metadata were enriched) ([#9547](https://github.com/MetaMask/core/pull/9547))

## [11.0.0]

Expand Down
225 changes: 210 additions & 15 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,35 @@ 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;
const MOCK_ASSET_ID_LOWERCASE =
'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId;
const MOCK_NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId;

/**
* Activate asset tracking by marking the UI open and the keyring unlocked,
* then flushing the async startup so the controller is in its running state.
*
* @param messenger - The root messenger used to publish lifecycle events.
*/
async function activateTracking(messenger: RootMessenger): Promise<void> {
(
messenger as unknown as {
publish: (topic: string, payload?: unknown) => void;
}
).publish('ClientController:stateChange', { isUiOpen: true });
messenger.publish('KeyringController:unlock');
await flushPromises();
}

function createMockInternalAccount(
overrides?: Partial<InternalAccount>,
): InternalAccount {
Expand Down Expand Up @@ -738,7 +760,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 +772,7 @@ describe('AssetsController', () => {
},
},
},
'BackendWebsocketDataSource',
'AccountActivityDataSource',
);

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

describe('handleActiveChainsUpdate', () => {
it('re-subscribes assets when chains are added', async () => {
await withController(async ({ controller }) => {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');
it('re-subscribes assets when chains are added, debounced and jittered', async () => {
await withController(async ({ controller, messenger }) => {
await activateTracking(messenger);
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();

// 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('lets a shorter-delay re-subscribe pre-empt a pending longer-delay one', async () => {
await withController(async ({ controller, messenger }) => {
await activateTracking(messenger);
jest.useFakeTimers();
// First update draws a long jitter, second update draws a short one.
const randomSpy = jest
.spyOn(Math, 'random')
.mockReturnValueOnce(0.9)
.mockReturnValueOnce(0.1);
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

expect(subscribeSpy).toHaveBeenCalledTimes(1);
const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
// Schedules a long-delay jittered re-subscribe (~4750ms)...
onActiveChainsUpdated('TestDataSource', ['eip155:1'], []);
// ...then a second update with a shorter delay (~750ms) pre-empts it.
onActiveChainsUpdated('TestDataSource', [], ['eip155:137']);

// Runs once at the shorter window.
jest.advanceTimersByTime(RESUBSCRIBE_DEBOUNCE_MS + 500);
expect(subscribeSpy).toHaveBeenCalledTimes(1);

// The pre-empted longer-delay timer does not fire a second time.
jest.advanceTimersByTime(RESUBSCRIBE_JITTER_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, messenger }) => {
await activateTracking(messenger);
jest.useFakeTimers();
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5);
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'],
);

// Advance past the base debounce plus the jitter window; the burst
// collapses into exactly one re-subscribe.
jest.advanceTimersByTime(
RESUBSCRIBE_DEBOUNCE_MS + RESUBSCRIBE_JITTER_MS,
);
expect(subscribeSpy).toHaveBeenCalledTimes(1);
} finally {
randomSpy.mockRestore();
jest.useRealTimers();
}
});
});

Expand All @@ -1640,14 +1747,29 @@ describe('AssetsController', () => {
});
});

it('re-subscribes assets when chains are removed', async () => {
await withController(async ({ controller }) => {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');
it('re-subscribes assets when chains are removed, debounced and jittered', async () => {
await withController(async ({ controller, messenger }) => {
await activateTracking(messenger);
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']);

// Removals 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();

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

Expand Down Expand Up @@ -1685,6 +1807,68 @@ describe('AssetsController', () => {
},
);
});

it('does not run a scheduled re-subscribe when the controller is disabled before the timer fires', async () => {
let enabled = true;

await withController(
{
controllerOptions: { isEnabled: (): boolean => enabled },
},
async ({ controller, messenger }) => {
await activateTracking(messenger);
jest.useFakeTimers();
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

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

// ...but the controller is disabled before the timer fires.
enabled = false;

jest.advanceTimersByTime(
RESUBSCRIBE_DEBOUNCE_MS + RESUBSCRIBE_JITTER_MS,
);

expect(subscribeSpy).not.toHaveBeenCalled();
} finally {
randomSpy.mockRestore();
jest.useRealTimers();
}
},
);
});

it('does not run a scheduled re-subscribe when the keyring locks before the timer fires', async () => {
await withController(async ({ controller, messenger }) => {
await activateTracking(messenger);
jest.useFakeTimers();
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const subscribeSpy = jest.spyOn(controller, 'subscribeAssetsPrice');

// The keyring locks (e.g. the app is backgrounded). A data source can
// still report chains going "down" as the WebSocket disconnects,
// which schedules a coalesced re-subscribe even though tracking has
// been torn down.
messenger.publish('KeyringController:lock');
const onActiveChainsUpdated = controller.getOnActiveChainsUpdated();
onActiveChainsUpdated('TestDataSource', [], ['eip155:1']);

jest.advanceTimersByTime(
RESUBSCRIBE_DEBOUNCE_MS + RESUBSCRIBE_JITTER_MS,
);

expect(subscribeSpy).not.toHaveBeenCalled();
} finally {
randomSpy.mockRestore();
jest.useRealTimers();
}
});
});
});

describe('handleAssetsUpdate - state updates', () => {
Expand Down Expand Up @@ -2233,8 +2417,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