-
-
Notifications
You must be signed in to change notification settings - Fork 287
feat: AUS Perps Watchlist #9010
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9c95b8e
e182afe
e6327ef
7bc7196
13c6264
a5c5a19
cb1e2ee
4b926a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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. | ||
|
|
@@ -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<void> { | ||
| 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<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', | ||
| ); | ||
|
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, | ||
| ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Concurrent toggles lose watchlist updatesMedium Severity Each toggle performs its own GET-merge-PUT without coordinating with other in-flight toggles. Rapid successive calls can read the same remote snapshot and the last write wins, so an earlier optimistic change may never reach AUS even though the UI already updated. Additional Locations (1)Reviewed by Cursor Bugbot for commit a5c5a19. Configure here. |
||
|
|
||
| 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; | ||
| }); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| 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, | ||
| }, | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }; | ||
| 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 | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.