Skip to content
Merged
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 @@ -481,6 +481,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
13 changes: 13 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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<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 ([#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]

### 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 @@ -109,6 +109,7 @@
},
"devDependencies": {
"@metamask/account-tree-controller": "^7.5.3",
"@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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
278 changes: 273 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 @@ -153,6 +157,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 @@ -912,6 +940,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<void> = Promise.resolve();

// Store options for dependency injection (allows core package to inject platform-specific services)
readonly #options: PerpsControllerOptions;

Expand Down Expand Up @@ -1665,6 +1708,14 @@ 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).
// 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.
});
Comment thread
cursor[bot] marked this conversation as resolved.

return; // Exit retry loop on success
} catch (error) {
lastError = ensureError(error, 'PerpsController.performInitialization');
Expand Down Expand Up @@ -5070,12 +5121,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 @@ -5087,17 +5147,54 @@ 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.
// 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 new Promise<void>((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'),
this.#getErrorContext('toggleWatchlistMarket', {
symbol,
network: currentNetwork,
action: isWatchlisted ? 'remove' : 'add',
}),
);
// Revert the optimistic update.
this.update((state) => {
state.watchlistMarkets[currentNetwork] = currentWatchlist;
});
}
}

/**
Expand All @@ -5121,6 +5218,177 @@ 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 thread
gambinish marked this conversation as resolved.

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,
);
Comment thread
cursor[bot] marked this conversation as resolved.

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];

// 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;
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.
// 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: [] },
};
const nextWatchlistMarkets: PerpsWatchlistMarkets = {
...existingWatchlist,
[exchangeKey]: { testnet, mainnet },
};
const nextPrefs: NotificationPreferences = {
...prefs,
perps: {
...prefs.perps,
watchlistMarkets: nextWatchlistMarkets,
},
Comment thread
cursor[bot] marked this conversation as resolved.
};
await this.messenger.call(
'AuthenticatedUserStorageService:putNotificationPreferences',
nextPrefs,
);
this.#debugLog('PerpsController: Local watchlist migrated to AUS', {
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(
ensureError(error, 'PerpsController.syncWatchlistFromRemote'),
this.#getErrorContext('syncWatchlistFromRemote'),
);
}
}

/**
* Report order events to data lake API with retry (non-blocking)
* Thin delegation to DataLakeService
Expand Down
Loading
Loading