Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` 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
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
249 changes: 244 additions & 5 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
NotificationPreferences,
PerpsWatchlistMarkets,
} from '@metamask/authenticated-user-storage';
import {
BaseController,
ControllerGetStateAction,
Expand Down Expand Up @@ -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<PerpsActiveProviderMode, keyof PerpsWatchlistMarkets>
> = {
hyperliquid: 'hyperliquid',
myx: 'myx',
};
return map[activeProvider] ?? null;
}

/**
* Resolves MYX auth config from provider credentials, handling
* testnet/mainnet fallback logic.
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<void> {
const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet';
const currentWatchlist = this.state.watchlistMarkets[currentNetwork];
const isWatchlisted = currentWatchlist.includes(symbol);
Expand All @@ -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;
});
}
}

/**
Expand All @@ -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<void> {
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',
);
Comment on lines +5157 to +5159
Copy link
Copy Markdown
Member Author

@gambinish gambinish Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aganglada One note on storage approach: why NotificationPreferences and not AssetsWatchlistBlob or some other purpose built storage blob like PerpsWatchlistBlob.

The AUS package actually has two separate storage endpoints:

  1. getAssetsWatchlist / setAssetsWatchlist/assets-watchlist — a dedicated, atomic watchlist blob ({ version: 1, assets: string[] })
  2. getNotificationPreferences / putNotificationPreferences/preferences/notifications — a combined blob covering wallet activity, marketing, perps, and social AI settings

At first glance assets-watchlist would be the cleaner fit — or potentially even a new purpose built storage endpoint perps-watchlist, but per the ticket spec we are following the existing schema. Using a separate endpoint would arguably be cleaner because we don't need to do this read-merge-write pattern, which runs the risk of accidentally clobbering our existing notification settings.

The tradeoff to be aware of with this approach: every watchlist toggle now does a GET → merge → PUT on the entire notification preferences blob. This is the same pattern NotificationServicesController uses for wallet account updates, so it's consistent with the existing codebase — but it does mean a concurrent notification preference change and a watchlist toggle could race. That's worth keeping in mind as the perps notification work continues.

Should we consider storing this under a dedicated endpoint in @metamask/authenticated-user-storage instead?

Copy link
Copy Markdown
Member Author

@gambinish gambinish Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do want to have this purpose built endpoint (I think we should), the changes needed would roughly follow the pattern of asset-watchlist:

  1. New server endpoint in user storage API (likely a GET/PUT)
  2. New methods and types on @metamask/authenticated-user-storage
  3. Simplify this sync code in perps-controller, which would make things much cleaner


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<void> {
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
Expand Down
8 changes: 7 additions & 1 deletion packages/perps-controller/src/types/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading