diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index 534fef139f2..428671cfb07 100644 --- a/packages/perps-controller/.sync-state.json +++ b/packages/perps-controller/.sync-state.json @@ -1,8 +1,8 @@ { - "lastSyncedMobileCommit": "d4c68052ccec878922a9909ee95306252a959ff8", - "lastSyncedMobileBranch": "fix/core-sync-bugbot", - "lastSyncedCoreCommit": "5ea3b550b9e52744443da89da4a929ba2a7b0df7", - "lastSyncedCoreBranch": "feat/perps/controller-apr-23rd", - "lastSyncedDate": "2026-04-23T10:22:26Z", - "sourceChecksum": "9afe08e6639a1705e1ac7b51f47d7617e1195504ec05928783e8bf0fcda44f9d" + "lastSyncedMobileCommit": "cf20fa64fa5d919c87005f35578498b37f522e2a", + "lastSyncedMobileBranch": "feat/unified-account", + "lastSyncedCoreCommit": "6f8329297fd1d61ff72d8359d32856d1bda7559f", + "lastSyncedCoreBranch": "feat/perps/controller-apr-30", + "lastSyncedDate": "2026-04-30T15:10:24Z", + "sourceChecksum": "a1ead6bd8f4bf1aae32c95aa27a3b6cddf1c4e4b74ea4761952ea313cb109c80" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d05e7550128..cca48c11a7a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,13 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Force unified-account abstraction mode for HyperLiquid users: deferred `dexAbstraction → unifiedAccount` migration triggered from withdraw, trade, and other action entry points so first trades and withdrawals see unified collateral ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Mode-aware spot folding and tradeable-balance support across unified-mode flows ([#8658](https://github.com/MetaMask/core/pull/8658)) + ### Changed +- Bump `@nktkas/hyperliquid` from `^0.30.2` to `^0.32.2` for `userAbstraction` / `userSetAbstraction` / `agentSetAbstraction` API surface ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Replace `agentSetAbstraction` wire-code magic string with a typed constant ([#8658](https://github.com/MetaMask/core/pull/8658)) - Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/account-tree-controller` from `^7.1.0` to `^7.2.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/transaction-controller` from `^64.4.0` to `^65.0.0` ([#8613](https://github.com/MetaMask/core/pull/8613)) - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +### Fixed + +- Keep users on `portfolioMargin` mode and recover the resolved abstraction mode after migration instead of evicting it ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Retry abstraction mode after transient `userAbstraction` failures and reset the memoized readiness promise after silent migration failures ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Close WebSocket-vs-REST race that could fold spot for Standard users and preserve abstraction REST results across active subscribers ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Drop the pre-fetch generation guard so `userAbstraction` always resolves; treat `#cachedSpotStateUserAddress` as an unambiguous spot owner ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Restore HyperLiquid withdrawal for Unified Account Mode users and support arb USDC withdraw balance in unified mode ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Harden unified-account migration handling and close MM Pay `$0` + analytics gaps ([#8658](https://github.com/MetaMask/core/pull/8658)) + ## [4.0.0] ### Added diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 85c16e647b3..ec8cc569e4a 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -61,7 +61,7 @@ "@metamask/controller-utils": "^11.20.0", "@metamask/messenger": "^1.2.0", "@metamask/utils": "^11.9.0", - "@nktkas/hyperliquid": "^0.30.2", + "@nktkas/hyperliquid": "^0.32.2", "bignumber.js": "^9.1.2", "reselect": "^5.1.1", "uuid": "^8.3.2" diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index dd4bebabf97..13a6dee16a9 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -151,6 +151,10 @@ export const PERPS_EVENT_PROPERTY = { // Pay-with UI (PERPS_UI_INTERACTION) INITIAL_PAYMENT_METHOD: 'initial_payment_method', NEW_PAYMENT_METHOD: 'new_payment_method', + + // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) + ABSTRACTION_MODE: 'abstraction_mode', + PREVIOUS_ABSTRACTION_MODE: 'previous_abstraction_mode', } as const; /** @@ -354,6 +358,8 @@ export const PERPS_EVENT_VALUE = { PARTIALLY_FILLED: 'partially_filled', FAILED: 'failed', SUCCESS: 'success', + ALREADY_ENABLED: 'already_enabled', + MIGRATION_REQUIRED: 'migration_required', }, SCREEN_TYPE: { MARKETS: 'markets', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 5f4610fa16e..4ce4047da6a 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1,9 +1,16 @@ import { CaipAccountId, hasProperty } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { ExchangeClient } from '@nktkas/hyperliquid'; +import type { + ExchangeClient, + UserAbstractionResponse, +} from '@nktkas/hyperliquid'; import { v4 as uuidv4 } from 'uuid'; import type { CandlePeriod } from '../constants/chartConfig'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../constants/eventNames'; import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, @@ -40,6 +47,7 @@ import { TradingReadinessCache, PerpsSigningCache, } from '../services/TradingReadinessCache'; +import { PerpsAnalyticsEvent } from '../types'; import type { AccountState, AssetRoute, @@ -109,6 +117,11 @@ import type { FrontendOrder, SpotMetaResponse, } from '../types/hyperliquid-types'; +import { + HL_ABSTRACTION_WIRE, + HL_UNIFIED_ACCOUNT_MODE, + hyperLiquidModeFoldsSpot, +} from '../types/hyperliquid-types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { ExtendedAssetMeta, ExtendedPerpDex } from '../types/perps-types'; import { @@ -344,13 +357,26 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #blocklistMarkets: string[]; - #useDexAbstraction: boolean; + // Emergency kill-switch for the Unified Account migration flow. Defaults + // to true and is the expected production state after HL's DEX Abstraction + // deprecation. Kept as a constructor option (not removed) so we can + // disable the migration via a hot-fix release if a regression surfaces + // in the wild — flipping this to false reverts to the legacy programmatic + // HIP-3 transfer path that already lives in the codebase. + #useUnifiedAccount: boolean; // True once DEX discovery has succeeded with real data (not a fallback). // When false, #ensureReadyPromise is reset after each init so the next // caller retries DEX discovery instead of reusing a degraded mapping. #dexDiscoveryComplete = false; + // True when the most recent #ensureUnifiedAccountEnabled run ended in a + // transient state that warrants retry (silent agent-key failure, REST + // userAbstraction lookup failure, or keyring locked). #ensureReady resets + // its memoized promise when this is set so the next entry retries the + // migration instead of returning the cached resolved promise. + #unifiedAccountSetupNeedsRetry = false; + // Pending promise to deduplicate concurrent getValidatedDexs() calls #pendingValidatedDexsPromise: Promise<(string | null)[]> | null = null; @@ -381,7 +407,7 @@ export class HyperLiquidProvider implements PerpsProvider { hip3Enabled?: boolean; allowlistMarkets?: string[]; blocklistMarkets?: string[]; - useDexAbstraction?: boolean; + useUnifiedAccount?: boolean; platformDependencies: PerpsPlatformDependencies; messenger: PerpsControllerMessengerBase; initialAssetMapping?: [string, number][]; @@ -399,8 +425,8 @@ export class HyperLiquidProvider implements PerpsProvider { this.#allowlistMarkets = options.allowlistMarkets ?? []; this.#blocklistMarkets = options.blocklistMarkets ?? []; - // Attempt native balance abstraction, fallback to programmatic transfer if unsupported - this.#useDexAbstraction = options.useDexAbstraction ?? true; + // Attempt unified account mode, fallback to programmatic transfer if unsupported + this.#useUnifiedAccount = options.useUnifiedAccount ?? true; // Initialize services with injected platform dependencies this.#clientService = new HyperLiquidClientService(this.#deps, { @@ -564,7 +590,7 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Attempt to enable HIP-3 native balance abstraction + * Attempt to enable HyperLiquid Unified Account mode for HIP-3 orders * * If successful, HyperLiquid automatically manages collateral transfers for HIP-3 orders. * If not supported, disables the flag to trigger programmatic transfer fallback. @@ -572,10 +598,28 @@ export class HyperLiquidProvider implements PerpsProvider { * IMPORTANT: Uses global singleton cache to prevent repeated signing requests * across provider reconnections (critical for hardware wallets). * + * @param options - Optional configuration. + * @param options.allowUserSigning - When true, runs the EIP-712 user-signed migration for `dexAbstraction` accounts. Defaults to false so init does not surface a signing prompt; action-time entry points (trading, withdraw) pass true. * @private */ - async #ensureDexAbstractionEnabled(): Promise { - if (!this.#useDexAbstraction) { + async #ensureUnifiedAccountEnabled(options?: { + allowUserSigning?: boolean; + }): Promise { + // dexAbstraction → unifiedAccount requires an EIP-712 prompt (HL blocks + // the agent path for that transition). Init calls with allowUserSigning=false so + // viewing the Perps section never surfaces a signing dialog. Trading and + // withdraw entry points pass allowUserSigning=true to drive the migration when + // the user actually intends to act. + const allowUserSigning = options?.allowUserSigning ?? false; + + // Optimistic reset — set true below only at the failure points that + // warrant retry (silent agent failure, REST lookup failure, keyring + // locked). Final-state outcomes (success, prompted-failure cached, + // already-on-compatible, defer, unknown mode, feature off) leave it + // false so #ensureReady can keep the memoized promise. + this.#unifiedAccountSetupNeedsRetry = false; + + if (!this.#useUnifiedAccount) { return; // Feature disabled } @@ -587,7 +631,7 @@ export class HyperLiquidProvider implements PerpsProvider { const cachedStatus = TradingReadinessCache.get(network, userAddress); if (cachedStatus?.attempted) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction already attempted (from global cache)', + 'HyperLiquidProvider: Unified Account setup already attempted (from global cache)', { user: userAddress, network, @@ -601,32 +645,43 @@ export class HyperLiquidProvider implements PerpsProvider { // Check if another provider instance is currently attempting this operation // This prevents concurrent signing attempts across providers during reconnection const inFlightPromise = PerpsSigningCache.isInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); if (inFlightPromise) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction in-flight, waiting...', + 'HyperLiquidProvider: Unified Account setup in-flight, waiting...', { network, userAddress }, ); await inFlightPromise; - return; // After waiting, the cache should be set by the other provider + // The other instance may have finished without writing the cache (e.g. + // an init-time call deferred a dexAbstraction migration). If the cache + // is still empty and we are an action-time caller (allowUserSigning=true), + // we must run our own attempt — otherwise the trade/withdraw would + // proceed in the deprecated mode. + const postWaitCache = TradingReadinessCache.get(network, userAddress); + if (postWaitCache?.attempted) { + return; + } + // Fall through to acquire our own lock and retry. } // Set in-flight lock to prevent concurrent attempts const completeInFlight = PerpsSigningCache.setInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); + let currentMode: UserAbstractionResponse | undefined; + try { // Re-check cache after acquiring lock (another provider might have finished) const recheckCache = TradingReadinessCache.get(network, userAddress); if (recheckCache?.attempted) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction completed by another provider', + 'HyperLiquidProvider: Unified Account setup completed by another provider', { network, userAddress }, ); completeInFlight(); @@ -635,84 +690,197 @@ export class HyperLiquidProvider implements PerpsProvider { const infoClient = this.#clientService.getInfoClient(); - // Check if already enabled on-chain (returns boolean | null) - const isEnabled = await infoClient.userDexAbstraction({ + // Check current abstraction mode on-chain + currentMode = await infoClient.userAbstraction({ user: userAddress, }); - if (isEnabled === true) { + if ( + currentMode === 'unifiedAccount' || + currentMode === 'portfolioMargin' + ) { + // portfolioMargin is a superset of unifiedAccount — it already supports + // auto-collateral management for HIP-3 orders and is more capital-efficient. + // Downgrading portfolio margin users to unifiedAccount would be harmful, + // so we treat both modes as already-enabled and skip migration. this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction already enabled on-chain', - { user: userAddress, network }, + 'HyperLiquidProvider: Account already in a compatible mode, skipping migration', + { user: userAddress, network, mode: currentMode }, ); - // Cache the enabled status to skip future checks + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.ALREADY_ENABLED, + }); TradingReadinessCache.set(network, userAddress, { attempted: true, enabled: true, }); + // Record the resolved mode in the subscription service so the next + // aggregation folds spot correctly without waiting for #refreshSpotState. + this.#subscriptionService.setUserAbstractionMode( + userAddress, + currentMode, + ); + completeInFlight(); + return; + } + + // Defer the user-signed transition until the user attempts an action. + // Cache is intentionally left untouched so the next entry re-evaluates; + // the read-only userAbstraction call is cheap and gated by the in-flight + // lock, preventing concurrent prompts. + if (currentMode === 'dexAbstraction' && !allowUserSigning) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Deferring dexAbstraction → unifiedAccount migration to action time', + { user: userAddress, network }, + ); + completeInFlight(); + return; + } + + // Bail on unknown modes BEFORE firing analytics or attempting dispatch. + // Keeps `migration_required` actionable (only fires for modes we can + // actually migrate) and avoids re-emitting on every reconnection. + if ( + currentMode !== 'dexAbstraction' && + currentMode !== 'default' && + currentMode !== 'disabled' + ) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unknown abstraction mode, skipping Unified Account migration', + { user: userAddress, network, mode: currentMode }, + ); completeInFlight(); return; } - // Enable DEX abstraction (one-time, irreversible, requires signature) + // Track which mode users are currently on before we attempt migration. + // This tells us the distribution of legacy modes across our user base. + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }); + + // Enable Unified Account mode. + // - default / disabled: agent wallet can do this silently (no prompt) + // - dexAbstraction: HL blocks the agent transition — requires the user's main + // wallet to sign an EIP-712 action via userSetAbstraction (one-time prompt) this.#deps.debugLogger.log( - 'HyperLiquidProvider: Enabling DEX abstraction (requires signature)', + 'HyperLiquidProvider: Enabling Unified Account mode', { user: userAddress, network, + previousMode: currentMode, note: 'HyperLiquid will auto-manage collateral for HIP-3 orders', }, ); const exchangeClient = this.#clientService.getExchangeClient(); - await exchangeClient.agentEnableDexAbstraction(); + if (currentMode === 'dexAbstraction') { + // Requires EIP-712 signature from the user's main wallet (one-time migration). + // HL blocks the dexAbstraction → unifiedAccount transition via the agent wallet, + // so userSetAbstraction (user-signed) is the only path for legacy users. + await exchangeClient.userSetAbstraction({ + user: userAddress, + abstraction: HL_UNIFIED_ACCOUNT_MODE, + }); + } else { + // default / disabled — silent agent transition, no user prompt + await exchangeClient.agentSetAbstraction({ + abstraction: HL_ABSTRACTION_WIRE.unifiedAccount, + }); + } this.#deps.debugLogger.log( - '✅ HyperLiquidProvider: DEX abstraction enabled successfully', + '✅ HyperLiquidProvider: Unified Account enabled successfully', ); - - // Cache success to prevent re-attempts on reconnection + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS, + }); TradingReadinessCache.set(network, userAddress, { attempted: true, enabled: true, }); + // Record the post-migration mode in the subscription service so it + // immediately re-aggregates with fold=true and surfaces the unified + // balance rather than waiting for the next #refreshSpotState. + this.#subscriptionService.setUserAbstractionMode( + userAddress, + HL_UNIFIED_ACCOUNT_MODE, + ); completeInFlight(); } catch (error) { // If keyring is locked, don't cache so it retries when unlocked if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { this.#deps.debugLogger.log( - '[ensureDexAbstractionEnabled] Keyring locked, will retry later', + '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', ); + this.#unifiedAccountSetupNeedsRetry = true; completeInFlight(); return; } - // Cache the attempt (even on failure) to prevent repeated signing requests - // This is CRITICAL for hardware wallets - if user rejects, don't ask again - TradingReadinessCache.set(network, userAddress, { - attempted: true, - enabled: false, - }); + // Cache failure ONLY for the user-prompted path + // (`dexAbstraction → unifiedAccount` via `userSetAbstraction`). The + // rationale for caching is "don't re-prompt a user who already saw the + // signature dialog and rejected it" — that doesn't apply to: + // - Read-only userAbstraction lookup failures (no prompt; transient). + // - Silent agent-key paths (`default`/`disabled` → `agentSetAbstraction` + // does not show a UI prompt; failures are typically transient HL + // outages and pinning them would leave users stuck in the + // deprecated mode for the rest of the session). + // Action-time retries pick up the unmigrated state and try again. + if (currentMode === 'dexAbstraction') { + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + } else { + // Silent agent-key failure (default/disabled) or read-only + // userAbstraction lookup failure — neither is a final state, so + // signal #ensureReady to drop its memoized promise and retry on + // the next entry instead of pinning the user in the deprecated + // mode for the provider's lifetime. + this.#unifiedAccountSetupNeedsRetry = true; + } + + const errorMessage = ensureError( + error, + 'HyperLiquidProvider.ensureUnifiedAccountEnabled', + ).message; this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction failed, cached to prevent retries', + 'HyperLiquidProvider: Unified Account setup failed', { user: userAddress, network, - error: ensureError( - error, - 'HyperLiquidProvider.ensureDexAbstractionEnabled', - ).message, + error: errorMessage, + // Cache writes only happen on the user-prompted dexAbstraction + // path (see P2-B logic above). Reflect that here so retry + // behaviour is debuggable from the log alone. + cached: currentMode === 'dexAbstraction', }, ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + ...(currentMode && { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + }), + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }); + completeInFlight(); - // Don't blindly disable the flag on any error this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.ensureDexAbstractionEnabled'), - this.#getErrorContext('ensureDexAbstractionEnabled', { - note: 'Could not enable DEX abstraction (may already be enabled, user rejected, or network error)', + ensureError(error, 'HyperLiquidProvider.ensureUnifiedAccountEnabled'), + this.#getErrorContext('ensureUnifiedAccountEnabled', { + note: 'Could not enable Unified Account (user rejected, or network error)', }), ); } @@ -758,9 +926,14 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#buildAssetMapping(); } - // NOTE: Signing operations (DEX abstraction, builder fee, referral) are now DEFERRED - // They are called on-demand in ensureReadyForTrading() when user attempts to trade - // This prevents QR popups when just viewing the Perps section (critical for hardware wallets) + // Attempt Unified Account migration as early as possible so users aren't + // blocked when they try to trade. Software-wallet dexAbstraction users can + // complete the one-time EIP-712 migration during initial setup so the first + // trade sees the unified balance. Hardware wallets remain deferred to + // action time to avoid QR / Ledger prompt spam while browsing. + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: !this.#walletService.isSelectedHardwareWallet(), + }); })(); // Await initialization - keep the promise so subsequent calls resolve immediately @@ -772,6 +945,12 @@ export class HyperLiquidProvider implements PerpsProvider { // Trading still works (main DEX mapping is populated), but HIP-3 markets // will be re-discovered on the next #ensureReady() call. this.#ensureReadyPromise = null; + } else if (this.#unifiedAccountSetupNeedsRetry) { + // Silent migration / lookup / keyring-locked failure left the cache + // empty. Without resetting the memoized promise, subsequent + // #ensureReady calls would skip retry and the user would be stuck + // in the deprecated mode for the provider's lifetime. + this.#ensureReadyPromise = null; } this.#deps.debugLogger.log('[ensureReady] Initialization complete'); } @@ -797,6 +976,11 @@ export class HyperLiquidProvider implements PerpsProvider { // First ensure basic initialization is complete await this.#ensureReady(); + // dexAbstraction users were deferred during init to avoid an EIP-712 prompt + // on Perps section open. Drive the migration here, gated by its own cache so + // already-migrated or already-rejected users are not re-prompted. + await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + // If trading setup already complete, return immediately if (this.#tradingSetupComplete) { return; @@ -830,9 +1014,6 @@ export class HyperLiquidProvider implements PerpsProvider { } } - // Attempt to enable native balance abstraction - await this.#ensureDexAbstractionEnabled(); - // Set up builder fee approval try { await this.#ensureBuilderFeeApproval(); @@ -3201,12 +3382,12 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#ensureUsdhCollateralForOrder(dexName, requiredMargin); - // DEX abstraction will pull USDH from spot automatically + // Unified Account will pull USDH from spot automatically return { transferInfo: null }; } - if (this.#useDexAbstraction) { - this.#deps.debugLogger.log('Using DEX abstraction (no manual transfer)', { + if (this.#useUnifiedAccount) { + this.#deps.debugLogger.log('Using Unified Account (no manual transfer)', { symbol, dex: dexName, }); @@ -3240,7 +3421,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#deps.debugLogger.log( 'Detected DEX abstraction is enabled, switching mode', ); - this.#useDexAbstraction = true; + this.#useUnifiedAccount = true; return { transferInfo: null }; } @@ -3959,7 +4140,7 @@ export class HyperLiquidProvider implements PerpsProvider { const totalMarginUsed = parseFloat(position.marginUsed); // Track HIP-3 transfers (full position close means all margin is freed) - if (isHip3Position && dexName && !this.#useDexAbstraction) { + if (isHip3Position && dexName && !this.#useUnifiedAccount) { hip3Transfers.push({ sourceDex: dexName, freedMargin: totalMarginUsed, @@ -4027,7 +4208,7 @@ export class HyperLiquidProvider implements PerpsProvider { const failureCount = statuses.length - successCount; // Handle HIP-3 margin transfers for successful closes - if (!this.#useDexAbstraction) { + if (!this.#useUnifiedAccount) { for (let i = 0; i < statuses.length; i++) { const status = statuses[i]; const isSuccess = @@ -4495,7 +4676,7 @@ export class HyperLiquidProvider implements PerpsProvider { result.success && isHip3Position && hip3Dex && - !this.#useDexAbstraction + !this.#useUnifiedAccount ) { this.#deps.debugLogger.log( 'Position closed successfully, initiating manual auto-transfer back', @@ -4510,10 +4691,10 @@ export class HyperLiquidProvider implements PerpsProvider { result.success && isHip3Position && hip3Dex && - this.#useDexAbstraction + this.#useUnifiedAccount ) { this.#deps.debugLogger.log( - 'Position closed - DEX abstraction will auto-return freed margin', + 'Position closed - Unified Account will auto-return freed margin', { coin: params.symbol, dex: hip3Dex, @@ -5588,28 +5769,45 @@ export class HyperLiquidProvider implements PerpsProvider { isTestnet: this.#clientService.isTestnetMode(), }); const dexs = await this.#getStandaloneValidatedDexs(); - const [standaloneSpotStateResult, standalonePerpsResults] = - await Promise.all([ - standaloneInfoClient - .spotClearinghouseState({ user: userAddress }) - .catch((error: unknown) => { - this.#deps.debugLogger.log( - 'Standalone spot state fetch failed — falling back to perps-only totals', - { - error: ensureError( - error, - 'HyperLiquidProvider.getAccountState.standalone.spot', - ).message, - }, - ); - return null; - }), - queryStandaloneClearinghouseStates( - standaloneInfoClient, - userAddress, - dexs, - ), - ]); + const [ + standaloneSpotStateResult, + standalonePerpsResults, + standaloneAbstractionResult, + ] = await Promise.all([ + standaloneInfoClient + .spotClearinghouseState({ user: userAddress }) + .catch((error: unknown) => { + this.#deps.debugLogger.log( + 'Standalone spot state fetch failed — falling back to perps-only totals', + { + error: ensureError( + error, + 'HyperLiquidProvider.getAccountState.standalone.spot', + ).message, + }, + ); + return null; + }), + queryStandaloneClearinghouseStates( + standaloneInfoClient, + userAddress, + dexs, + ), + standaloneInfoClient + .userAbstraction({ user: userAddress }) + .catch((error: unknown) => { + this.#deps.debugLogger.log( + 'Standalone userAbstraction fetch failed; spot fold disabled until the mode resolves', + { + error: ensureError( + error, + 'HyperLiquidProvider.getAccountState.standalone.abstraction', + ).message, + }, + ); + return null; + }), + ]); // Aggregate account states across all DEXs, then apply spot-backed // adjustments so streamed/standalone/full paths report the same totals. @@ -5619,6 +5817,11 @@ export class HyperLiquidProvider implements PerpsProvider { const aggregatedAccountState = addSpotBalanceToAccountState( aggregateAccountStates(dexAccountStates), standaloneSpotStateResult, + { + foldIntoCollateral: hyperLiquidModeFoldsSpot( + standaloneAbstractionResult, + ), + }, ); this.#deps.debugLogger.log( @@ -5651,14 +5854,31 @@ export class HyperLiquidProvider implements PerpsProvider { // Get Spot balance (global, not DEX-specific) and Perps states across all DEXs. // One transient DEX failure should not blank the entire account state. - const [spotStateResult, perpsStateResult] = await Promise.allSettled([ - infoClient.spotClearinghouseState({ user: userAddress }), - this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => - infoClient.clearinghouseState(userParam), - ), - ]); + const [spotStateResult, perpsStateResult, abstractionResult] = + await Promise.allSettled([ + infoClient.spotClearinghouseState({ user: userAddress }), + this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => + infoClient.clearinghouseState(userParam), + ), + infoClient.userAbstraction({ user: userAddress }), + ]); const spotState = spotStateResult.status === 'fulfilled' ? spotStateResult.value : null; + const abstractionMode = + abstractionResult.status === 'fulfilled' + ? abstractionResult.value + : null; + if (abstractionResult.status === 'rejected') { + this.#deps.debugLogger.log( + 'User abstraction fetch failed; spot fold disabled until the mode resolves', + { + error: ensureError( + abstractionResult.reason, + 'HyperLiquidProvider.getAccountState.abstraction', + ).message, + }, + ); + } const perpsResponse = perpsStateResult.status === 'fulfilled' ? perpsStateResult.value @@ -5737,6 +5957,9 @@ export class HyperLiquidProvider implements PerpsProvider { const aggregatedAccountState = addSpotBalanceToAccountState( aggregateAccountStates(dexAccountStates), spotState, + { + foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode), + }, ); // Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts) @@ -6821,6 +7044,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Step 4: Ensure client is ready this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); + await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); const exchangeClient = this.#clientService.getExchangeClient(); this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); @@ -6829,9 +7053,16 @@ export class HyperLiquidProvider implements PerpsProvider { 'HyperLiquidProvider: CHECKING ACCOUNT BALANCE', ); const accountState = await this.getAccountState(); - const availableBalance = parseFloat(accountState.availableBalance); + // Release-branch bridge for Unified Account: availableToTradeBalance + // includes collateral HL can draw in target mode. The larger balance + // contract will replace this with an explicit withdrawableBalance field. + const availableBalance = parseFloat( + accountState.availableToTradeBalance ?? accountState.availableBalance, + ); this.#deps.debugLogger.log('HyperLiquidProvider: ACCOUNT BALANCE', { availableBalance, + clearinghouseAvailableBalance: accountState.availableBalance, + availableToTradeBalance: accountState.availableToTradeBalance, totalBalance: accountState.totalBalance, marginUsed: accountState.marginUsed, unrealizedPnl: accountState.unrealizedPnl, @@ -7835,7 +8066,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Clear session caches (ensures fresh state on reconnect/account switch) this.#referralCheckCache.clear(); this.#builderFeeCheckCache.clear(); - // NOTE: DexAbstractionCache is global and NOT cleared on disconnect + // NOTE: UnifiedAccountCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); this.#cachedSpotMeta = null; diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 4c987b3d1fb..dc279262b57 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -37,11 +37,16 @@ import type { PerpsPlatformDependencies, PerpsLogger, } from '../types'; -import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; +import { hyperLiquidModeFoldsSpot } from '../types/hyperliquid-types'; +import type { + SpotClearinghouseStateResponse, + UserAbstractionResponse, +} from '../types/hyperliquid-types'; import { addSpotBalanceToAccountState, calculateWeightedReturnOnEquity, } from '../utils/accountUtils'; +import type { AddSpotBalanceOptions } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptPositionFromSDK, @@ -175,6 +180,8 @@ export class HyperLiquidSubscriptionService { #cachedSpotStateUserAddress: string | null = null; + readonly #abstractionModeByUser = new Map(); + #spotStatePromise?: Promise; #spotStatePromiseUserAddress?: string; @@ -712,7 +719,9 @@ export class HyperLiquidSubscriptionService { } #hashAccountState(account: AccountState): string { - return `${account.availableBalance}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`; + return `${account.availableBalance}:${account.availableToTradeBalance ?? ''}:${ + account.totalBalance + }:${account.marginUsed}:${account.unrealizedPnl}`; } // Cache hashes to avoid recomputation @@ -1020,16 +1029,74 @@ export class HyperLiquidSubscriptionService { returnOnEquity, }, this.#cachedSpotState, + this.#getSpotBalanceOptions(), ); } + #getAbstractionModeForUser( + userAddress?: string | null, + ): UserAbstractionResponse | null { + if (!userAddress) { + return null; + } + + return this.#abstractionModeByUser.get(userAddress.toLowerCase()) ?? null; + } + + #getSpotBalanceOptions(): AddSpotBalanceOptions { + return { + foldIntoCollateral: hyperLiquidModeFoldsSpot( + this.#getAbstractionModeForUser(this.#cachedSpotStateUserAddress), + ), + }; + } + + /** + * Record a user's resolved abstraction mode and immediately re-aggregate. + * Call after the provider has confirmed the on-chain mode (already-enabled + * or just-migrated). Setting the mode (rather than deleting it) ensures + * `hyperLiquidModeFoldsSpot` returns the correct fold decision on the next + * aggregation — a delete would leave the user pinned to fail-closed + * (no fold) until the next refresh, under-reporting balance for Unified + * and Portfolio Margin users. + * + * Seals `#cachedSpotStateUserAddress` if spot is already cached for this + * user (fast-path optimization for the next `#ensureSpotState`). Skips the + * seal if spot belongs to a different user — the next refresh will sort + * everything out. + * + * @param userAddress - The EVM address whose mode is being recorded. + * @param mode - The current abstraction mode for this user. + */ + public setUserAbstractionMode( + userAddress: string, + mode: UserAbstractionResponse, + ): void { + const lower = userAddress.toLowerCase(); + this.#abstractionModeByUser.set(lower, mode); + + // No need to seal #cachedSpotStateUserAddress here — the WS handler and + // #refreshSpotState success path always set it to the spot owner. The + // re-aggregation below will pick up the new mode via the now-populated + // #abstractionModeByUser entry. + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } + async #ensureSpotState(accountId?: CaipAccountId): Promise { const userAddress = await this.#walletService.getUserAddressWithDefault(accountId); + const lowerUserAddress = userAddress.toLowerCase(); + // Fast-path only when we have spot for this user AND a resolved + // abstraction mode. Without the mode, `#getSpotBalanceOptions` would + // fall back to fail-closed (no fold), under-reporting Unified / + // Portfolio Margin balances — force a refresh instead. if ( this.#cachedSpotState && - this.#cachedSpotStateUserAddress === userAddress + this.#cachedSpotStateUserAddress === lowerUserAddress && + this.#abstractionModeByUser.has(lowerUserAddress) ) { return; } @@ -1076,23 +1143,75 @@ export class HyperLiquidSubscriptionService { this.#walletService.createWalletAdapter(), ); - if (generation !== this.#spotStateGeneration) { - return; - } - + // Don't bail here even if generation has bumped (e.g. WS spot snapshot + // arrived while we awaited the subscription client). We still need to + // resolve `userAbstraction` for this user — the mode is user-keyed, + // independent of the spot generation, and the post-fetch path below + // correctly handles the generation-changed case (seal + re-aggregate + // instead of overwriting WS spot). const infoClient = this.#clientService.getInfoClient(); - const result = await infoClient.spotClearinghouseState({ - user: userAddress, - }); + const [spotResult, abstractionResult] = await Promise.allSettled([ + infoClient.spotClearinghouseState({ + user: userAddress, + }), + infoClient.userAbstraction({ user: userAddress }), + ]); + + const lowerUserAddress = userAddress.toLowerCase(); + + // Record the abstraction mode regardless of generation. The mode is + // user-keyed (independent of the spot snapshot generation) so a WS + // push that bumped generation while we awaited cannot make this + // result wrong for this user. Discarding it would strand + // Unified / Portfolio Margin users at fail-closed until another + // subscribe runs — exactly the race the WS-vs-REST guard creates. + if (abstractionResult.status === 'fulfilled') { + this.#abstractionModeByUser.set( + lowerUserAddress, + abstractionResult.value, + ); + } else { + this.#deps.debugLogger.log( + 'User abstraction fetch failed during spot refresh; spot fold disabled until the mode resolves', + { + error: ensureError( + abstractionResult.reason, + 'HyperLiquidSubscriptionService.refreshSpotState.abstraction', + ).message, + }, + ); + } - // Drop stale results: cleanUp/clearAll or a newer fetch bumped generation. - // Writing here would re-populate the cache with a different user's data. if (generation !== this.#spotStateGeneration) { + // A WS push superseded our spot snapshot. The earlier WS-driven + // aggregation ran with a null mode (fail-closed), so subscribers + // may currently be under-reported. If we just resolved the mode + // for the user whose spot is cached (strict match — null cache + // owner could mean cleanUp ran for a different user), re-aggregate + // now so the active subscribers immediately see the correct fold. + if ( + abstractionResult.status === 'fulfilled' && + this.#cachedSpotState && + this.#cachedSpotStateUserAddress === lowerUserAddress + ) { + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } return; } - this.#cachedSpotState = result; - this.#cachedSpotStateUserAddress = userAddress; + if (spotResult.status === 'rejected') { + throw spotResult.reason; + } + + this.#cachedSpotState = spotResult.value; + // Always record the spot owner so subsequent #ensureSpotState calls + // and recovery branches can identify whose data is cached. Fast-path + // eligibility is gated separately by #abstractionModeByUser.has(...); + // a transient abstraction failure leaves the user out of the map and + // the next #ensureSpotState retries both fetches. + this.#cachedSpotStateUserAddress = lowerUserAddress; if (this.#dexAccountCache.size > 0) { this.#aggregateAndNotifySubscribers(); @@ -1144,10 +1263,11 @@ export class HyperLiquidSubscriptionService { // its result instead of overwriting this fresher WS snapshot. this.#spotStateGeneration += 1; this.#cachedSpotState = event.spotState; - // Normalize to match REST path (stores lowercase) so the - // #ensureSpotState strict-equal check hits the cache regardless - // of whether HL returns a checksummed or lowercase user field. - this.#cachedSpotStateUserAddress = event.user.toLowerCase(); + // Always record the spot owner so subsequent generation guards + // and recovery branches can identify whose data is cached. + // Fast-path eligibility is gated separately in #ensureSpotState + // by checking #abstractionModeByUser.has(...). + this.#cachedSpotStateUserAddress = userAddress.toLowerCase(); if (this.#dexAccountCache.size > 0) { this.#aggregateAndNotifySubscribers(); @@ -1623,6 +1743,7 @@ export class HyperLiquidSubscriptionService { const spotAdjustedAccount = addSpotBalanceToAccountState( accountState, this.#cachedSpotState, + this.#getSpotBalanceOptions(), ); const positionsHash = this.#hashPositions(positionsWithTPSL); @@ -2167,6 +2288,7 @@ export class HyperLiquidSubscriptionService { this.#cachedAccount = null; this.#cachedSpotState = null; this.#cachedSpotStateUserAddress = null; + this.#abstractionModeByUser.clear(); // Bump generation so any in-flight spot fetch from a prior user discards // its result instead of re-populating the cache post-cleanup. this.#spotStateGeneration += 1; @@ -3945,6 +4067,7 @@ export class HyperLiquidSubscriptionService { this.#dexAccountCache.clear(); this.#cachedSpotState = null; this.#cachedSpotStateUserAddress = null; + this.#abstractionModeByUser.clear(); this.#spotStateGeneration += 1; this.#spotStatePromise = undefined; this.#spotStatePromiseUserAddress = undefined; diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index d1ac687ce7a..144f53af326 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -1,4 +1,8 @@ -import { parseCaipAccountId, isValidHexAddress } from '@metamask/utils'; +import { + hasProperty, + isValidHexAddress, + parseCaipAccountId, +} from '@metamask/utils'; import type { CaipAccountId, Hex } from '@metamask/utils'; import { getChainId } from '../constants/hyperLiquidConfig'; @@ -8,7 +12,14 @@ import type { PerpsTypedMessageParams, } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils'; + +// Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this +// service portable between mobile and the core monorepo. +const HARDWARE_KEYRING_TYPES = new Set([ + 'Ledger Hardware', + 'QR Hardware Wallet Device', +]); /** * Service for MetaMask wallet integration with HyperLiquid SDK @@ -41,6 +52,29 @@ export class HyperLiquidWalletService { return this.#messenger.call('KeyringController:getState').isUnlocked; } + /** + * Check whether the selected EVM account is backed by hardware. + * + * @returns True for Ledger / QR hardware keyrings; false for software accounts. + */ + public isSelectedHardwareWallet(): boolean { + const selectedEvmAccount = findEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); + if (!selectedEvmAccount || !hasProperty(selectedEvmAccount, 'metadata')) { + return false; + } + + const metadata = selectedEvmAccount.metadata as + | { keyring?: { type?: string } } + | undefined; + const keyringType = metadata?.keyring?.type; + + return Boolean(keyringType && HARDWARE_KEYRING_TYPES.has(keyringType)); + } + /** * Sign typed data via DI keyring controller * diff --git a/packages/perps-controller/src/services/TradingReadinessCache.ts b/packages/perps-controller/src/services/TradingReadinessCache.ts index d1fc5343226..082c7e27b04 100644 --- a/packages/perps-controller/src/services/TradingReadinessCache.ts +++ b/packages/perps-controller/src/services/TradingReadinessCache.ts @@ -8,13 +8,13 @@ * are recreated on account/network changes, which would reset instance-level caches. * * Tracks three signing operations: - * 1. DEX Abstraction enablement (one-time, irreversible) + * 1. Unified Account enablement (one-time, replaces deprecated DEX abstraction) * 2. Builder Fee approval (required for trading) * 3. Referral code setup (one-time per account) * * Cache Structure: * - Key: `network:userAddress` (e.g., "mainnet:0x123...") - * - Value: { dexAbstraction, builderFee, referral, timestamp } + * - Value: { unifiedAccount, builderFee, referral, timestamp } * * Lifecycle: * - Cache persists throughout app session @@ -28,7 +28,7 @@ type SigningOperationState = { }; type PerpsSigningCacheEntry = { - dexAbstraction: SigningOperationState; + unifiedAccount: SigningOperationState; builderFee: SigningOperationState; referral: SigningOperationState; timestamp: number; // When this entry was last updated @@ -71,7 +71,7 @@ class PerpsSigningCacheManager { * @returns The resulting string value. */ public isInFlight( - operationType: 'dexAbstraction' | 'builderFee' | 'referral', + operationType: 'unifiedAccount' | 'builderFee' | 'referral', network: 'mainnet' | 'testnet', userAddress: string, ): Promise | undefined { @@ -89,7 +89,7 @@ class PerpsSigningCacheManager { * @returns The resulting string value. */ public setInFlight( - operationType: 'dexAbstraction' | 'builderFee' | 'referral', + operationType: 'unifiedAccount' | 'builderFee' | 'referral', network: 'mainnet' | 'testnet', userAddress: string, ): () => void { @@ -117,7 +117,7 @@ class PerpsSigningCacheManager { let entry = this.#cache.get(key); if (!entry) { entry = { - dexAbstraction: { attempted: false, success: false }, + unifiedAccount: { attempted: false, success: false }, builderFee: { attempted: false, success: false }, referral: { attempted: false, success: false }, timestamp: Date.now(), @@ -127,10 +127,10 @@ class PerpsSigningCacheManager { return entry; } - // ===== DEX Abstraction Methods ===== + // ===== Unified Account Methods ===== /** - * Get DEX abstraction cache entry (legacy compatibility) + * Get unified account cache entry (legacy compatibility) * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -146,14 +146,14 @@ class PerpsSigningCacheManager { return undefined; } return { - attempted: entry.dexAbstraction.attempted, - enabled: entry.dexAbstraction.success, + attempted: entry.unifiedAccount.attempted, + enabled: entry.unifiedAccount.success, timestamp: entry.timestamp, }; } /** - * Set DEX abstraction cache entry (legacy compatibility) + * Set unified account cache entry (legacy compatibility) * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -167,7 +167,7 @@ class PerpsSigningCacheManager { data: { attempted: boolean; enabled: boolean }, ): void { const entry = this.#getOrCreateEntry(network, userAddress); - entry.dexAbstraction = { attempted: data.attempted, success: data.enabled }; + entry.unifiedAccount = { attempted: data.attempted, success: data.enabled }; entry.timestamp = Date.now(); } @@ -244,27 +244,27 @@ class PerpsSigningCacheManager { // ===== General Methods ===== /** - * Clear only DEX abstraction state for a specific network and user address + * Clear only unified account state for a specific network and user address * This preserves builder fee and referral states * * @param network - The network environment. * @param userAddress - The user's wallet address. */ - public clearDexAbstraction( + public clearUnifiedAccount( network: 'mainnet' | 'testnet', userAddress: string, ): void { const key = this.#getCacheKey(network, userAddress); const entry = this.#cache.get(key); if (entry) { - entry.dexAbstraction = { attempted: false, success: false }; + entry.unifiedAccount = { attempted: false, success: false }; entry.timestamp = Date.now(); } } /** * Clear only builder fee state for a specific network and user address - * This preserves DEX abstraction and referral states + * This preserves unified account and referral states * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -283,7 +283,7 @@ class PerpsSigningCacheManager { /** * Clear only referral state for a specific network and user address - * This preserves DEX abstraction and builder fee states + * This preserves unified account and builder fee states * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -302,7 +302,7 @@ class PerpsSigningCacheManager { /** * Clear entire cache entry for a specific network and user address - * WARNING: This clears ALL signing operation states (dexAbstraction, builderFee, referral) + * WARNING: This clears ALL signing operation states (unifiedAccount, builderFee, referral) * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -347,7 +347,7 @@ class PerpsSigningCacheManager { const entries: string[] = []; this.#cache.forEach((entry, key) => { entries.push( - `${key}: dex=${entry.dexAbstraction.attempted}/${entry.dexAbstraction.success}, ` + + `${key}: unified=${entry.unifiedAccount.attempted}/${entry.unifiedAccount.success}, ` + `builder=${entry.builderFee.attempted}/${entry.builderFee.success}, ` + `referral=${entry.referral.attempted}/${entry.referral.success}`, ); diff --git a/packages/perps-controller/src/types/hyperliquid-types.ts b/packages/perps-controller/src/types/hyperliquid-types.ts index d18f9d1cb20..f0e0f8795e1 100644 --- a/packages/perps-controller/src/types/hyperliquid-types.ts +++ b/packages/perps-controller/src/types/hyperliquid-types.ts @@ -17,8 +17,52 @@ import type { PredictedFundingsResponse, OrderParameters, SpotMetaResponse, + UserAbstractionResponse, } from '@nktkas/hyperliquid'; +/** + * Wire codes accepted by `agentSetAbstraction({ abstraction })`. The SDK + * types these as a `"i" | "u" | "p"` literal union with no exported constant. + * + * Only `unifiedAccount` is referenced by the current migration flow; the + * other entries document the full SDK wire format so a future caller + * (e.g. emergency rollback to `disabled`, or opting into `portfolioMargin`) + * does not have to re-discover the codes. + */ +export const HL_ABSTRACTION_WIRE = { + disabled: 'i', + unifiedAccount: 'u', + portfolioMargin: 'p', +} as const; + +/** + * Long-form abstraction-mode value targeted by the migration. Used as the + * `abstraction` parameter for `userSetAbstraction` and as the success / target + * value reported by Account Setup analytics. + */ +export const HL_UNIFIED_ACCOUNT_MODE = 'unifiedAccount' as const; + +/** + * True when the given HL abstraction mode treats spot balances as perps + * collateral. Fail-CLOSED on missing mode: until userAbstraction has been + * resolved we do not fold spot, because over-reporting withdrawable funds + * for Standard / dexAbstraction users (which `withdraw3` cannot actually + * draw) is worse than briefly under-reporting for Unified users during the + * initial subscription window or a transient REST outage. + * + * @param mode - Abstraction mode returned by HyperLiquid. + * @returns Whether spot balances should fold into perps collateral. + */ +export function hyperLiquidModeFoldsSpot( + mode?: UserAbstractionResponse | null, +): boolean { + if (mode === null || mode === undefined) { + return false; + } + + return mode === 'unifiedAccount' || mode === 'portfolioMargin'; +} + // Clearinghouse (Account) Types export type AssetPosition = ClearinghouseStateResponse['assetPositions'][number]; @@ -44,4 +88,5 @@ export type { MetaAndAssetCtxsResponse, PredictedFundingsResponse, SpotMetaResponse, + UserAbstractionResponse, }; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 9cbd49d8e36..1b3e87d1e63 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1231,6 +1231,7 @@ export enum PerpsAnalyticsEvent { UiInteraction = 'Perp UI Interaction', RiskManagement = 'Perp Risk Management', PerpsError = 'Perp Error', + AccountSetup = 'Perp Account Setup', } /** diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index bf8efa35cff..13c90d960a0 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -16,7 +16,7 @@ function isEvmAccountType(type: string): boolean { export function findEvmAccount( accounts: (InternalAccount | PerpsInternalAccount)[], -): { address: string; type: string } | null { +): InternalAccount | PerpsInternalAccount | null { const evmAccount = accounts.find( (account) => account && isEvmAccountType(account.type as InternalAccount['type']), @@ -90,10 +90,16 @@ export function calculateWeightedReturnOnEquity( return weightedROE.toString(); } -// Spot coins counted toward currently supported funded-state gating. -// Today the in-app HyperLiquid market surface is USDC-collateralized only, -// so USDH must not inflate the shared funded-state path that hides Add Funds. -// Non-stablecoin spot assets (HYPE, PURR, …) also remain excluded. +export type AddSpotBalanceOptions = { + /** + * Whether the user's abstraction mode folds spot balances into perps + * collateral. Standard / DEX abstraction keep spot separate. + */ + foldIntoCollateral?: boolean; +}; + +// The release-branch balance bridge is USDC-only. Non-USDC spot assets must +// not inflate the balances shown or validated by withdraw/payment flows. const SPOT_COLLATERAL_COINS = new Set(['USDC']); export function getSpotBalance( @@ -137,7 +143,12 @@ export function getSpotHold( export function addSpotBalanceToAccountState( accountState: AccountState, spotState?: SpotClearinghouseStateResponse | null, + options?: AddSpotBalanceOptions, ): AccountState { + // Fail-closed default: align with `hyperLiquidModeFoldsSpot(null) → false`. + // A caller that omits `options` should NOT silently fold spot — that would + // over-report withdrawable funds for Standard / dexAbstraction users. + const foldIntoCollateral = options?.foldIntoCollateral ?? false; const spotBalance = getSpotBalance(spotState); const spotHold = getSpotHold(spotState); const freeSpot = Math.max(0, spotBalance - spotHold); @@ -160,9 +171,19 @@ export function addSpotBalanceToAccountState( }; } - const availableToTrade = Number.isFinite(currentAvailable) - ? (currentAvailable + freeSpot).toString() - : freeSpot.toString(); + // Folding is gated strictly on the resolved abstraction mode. Standard / + // DEX-abstraction users keep perps and spot independent, so spot must NOT + // surface as a perps-withdrawable balance for them — withdraw3 only draws + // from the perps ledger in those modes. Unified / portfolio-margin users + // get the fold; live callers fail-CLOSED via `hyperLiquidModeFoldsSpot` + // when mode is unresolved (avoids over-reporting funds withdraw3 cannot + // actually draw during the initial subscription window). + let availableToTrade = accountState.availableBalance; + if (foldIntoCollateral) { + availableToTrade = Number.isFinite(currentAvailable) + ? (currentAvailable + freeSpot).toString() + : freeSpot.toString(); + } // Subtract spotHold to avoid double-counting on Unified/PM accounts: // marginSummary.accountValue already includes the margin that HL diff --git a/yarn.lock b/yarn.lock index 5db0a141fea..87814fe47fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4960,7 +4960,7 @@ __metadata: "@metamask/transaction-controller": "npm:^65.0.0" "@metamask/utils": "npm:^11.9.0" "@myx-trade/sdk": "npm:^0.1.265" - "@nktkas/hyperliquid": "npm:^0.30.2" + "@nktkas/hyperliquid": "npm:^0.32.2" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" @@ -5855,24 +5855,21 @@ __metadata: languageName: node linkType: hard -"@nktkas/hyperliquid@npm:^0.30.2": - version: 0.30.3 - resolution: "@nktkas/hyperliquid@npm:0.30.3" +"@nktkas/hyperliquid@npm:^0.32.2": + version: 0.32.2 + resolution: "@nktkas/hyperliquid@npm:0.32.2" dependencies: - "@nktkas/rews": "npm:^1.2.3" - "@noble/hashes": "npm:^2.0.1" - micro-eth-signer: "npm:^0.18.1" - valibot: "npm:1.2.0" - bin: - hyperliquid: esm/bin/cli.js - checksum: 10/77af9142ab2bdc7d042d3b621c72391e5bb0d8ed3b39209ff33eb6e274e809026554c92fba5fe0318a83cc24690f5c23e9006c82704b779fac3575f1da0949f8 + "@nktkas/rews": "npm:^2" + "@noble/hashes": "npm:^2" + valibot: "npm:1.3.1" + checksum: 10/58ffc50d51aa5842285697c45b2c8bc80a7e0a610b82220902f8f7acee2e58c4099dcc195ce1456130869c3e33c93d6445f02833a59997e8b937398991d8829e languageName: node linkType: hard -"@nktkas/rews@npm:^1.2.3": - version: 1.2.3 - resolution: "@nktkas/rews@npm:1.2.3" - checksum: 10/032d7373ba976167d6f8f24746e9f2ebf20768811943ce8d33ffbb28fab0ad6259800177bd9964449f8fd67c5ef7e781fcee9f1d9bbbffd55f133e4a4c6d9fce +"@nktkas/rews@npm:^2": + version: 2.1.1 + resolution: "@nktkas/rews@npm:2.1.1" + checksum: 10/34fa67f8f2a4321fdfd24c7e7e4c26510b1e09c64245a390d61490077030e0c64d3a43c0b5dc24ec5d35e679ebee90804e3ea7a3b3ed5c37de81887d1156db31 languageName: node linkType: hard @@ -5919,15 +5916,6 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^2.0.0": - version: 2.0.1 - resolution: "@noble/curves@npm:2.0.1" - dependencies: - "@noble/hashes": "npm:2.0.1" - checksum: 10/e826af523f40a671601a6d07f98df16c3afe1cbd0349c3ba4d7b31f6dba7dc743822719f260bd291716b6b42b8dc327f94a76b4852359aa85f79df461eb22bfc - languageName: node - linkType: hard - "@noble/hashes@npm:1.3.2": version: 1.3.2 resolution: "@noble/hashes@npm:1.3.2" @@ -5949,10 +5937,10 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:2.0.1, @noble/hashes@npm:^2.0.0, @noble/hashes@npm:^2.0.1": - version: 2.0.1 - resolution: "@noble/hashes@npm:2.0.1" - checksum: 10/f4d00e7564eb4ff4e6d16be151dd0e404aede35f91e4372b0a8a6ec888379c1dd1e02c721b480af8e7853bea9637185b5cb9533970c5b77d60c254ead0cfd8f7 +"@noble/hashes@npm:^2": + version: 2.2.0 + resolution: "@noble/hashes@npm:2.2.0" + checksum: 10/b1b78bedc2a01394be047429f3d888905015fe8a09f1b7e43e0b5736b54133df62f73dcc73ede43af38e96e86156afb45b86973fdeaa95d9f0880333c3fc0907 languageName: node linkType: hard @@ -6451,13 +6439,6 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:2.0.0": - version: 2.0.0 - resolution: "@scure/base@npm:2.0.0" - checksum: 10/8fb86024f22e9c532d513b8df8a672252e58bd5695920ce646162287f0accd38e89cab58722a738b3d247b5dcf7760362ae2d82d502be7e62a555f5d98f8a110 - languageName: node - linkType: hard - "@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" @@ -12262,17 +12243,6 @@ __metadata: languageName: node linkType: hard -"micro-eth-signer@npm:^0.18.1": - version: 0.18.1 - resolution: "micro-eth-signer@npm:0.18.1" - dependencies: - "@noble/curves": "npm:^2.0.0" - "@noble/hashes": "npm:^2.0.0" - micro-packed: "npm:^0.8.0" - checksum: 10/daa1127b0f4bffa1ffbe0c0d0f0d5bab98636697e1936a0fa552e0bb3b853b3f6733198219d2791323160feb30c12622b366dffd18ad794ec68a0a8fbaa255f1 - languageName: node - linkType: hard - "micro-ftch@npm:^0.3.1": version: 0.3.1 resolution: "micro-ftch@npm:0.3.1" @@ -12280,15 +12250,6 @@ __metadata: languageName: node linkType: hard -"micro-packed@npm:^0.8.0": - version: 0.8.0 - resolution: "micro-packed@npm:0.8.0" - dependencies: - "@scure/base": "npm:2.0.0" - checksum: 10/94bc96387be56d95ca758fcaddbeacdd8095344c3cc51b813637587f4b013853088f046d9a2e81354d583f384ec44c35aa008683aa13397d700ddf7b70aff77e - languageName: node - linkType: hard - "micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" @@ -15009,15 +14970,15 @@ __metadata: languageName: node linkType: hard -"valibot@npm:1.2.0": - version: 1.2.0 - resolution: "valibot@npm:1.2.0" +"valibot@npm:1.3.1": + version: 1.3.1 + resolution: "valibot@npm:1.3.1" peerDependencies: typescript: ">=5" peerDependenciesMeta: typescript: optional: true - checksum: 10/5f9c15e6f5a2b8eae75332a3317e46e995a1763efe1b91e57bc5064e36f0feba734367c88013d53255bdf09fb9204bf3598d2ca0c3f468c8726095b1c3551926 + checksum: 10/e14d085fa87fbf41f76d040cdcf17e31527f868c8b82f878bc488a5bc3bc81162406c605182fc720473ec6dcff05393b89fb4a921a4206d9f7b6f76e3c93cf34 languageName: node linkType: hard