From b4358bf21ff0e98e85e8b9ccc079434287f7c09b Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 13 May 2026 14:38:29 -0700 Subject: [PATCH 1/2] chore: sync perps controller may 15 2026 --- packages/perps-controller/.sync-state.json | 12 +-- packages/perps-controller/CHANGELOG.md | 6 ++ .../src/providers/HyperLiquidProvider.ts | 46 ++++++----- .../src/services/HyperLiquidWalletService.ts | 5 +- .../src/services/RewardsIntegrationService.ts | 24 +++++- .../src/services/TradingReadinessCache.ts | 3 +- .../src/services/TradingService.ts | 77 +++++++++++++++++++ packages/perps-controller/src/types/index.ts | 10 ++- .../perps-controller/src/utils/errorUtils.ts | 30 +++++++- .../src/utils/hyperLiquidAbstraction.ts | 26 +++++++ 10 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 packages/perps-controller/src/utils/hyperLiquidAbstraction.ts diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index 7156bcbf90..011cbcfac8 100644 --- a/packages/perps-controller/.sync-state.json +++ b/packages/perps-controller/.sync-state.json @@ -1,8 +1,8 @@ { - "lastSyncedMobileCommit": "106369c46bd01ee87faeaf51530fe67ad03ca178", - "lastSyncedMobileBranch": "fix/perps/flip-position-tat-2123", - "lastSyncedCoreCommit": "db4cc9ec8ff8530d8e9e0e3737b358e3269ce1b2", - "lastSyncedCoreBranch": "feat/perps/controller-sync-may-5", - "lastSyncedDate": "2026-05-05T20:50:44Z", - "sourceChecksum": "894eb0960741e569f1f025dbaaff6e1ffcab2489a80ad71e74cdf43af98aa764" + "lastSyncedMobileCommit": "35953448cf3c32b2867de8fe0599a356925913ef", + "lastSyncedMobileBranch": "main", + "lastSyncedCoreCommit": "3e549deb97d362c6798a0062dd8b01ac481615c4", + "lastSyncedCoreBranch": "main", + "lastSyncedDate": "2026-05-13T21:35:30Z", + "sourceChecksum": "79a9acc7ad058802b357c6f54774799229b44e9418e802f2f7958e345e16cf59" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 400ad2bcfa..c795417988 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,9 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Pass the perps builder base fee into rewards discount resolution and treat unhydrated rewards subscription state as retryable instead of a definitive no-discount result ([#0000](https://github.com/MetaMask/core/pull/0000)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) +### Fixed + +- Defer signing-backed HyperLiquid unified-account setup for hardware wallets across migratable abstraction modes, including Ledger, Trezor, OneKey, Lattice, and QR keyrings, to avoid repeated signing prompts while browsing ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Improve logging and retry classification for failed cancel/close/TP-SL operations and SDK-wrapped keyring-locked errors ([#0000](https://github.com/MetaMask/core/pull/0000)) + ## [6.0.1] ### Changed diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 9ef2058fc8..7019c270aa 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -128,7 +128,8 @@ import { addSpotBalanceToAccountState, aggregateAccountStates, } from '../utils/accountUtils'; -import { ensureError } from '../utils/errorUtils'; +import { ensureError, isKeyringLockedError } from '../utils/errorUtils'; +import { shouldDeferUnifiedAccountSetup } from '../utils/hyperLiquidAbstraction'; import { adaptAccountStateFromSDK, adaptHyperLiquidLedgerUpdateToUserHistoryItem, @@ -627,7 +628,8 @@ export class HyperLiquidProvider implements PerpsProvider { const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; // Check global cache first to avoid repeated signing requests - // This is CRITICAL for hardware wallets to prevent QR popup spam + // This is CRITICAL for hardware wallets to prevent repeated signing prompts + // while browsing. const cachedStatus = TradingReadinessCache.get(network, userAddress); if (cachedStatus?.attempted) { this.#deps.debugLogger.log( @@ -726,14 +728,14 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - // Defer the user-signed transition until the user attempts an action. + // Defer signing-backed transitions 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) { + if (shouldDeferUnifiedAccountSetup(currentMode, allowUserSigning)) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: Deferring dexAbstraction → unifiedAccount migration to action time', - { user: userAddress, network }, + 'HyperLiquidProvider: Deferring unified account migration to action time', + { user: userAddress, network, mode: currentMode }, ); completeInFlight(); return; @@ -814,8 +816,9 @@ export class HyperLiquidProvider implements PerpsProvider { ); completeInFlight(); } catch (error) { - // If keyring is locked, don't cache so it retries when unlocked - if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', ); @@ -927,10 +930,10 @@ export class HyperLiquidProvider implements PerpsProvider { } // 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. + // blocked when they try to trade. Software wallets can complete the + // signing-backed migration during initial setup so the first trade sees + // the unified balance. Hardware wallets remain deferred to action time to + // avoid repeated signing prompts while browsing. await this.#ensureUnifiedAccountEnabled({ allowUserSigning: !this.#walletService.isSelectedHardwareWallet(), }); @@ -963,7 +966,7 @@ export class HyperLiquidProvider implements PerpsProvider { * - Builder fee approval (required for orders) * - Referral code setup (attribution) * - * These operations are DEFERRED from ensureReady() to avoid QR popup spam + * These operations are DEFERRED from ensureReady() to avoid hardware wallet prompt spam * when users are just viewing the Perps section (critical for hardware wallets). * * Call this method before any trading operation (placeOrder, cancelOrder, etc.) @@ -2542,11 +2545,12 @@ export class HyperLiquidProvider implements PerpsProvider { const cacheKey = this.#getCacheKey(network, userAddress); // Check GLOBAL cache first to avoid repeated signing requests across reconnections - // This is CRITICAL for hardware wallets to prevent QR popup spam + // This is CRITICAL for hardware wallets to prevent repeated signing prompts + // while browsing. const globalCached = PerpsSigningCache.getBuilderFee(network, userAddress); if (globalCached?.attempted) { this.#deps.debugLogger.log( - '[ensureBuilderFeeApproval] Using global cache (prevents QR popup spam)', + '[ensureBuilderFeeApproval] Using global cache (prevents hardware wallet prompt spam)', { network, success: globalCached.success }, ); if (globalCached.success) { @@ -2650,8 +2654,9 @@ export class HyperLiquidProvider implements PerpsProvider { } completeInFlight(); } catch (error) { - // If keyring is locked, don't cache so it retries when unlocked - if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Keyring locked, will retry later', ); @@ -8374,7 +8379,7 @@ export class HyperLiquidProvider implements PerpsProvider { const globalCached = PerpsSigningCache.getReferral(network, userAddress); if (globalCached?.attempted) { this.#deps.debugLogger.log( - '[ensureReferralSet] Using global cache (prevents QR popup spam)', + '[ensureReferralSet] Using global cache (prevents hardware wallet prompt spam)', { network, success: globalCached.success }, ); return; @@ -8465,8 +8470,9 @@ export class HyperLiquidProvider implements PerpsProvider { } completeInFlight(); } catch (error) { - // If keyring is locked, don't cache so it retries when unlocked - if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureReferralSet] Keyring locked, will retry later', ); diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 144f53af32..1f8dbcbbf8 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -18,6 +18,9 @@ import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils'; // service portable between mobile and the core monorepo. const HARDWARE_KEYRING_TYPES = new Set([ 'Ledger Hardware', + 'Trezor Hardware', + 'OneKey Hardware', + 'Lattice Hardware', 'QR Hardware Wallet Device', ]); @@ -55,7 +58,7 @@ export class HyperLiquidWalletService { /** * Check whether the selected EVM account is backed by hardware. * - * @returns True for Ledger / QR hardware keyrings; false for software accounts. + * @returns True for MetaMask hardware keyrings; false for software accounts. */ public isSelectedHardwareWallet(): boolean { const selectedEvmAccount = findEvmAccount( diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index ffd8eadbab..a74e231f58 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -1,3 +1,7 @@ +import { + BASIS_POINTS_DIVISOR, + BUILDER_FEE_CONFIG, +} from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { PerpsPlatformDependencies } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; @@ -118,9 +122,23 @@ export class RewardsIntegrationService { return undefined; } - // Use rewards via DI (no RewardsController in Core yet) - const discountBips = - await this.#deps.rewards.getPerpsDiscountForAccount(caipAccountId); + // Use rewards via DI (no RewardsController in Core yet). + // The rewards controller needs the perps MetaMask builder base fee in + // bips to convert an absolute VIP fee into a discount fraction. + const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( + caipAccountId, + BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR, + ); + + // null = subscription state not hydrated yet; surface as undefined so + // callers don't treat it as a definitive "no discount" answer. + if (discountBips === null) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', + { address: evmAccount.address, caipAccountId }, + ); + return undefined; + } this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount calculated', diff --git a/packages/perps-controller/src/services/TradingReadinessCache.ts b/packages/perps-controller/src/services/TradingReadinessCache.ts index 082c7e27b0..2f2b79d98f 100644 --- a/packages/perps-controller/src/services/TradingReadinessCache.ts +++ b/packages/perps-controller/src/services/TradingReadinessCache.ts @@ -2,7 +2,8 @@ * Global singleton cache for Perps signing operations * * This cache persists across provider reconnections to prevent repeated - * signing requests for hardware wallets. Critical for preventing QR popup spam. + * signing requests for hardware wallets. Critical for preventing repeated + * hardware wallet signing prompts. * * Cache is intentionally kept separate from provider instances because providers * are recreated on account/network changes, which would reset instance-level caches. diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 18b2356ef0..4f9191691c 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1123,6 +1123,15 @@ export class TradingService { }, ); + this.#deps.logger.error( + ensureError(result.error, 'TradingService.cancelOrder'), + this.#getErrorContext('cancelOrder', { + symbol: params.symbol, + orderId: params.orderId, + providerError: result.error ?? 'Unknown error', + }), + ); + traceData = { success: false, error: result.error ?? 'Unknown error' }; } @@ -1298,6 +1307,31 @@ export class TradingService { }; }, ['orders']); // Disconnect orders stream during operation + if ( + provider.cancelOrders && + operationResult && + operationResult.failureCount > 0 + ) { + const failureSummary = operationResult.results + .filter((result) => !result.success) + .map( + (result) => + `${result.symbol}/${result.orderId}: ${result.error ?? 'Unknown error'}`, + ) + .join('; '); + + this.#deps.logger.error( + new Error( + `cancelOrders batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + ), + this.#getErrorContext('cancelOrders', { + successCount: operationResult.successCount, + failureCount: operationResult.failureCount, + cancelAll: params.cancelAll, + }), + ); + } + return operationResult; } catch (error) { operationError = @@ -1416,6 +1450,14 @@ export class TradingService { this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); } else { traceData = { success: false, error: result.error ?? 'Unknown error' }; + + this.#deps.logger.error( + ensureError(result.error, 'TradingService.closePosition'), + this.#getErrorContext('closePosition', { + symbol: params.symbol, + providerError: result.error ?? 'Unknown error', + }), + ); } // Track analytics (success or failure, includes partial fills) @@ -1602,6 +1644,31 @@ export class TradingService { }; } + if ( + provider.closePositions && + operationResult && + operationResult.failureCount > 0 + ) { + const failureSummary = operationResult.results + .filter((result) => !result.success) + .map( + (result) => `${result.symbol}: ${result.error ?? 'Unknown error'}`, + ) + .join('; '); + + this.#deps.logger.error( + new Error( + `closePositions batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + ), + this.#getErrorContext('closePositions', { + successCount: operationResult.successCount, + failureCount: operationResult.failureCount, + symbols: params.symbols?.length ?? 0, + closeAll: params.closeAll, + }), + ); + } + return operationResult; } catch (error) { operationError = @@ -1722,6 +1789,16 @@ export class TradingService { } catch (error) { errorMessage = error instanceof Error ? error.message : 'Unknown error'; traceData = { success: false, error: errorMessage }; + + this.#deps.logger.error( + ensureError(error, 'TradingService.updatePositionTPSL'), + this.#getErrorContext('updatePositionTPSL', { + symbol: params.symbol, + hasTakeProfit: Boolean(params.takeProfitPrice), + hasStopLoss: Boolean(params.stopLossPrice), + }), + ); + throw error; } finally { const completionDuration = this.#deps.performance.now() - startTime; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7be2c978b6..95ac7081f6 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1589,11 +1589,17 @@ export type PerpsPlatformDependencies = { rewards: { /** * Get fee discount for an account from the RewardsController. - * Returns discount in basis points (e.g., 6500 = 65% discount) + * Returns discount in basis points (e.g., 6500 = 65% discount), or null + * when subscription state hasn't hydrated yet — callers should skip + * caching null results and retry on the next fee calculation. + * + * Pass the perps MetaMask builder base fee in bips so the rewards + * controller can convert an absolute VIP fee into a discount fraction. */ getPerpsDiscountForAccount( caipAccountId: `${string}:${string}:${string}`, - ): Promise; + baseFeeBips: number, + ): Promise; }; }; diff --git a/packages/perps-controller/src/utils/errorUtils.ts b/packages/perps-controller/src/utils/errorUtils.ts index 2e0725361f..51bf49a744 100644 --- a/packages/perps-controller/src/utils/errorUtils.ts +++ b/packages/perps-controller/src/utils/errorUtils.ts @@ -1,9 +1,11 @@ /** - * Utility functions for error handling across the application. - * These are general-purpose utilities, not domain-specific. + * Utility functions for error handling across Perps controller code. + * Includes generic error helpers and Perps error classification helpers. */ import { hasProperty } from '@metamask/utils'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; + /** * Detects expected cancellation/abort errors that should not be reported to Sentry. * These occur during normal navigation or view teardown when in-flight fetch requests @@ -23,6 +25,30 @@ export function isAbortError(error: unknown): boolean { return false; } +/** + * Detects keyring-locked errors, including SDK-wrapped errors that preserve the + * original error in `cause`. + * + * @param error - The error to check. + * @returns True if any error in the cause chain is KEYRING_LOCKED. + */ +export function isKeyringLockedError(error: unknown): boolean { + let current: unknown = error; + const seen = new Set(); + + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + + if (current.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + return true; + } + + current = (current as { cause?: unknown }).cause; + } + + return false; +} + /** * Ensures we have a proper Error object for logging. * Converts unknown/string errors to proper Error instances. diff --git a/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts b/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts new file mode 100644 index 0000000000..7f45768b26 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts @@ -0,0 +1,26 @@ +import type { HyperLiquidAbstractionMode } from '../types/hyperliquid-types'; + +const MIGRATABLE_ABSTRACTION_MODES = new Set([ + 'dexAbstraction', + 'default', + 'disabled', +]); + +/** + * Determine whether unified-account setup should be deferred until a user + * explicitly starts a trading or withdrawal action. + * + * @param currentMode - The user's current HyperLiquid abstraction mode. + * @param allowUserSigning - Whether the caller is allowed to trigger wallet signing. + * @returns True when migration would require a signing-backed mutation that should be deferred. + */ +export function shouldDeferUnifiedAccountSetup( + currentMode: HyperLiquidAbstractionMode | undefined, + allowUserSigning: boolean, +): boolean { + return ( + !allowUserSigning && + currentMode !== undefined && + MIGRATABLE_ABSTRACTION_MODES.has(currentMode) + ); +} From 84a5bb6a165eabb44b557058bce610dcdafc065d Mon Sep 17 00:00:00 2001 From: Nick Gambino Date: Wed, 13 May 2026 11:43:53 -1000 Subject: [PATCH 2/2] Update CHANGELOG.md --- packages/perps-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c795417988..857c54fca8 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Pass the perps builder base fee into rewards discount resolution and treat unhydrated rewards subscription state as retryable instead of a definitive no-discount result ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Pass the perps builder base fee into rewards discount resolution and treat unhydrated rewards subscription state as retryable instead of a definitive no-discount result ([#8803](https://github.com/MetaMask/core/pull/8803)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ### Fixed -- Defer signing-backed HyperLiquid unified-account setup for hardware wallets across migratable abstraction modes, including Ledger, Trezor, OneKey, Lattice, and QR keyrings, to avoid repeated signing prompts while browsing ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Improve logging and retry classification for failed cancel/close/TP-SL operations and SDK-wrapped keyring-locked errors ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Defer signing-backed HyperLiquid unified-account setup for hardware wallets across migratable abstraction modes, including Ledger, Trezor, OneKey, Lattice, and QR keyrings, to avoid repeated signing prompts while browsing ([#8803](https://github.com/MetaMask/core/pull/8803)) +- Improve logging and retry classification for failed cancel/close/TP-SL operations and SDK-wrapped keyring-locked errors ([#8803](https://github.com/MetaMask/core/pull/8803)) ## [6.0.1]