From 9c95b8e845a595ec4de8a41df56e53dee67df7f5 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 4 Jun 2026 14:40:24 -0700 Subject: [PATCH 01/17] feat: aus watchlist init --- packages/perps-controller/CHANGELOG.md | 9 + packages/perps-controller/package.json | 1 + .../perps-controller/src/PerpsController.ts | 248 +++++++++- .../perps-controller/src/types/messenger.ts | 8 +- .../tests/src/PerpsController.state.test.ts | 428 +++++++++++++++++- packages/perps-controller/tsconfig.build.json | 1 + packages/perps-controller/tsconfig.json | 3 + yarn.lock | 1 + 8 files changed, 681 insertions(+), 18 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8af2f9c01a..4e963db84e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. + - On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. + - When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. + - `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. +- Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + ## [7.0.0] ### Added diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 0ed7be9b37..f2df9460a5 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -108,6 +108,7 @@ }, "devDependencies": { "@metamask/account-tree-controller": "^7.5.1", + "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^0.1.3", "@metamask/keyring-controller": "^26.0.0", diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8e57403634..e62439cb92 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -6,6 +6,10 @@ import { } from '@metamask/base-controller'; import type { StateChangeListener } from '@metamask/base-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import type { + NotificationPreferences, + PerpsWatchlistMarkets, +} from '@metamask/authenticated-user-storage'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; @@ -149,6 +153,28 @@ export function firstNonEmpty(...vals: (string | undefined)[]): string { ); } +/** + * Maps an active provider mode to the corresponding exchange key used in the + * AUS {@link PerpsWatchlistMarkets} schema. + * + * Returns `null` for modes that are not yet represented in the AUS schema + * (e.g. `'aggregated'`), which signals callers to skip remote sync and fall + * back to local state only. Add new entries here as additional DEX providers + * gain AUS watchlist support. + * + * @param activeProvider - The current active provider mode from controller state. + * @returns The matching `PerpsWatchlistMarkets` key, or `null` if unsupported. + */ +export function resolveWatchlistExchangeKey( + activeProvider: PerpsActiveProviderMode, +): keyof PerpsWatchlistMarkets | null { + const map: Partial> = { + hyperliquid: 'hyperliquid', + myx: 'myx', + }; + return map[activeProvider] ?? null; +} + /** * Resolves MYX auth config from provider credentials, handling * testnet/mainnet fallback logic. @@ -1647,6 +1673,12 @@ export class PerpsController extends BaseController< attempts: attempt, }); + // Hydrate watchlist from AUS (non-blocking — transient failures are + // caught inside and must not prevent init from completing). + this.#syncWatchlistFromRemote().catch(() => { + // Errors are already logged inside #syncWatchlistFromRemote. + }); + return; // Exit retry loop on success } catch (error) { lastError = ensureError(error, 'PerpsController.performInitialization'); @@ -5006,12 +5038,21 @@ export class PerpsController extends BaseController< } /** - * Toggle watchlist status for a market - * Watchlist markets are stored per network (testnet/mainnet) + * Toggle watchlist status for a market. + * + * Updates local state immediately (optimistic UI) and then syncs the new + * watchlist to AuthenticatedUserStorageService. If the remote write fails, + * the local state is reverted so it stays consistent with AUS. + * + * When the user is unauthenticated, or the active provider is not yet + * supported by the AUS schema, the controller continues operating with + * local-persisted state only — no error is surfaced to the caller. + * + * Watchlist markets are stored per network (testnet/mainnet). * * @param symbol - The trading pair symbol. */ - toggleWatchlistMarket(symbol: string): void { + async toggleWatchlistMarket(symbol: string): Promise { const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; const currentWatchlist = this.state.watchlistMarkets[currentNetwork]; const isWatchlisted = currentWatchlist.includes(symbol); @@ -5023,17 +5064,45 @@ export class PerpsController extends BaseController< action: isWatchlisted ? 'remove' : 'add', }); + // Step 1: Optimistic local state update — UI reflects change immediately. this.update((state) => { if (isWatchlisted) { - // Remove from watchlist state.watchlistMarkets[currentNetwork] = currentWatchlist.filter( (marketSymbol) => marketSymbol !== symbol, ); } else { - // Add to watchlist state.watchlistMarkets[currentNetwork] = [...currentWatchlist, symbol]; } }); + + this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.UiInteraction, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FAVORITE_TOGGLED, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.ACTION_TYPE]: isWatchlisted + ? PERPS_EVENT_VALUE.ACTION_TYPE.UNFAVORITE_MARKET + : PERPS_EVENT_VALUE.ACTION_TYPE.FAVORITE_MARKET, + [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: + this.state.watchlistMarkets[currentNetwork].length, + }); + + // Step 2: Persist to AUS; revert local state if the write fails. + try { + await this.#persistWatchlistToRemote(currentNetwork); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.toggleWatchlistMarket'), + this.#getErrorContext('toggleWatchlistMarket', { + symbol, + network: currentNetwork, + action: isWatchlisted ? 'remove' : 'add', + }), + ); + // Revert the optimistic update. + this.update((state) => { + state.watchlistMarkets[currentNetwork] = currentWatchlist; + }); + } } /** @@ -5057,6 +5126,175 @@ export class PerpsController extends BaseController< return this.state.watchlistMarkets[currentNetwork]; } + /** + * Writes the current local watchlist to AuthenticatedUserStorageService + * using a read-merge-write strategy to avoid overwriting other preferences. + * + * Skips silently when: + * - The active provider has no AUS exchange key (e.g. `'aggregated'`). + * - The remote preferences blob does not yet exist (returns `null` / 404). + * In that case, `NotificationServicesController.createOnChainTriggers` is + * the canonical owner that creates the initial blob. + * + * Throws on remote write failure so the caller can decide whether to revert. + * + * @param network - Which network's list to sync ('testnet' | 'mainnet'). + */ + async #persistWatchlistToRemote( + network: 'testnet' | 'mainnet', + ): Promise { + const exchangeKey = resolveWatchlistExchangeKey(this.state.activeProvider); + if (!exchangeKey) { + this.#debugLog( + 'PerpsController: Skipping AUS watchlist sync — provider not mapped', + { activeProvider: this.state.activeProvider }, + ); + return; + } + + const prefs = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + + if (!prefs) { + this.#debugLog( + 'PerpsController: Skipping AUS watchlist write — preferences blob not yet initialised', + { exchangeKey, network }, + ); + return; + } + + const existingWatchlist: PerpsWatchlistMarkets = prefs.perps + .watchlistMarkets ?? { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }; + + const nextWatchlistMarkets: PerpsWatchlistMarkets = { + ...existingWatchlist, + [exchangeKey]: { + ...existingWatchlist[exchangeKey], + [network]: this.state.watchlistMarkets[network], + }, + }; + + const nextPrefs: NotificationPreferences = { + ...prefs, + perps: { + ...prefs.perps, + watchlistMarkets: nextWatchlistMarkets, + }, + }; + + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + nextPrefs, + ); + + this.#debugLog('PerpsController: Watchlist synced to AUS', { + exchangeKey, + network, + count: this.state.watchlistMarkets[network].length, + }); + } + + /** + * Hydrates `state.watchlistMarkets` from AuthenticatedUserStorageService on + * controller initialisation. + * + * AUS is the source of truth; local state is used as an offline cache. + * This method also handles the one-time migration from local-only state to + * AUS for users who had a watchlist before AUS sync was introduced. + * + * All remote errors are swallowed so a transient network failure does not + * block the rest of `init()`. + */ + async #syncWatchlistFromRemote(): Promise { + const exchangeKey = resolveWatchlistExchangeKey(this.state.activeProvider); + if (!exchangeKey) { + this.#debugLog( + 'PerpsController: Skipping AUS watchlist hydration — provider not mapped', + { activeProvider: this.state.activeProvider }, + ); + return; + } + + try { + const prefs = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + + if (!prefs) { + this.#debugLog( + 'PerpsController: No AUS preferences blob — using local watchlist', + ); + return; + } + + const remoteExchangeWatchlist = prefs.perps.watchlistMarkets?.[exchangeKey]; + + if (remoteExchangeWatchlist) { + // AUS has data for this exchange — hydrate local state from it. + this.update((state) => { + state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; + state.watchlistMarkets.mainnet = remoteExchangeWatchlist.mainnet; + }); + this.#debugLog( + 'PerpsController: Watchlist hydrated from AUS', + { + exchangeKey, + testnetCount: remoteExchangeWatchlist.testnet.length, + mainnetCount: remoteExchangeWatchlist.mainnet.length, + }, + ); + } else { + // Blob exists but has no watchlist for this exchange yet. + // If local state has any markets, push them up as a one-time migration. + const { testnet, mainnet } = this.state.watchlistMarkets; + const hasLocalMarkets = testnet.length > 0 || mainnet.length > 0; + + if (hasLocalMarkets) { + this.#debugLog( + 'PerpsController: Migrating local watchlist to AUS', + { exchangeKey, testnetCount: testnet.length, mainnetCount: mainnet.length }, + ); + // Push testnet and mainnet together via a single read-merge-write. + // #persistWatchlistToRemote writes the network passed to it; call it + // for whichever networks have data (or both — duplicate writes are + // idempotent since we read before each write, but a single combined + // write is cleaner). We combine both networks in one PUT here. + const existingWatchlist: PerpsWatchlistMarkets = { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }; + const nextWatchlistMarkets: PerpsWatchlistMarkets = { + ...existingWatchlist, + [exchangeKey]: { testnet, mainnet }, + }; + const nextPrefs: NotificationPreferences = { + ...prefs, + perps: { + ...prefs.perps, + watchlistMarkets: nextWatchlistMarkets, + }, + }; + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + nextPrefs, + ); + this.#debugLog('PerpsController: Local watchlist migrated to AUS', { + exchangeKey, + }); + } + } + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.syncWatchlistFromRemote'), + this.#getErrorContext('syncWatchlistFromRemote'), + ); + } + } + /** * Report order events to data lake API with retry (non-blocking) * Thin delegation to DataLakeService diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index 5489121617..96d3a1b6f6 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -6,6 +6,10 @@ import type { AccountsControllerGetSelectedAccountAction, AccountsControllerSelectedAccountChangeEvent, } from '@metamask/accounts-controller'; +import type { + AuthenticatedUserStorageServiceGetNotificationPreferencesAction, + AuthenticatedUserStorageServicePutNotificationPreferencesAction, +} from '@metamask/authenticated-user-storage'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, @@ -38,7 +42,9 @@ export type PerpsControllerAllowedActions = | RemoteFeatureFlagControllerGetStateAction | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction - | AuthenticationController.AuthenticationControllerGetBearerTokenAction; + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticatedUserStorageServiceGetNotificationPreferencesAction + | AuthenticatedUserStorageServicePutNotificationPreferencesAction; /** * Events from other controllers that PerpsController is allowed to subscribe to. diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 99871b8c9f..c797535ece 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -998,27 +998,27 @@ describe('PerpsController', () => { expect(watchlist).toEqual([]); }); - it('toggles watchlist market (add)', () => { - controller.toggleWatchlistMarket('BTC'); + it('toggles watchlist market (add)', async () => { + await controller.toggleWatchlistMarket('BTC'); const watchlist = controller.getWatchlistMarkets(); expect(watchlist).toContain('BTC'); expect(controller.isWatchlistMarket('BTC')).toBe(true); }); - it('toggles watchlist market (remove)', () => { - controller.toggleWatchlistMarket('BTC'); - controller.toggleWatchlistMarket('BTC'); + it('toggles watchlist market (remove)', async () => { + await controller.toggleWatchlistMarket('BTC'); + await controller.toggleWatchlistMarket('BTC'); const watchlist = controller.getWatchlistMarkets(); expect(watchlist).not.toContain('BTC'); expect(controller.isWatchlistMarket('BTC')).toBe(false); }); - it('handles multiple watchlist markets', () => { - controller.toggleWatchlistMarket('BTC'); - controller.toggleWatchlistMarket('ETH'); - controller.toggleWatchlistMarket('SOL'); + it('handles multiple watchlist markets', async () => { + await controller.toggleWatchlistMarket('BTC'); + await controller.toggleWatchlistMarket('ETH'); + await controller.toggleWatchlistMarket('SOL'); const watchlist = controller.getWatchlistMarkets(); expect(watchlist).toHaveLength(3); @@ -1027,12 +1027,12 @@ describe('PerpsController', () => { expect(watchlist).toContain('SOL'); }); - it('persist watchlist per network', () => { + it('persist watchlist per network', async () => { // Add to watchlist on mainnet (default is testnet in dev, so set to false) controller.testUpdate((state) => { state.isTestnet = false; }); - controller.toggleWatchlistMarket('BTC'); + await controller.toggleWatchlistMarket('BTC'); const mainnetWatchlist = controller.getWatchlistMarkets(); expect(mainnetWatchlist).toContain('BTC'); @@ -1045,7 +1045,7 @@ describe('PerpsController', () => { expect(testnetWatchlist).toEqual([]); // Add to watchlist on testnet - controller.toggleWatchlistMarket('ETH'); + await controller.toggleWatchlistMarket('ETH'); expect(controller.getWatchlistMarkets()).toContain('ETH'); expect(controller.isWatchlistMarket('ETH')).toBe(true); @@ -1058,6 +1058,410 @@ describe('PerpsController', () => { }); }); + describe('AUS watchlist sync', () => { + /** + * Minimal valid NotificationPreferences blob used across these tests. + * `watchlistMarkets` is intentionally absent so individual tests can + * control whether the field is present or not. + */ + const MOCK_PREFS_BASE = { + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: [], + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }, + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + socialAI: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + mutedTraderProfileIds: [], + }, + } as const; + + let ausController: TestablePerpsController; + let mockAusCall: jest.Mock; + let mockAusInfrastructure: jest.Mocked; + + beforeEach(() => { + mockAusCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + // By default, behave as if no blob exists (unauthenticated / 404). + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(null); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.resolve(undefined); + } + return undefined; + }); + + mockAusInfrastructure = createMockInfrastructure(); + ausController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockAusInfrastructure, + }); + }); + + it('local state updates immediately (optimistic) when AUS returns null blob', async () => { + // AUS returns null → no remote write, but local state should still change. + await ausController.toggleWatchlistMarket('BTC'); + + expect(ausController.getWatchlistMarkets()).toContain('BTC'); + expect( + mockAusCall, + ).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('writes merged watchlist to AUS when a preferences blob exists', async () => { + const existingPrefs = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(existingPrefs); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.resolve(undefined); + } + return undefined; + }); + + // Default state is testnet; toggle on testnet. + ausController.testUpdate((state) => { + state.isTestnet = true; + state.activeProvider = 'hyperliquid'; + }); + + await ausController.toggleWatchlistMarket('BTC'); + + expect(ausController.getWatchlistMarkets()).toContain('BTC'); + + // Verify put was called with merged prefs. + expect(mockAusCall).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.objectContaining({ + perps: expect.objectContaining({ + watchlistMarkets: expect.objectContaining({ + hyperliquid: expect.objectContaining({ + testnet: expect.arrayContaining(['BTC']), + }), + }), + }), + }), + ); + }); + + it('reverts local state when AUS PUT fails', async () => { + const existingPrefs = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(existingPrefs); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.reject(new Error('AUS server error')); + } + return undefined; + }); + + ausController.testUpdate((state) => { + state.isTestnet = false; + state.activeProvider = 'hyperliquid'; + }); + + // After toggle, local state should optimistically contain BTC. + // After PUT fails, it should be reverted. + await ausController.toggleWatchlistMarket('BTC'); + + expect(ausController.getWatchlistMarkets()).not.toContain('BTC'); + expect(mockAusInfrastructure.logger.error).toHaveBeenCalled(); + }); + + it('skips AUS sync when activeProvider is aggregated', async () => { + ausController.testUpdate((state) => { + (state as any).activeProvider = 'aggregated'; + }); + + await ausController.toggleWatchlistMarket('BTC'); + + // Local state changes. + expect(ausController.getWatchlistMarkets()).toContain('BTC'); + // AUS is never contacted. + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('does not throw when AUS GET throws (unauthenticated)', async () => { + mockAusCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.reject(new Error('Unauthenticated')); + } + return undefined; + }); + + ausController.testUpdate((state) => { + state.isTestnet = false; + }); + + // Should not throw — failure is handled internally. + await expect( + ausController.toggleWatchlistMarket('BTC'), + ).resolves.toBeUndefined(); + + // Local state is reverted since the AUS path failed. + expect(ausController.getWatchlistMarkets()).not.toContain('BTC'); + }); + + it('tracks analytics event when toggling watchlist market', async () => { + ausController.testUpdate((state) => { + state.isTestnet = false; + state.activeProvider = 'hyperliquid'; + }); + + await ausController.toggleWatchlistMarket('ETH'); + + expect( + mockAusInfrastructure.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + PerpsAnalyticsEvent.UiInteraction, + expect.objectContaining({ + interaction_type: 'favorite_toggled', + asset: 'ETH', + }), + ); + }); + + describe('init hydration from AUS', () => { + it('hydrates local watchlist from AUS on successful init', async () => { + const remotePrefs = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { + testnet: ['BTC', 'ETH'], + mainnet: ['SOL'], + }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(remotePrefs); + } + return undefined; + }); + + ausController.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + await ausController.init(); + + // Allow the non-blocking #syncWatchlistFromRemote promise to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ausController.state.watchlistMarkets.testnet).toEqual([ + 'BTC', + 'ETH', + ]); + expect(ausController.state.watchlistMarkets.mainnet).toEqual(['SOL']); + }); + + it('performs one-time migration when blob exists but has no watchlist for the active provider', async () => { + const remotePrefsWithoutWatchlist = { ...MOCK_PREFS_BASE }; + + mockAusCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(remotePrefsWithoutWatchlist); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.resolve(undefined); + } + return undefined; + }); + + // Local state has some markets saved before AUS was introduced. + const initialState = getDefaultPerpsControllerState(); + initialState.watchlistMarkets.testnet = ['BTC']; + initialState.watchlistMarkets.mainnet = ['ETH', 'SOL']; + initialState.activeProvider = 'hyperliquid'; + + const migrationController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: initialState, + infrastructure: mockAusInfrastructure, + }); + + await migrationController.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify local markets were pushed to AUS. + expect(mockAusCall).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.objectContaining({ + perps: expect.objectContaining({ + watchlistMarkets: expect.objectContaining({ + hyperliquid: expect.objectContaining({ + testnet: ['BTC'], + mainnet: ['ETH', 'SOL'], + }), + }), + }), + }), + ); + }); + + it('skips hydration when AUS blob is null', async () => { + // AUS returns null — local state is untouched. + const localState = getDefaultPerpsControllerState(); + localState.watchlistMarkets.mainnet = ['BTC']; + localState.activeProvider = 'hyperliquid'; + + const nullBlobController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: localState, + infrastructure: mockAusInfrastructure, + }); + + await nullBlobController.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Local state unchanged. + expect(nullBlobController.state.watchlistMarkets.mainnet).toEqual([ + 'BTC', + ]); + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('does not throw when AUS GET throws during init', async () => { + mockAusCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.reject(new Error('Network error')); + } + return undefined; + }); + + // init() should still succeed; the watchlist sync error is handled internally. + await expect(ausController.init()).resolves.toBeUndefined(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAusInfrastructure.logger.error).toHaveBeenCalled(); + }); + }); + }); + describe('additional subscriptions', () => { it('subscribes to orders', () => { const mockUnsubscribe = jest.fn(); diff --git a/packages/perps-controller/tsconfig.build.json b/packages/perps-controller/tsconfig.build.json index 02b3a42838..3b9c75ab90 100644 --- a/packages/perps-controller/tsconfig.build.json +++ b/packages/perps-controller/tsconfig.build.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../account-tree-controller/tsconfig.build.json" }, + { "path": "../authenticated-user-storage/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../bridge-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, diff --git a/packages/perps-controller/tsconfig.json b/packages/perps-controller/tsconfig.json index 324879cf43..8f5557e19c 100644 --- a/packages/perps-controller/tsconfig.json +++ b/packages/perps-controller/tsconfig.json @@ -7,6 +7,9 @@ { "path": "../account-tree-controller" }, + { + "path": "../authenticated-user-storage" + }, { "path": "../base-controller" }, diff --git a/yarn.lock b/yarn.lock index 4d08c1d875..f6a2407e8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7890,6 +7890,7 @@ __metadata: dependencies: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^7.5.1" + "@metamask/authenticated-user-storage": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" From e182afe9bea96b594475081b4ad8f9c12780054b Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 4 Jun 2026 15:29:58 -0700 Subject: [PATCH 02/17] chore: update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 54235840db..898006dfac 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,7 @@ linkStyle default opacity:0.5 perps_controller --> controller_utils; perps_controller --> messenger; perps_controller --> account_tree_controller; + perps_controller --> authenticated_user_storage; perps_controller --> geolocation_controller; perps_controller --> keyring_controller; perps_controller --> network_controller; From e6327ef270c63337e4a3330130b94eb0c1b17bc3 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 4 Jun 2026 15:31:19 -0700 Subject: [PATCH 03/17] fix: lint --- .../perps-controller/src/PerpsController.ts | 37 ++++++++++--------- .../tests/src/PerpsController.state.test.ts | 4 +- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index e62439cb92..8368922e6d 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1,3 +1,7 @@ +import type { + NotificationPreferences, + PerpsWatchlistMarkets, +} from '@metamask/authenticated-user-storage'; import { BaseController, ControllerGetStateAction, @@ -6,10 +10,6 @@ import { } from '@metamask/base-controller'; import type { StateChangeListener } from '@metamask/base-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; -import type { - NotificationPreferences, - PerpsWatchlistMarkets, -} from '@metamask/authenticated-user-storage'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; @@ -168,7 +168,9 @@ export function firstNonEmpty(...vals: (string | undefined)[]): string { export function resolveWatchlistExchangeKey( activeProvider: PerpsActiveProviderMode, ): keyof PerpsWatchlistMarkets | null { - const map: Partial> = { + const map: Partial< + Record + > = { hyperliquid: 'hyperliquid', myx: 'myx', }; @@ -5231,7 +5233,8 @@ export class PerpsController extends BaseController< return; } - const remoteExchangeWatchlist = prefs.perps.watchlistMarkets?.[exchangeKey]; + const remoteExchangeWatchlist = + prefs.perps.watchlistMarkets?.[exchangeKey]; if (remoteExchangeWatchlist) { // AUS has data for this exchange — hydrate local state from it. @@ -5239,14 +5242,11 @@ export class PerpsController extends BaseController< state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; state.watchlistMarkets.mainnet = remoteExchangeWatchlist.mainnet; }); - this.#debugLog( - 'PerpsController: Watchlist hydrated from AUS', - { - exchangeKey, - testnetCount: remoteExchangeWatchlist.testnet.length, - mainnetCount: remoteExchangeWatchlist.mainnet.length, - }, - ); + this.#debugLog('PerpsController: Watchlist hydrated from AUS', { + exchangeKey, + testnetCount: remoteExchangeWatchlist.testnet.length, + mainnetCount: remoteExchangeWatchlist.mainnet.length, + }); } else { // Blob exists but has no watchlist for this exchange yet. // If local state has any markets, push them up as a one-time migration. @@ -5254,10 +5254,11 @@ export class PerpsController extends BaseController< const hasLocalMarkets = testnet.length > 0 || mainnet.length > 0; if (hasLocalMarkets) { - this.#debugLog( - 'PerpsController: Migrating local watchlist to AUS', - { exchangeKey, testnetCount: testnet.length, mainnetCount: mainnet.length }, - ); + this.#debugLog('PerpsController: Migrating local watchlist to AUS', { + exchangeKey, + testnetCount: testnet.length, + mainnetCount: mainnet.length, + }); // Push testnet and mainnet together via a single read-merge-write. // #persistWatchlistToRemote writes the network passed to it; call it // for whichever networks have data (or both — duplicate writes are diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index c797535ece..83d1de535f 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -1127,9 +1127,7 @@ describe('PerpsController', () => { await ausController.toggleWatchlistMarket('BTC'); expect(ausController.getWatchlistMarkets()).toContain('BTC'); - expect( - mockAusCall, - ).toHaveBeenCalledWith( + expect(mockAusCall).toHaveBeenCalledWith( 'AuthenticatedUserStorageService:getNotificationPreferences', ); expect(mockAusCall).not.toHaveBeenCalledWith( From 13c62648d190bd29692c0bb650c7e693e4a3e120 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 15 Jun 2026 12:45:03 -0700 Subject: [PATCH 04/17] chore: regenerate types --- .../src/PerpsController-method-action-types.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 1351266616..e1880e9f24 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -976,8 +976,17 @@ export type PerpsControllerSaveOrderBookGroupingAction = { }; /** - * Toggle watchlist status for a market - * Watchlist markets are stored per network (testnet/mainnet) + * Toggle watchlist status for a market. + * + * Updates local state immediately (optimistic UI) and then syncs the new + * watchlist to AuthenticatedUserStorageService. If the remote write fails, + * the local state is reverted so it stays consistent with AUS. + * + * When the user is unauthenticated, or the active provider is not yet + * supported by the AUS schema, the controller continues operating with + * local-persisted state only — no error is surfaced to the caller. + * + * Watchlist markets are stored per network (testnet/mainnet). * * @param symbol - The trading pair symbol. */ From 4b926a31be943e4c05f900bba76bdb094895b7eb Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 18 Jun 2026 12:56:53 -0500 Subject: [PATCH 05/17] fix: move changelog entry to unreleased added section --- packages/perps-controller/CHANGELOG.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d5a10237df..ac62d11a86 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#9010](https://github.com/MetaMask/core/pull/9010)) + - `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. + - On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. + - When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. + - `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. +- Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#9010](https://github.com/MetaMask/core/pull/9010)) + ## [8.2.0] ### Added @@ -18,12 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended `PERPS_EVENT_VALUE.INTERACTION_TYPE` with `MARKET_LIST_FILTER` - Extended `PERPS_EVENT_VALUE.BUTTON_CLICKED` with `WATCHLIST`, `TOP_MOVERS`, `WHATS_HAPPENING` - Extended `PERPS_EVENT_VALUE.BUTTON_LOCATION` with `ASSET_DETAILS` -- Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#9010](https://github.com/MetaMask/core/pull/9010)) - - `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. - - On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. - - When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. - - `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. -- Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#9010](https://github.com/MetaMask/core/pull/9010)) ### Changed From c174c27dbc7e819430bf49f2cf5ed038a2b3114f Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 22 Jun 2026 11:51:55 -0700 Subject: [PATCH 06/17] chore: update yarn --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 14e2868a04..c726bb4884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5799,7 +5799,7 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@npm:^2.1.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": +"@metamask/authenticated-user-storage@npm:^2.0.0, @metamask/authenticated-user-storage@npm:^2.1.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": version: 0.0.0-use.local resolution: "@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage" dependencies: From 1c546d5a28fbf08403e3585798cb2d207610c3c1 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 22 Jun 2026 12:03:23 -0700 Subject: [PATCH 07/17] chore: bump package --- packages/perps-controller/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index be5cd36e8f..2e1fefc710 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -109,7 +109,7 @@ }, "devDependencies": { "@metamask/account-tree-controller": "^7.5.2", - "@metamask/authenticated-user-storage": "^2.0.0", + "@metamask/authenticated-user-storage": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^0.1.3", "@metamask/keyring-controller": "^27.1.0", diff --git a/yarn.lock b/yarn.lock index c726bb4884..0c124feec8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5799,7 +5799,7 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@npm:^2.0.0, @metamask/authenticated-user-storage@npm:^2.1.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": +"@metamask/authenticated-user-storage@npm:^2.1.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": version: 0.0.0-use.local resolution: "@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage" dependencies: @@ -7879,7 +7879,7 @@ __metadata: dependencies: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^7.5.2" - "@metamask/authenticated-user-storage": "npm:^2.0.0" + "@metamask/authenticated-user-storage": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" From e459cdd0a0cbaaeb149f9980920b58ab6718300b Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 22 Jun 2026 13:03:14 -0700 Subject: [PATCH 08/17] chore: bugbots --- .../perps-controller/src/PerpsController.ts | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 2e4aed958a..54978feb92 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -935,6 +935,21 @@ export class PerpsController extends BaseController< #eligibilityCheckDeferred: boolean; + /** + * Serial promise queue for all AUS watchlist operations (hydration and + * individual toggles). Chaining every operation onto this field ensures + * that: + * + * - A toggle that fires immediately after init() always runs *after* the + * init hydration finishes (Bug 3). + * - Concurrent toggles are serialised so the last PUT reflects all changes + * rather than racing with each other (Bug 4). + * + * Errors from individual operations are swallowed inside the queue so that + * a failed operation does not stall subsequent ones. + */ + #ausQueue: Promise = Promise.resolve(); + // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -1688,7 +1703,9 @@ export class PerpsController extends BaseController< // Hydrate watchlist from AUS (non-blocking — transient failures are // caught inside and must not prevent init from completing). - this.#syncWatchlistFromRemote().catch(() => { + // Assigning to #ausQueue ensures subsequent toggleWatchlistMarket + // calls wait for hydration before running their own GET-merge-PUT. + this.#ausQueue = this.#syncWatchlistFromRemote().catch(() => { // Errors are already logged inside #syncWatchlistFromRemote. }); @@ -5145,8 +5162,17 @@ export class PerpsController extends BaseController< }); // Step 2: Persist to AUS; revert local state if the write fails. + // Enqueue behind #ausQueue so that: + // - concurrent toggles serialize their GET-merge-PUT sequences, and + // - any in-flight init hydration completes before we issue a write. try { - await this.#persistWatchlistToRemote(currentNetwork); + await new Promise((resolve, reject) => { + this.#ausQueue = this.#ausQueue + .then(() => this.#persistWatchlistToRemote(currentNetwork)) + .then(resolve, reject) + // Swallow the error on the queue chain so later operations can run. + .catch(() => undefined); + }); } catch (error) { this.#logError( ensureError(error, 'PerpsController.toggleWatchlistMarket'), @@ -5292,7 +5318,15 @@ export class PerpsController extends BaseController< const remoteExchangeWatchlist = prefs.perps.watchlistMarkets?.[exchangeKey]; - if (remoteExchangeWatchlist) { + // Only treat remote as the source of truth when it has actual symbols. + // An empty { testnet: [], mainnet: [] } blob (e.g. created by another + // device that had no watchlist) must not silently wipe local favorites. + const remoteHasContent = + remoteExchangeWatchlist !== undefined && + (remoteExchangeWatchlist.testnet.length > 0 || + remoteExchangeWatchlist.mainnet.length > 0); + + if (remoteHasContent && remoteExchangeWatchlist) { // AUS has data for this exchange — hydrate local state from it. this.update((state) => { state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; @@ -5316,11 +5350,10 @@ export class PerpsController extends BaseController< mainnetCount: mainnet.length, }); // Push testnet and mainnet together via a single read-merge-write. - // #persistWatchlistToRemote writes the network passed to it; call it - // for whichever networks have data (or both — duplicate writes are - // idempotent since we read before each write, but a single combined - // write is cleaner). We combine both networks in one PUT here. - const existingWatchlist: PerpsWatchlistMarkets = { + // Start from existing remote watchlistMarkets (or empty fallback) so + // that other exchanges already stored in AUS are not overwritten. + const existingWatchlist: PerpsWatchlistMarkets = prefs.perps + .watchlistMarkets ?? { hyperliquid: { testnet: [], mainnet: [] }, myx: { testnet: [], mainnet: [] }, }; From 36fb4bef72cd303a1d9857ca06eef91d4a9ab561 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 22 Jun 2026 13:15:06 -0700 Subject: [PATCH 09/17] chore: add unit tests --- .../tests/src/PerpsController.state.test.ts | 237 ++++++------------ 1 file changed, 77 insertions(+), 160 deletions(-) diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 83d1de535f..134985a183 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -43,17 +43,8 @@ import { firstNonEmpty, resolveMyxAuthConfig, } from '../../src/PerpsController'; -import type { PerpsControllerState } from '../../src/PerpsController'; import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; -import type { - AccountState, - GetAvailableDexsParams, - PerpsProvider, - PerpsPlatformDependencies, - PerpsProviderType, - SubscribeAccountParams, -} from '../../src/types'; import { PerpsAnalyticsEvent } from '../../src/types'; jest.mock('../../src/providers/HyperLiquidProvider'); @@ -64,7 +55,7 @@ const mockAddTransaction = jest.fn(); jest.mock( '../../../util/transaction-controller', () => ({ - addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + addTransaction: (...args) => mockAddTransaction(...args), }), { virtual: true }, ); @@ -237,7 +228,7 @@ jest.mock('../../src/services/DataLakeService', () => ({ // Mock FeatureFlagConfigurationService as a class with instance methods const mockFeatureFlagConfigurationServiceInstance = { - refreshEligibility: jest.fn((options: any) => { + refreshEligibility: jest.fn((options) => { // Simulate the service's behavior: extract blocked regions from remote flags const remoteFlags = options.remoteFeatureFlagControllerState.remoteFeatureFlags; @@ -270,7 +261,7 @@ const mockFeatureFlagConfigurationServiceInstance = { } }), refreshHip3Config: jest.fn(), - setBlockedRegions: jest.fn((options: any) => { + setBlockedRegions: jest.fn((options) => { // Simulate setBlockedRegions behavior const { list, source, context } = options; if (context.setBlockedRegionList && context.getBlockedRegionList) { @@ -303,137 +294,74 @@ jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ * This follows the pattern used in RewardsController.test.ts */ class TestablePerpsController extends PerpsController { - /** - * Test-only method to update state directly. - * Exposed for scenarios where state needs to be manipulated - * outside the normal public API (e.g., testing error conditions). - * @param callback - */ - public testUpdate(callback: (state: PerpsControllerState) => void) { + testUpdate(callback) { this.update(callback); } - /** - * Test-only method to mark controller as initialized. - * Common test scenario that requires internal state changes. - */ - public testMarkInitialized() { + testMarkInitialized() { this.isInitialized = true; this.update((state) => { state.initializationState = InitializationState.Initialized; }); } - /** - * Test-only method to set the providers map with complete providers. - * Used in most tests to inject mock providers. - * Also sets activeProviderInstance to the first provider (default provider). - * @param providers - */ - public testSetProviders(providers: Map) { + testSetProviders(providers) { this.providers = providers; - // Set activeProviderInstance to the first provider (typically 'hyperliquid') const firstProvider = providers.values().next().value; if (firstProvider) { this.activeProviderInstance = firstProvider; } } - /** - * Test-only method to set the providers map with partial providers. - * Used explicitly in tests that verify error handling with incomplete providers. - * Type cast is intentional and necessary for testing graceful degradation. - * @param providers - */ - public testSetPartialProviders( - providers: Map>, - ) { - this.providers = providers as Map; + testSetPartialProviders(providers) { + this.providers = providers; } - /** - * Test-only method to get the providers map. - * Used to verify provider state in tests. - */ - public testGetProviders(): Map { + testGetProviders() { return this.providers; } - /** - * Test-only method to set initialization state. - * Allows tests to simulate both initialized and uninitialized states. - * @param value - */ - public testSetInitialized(value: boolean) { + testSetInitialized(value) { this.isInitialized = value; } - /** - * Test-only method to get initialization state. - * Used to verify initialization status in tests. - */ - public testGetInitialized(): boolean { + testGetInitialized() { return this.isInitialized; } - /** - * Test-only method to get blocked region list. - * Used to verify geo-blocking configuration in tests. - */ - public testGetBlockedRegionList(): { source: string; list: string[] } { + testGetBlockedRegionList() { return this.blockedRegionList; } - /** - * Test-only method to set blocked region list. - * Used to test priority logic (remote vs fallback). - * @param list - * @param source - */ - public testSetBlockedRegionList( - list: string[], - source: 'remote' | 'fallback', - ) { + testSetBlockedRegionList(list, source) { this.setBlockedRegionList(list, source); } - /** - * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. - * Wrapper is necessary because protected methods can't be called from test code. - * @param remoteFlags - */ - public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + testRefreshEligibilityOnFeatureFlagChange(remoteFlags) { this.refreshEligibilityOnFeatureFlagChange(remoteFlags); } - /** - * Test accessor for protected method reportOrderToDataLake. - * Wrapper is necessary because protected methods can't be called from test code. - * @param data - */ - public testReportOrderToDataLake(data: any): Promise { + testReportOrderToDataLake(data) { return this.reportOrderToDataLake(data); } - public testHasStandaloneProvider(): boolean { + testHasStandaloneProvider() { return this.hasStandaloneProvider(); } - public testRegisterMYXProvider( - MYXProvider: new (opts: Record) => PerpsProvider, - ) { - this.registerMYXProvider(MYXProvider as never); + testRegisterMYXProvider(MYXProvider) { + this.registerMYXProvider(MYXProvider); } - public testHandleMYXImportError(error: unknown) { + testHandleMYXImportError(error) { this.handleMYXImportError(error); } } describe('PerpsController', () => { - let controller: TestablePerpsController; - let mockProvider: jest.Mocked; - let mockInfrastructure: jest.Mocked; + let controller; + let mockProvider; + let mockInfrastructure; // Helper to mark controller as initialized for tests const markControllerAsInitialized = () => { @@ -443,34 +371,29 @@ describe('PerpsController', () => { beforeEach(() => { jest.clearAllMocks(); - ( - jest.requireMock('../../src/services/EligibilityService') - .EligibilityService as jest.Mock - ).mockImplementation(() => mockEligibilityServiceInstance); - ( - jest.requireMock('../../src/services/DepositService') - .DepositService as jest.Mock - ).mockImplementation(() => mockDepositServiceInstance); - ( - jest.requireMock('../../src/services/MarketDataService') - .MarketDataService as jest.Mock - ).mockImplementation(() => mockMarketDataServiceInstance); - ( - jest.requireMock('../../src/services/TradingService') - .TradingService as jest.Mock - ).mockImplementation(() => mockTradingServiceInstance); - ( - jest.requireMock('../../src/services/AccountService') - .AccountService as jest.Mock - ).mockImplementation(() => mockAccountServiceInstance); - ( - jest.requireMock('../../src/services/DataLakeService') - .DataLakeService as jest.Mock - ).mockImplementation(() => mockDataLakeServiceInstance); - ( - jest.requireMock('../../src/services/FeatureFlagConfigurationService') - .FeatureFlagConfigurationService as jest.Mock - ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + jest + .requireMock('../../src/services/EligibilityService') + .EligibilityService.mockImplementation(() => mockEligibilityServiceInstance); + jest + .requireMock('../../src/services/DepositService') + .DepositService.mockImplementation(() => mockDepositServiceInstance); + jest + .requireMock('../../src/services/MarketDataService') + .MarketDataService.mockImplementation(() => mockMarketDataServiceInstance); + jest + .requireMock('../../src/services/TradingService') + .TradingService.mockImplementation(() => mockTradingServiceInstance); + jest + .requireMock('../../src/services/AccountService') + .AccountService.mockImplementation(() => mockAccountServiceInstance); + jest + .requireMock('../../src/services/DataLakeService') + .DataLakeService.mockImplementation(() => mockDataLakeServiceInstance); + jest + .requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService.mockImplementation( + () => mockFeatureFlagConfigurationServiceInstance, + ); mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); @@ -496,7 +419,7 @@ describe('PerpsController', () => { mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( - (options: any) => { + (options) => { const remoteFlags = options.remoteFeatureFlagControllerState.remoteFeatureFlags; const perpsGeoBlockedRegionsFeatureFlag = @@ -531,7 +454,7 @@ describe('PerpsController', () => { }, ); mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( - (options: any) => { + (options) => { const { list, source, context } = options; if (context.setBlockedRegionList && context.getBlockedRegionList) { const currentList = context.getBlockedRegionList(); @@ -555,12 +478,10 @@ describe('PerpsController', () => { ); // Reset Engine.context mocks to default state to prevent test interdependence - ( - Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(null); - ( - Engine.context.NetworkController.getNetworkClientById as jest.Mock - ).mockReturnValue({ configuration: { chainId: '0x1' } }); + Engine.context.RewardsController.getPerpsDiscountForAccount.mockResolvedValue(null); + Engine.context.NetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: '0x1' }, + }); // Create a fresh mock provider for each test mockProvider = createMockHyperLiquidProvider(); @@ -589,11 +510,9 @@ describe('PerpsController', () => { ); mockProvider.getWithdrawalRoutes.mockReturnValue([]); - ( - HyperLiquidProvider as jest.MockedClass - ).mockImplementation(() => mockProvider); + HyperLiquidProvider.mockImplementation(() => mockProvider); - const mockCall = jest.fn().mockImplementation((action: string) => { + const mockCall = jest.fn().mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -643,13 +562,13 @@ describe('PerpsController', () => { value !== null && 'mockClear' in value ) { - (value as jest.Mock).mockClear(); + value.mockClear(); } }); } - (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); - (mockInfrastructure.logger.error as jest.Mock).mockClear(); - (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + mockInfrastructure.metrics.trackPerpsEvent.mockClear(); + mockInfrastructure.logger.error.mockClear(); + mockInfrastructure.debugLogger.log.mockClear(); }); describe('state management', () => { it('returns positions without updating state', async () => { @@ -661,7 +580,7 @@ describe('PerpsController', () => { positionValue: '5000', unrealizedPnl: '500', marginUsed: '2500', - leverage: { type: 'cross' as const, value: 2 }, + leverage: { type: 'cross', value: 2 }, liquidationPrice: '1500', maxLeverage: 100, returnOnEquity: '10.0', @@ -808,7 +727,7 @@ describe('PerpsController', () => { newOrder: { symbol: 'BTC', isBuy: true, - orderType: 'limit' as const, + orderType: 'limit', price: '51000', size: '0.2', }, @@ -844,7 +763,7 @@ describe('PerpsController', () => { newOrder: { symbol: 'BTC', isBuy: true, - orderType: 'limit' as const, + orderType: 'limit', price: '51000', size: '0.2', }, @@ -1083,14 +1002,14 @@ describe('PerpsController', () => { pushNotificationsEnabled: false, mutedTraderProfileIds: [], }, - } as const; + }; - let ausController: TestablePerpsController; - let mockAusCall: jest.Mock; - let mockAusInfrastructure: jest.Mocked; + let ausController; + let mockAusCall; + let mockAusInfrastructure; beforeEach(() => { - mockAusCall = jest.fn().mockImplementation((action: string) => { + mockAusCall = jest.fn().mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -1148,7 +1067,7 @@ describe('PerpsController', () => { }, }; - mockAusCall.mockImplementation((action: string) => { + mockAusCall.mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: {} }; } @@ -1204,7 +1123,7 @@ describe('PerpsController', () => { }, }; - mockAusCall.mockImplementation((action: string) => { + mockAusCall.mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: {} }; } @@ -1238,7 +1157,7 @@ describe('PerpsController', () => { it('skips AUS sync when activeProvider is aggregated', async () => { ausController.testUpdate((state) => { - (state as any).activeProvider = 'aggregated'; + state.activeProvider = 'aggregated'; }); await ausController.toggleWatchlistMarket('BTC'); @@ -1256,7 +1175,7 @@ describe('PerpsController', () => { }); it('does not throw when AUS GET throws (unauthenticated)', async () => { - mockAusCall.mockImplementation((action: string) => { + mockAusCall.mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: {} }; } @@ -1317,7 +1236,7 @@ describe('PerpsController', () => { }, }; - mockAusCall.mockImplementation((action: string) => { + mockAusCall.mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -1353,7 +1272,7 @@ describe('PerpsController', () => { it('performs one-time migration when blob exists but has no watchlist for the active provider', async () => { const remotePrefsWithoutWatchlist = { ...MOCK_PREFS_BASE }; - mockAusCall.mockImplementation((action: string) => { + mockAusCall.mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -1433,7 +1352,7 @@ describe('PerpsController', () => { }); it('does not throw when AUS GET throws during init', async () => { - mockAusCall.mockImplementation((action: string) => { + mockAusCall.mockImplementation((action) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -1498,15 +1417,13 @@ describe('PerpsController', () => { it('updates accountState when subscribeToAccount callback receives non-null account', () => { const originalCallback = jest.fn(); - let wrappedCallback: (account: AccountState | null) => void = () => { + let wrappedCallback = () => { /* assigned by mock */ }; - mockProvider.subscribeToAccount.mockImplementation( - (p: SubscribeAccountParams) => { - wrappedCallback = p.callback; - return jest.fn(); - }, - ); + mockProvider.subscribeToAccount.mockImplementation((p) => { + wrappedCallback = p.callback; + return jest.fn(); + }); markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); From 8a412209ab3427b2226465300e23dc0703ce45b8 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 22 Jun 2026 16:07:43 -0700 Subject: [PATCH 10/17] chore: update unit test --- .../tests/src/PerpsController.state.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 134985a183..4a585e597d 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -3,7 +3,6 @@ * PerpsController Tests * Clean, focused test suite for PerpsController */ - /* eslint-disable @typescript-eslint/no-explicit-any */ import { @@ -373,13 +372,17 @@ describe('PerpsController', () => { jest .requireMock('../../src/services/EligibilityService') - .EligibilityService.mockImplementation(() => mockEligibilityServiceInstance); + .EligibilityService.mockImplementation( + () => mockEligibilityServiceInstance, + ); jest .requireMock('../../src/services/DepositService') .DepositService.mockImplementation(() => mockDepositServiceInstance); jest .requireMock('../../src/services/MarketDataService') - .MarketDataService.mockImplementation(() => mockMarketDataServiceInstance); + .MarketDataService.mockImplementation( + () => mockMarketDataServiceInstance, + ); jest .requireMock('../../src/services/TradingService') .TradingService.mockImplementation(() => mockTradingServiceInstance); @@ -478,7 +481,9 @@ describe('PerpsController', () => { ); // Reset Engine.context mocks to default state to prevent test interdependence - Engine.context.RewardsController.getPerpsDiscountForAccount.mockResolvedValue(null); + Engine.context.RewardsController.getPerpsDiscountForAccount.mockResolvedValue( + null, + ); Engine.context.NetworkController.getNetworkClientById.mockReturnValue({ configuration: { chainId: '0x1' }, }); From 34807d6050123c013be5d10167fefbab33cabf2d Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 22 Jun 2026 16:31:28 -0700 Subject: [PATCH 11/17] chore: remove unused directive --- .../perps-controller/tests/src/PerpsController.state.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 4a585e597d..10b6010812 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -3,7 +3,6 @@ * PerpsController Tests * Clean, focused test suite for PerpsController */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { GasFeeEstimateLevel, From f79c1b672d10756208346fe3f4b47995528481e2 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 23 Jun 2026 11:26:17 -0700 Subject: [PATCH 12/17] fix: remote sync empty state case --- .../perps-controller/src/PerpsController.ts | 14 ++--- .../tests/src/PerpsController.state.test.ts | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 54978feb92..cbcaef4b04 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5318,15 +5318,11 @@ export class PerpsController extends BaseController< const remoteExchangeWatchlist = prefs.perps.watchlistMarkets?.[exchangeKey]; - // Only treat remote as the source of truth when it has actual symbols. - // An empty { testnet: [], mainnet: [] } blob (e.g. created by another - // device that had no watchlist) must not silently wipe local favorites. - const remoteHasContent = - remoteExchangeWatchlist !== undefined && - (remoteExchangeWatchlist.testnet.length > 0 || - remoteExchangeWatchlist.mainnet.length > 0); - - if (remoteHasContent && remoteExchangeWatchlist) { + // AUS is the source of truth: the presence of the exchange key in the + // blob (even with empty arrays) signals that this device has already + // been migrated and the remote state must be honored — including an + // intentional clear. Only an absent key triggers the one-time migration. + if (remoteExchangeWatchlist !== undefined) { // AUS has data for this exchange — hydrate local state from it. this.update((state) => { state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 10b6010812..35ef732ba8 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -1273,6 +1273,64 @@ describe('PerpsController', () => { expect(ausController.state.watchlistMarkets.mainnet).toEqual(['SOL']); }); + it('hydrates (clears) local watchlist when remote entry exists with empty arrays', async () => { + // Remote blob has the hyperliquid key present but both arrays are empty — + // this represents an intentional clear by another device. The controller + // must honor the remote state rather than treating it as "not migrated". + const remotePrefsEmptyWatchlist = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(remotePrefsEmptyWatchlist); + } + return undefined; + }); + + // Local state has stale favorites from before the remote clear. + const staleState = getDefaultPerpsControllerState(); + staleState.activeProvider = 'hyperliquid'; + staleState.watchlistMarkets.testnet = ['BTC', 'ETH']; + staleState.watchlistMarkets.mainnet = ['SOL']; + + const clearController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: staleState, + infrastructure: mockAusInfrastructure, + }); + + await clearController.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Local state must be cleared to match the remote empty arrays. + expect(clearController.state.watchlistMarkets.testnet).toEqual([]); + expect(clearController.state.watchlistMarkets.mainnet).toEqual([]); + + // No migration PUT should be issued — the remote key is present. + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + it('performs one-time migration when blob exists but has no watchlist for the active provider', async () => { const remotePrefsWithoutWatchlist = { ...MOCK_PREFS_BASE }; From f067df06b4752d928296d33fd1905b6ee5121420 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 23 Jun 2026 13:36:23 -0700 Subject: [PATCH 13/17] fix: lint --- .../perps-controller/src/PerpsController.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 496d45bb42..150feec05e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5326,22 +5326,11 @@ export class PerpsController extends BaseController< const remoteExchangeWatchlist = prefs.perps.watchlistMarkets?.[exchangeKey]; - // AUS is the source of truth: the presence of the exchange key in the - // blob (even with empty arrays) signals that this device has already - // been migrated and the remote state must be honored — including an - // intentional clear. Only an absent key triggers the one-time migration. - if (remoteExchangeWatchlist !== undefined) { - // AUS has data for this exchange — hydrate local state from it. - this.update((state) => { - state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; - state.watchlistMarkets.mainnet = remoteExchangeWatchlist.mainnet; - }); - this.#debugLog('PerpsController: Watchlist hydrated from AUS', { - exchangeKey, - testnetCount: remoteExchangeWatchlist.testnet.length, - mainnetCount: remoteExchangeWatchlist.mainnet.length, - }); - } else { + // AUS is the source of truth: an absent exchange key means this device + // has not been migrated yet — push any local favorites up once. + // A present key (even with empty arrays) must be honored as-is, + // including an intentional remote clear. + if (remoteExchangeWatchlist === undefined) { // Blob exists but has no watchlist for this exchange yet. // If local state has any markets, push them up as a one-time migration. const { testnet, mainnet } = this.state.watchlistMarkets; @@ -5380,6 +5369,17 @@ export class PerpsController extends BaseController< exchangeKey, }); } + } else { + // AUS has an entry for this exchange — hydrate local state from it. + this.update((state) => { + state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; + state.watchlistMarkets.mainnet = remoteExchangeWatchlist.mainnet; + }); + this.#debugLog('PerpsController: Watchlist hydrated from AUS', { + exchangeKey, + testnetCount: remoteExchangeWatchlist.testnet.length, + mainnetCount: remoteExchangeWatchlist.mainnet.length, + }); } } catch (error) { this.#logError( From ccdae9b04e9e011dde61dc5e902eb95914678fb8 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 23 Jun 2026 13:48:18 -0700 Subject: [PATCH 14/17] chore: update changelog --- packages/perps-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 003fe6ed13..04418ddfc0 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a 10-second fetch timeout to `TerminalMarketService` so a stalled Terminal API degrades to the provider promptly instead of blocking indefinitely ([#9224](https://github.com/MetaMask/core/pull/9224)) - Only override the provider display name when Terminal supplies a non-null value, preventing symbol fallback from replacing good provider names ([#9224](https://github.com/MetaMask/core/pull/9224)) +- Fix `#syncWatchlistFromRemote` to use exchange-key presence instead of symbol count when deciding whether to hydrate from AUS, so an intentionally cleared remote watchlist is honored rather than overwritten by stale local favorites ([#9010](https://github.com/MetaMask/core/pull/9010)) ## [8.2.0] From b64c8fe9b19eb7434278d048533b40b46bdf8dce Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 23 Jun 2026 13:56:07 -0700 Subject: [PATCH 15/17] chore: update changelog --- packages/perps-controller/CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 04418ddfc0..11c398d56a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,16 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [8.3.0] - -### Added - - Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#9010](https://github.com/MetaMask/core/pull/9010)) - `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. - On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. - When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. - `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. - Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#9010](https://github.com/MetaMask/core/pull/9010)) +- Fix `#syncWatchlistFromRemote` to use exchange-key presence instead of symbol count when deciding whether to hydrate from AUS, so an intentionally cleared remote watchlist is honored rather than overwritten by stale local favorites ([#9010](https://github.com/MetaMask/core/pull/9010)) + +## [8.3.0] + +### Added + - Add Terminal API integration for market data, controlled via `useTerminalApi` parameter on `GetMarketsParams` / `GetMarketDataWithPricesParams` ([#9137](https://github.com/MetaMask/core/pull/9137)) - `TerminalMarketService` fetches structured market metadata from the injected `terminalApiUrl` with a 5-minute cache TTL. - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. Terminal results respect the same allowlist/blocklist filtering as the provider path. @@ -38,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a 10-second fetch timeout to `TerminalMarketService` so a stalled Terminal API degrades to the provider promptly instead of blocking indefinitely ([#9224](https://github.com/MetaMask/core/pull/9224)) - Only override the provider display name when Terminal supplies a non-null value, preventing symbol fallback from replacing good provider names ([#9224](https://github.com/MetaMask/core/pull/9224)) -- Fix `#syncWatchlistFromRemote` to use exchange-key presence instead of symbol count when deciding whether to hydrate from AUS, so an intentionally cleared remote watchlist is honored rather than overwritten by stale local favorites ([#9010](https://github.com/MetaMask/core/pull/9010)) ## [8.2.0] From 4c8acf57eb2b9ffd1736a9a643e580a15036162a Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 23 Jun 2026 14:01:14 -0700 Subject: [PATCH 16/17] chore: update changelog --- packages/perps-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 11c398d56a..b7895f96d7 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + - Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#9010](https://github.com/MetaMask/core/pull/9010)) - `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. - On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. - When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. - `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. - Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#9010](https://github.com/MetaMask/core/pull/9010)) + +### Fixed + - Fix `#syncWatchlistFromRemote` to use exchange-key presence instead of symbol count when deciding whether to hydrate from AUS, so an intentionally cleared remote watchlist is honored rather than overwritten by stale local favorites ([#9010](https://github.com/MetaMask/core/pull/9010)) ## [8.3.0] From f1d5db8f4b54cb4f9f708539e50562aaaf78e63a Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 23 Jun 2026 14:15:06 -0700 Subject: [PATCH 17/17] fix: changelog --- packages/perps-controller/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index b7895f96d7..2fa4a0659a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#9010](https://github.com/MetaMask/core/pull/9010)) - - `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. - - On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. - - When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. - - `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. +- `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. +- On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. +- When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. +- `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. - Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#9010](https://github.com/MetaMask/core/pull/9010)) ### Fixed