Skip to content
35 changes: 8 additions & 27 deletions app/components/UI/Perps/hooks/usePerpsWatchlistActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,22 @@ interface UsePerpsWatchlistActionsResult {
/**
* Adds a market to the watchlist.
*
* Currently wraps the synchronous PerpsController.toggleWatchlistMarket call.
*
* TODO(TAT-2663 — optimistic UI): Before the await, apply an optimistic local
* state update (e.g. pass an onOptimisticUpdate callback from the caller).
* On failure, roll back that state and show the addError toast below.
*
* TODO(TAT-2663 — User Storage API): Replace / augment the controller call
* with a POST to the User Storage API once it is available.
* Delegates to PerpsController.toggleWatchlistMarket, which performs an
* optimistic local update and syncs to AuthenticatedUserStorageService.
* Falls back to local-only when unauthenticated.
*/
addToWatchlist: (symbol: string) => Promise<void>;
/**
* Removes a market from the watchlist.
*
* Same optimistic UI and User Storage seams as addToWatchlist above.
* Same optimistic-update and AUS sync behaviour as addToWatchlist.
*/
removeFromWatchlist: (symbol: string) => Promise<void>;
}

/**
* Provides add/remove watchlist actions with analytics, error reporting,
* and structural seams for optimistic UI and the future User Storage API.
* Provides add/remove watchlist actions with analytics and error reporting.
* The controller handles optimistic UI, AUS sync, and graceful degradation.
*
* @param source - Analytics source identifying where the action was triggered
* (e.g. PERPS_EVENT_VALUE.SOURCE.PERPS_HOME_WATCHLIST).
Expand All @@ -57,12 +52,7 @@ export const usePerpsWatchlistActions = (
return;
}

// TODO(TAT-2663 — optimistic UI): Call onOptimisticUpdate?.() here
// to instantly reflect the add in the UI before the async call resolves.

controller.toggleWatchlistMarket(symbol);

// TODO(TAT-2663 — User Storage API): await userStorageApi.addWatchlistMarket(symbol)
await controller.toggleWatchlistMarket(symbol);

const watchlistCount = controller.getWatchlistMarkets().length;
track(MetaMetricsEvents.PERPS_UI_INTERACTION, {
Expand All @@ -75,9 +65,6 @@ export const usePerpsWatchlistActions = (
[PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: watchlistCount,
});
} catch (error) {
// TODO(TAT-2663 — optimistic UI): Roll back the optimistic state
// update here (e.g. call onRollback?.()) before showing the error toast.

Logger.error(ensureError(error, 'usePerpsWatchlistActions.add'), {
tags: {
feature: PERPS_CONSTANTS.FeatureName,
Expand All @@ -99,12 +86,8 @@ export const usePerpsWatchlistActions = (
const removeFromWatchlist = useCallback(
async (symbol: string): Promise<void> => {
try {
// TODO(TAT-2663 — optimistic UI): Apply optimistic remove here.

const controller = Engine.context.PerpsController;
controller.toggleWatchlistMarket(symbol);

// TODO(TAT-2663 — User Storage API): await userStorageApi.removeWatchlistMarket(symbol)
await controller.toggleWatchlistMarket(symbol);
Comment thread
cursor[bot] marked this conversation as resolved.

const watchlistCount = controller.getWatchlistMarkets().length;
track(MetaMetricsEvents.PERPS_UI_INTERACTION, {
Expand All @@ -117,8 +100,6 @@ export const usePerpsWatchlistActions = (
[PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: watchlistCount,
});
} catch (error) {
// TODO(TAT-2663 — optimistic UI): Roll back the optimistic remove here.

Logger.error(ensureError(error, 'usePerpsWatchlistActions.remove'), {
tags: {
feature: PERPS_CONSTANTS.FeatureName,
Expand Down
243 changes: 243 additions & 0 deletions app/core/Engine/controllers/perps-controller/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import {
PerpsControllerState,
InitializationState,
MARKET_SORTING_CONFIG,
PerpsPlatformDependencies,
} from '@metamask/perps-controller';
import { perpsControllerInit } from '.';
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';
import { getPerpsControllerMessenger } from '../../messengers/perps-controller-messenger';
import type { NotificationPreferences } from '@metamask/authenticated-user-storage';

// Mock mobile-specific modules that ./index.ts imports to avoid pulling in
// Engine and React Native dependencies in the test environment
Expand Down Expand Up @@ -162,3 +165,243 @@ describe('perps controller init', () => {
expect(perpsControllerState).toStrictEqual(initialPerpsControllerState);
});
});

// ---------------------------------------------------------------------------
// Integration: watchlist ↔ AuthenticatedUserStorageService
//
// The describe block above mocks PerpsController as jest.fn() (needed to test
// the init wiring). These tests need the real controller, so they use
// jest.requireActual to reach the preview build directly.
// ---------------------------------------------------------------------------

function buildSeedPrefs(
overrides: Partial<NotificationPreferences> = {},
): NotificationPreferences {
return {
walletActivity: {
inAppNotificationsEnabled: false,
pushNotificationsEnabled: false,
accounts: [],
},
marketing: {
inAppNotificationsEnabled: false,
pushNotificationsEnabled: false,
},
perps: {
inAppNotificationsEnabled: false,
pushNotificationsEnabled: false,
watchlistMarkets: {
hyperliquid: { testnet: [], mainnet: [] },
myx: { testnet: [], mainnet: [] },
},
},
socialAI: {
inAppNotificationsEnabled: false,
pushNotificationsEnabled: false,
mutedTraderProfileIds: [],
},
...overrides,
};
}

function buildTestInfrastructure(): PerpsPlatformDependencies {
return {
logger: { error: jest.fn() },
debugLogger: { log: jest.fn() },
metrics: { isEnabled: jest.fn(() => false), trackPerpsEvent: jest.fn() },
performance: { now: jest.fn(() => 0) },
streamManager: {
pauseChannel: jest.fn(),
resumeChannel: jest.fn(),
clearAllChannels: jest.fn(),
},
featureFlags: { validateVersionGated: jest.fn(() => false) },
marketDataFormatters: {
formatVolume: jest.fn(() => ''),
formatPerpsFiat: jest.fn(() => ''),
formatPercentage: jest.fn(() => ''),
priceRangesUniversal: [],
},
cacheInvalidator: { invalidate: jest.fn(), invalidateAll: jest.fn() },
diskCache: {
// Called synchronously in the constructor (#hydrateCacheFromDiskSync).
getItemSync: jest.fn(() => null),
getItem: jest.fn(() => Promise.resolve(null)),
setItem: jest.fn(() => Promise.resolve()),
removeItem: jest.fn(() => Promise.resolve()),
},
tracer: {
trace: jest.fn(),
endTrace: jest.fn(),
setMeasurement: jest.fn(),
addBreadcrumb: jest.fn(),
},
rewards: { getPerpsDiscountForAccount: jest.fn(() => Promise.resolve(0)) },
};
}

function buildRealController(
getNotificationPreferencesImpl: () => Promise<NotificationPreferences | null>,
) {
// Use the real PerpsController from the preview build — the top-level
// jest.mock replaces it with jest.fn() for the init tests above, so we
// reach through with requireActual here.
const {
PerpsController: RealPerpsController,
getDefaultPerpsControllerState,
} = jest.requireActual('@metamask/perps-controller/PerpsController') as {
PerpsController: typeof PerpsController;
getDefaultPerpsControllerState: () => PerpsControllerState;
};

const baseMessenger = new ExtendedMessenger<MockAnyNamespace>({
namespace: MOCK_ANY_NAMESPACE,
});

const getSpy = jest.fn().mockImplementation(getNotificationPreferencesImpl);
const putSpy = jest.fn().mockResolvedValue(undefined);

baseMessenger.registerActionHandler(
'AuthenticatedUserStorageService:getNotificationPreferences',
getSpy,
);
baseMessenger.registerActionHandler(
'AuthenticatedUserStorageService:putNotificationPreferences',
putSpy,
);

const controllerMessenger = getPerpsControllerMessenger(baseMessenger);

const controller = new RealPerpsController({
messenger: controllerMessenger,
state: getDefaultPerpsControllerState(),
infrastructure: buildTestInfrastructure(),
clientConfig: {},
});

return { controller, getSpy, putSpy };
}

describe('PerpsController watchlist ↔ AuthenticatedUserStorageService', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('toggleWatchlistMarket — add', () => {
it('calls putNotificationPreferences with the new symbol in hyperliquid.mainnet', async () => {
const { controller, putSpy } = buildRealController(() =>
Promise.resolve(buildSeedPrefs()),
);

await controller.toggleWatchlistMarket('BTC');

expect(putSpy).toHaveBeenCalledTimes(1);
expect(putSpy).toHaveBeenCalledWith(
expect.objectContaining({
perps: expect.objectContaining({
watchlistMarkets: expect.objectContaining({
hyperliquid: expect.objectContaining({
mainnet: expect.arrayContaining(['BTC']),
}),
}),
}),
}),
);
});

it('keeps the local watchlist state after a successful AUS write', async () => {
const { controller } = buildRealController(() =>
Promise.resolve(buildSeedPrefs()),
);

await controller.toggleWatchlistMarket('ETH');

expect(controller.getWatchlistMarkets()).toContain('ETH');
});

it('merges with existing remote symbols — does not overwrite the whole list', async () => {
const seedWithSol = buildSeedPrefs({
perps: {
inAppNotificationsEnabled: false,
pushNotificationsEnabled: false,
watchlistMarkets: {
hyperliquid: { testnet: [], mainnet: ['SOL'] },
myx: { testnet: [], mainnet: [] },
},
},
});

const { controller, putSpy } = buildRealController(() =>
Promise.resolve(seedWithSol),
);
// Seed local state with SOL first
await controller.toggleWatchlistMarket('SOL');
putSpy.mockClear();

await controller.toggleWatchlistMarket('BTC');

expect(putSpy).toHaveBeenCalledWith(
expect.objectContaining({
perps: expect.objectContaining({
watchlistMarkets: expect.objectContaining({
hyperliquid: expect.objectContaining({
mainnet: expect.arrayContaining(['SOL', 'BTC']),
}),
}),
}),
}),
);
});
});

describe('toggleWatchlistMarket — remove', () => {
it('calls putNotificationPreferences without the removed symbol', async () => {
const { controller, putSpy } = buildRealController(() =>
Promise.resolve(buildSeedPrefs()),
);
await controller.toggleWatchlistMarket('BTC'); // add
putSpy.mockClear();

await controller.toggleWatchlistMarket('BTC'); // remove

expect(putSpy).toHaveBeenCalledTimes(1);
const [calledPrefs] = putSpy.mock.calls[0] as [NotificationPreferences];
expect(
calledPrefs.perps.watchlistMarkets?.hyperliquid.mainnet,
).not.toContain('BTC');
});

it('reverts local state when the AUS write fails', async () => {
const { controller, putSpy } = buildRealController(() =>
Promise.resolve(buildSeedPrefs()),
);
await controller.toggleWatchlistMarket('BTC');
expect(controller.getWatchlistMarkets()).toContain('BTC');

putSpy.mockRejectedValueOnce(new Error('network error'));
await controller.toggleWatchlistMarket('BTC'); // remove attempt fails

expect(controller.getWatchlistMarkets()).toContain('BTC');
});
});

describe('AUS write is skipped when preferences blob is not yet initialised', () => {
it('does not call putNotificationPreferences when getNotificationPreferences returns null', async () => {
const { controller, putSpy } = buildRealController(() =>
Promise.resolve(null),
);

await controller.toggleWatchlistMarket('BTC');

expect(putSpy).not.toHaveBeenCalled();
});

it('keeps the optimistic local update even when the remote write is skipped', async () => {
const { controller } = buildRealController(() => Promise.resolve(null));

await controller.toggleWatchlistMarket('BTC');

expect(controller.getWatchlistMarkets()).toContain('BTC');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,22 @@ describe('PerpsController Messenger', () => {
}),
);
});

it('delegates AUS notification-preference actions to the perps controller messenger', () => {
const baseControllerMessenger = new ExtendedMessenger<MockAnyNamespace>({
namespace: MOCK_ANY_NAMESPACE,
});
const delegateSpy = jest.spyOn(baseControllerMessenger, 'delegate');

getPerpsControllerMessenger(baseControllerMessenger);

expect(delegateSpy).toHaveBeenCalledWith(
expect.objectContaining({
actions: expect.arrayContaining([
'AuthenticatedUserStorageService:getNotificationPreferences',
'AuthenticatedUserStorageService:putNotificationPreferences',
]),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type AllowedEvents = MessengerEvents<PerpsControllerMessenger>;
* PerpsController uses the messenger for all cross-controller communication:
* NetworkController, KeyringController, TransactionController,
* RemoteFeatureFlagController, AccountsController, AccountTreeController,
* AuthenticationController.
* AuthenticationController, AuthenticatedUserStorageService.
* The root messenger already registers actions for these controllers,
* so the child messenger can call them through the parent.
*
Expand Down Expand Up @@ -45,6 +45,8 @@ export function getPerpsControllerMessenger(
'AccountsController:getSelectedAccount',
'AccountTreeController:getAccountsFromSelectedAccountGroup',
'AuthenticationController:getBearerToken',
'AuthenticatedUserStorageService:getNotificationPreferences',
'AuthenticatedUserStorageService:putNotificationPreferences',
],
events: [
'RemoteFeatureFlagController:stateChange',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
]
},
"resolutions": {
"@metamask/perps-controller": "^9.0.0",
"@metamask/network-controller": "33.0.0",
"@react-native-community/viewpager": "patch:@react-native-community/viewpager@npm%3A3.3.0#~/.yarn/patches/@react-native-community-viewpager-npm-3.3.0.patch",
"@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.5",
Expand Down
Loading
Loading