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; 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..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, @@ -149,6 +153,30 @@ 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< + Record + > = { + hyperliquid: 'hyperliquid', + myx: 'myx', + }; + return map[activeProvider] ?? null; +} + /** * Resolves MYX auth config from provider credentials, handling * testnet/mainnet fallback logic. @@ -1647,6 +1675,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 +5040,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 +5066,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 +5128,174 @@ 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..83d1de535f 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,408 @@ 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"