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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/perps-controller/.sync-state.json
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 6 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ([#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 ([#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]

### Changed
Expand Down
46 changes: 26 additions & 20 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
);
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils';
// service portable between mobile and the core monorepo.
const HARDWARE_KEYRING_TYPES = new Set<string>([
'Ledger Hardware',
'Trezor Hardware',
'OneKey Hardware',
'Lattice Hardware',
'QR Hardware Wallet Device',
]);

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions packages/perps-controller/src/services/TradingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
baseFeeBips: number,
): Promise<number | null>;
};
};

Expand Down
30 changes: 28 additions & 2 deletions packages/perps-controller/src/utils/errorUtils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<unknown>();

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.
Expand Down
Loading
Loading