From b5c386c261e417a77fb271272f7c8b3429474bd4 Mon Sep 17 00:00:00 2001
From: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Date: Mon, 2 Feb 2026 16:25:23 +0800
Subject: [PATCH 01/18] fix(perps): add spotMeta caching to reduce API calls on
HIP-3 markets cp-7.63.0 (#25493)
## **Description**
**fix(perps): add spotMeta caching to reduce API calls on HIP-3
markets**
This PR adds session-based caching for HyperLiquid's global `spotMeta`
endpoint to avoid redundant API calls during HIP-3 operations.
### Context
Following a rate limiting incident where excessive API calls triggered
HyperLiquid's rate limits (2000 msg/min), this is a defensive
improvement to reduce unnecessary network requests.
### Problem
The `spotMeta` API (which returns token metadata like USDC/USDH indices)
was being called multiple times per trading session:
- `getUsdcTokenId()` - called during transfers
- `isUsdhCollateralDex()` - called to check collateral type
- `swapUsdcToUsdh()` - called during HIP-3 USDH swaps
Each call was making a fresh API request, even though the data (token
indices) doesn't change during a session.
### Solution
- Added `cachedSpotMeta` property for session-based caching (no TTL -
token indices are stable)
- Added `getCachedSpotMeta()` method that returns cached data or fetches
once
- Pre-fetch spotMeta in `ensureReadyForTrading()` when HIP-3 is enabled
(non-blocking)
- Cache invalidated on `disconnect()` to ensure fresh state on
reconnect/account switch
### Design Decisions
- **Global cache** (not per-DEX): `spotMeta` is a global endpoint
returning all tokens
- **Session-based** (no TTL): Token indices don't change during a
session
- **Graceful fallback**: If pre-fetch fails, methods fetch on-demand
- Follows existing patterns: `getCachedMeta()`, `getCachedPerpDexs()`
## **Changelog**
CHANGELOG entry: Fixed excessive API calls on HIP-3 markets by caching
spot metadata
## **Related issues**
Fixes: N/A (Defensive improvement following rate limiting incident)
## **Manual testing steps**
```gherkin
Feature: SpotMeta caching for HIP-3 operations
Scenario: User places order on HIP-3 DEX (SILVER)
Given user has connected wallet with USDC balance
And user is on a HIP-3 enabled DEX (e.g., SILVER)
When user places an order
Then order should succeed
And spotMeta API should only be called once per session (check debug logs)
Scenario: User disconnects and reconnects
Given user has placed orders (spotMeta is cached)
When user disconnects wallet
And user reconnects wallet
Then spotMeta cache should be cleared
And next HIP-3 operation should fetch fresh spotMeta
```
## **Screenshots/Recordings**
N/A - Internal optimization, no UI changes
### **Before**
Multiple `spotMeta` API calls per session (one per HIP-3 operation)
### **After**
Single `spotMeta` API call per session, cached for subsequent operations
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Touches perps trading setup and HIP-3 collateral/token resolution by
introducing session-cached `spotMeta`; incorrect caching or cache
invalidation could affect order/transfer flows, though it falls back to
on-demand fetch and clears on disconnect.
>
> **Overview**
> Reduces HyperLiquid rate-limit pressure on HIP-3 flows by adding a
**session-level `spotMeta` cache** in `HyperLiquidProvider`, prefetching
it during `ensureReadyForTrading()`, reusing it in USDC/USDH collateral
checks and swaps, and clearing it on disconnect.
>
> Standardizes error handling across perps controllers/providers by
extending `ensureError` with optional context (including better messages
for `null`/`undefined`) and replacing ad-hoc `instanceof Error` checks
in connection, streaming, deposit/testnet toggle, and provider
operations; updates related tests and types (`SpotMetaResponse`).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
3ee4cb2d286c6aab238fda65b1e1c8d47d0e5283. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/Perps/controllers/PerpsController.ts | 17 +--
.../providers/HyperLiquidProvider.test.ts | 19 ++-
.../providers/HyperLiquidProvider.ts | 132 ++++++++++++++----
.../providers/PerpsConnectionProvider.tsx | 41 +++---
.../UI/Perps/providers/PerpsStreamManager.tsx | 16 +--
.../Perps/services/PerpsConnectionManager.ts | 41 +++---
.../UI/Perps/types/hyperliquid-types.ts | 2 +
app/util/errorUtils.test.ts | 58 ++++++--
app/util/errorUtils.ts | 10 +-
9 files changed, 231 insertions(+), 105 deletions(-)
diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts
index 1a13813a398..4eab31239bd 100644
--- a/app/components/UI/Perps/controllers/PerpsController.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.ts
@@ -1628,8 +1628,10 @@ export class PerpsController extends BaseController<
})
.catch((error) => {
// Check if user denied/cancelled the transaction
- const errorMessage =
- error instanceof Error ? error.message : String(error);
+ const errorMessage = ensureError(
+ error,
+ 'PerpsController.initiateDeposit',
+ ).message;
const userCancelled =
errorMessage.includes('User denied') ||
errorMessage.includes('User rejected') ||
@@ -1677,8 +1679,10 @@ export class PerpsController extends BaseController<
};
} catch (error) {
// Check if user denied/cancelled the transaction
- const errorMessage =
- error instanceof Error ? error.message : String(error);
+ const errorMessage = ensureError(
+ error,
+ 'PerpsController.initiateDeposit',
+ ).message;
const userCancelled =
errorMessage.includes('User denied') ||
errorMessage.includes('User rejected') ||
@@ -2143,10 +2147,7 @@ export class PerpsController extends BaseController<
return {
success: false,
isTestnet: this.state.isTestnet,
- error:
- error instanceof Error
- ? error.message
- : PERPS_ERROR_CODES.UNKNOWN_ERROR,
+ error: ensureError(error, 'PerpsController.toggleTestnet').message,
};
} finally {
this.isReinitializing = false;
diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts
index 202d16c9d96..a20b41654eb 100644
--- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts
+++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts
@@ -241,9 +241,10 @@ const createMockInfoClient = (overrides: Record = {}) => ({
]),
spotMeta: jest.fn().mockResolvedValue({
tokens: [
- { name: 'USDC', tokenId: '0xdef456' },
- { name: 'USDT', tokenId: '0x789abc' },
+ { name: 'USDC', tokenId: '0xdef456', index: 0 },
+ { name: 'USDT', tokenId: '0x789abc', index: 1 },
],
+ universe: [],
}),
...overrides,
});
@@ -6397,7 +6398,8 @@ describe('HyperLiquidProvider', () => {
mockClientService.getInfoClient = jest.fn().mockReturnValue(
createMockInfoClient({
spotMeta: jest.fn().mockResolvedValue({
- tokens: [{ name: 'USDC', tokenId: '0xabc123' }],
+ tokens: [{ name: 'USDC', tokenId: '0xabc123', index: 0 }],
+ universe: [],
}),
}),
);
@@ -6487,7 +6489,8 @@ describe('HyperLiquidProvider', () => {
it('calls getUsdcTokenId to get correct token', async () => {
// Arrange
const mockSpotMeta = jest.fn().mockResolvedValue({
- tokens: [{ name: 'USDC', tokenId: '0xspecific' }],
+ tokens: [{ name: 'USDC', tokenId: '0xspecific', index: 0 }],
+ universe: [],
});
mockClientService.getInfoClient = jest
.fn()
@@ -6597,9 +6600,10 @@ describe('HyperLiquidProvider', () => {
// Arrange
const mockSpotMeta = {
tokens: [
- { name: 'USDC', tokenId: '0xdef456' },
- { name: 'USDT', tokenId: '0x789abc' },
+ { name: 'USDC', tokenId: '0xdef456', index: 0 },
+ { name: 'USDT', tokenId: '0x789abc', index: 1 },
],
+ universe: [],
};
mockClientService.getInfoClient = jest.fn().mockReturnValue(
createMockInfoClient({
@@ -6619,7 +6623,8 @@ describe('HyperLiquidProvider', () => {
it('throws error when USDC token not found in metadata', async () => {
// Arrange
const mockSpotMeta = {
- tokens: [{ name: 'USDT', tokenId: '0x789abc' }],
+ tokens: [{ name: 'USDT', tokenId: '0x789abc', index: 0 }],
+ universe: [],
};
mockClientService.getInfoClient = jest.fn().mockReturnValue(
createMockInfoClient({
diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
index 9ae800796ca..f21fb1a7581 100644
--- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
+++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
@@ -63,6 +63,7 @@ import type {
MetaResponse,
PerpsAssetCtx,
FrontendOrder,
+ SpotMetaResponse,
} from '../../types/hyperliquid-types';
import {
createErrorResult,
@@ -254,6 +255,10 @@ export class HyperLiquidProvider implements PerpsProvider {
// Filtering is applied on-demand (cheap array operations) - no need for separate processed cache
private cachedMetaByDex = new Map();
+ // Session cache for spot metadata (contains USDC/USDH token info for HIP-3 collateral checks)
+ // Pre-fetched in ensureReadyForTrading() to avoid API failures during order placement
+ private cachedSpotMeta: SpotMetaResponse | null = null;
+
// Cache for perpDexs data (deployerFeeScale for dynamic fee calculation)
// TTL-based cache - fee scales rarely change
private perpDexsCache: {
@@ -573,7 +578,10 @@ export class HyperLiquidProvider implements PerpsProvider {
{
user: userAddress,
network,
- error: error instanceof Error ? error.message : String(error),
+ error: ensureError(
+ error,
+ 'HyperLiquidProvider.ensureDexAbstractionEnabled',
+ ).message,
},
);
@@ -679,6 +687,20 @@ export class HyperLiquidProvider implements PerpsProvider {
);
this.tradingSetupPromise = (async () => {
+ // Pre-fetch spotMeta for HIP-3 operations (non-blocking if it fails)
+ // This ensures token info (USDC/USDH indices) is available during order placement
+ if (this.hip3Enabled) {
+ try {
+ await this.getCachedSpotMeta();
+ } catch (error) {
+ this.deps.debugLogger.log(
+ '[ensureReadyForTrading] spotMeta pre-fetch failed, will retry when needed',
+ error,
+ );
+ // Don't throw - spotMeta will be fetched on-demand if needed
+ }
+ }
+
// Attempt to enable native balance abstraction
await this.ensureDexAbstractionEnabled();
@@ -1077,6 +1099,36 @@ export class HyperLiquidProvider implements PerpsProvider {
return meta;
}
+ /**
+ * Fetch spot metadata with session-based caching
+ * Contains token info (USDC, USDH indices) needed for HIP-3 collateral checks
+ * Pre-fetched in ensureReadyForTrading() to ensure availability during order placement
+ * @returns SpotMetaResponse with tokens and universe data
+ */
+ private async getCachedSpotMeta(): Promise {
+ if (this.cachedSpotMeta) {
+ this.deps.debugLogger.log('[getCachedSpotMeta] Using cached spotMeta', {
+ tokensCount: this.cachedSpotMeta.tokens.length,
+ universeCount: this.cachedSpotMeta.universe.length,
+ });
+ return this.cachedSpotMeta;
+ }
+
+ const infoClient = this.clientService.getInfoClient();
+ const spotMeta = await infoClient.spotMeta();
+
+ this.cachedSpotMeta = spotMeta;
+ this.deps.debugLogger.log(
+ '[getCachedSpotMeta] Fetched and cached spotMeta',
+ {
+ tokensCount: spotMeta.tokens.length,
+ universeCount: spotMeta.universe.length,
+ },
+ );
+
+ return spotMeta;
+ }
+
/**
* Fetch perpDexs data with TTL-based caching
* Returns deployerFeeScale info needed for dynamic fee calculation
@@ -1269,8 +1321,7 @@ export class HyperLiquidProvider implements PerpsProvider {
return this.cachedUsdcTokenId;
}
- const infoClient = this.clientService.getInfoClient();
- const spotMeta = await infoClient.spotMeta();
+ const spotMeta = await this.getCachedSpotMeta();
const usdcToken = spotMeta.tokens.find((tok) => tok.name === 'USDC');
if (!usdcToken) {
@@ -1291,8 +1342,7 @@ export class HyperLiquidProvider implements PerpsProvider {
*/
private async isUsdhCollateralDex(dexName: string): Promise {
const meta = await this.getCachedMeta({ dexName });
- const infoClient = this.clientService.getInfoClient();
- const spotMeta = await infoClient.spotMeta();
+ const spotMeta = await this.getCachedSpotMeta();
const collateralToken = spotMeta.tokens.find(
(tok: { index: number }) => tok.index === meta.collateralToken,
@@ -1403,7 +1453,10 @@ export class HyperLiquidProvider implements PerpsProvider {
return { success: false, error: PERPS_ERROR_CODES.TRANSFER_FAILED };
} catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
+ const errorMsg = ensureError(
+ error,
+ 'HyperLiquidProvider.transferUSDCToPerps',
+ ).message;
this.deps.debugLogger.log(
'HyperLiquidProvider: USDC transfer to spot failed',
{
@@ -1421,8 +1474,7 @@ export class HyperLiquidProvider implements PerpsProvider {
private async swapUsdcToUsdh(
amount: number,
): Promise<{ success: boolean; filledSize?: number; error?: string }> {
- const infoClient = this.clientService.getInfoClient();
- const spotMeta = await infoClient.spotMeta();
+ const spotMeta = await this.getCachedSpotMeta();
// Find USDH and USDC tokens by name
const usdhToken = spotMeta.tokens.find(
@@ -1464,6 +1516,7 @@ export class HyperLiquidProvider implements PerpsProvider {
);
// Get current mid price
+ const infoClient = this.clientService.getInfoClient();
const allMids = await infoClient.allMids();
const pairKey = `@${usdhUsdcPair.index}`;
const usdhPrice = parseFloat(allMids[pairKey] || '1');
@@ -1560,7 +1613,10 @@ export class HyperLiquidProvider implements PerpsProvider {
return { success: true, filledSize };
} catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
+ const errorMsg = ensureError(
+ error,
+ 'HyperLiquidProvider.swapUSDCToUSDH',
+ ).message;
this.deps.debugLogger.log('HyperLiquidProvider: USDC→USDH swap error', {
error: errorMsg,
});
@@ -1886,7 +1942,7 @@ export class HyperLiquidProvider implements PerpsProvider {
* Map HyperLiquid API errors to standardized PERPS_ERROR_CODES
*/
private mapError(error: unknown): Error {
- const message = error instanceof Error ? error.message : String(error);
+ const message = ensureError(error, 'HyperLiquidProvider.mapError').message;
for (const [pattern, code] of Object.entries(this.errorMappings)) {
if (message.toLowerCase().includes(pattern.toLowerCase())) {
@@ -1895,7 +1951,7 @@ export class HyperLiquidProvider implements PerpsProvider {
}
// Return original error to preserve stack trace for unmapped errors
- return error instanceof Error ? error : new Error(String(error));
+ return ensureError(error, 'HyperLiquidProvider.mapError');
}
/**
@@ -2119,7 +2175,10 @@ export class HyperLiquidProvider implements PerpsProvider {
'[ensureBuilderFeeApproval] Failed, cached to prevent retries',
{
network,
- error: error instanceof Error ? error.message : String(error),
+ error: ensureError(
+ error,
+ 'HyperLiquidProvider.ensureBuilderFeeApproval',
+ ).message,
},
);
@@ -3096,8 +3155,10 @@ export class HyperLiquidProvider implements PerpsProvider {
} catch (error) {
// Retry mechanism for $10 minimum order errors
// This handles the case where UI price feed slightly differs from HyperLiquid's orderbook price
- const errorMessage =
- error instanceof Error ? error.message : String(error);
+ const errorMessage = ensureError(
+ error,
+ 'HyperLiquidProvider.placeOrder',
+ ).message;
const isMinimumOrderError =
errorMessage.includes('Order must have minimum value of $10') ||
errorMessage.includes('Order 0: Order must have minimum value');
@@ -4169,8 +4230,9 @@ export class HyperLiquidProvider implements PerpsProvider {
success: true,
};
} catch (error) {
+ const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin');
this.deps.logger.error(
- ensureError(error),
+ safeError,
this.getErrorContext('updateMargin', {
symbol: params.symbol,
amount: params.amount,
@@ -4178,7 +4240,7 @@ export class HyperLiquidProvider implements PerpsProvider {
);
return {
success: false,
- error: error instanceof Error ? error.message : String(error),
+ error: safeError.message,
};
}
}
@@ -5855,18 +5917,19 @@ export class HyperLiquidProvider implements PerpsProvider {
error: errorMessage,
};
} catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : 'Unknown error';
+ const safeError = ensureError(
+ error,
+ 'HyperLiquidProvider.initiateWithdrawal',
+ );
this.deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL EXCEPTION', {
- error: errorMessage,
- errorType:
- error instanceof Error ? error.constructor.name : typeof error,
- stack: error instanceof Error ? error.stack : undefined,
+ error: safeError.message,
+ errorType: safeError.name,
+ stack: safeError.stack,
params,
timestamp: new Date().toISOString(),
});
this.deps.logger.error(
- ensureError(error),
+ safeError,
this.getErrorContext('withdraw', {
assetId: params.assetId,
amount: params.amount,
@@ -5959,17 +6022,21 @@ export class HyperLiquidProvider implements PerpsProvider {
throw new Error(PERPS_ERROR_CODES.TRANSFER_FAILED);
} catch (error) {
+ const safeError = ensureError(
+ error,
+ 'HyperLiquidProvider.transferToSpot',
+ );
this.deps.debugLogger.log('❌ HyperLiquidProvider: TRANSFER FAILED', {
- error: error instanceof Error ? error.message : String(error),
+ error: safeError.message,
params,
});
this.deps.logger.error(
- ensureError(error),
+ safeError,
this.getErrorContext('transferBetweenDexs', { ...params }),
);
return {
success: false,
- error: error instanceof Error ? error.message : String(error),
+ error: safeError.message,
};
}
}
@@ -6555,12 +6622,15 @@ export class HyperLiquidProvider implements PerpsProvider {
}
} catch (error) {
// Silently fall back to base rates
+ const safeError = ensureError(
+ error,
+ 'HyperLiquidProvider.getFeeSchedule',
+ );
this.deps.debugLogger.log(
'Fee API Call Failed - Falling Back to Base Rates',
{
- error: error instanceof Error ? error.message : String(error),
- errorType:
- error instanceof Error ? error.constructor.name : typeof error,
+ error: safeError.message,
+ errorType: safeError.name,
fallbackTakerRate: FEE_RATES.taker,
fallbackMakerRate: FEE_RATES.maker,
userAddress: 'unknown',
@@ -6687,6 +6757,7 @@ export class HyperLiquidProvider implements PerpsProvider {
// NOTE: DexAbstractionCache is global and NOT cleared on disconnect
// to prevent repeated signing requests across reconnections
this.cachedMetaByDex.clear();
+ this.cachedSpotMeta = null;
this.perpDexsCache = { data: null, timestamp: 0 };
// Await pending initialization before clearing to prevent the IIFE from
@@ -7029,7 +7100,8 @@ export class HyperLiquidProvider implements PerpsProvider {
'[ensureReferralSet] Error, cached to prevent retries',
{
network,
- error: error instanceof Error ? error.message : String(error),
+ error: ensureError(error, 'HyperLiquidProvider.ensureReferralSet')
+ .message,
},
);
completeInFlight();
diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx
index 35b407bf07a..c5e76702f80 100644
--- a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx
+++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx
@@ -100,7 +100,7 @@ export const PerpsConnectionProvider: React.FC<
try {
await PerpsConnectionManager.connect();
} catch (err) {
- Logger.error(err as Error, {
+ Logger.error(ensureError(err, 'PerpsConnectionProvider.connect'), {
message: 'PerpsConnectionProvider: Error during connect',
context: 'PerpsConnectionProvider.connect',
});
@@ -128,7 +128,7 @@ export const PerpsConnectionProvider: React.FC<
try {
await PerpsConnectionManager.disconnect();
} catch (err) {
- Logger.error(err as Error, {
+ Logger.error(ensureError(err, 'PerpsConnectionProvider.disconnect'), {
message: 'PerpsConnectionProvider: Error during disconnect',
context: 'PerpsConnectionProvider.disconnect',
});
@@ -166,10 +166,13 @@ export const PerpsConnectionProvider: React.FC<
// Use the existing reconnectWithNewContext method from the singleton
await PerpsConnectionManager.reconnectWithNewContext(options);
} catch (err) {
- Logger.error(err as Error, {
- message: 'PerpsConnectionProvider: Error during reconnect',
- context: 'PerpsConnectionProvider.reconnectWithNewContext',
- });
+ Logger.error(
+ ensureError(err, 'PerpsConnectionProvider.reconnectWithNewContext'),
+ {
+ message: 'PerpsConnectionProvider: Error during reconnect',
+ context: 'PerpsConnectionProvider.reconnectWithNewContext',
+ },
+ );
}
// Always update state after reconnection attempt
const state = PerpsConnectionManager.getConnectionState();
@@ -185,11 +188,14 @@ export const PerpsConnectionProvider: React.FC<
try {
await PerpsConnectionManager.connect();
} catch (err) {
- Logger.error(err as Error, {
- message: 'PerpsConnectionProvider: Error in lifecycle onConnect',
- context:
- 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect',
- });
+ Logger.error(
+ ensureError(err, 'PerpsConnectionProvider.lifecycle.onConnect'),
+ {
+ message: 'PerpsConnectionProvider: Error in lifecycle onConnect',
+ context:
+ 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect',
+ },
+ );
}
const state = PerpsConnectionManager.getConnectionState();
setConnectionState(state);
@@ -198,11 +204,14 @@ export const PerpsConnectionProvider: React.FC<
try {
await PerpsConnectionManager.disconnect();
} catch (err) {
- Logger.error(err as Error, {
- message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect',
- context:
- 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect',
- });
+ Logger.error(
+ ensureError(err, 'PerpsConnectionProvider.lifecycle.onDisconnect'),
+ {
+ message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect',
+ context:
+ 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect',
+ },
+ );
}
const state = PerpsConnectionManager.getConnectionState();
setConnectionState(state);
diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx
index c444146abf6..0cc19de3876 100644
--- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx
+++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx
@@ -4,6 +4,7 @@ import performance from 'react-native-performance';
import Engine from '../../../../core/Engine';
import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
import Logger from '../../../../util/Logger';
+import { ensureError } from '../../../../util/errorUtils';
import {
trace,
endTrace,
@@ -410,7 +411,7 @@ class PriceStreamChannel extends StreamChannel> {
this.cleanupPrewarm();
};
} catch (error) {
- Logger.error(error instanceof Error ? error : new Error(String(error)), {
+ Logger.error(ensureError(error, 'PriceStreamChannel.prewarm'), {
context: 'PriceStreamChannel.prewarm',
});
// Return no-op cleanup function
@@ -1181,7 +1182,7 @@ class MarketDataChannel extends StreamChannel {
// Don't await - just trigger the fetch and handle errors
this.fetchMarketData().catch((error) => {
Logger.error(
- error instanceof Error ? error : new Error(String(error)),
+ ensureError(error, 'PerpsStreamManager.fetchMarketData.background'),
'PerpsStreamManager: Failed to fetch market data',
);
});
@@ -1238,13 +1239,10 @@ class MarketDataChannel extends StreamChannel {
});
} catch (error) {
const fetchTime = Date.now() - fetchStartTime;
- Logger.error(
- error instanceof Error ? error : new Error(String(error)),
- {
- context: 'PerpsStreamManager.fetchMarketData',
- fetchTimeMs: fetchTime,
- },
- );
+ Logger.error(ensureError(error, 'PerpsStreamManager.fetchMarketData'), {
+ context: 'PerpsStreamManager.fetchMarketData',
+ fetchTimeMs: fetchTime,
+ });
// Keep existing cache if fetch fails
const existing = this.cache.get('markets');
if (existing) {
diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts
index a1874c4e97a..3fd3358e897 100644
--- a/app/components/UI/Perps/services/PerpsConnectionManager.ts
+++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts
@@ -559,36 +559,31 @@ class PerpsConnectionManagerClass {
this.clearConnectionTimeout();
// Capture exception with connection context
- captureException(
- error instanceof Error ? error : new Error(String(error)),
- {
- tags: {
- component: 'PerpsConnectionManager',
- action: 'connection_connection',
- operation: 'connection_management',
+ captureException(ensureError(error, 'PerpsConnectionManager.connect'), {
+ tags: {
+ component: 'PerpsConnectionManager',
+ action: 'connection_connection',
+ operation: 'connection_management',
+ provider: 'hyperliquid',
+ },
+ extra: {
+ connectionContext: {
provider: 'hyperliquid',
- },
- extra: {
- connectionContext: {
- provider: 'hyperliquid',
- timestamp: new Date().toISOString(),
- isTestnet:
- Engine.context.PerpsController?.getCurrentNetwork?.() ===
- 'testnet',
- },
+ timestamp: new Date().toISOString(),
+ isTestnet:
+ Engine.context.PerpsController?.getCurrentNetwork?.() ===
+ 'testnet',
},
},
- );
+ });
traceData = {
success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
+ error: ensureError(error, 'PerpsConnectionManager.connect').message,
};
// Set error state for UI
- this.setError(
- error instanceof Error ? error : new Error(String(error)),
- );
+ this.setError(ensureError(error, 'PerpsConnectionManager.connect'));
DevLogger.log('PerpsConnectionManager: Connection failed', error);
throw error;
} finally {
@@ -805,11 +800,11 @@ class PerpsConnectionManagerClass {
traceData = {
success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
+ error: ensureError(error, 'PerpsConnectionManager.reconnect').message,
};
// Set error state for UI - this is critical for reliability
- this.setError(error instanceof Error ? error : new Error(String(error)));
+ this.setError(ensureError(error, 'PerpsConnectionManager.reconnect'));
DevLogger.log(
'PerpsConnectionManager: Reconnection with new context failed',
error,
diff --git a/app/components/UI/Perps/types/hyperliquid-types.ts b/app/components/UI/Perps/types/hyperliquid-types.ts
index e1384805144..d18f9d1cb20 100644
--- a/app/components/UI/Perps/types/hyperliquid-types.ts
+++ b/app/components/UI/Perps/types/hyperliquid-types.ts
@@ -16,6 +16,7 @@ import type {
AllMidsResponse,
PredictedFundingsResponse,
OrderParameters,
+ SpotMetaResponse,
} from '@nktkas/hyperliquid';
// Clearinghouse (Account) Types
@@ -42,4 +43,5 @@ export type {
AllMidsResponse,
MetaAndAssetCtxsResponse,
PredictedFundingsResponse,
+ SpotMetaResponse,
};
diff --git a/app/util/errorUtils.test.ts b/app/util/errorUtils.test.ts
index 30b92bcd0a9..c345ac41aa1 100644
--- a/app/util/errorUtils.test.ts
+++ b/app/util/errorUtils.test.ts
@@ -1,66 +1,102 @@
import { ensureError } from './errorUtils';
describe('ensureError', () => {
- it('should return the same Error instance when passed an Error', () => {
+ it('returns the same Error instance when passed an Error', () => {
const originalError = new Error('Test error');
+
const result = ensureError(originalError);
expect(result).toBe(originalError);
expect(result.message).toBe('Test error');
});
- it('should convert string to Error with the string as message', () => {
+ it('converts string to Error with the string as message', () => {
const result = ensureError('String error message');
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('String error message');
});
- it('should convert number to Error with number as string message', () => {
+ it('converts number to Error with number as string message', () => {
const result = ensureError(42);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('42');
});
- it('should convert null to Error with "null" as message', () => {
+ it('converts null to Error with descriptive message', () => {
const result = ensureError(null);
expect(result).toBeInstanceOf(Error);
- expect(result.message).toBe('null');
+ expect(result.message).toBe('Unknown error (no details provided)');
});
- it('should convert undefined to Error with "undefined" as message', () => {
+ it('converts undefined to Error with descriptive message', () => {
const result = ensureError(undefined);
expect(result).toBeInstanceOf(Error);
- expect(result.message).toBe('undefined');
+ expect(result.message).toBe('Unknown error (no details provided)');
+ });
+
+ it('includes context in message when provided with undefined', () => {
+ const result = ensureError(undefined, 'PerpsConnectionProvider.connect');
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe(
+ 'Unknown error (no details provided) [PerpsConnectionProvider.connect]',
+ );
});
- it('should convert object to Error with stringified object as message', () => {
+ it('includes context in message when provided with null', () => {
+ const result = ensureError(null, 'PerpsStreamManager.prewarm');
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe(
+ 'Unknown error (no details provided) [PerpsStreamManager.prewarm]',
+ );
+ });
+
+ it('does not modify Error instance when context is provided', () => {
+ const originalError = new Error('Original error');
+
+ const result = ensureError(originalError, 'SomeContext');
+
+ expect(result).toBe(originalError);
+ expect(result.message).toBe('Original error');
+ });
+
+ it('does not include context for string errors', () => {
+ const result = ensureError('String error', 'SomeContext');
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe('String error');
+ });
+
+ it('converts object to Error with stringified object as message', () => {
const obj = { code: 'ERROR_CODE', details: 'Some details' };
+
const result = ensureError(obj);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('[object Object]');
});
- it('should convert boolean to Error with string representation', () => {
+ it('converts boolean to Error with string representation', () => {
const result = ensureError(false);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('false');
});
- it('should preserve Error subclasses', () => {
+ it('preserves Error subclasses', () => {
class CustomError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomError';
}
}
-
const customError = new CustomError('Custom error message');
+
const result = ensureError(customError);
expect(result).toBe(customError);
diff --git a/app/util/errorUtils.ts b/app/util/errorUtils.ts
index 46506c737f5..d2e9a6025ea 100644
--- a/app/util/errorUtils.ts
+++ b/app/util/errorUtils.ts
@@ -6,12 +6,20 @@
/**
* Ensures we have a proper Error object for logging.
* Converts unknown/string errors to proper Error instances.
+ * Handles undefined/null specially for better Sentry context.
* @param error - The caught error (could be Error, string, or unknown)
+ * @param context - Optional context string to help identify the source of the error
* @returns A proper Error instance
*/
-export function ensureError(error: unknown): Error {
+export function ensureError(error: unknown, context?: string): Error {
if (error instanceof Error) {
return error;
}
+ // Handle undefined/null specifically for better error context
+ // e.g. Hyperliquid SDK may reject with undefined when AbortSignal.reason is not set
+ if (error === undefined || error === null) {
+ const baseMessage = 'Unknown error (no details provided)';
+ return new Error(context ? `${baseMessage} [${context}]` : baseMessage);
+ }
return new Error(String(error));
}
From 7d209b312761dd7ee8e8d29c4a1b885bc3b59efd Mon Sep 17 00:00:00 2001
From: ieow <4881057+ieow@users.noreply.github.com>
Date: Mon, 2 Feb 2026 16:33:55 +0800
Subject: [PATCH 02/18] fix: update preference error handling (#25360)
## **Description**
KeyringController.verifyPassword throw `Error: error in DoCipher,
status: 2` instead of `incorrect password`.
This is causing the biometric setting not showing any alert when user
key in incorrect password
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: create wallet without biometric
Scenario: user enable biometric from setting
Given user enable biometric from setting
When user enter wrong password
Then alert should prompt for invalid passwrod
```
## **Screenshots/Recordings**
### **Before**
https://github.com/user-attachments/assets/fc83639e-8a0a-4b1f-a2a0-619cc5017989
### **After**
https://github.com/user-attachments/assets/2537e861-d109-463b-939c-af7379fef529
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Low risk: a small change to error-message matching in
`updateAuthPreference` to improve user feedback, without altering
password verification or storage flows.
>
> **Overview**
> Improves `updateAuthPreference` error handling so an incorrect
password on Android that surfaces as the cipher error `error in
DoCipher, status: 2` is treated like an invalid password.
>
> This adds `UNLOCK_WALLET_ERROR_MESSAGES.ANDROID_WRONG_PASSWORD_2`
matching to trigger the existing invalid-password alert and analytics
tracking instead of falling through to generic logging.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
f3e26db19784f06906536dfeaaf40c53c26402d7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/core/Authentication/Authentication.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts
index df4f196253a..ba3607552a9 100644
--- a/app/core/Authentication/Authentication.ts
+++ b/app/core/Authentication/Authentication.ts
@@ -19,6 +19,7 @@ import {
import { setCompletedOnboarding } from '../../actions/onboarding';
import AUTHENTICATION_TYPE from '../../constants/userProperties';
import AuthenticationError from './AuthenticationError';
+import { UNLOCK_WALLET_ERROR_MESSAGES } from './constants';
import { UserCredentials, BIOMETRY_TYPE } from 'react-native-keychain';
import {
AUTHENTICATION_APP_TRIGGERED_AUTH_ERROR,
@@ -1680,7 +1681,12 @@ class AuthenticationService {
);
}
- if (errorWithMessage.message === 'Invalid password') {
+ if (
+ errorWithMessage.message === 'Invalid password' ||
+ errorWithMessage.message.includes(
+ UNLOCK_WALLET_ERROR_MESSAGES.ANDROID_WRONG_PASSWORD_2,
+ )
+ ) {
Alert.alert(
strings('app_settings.invalid_password'),
strings('app_settings.invalid_password_message'),
From c166be0bc188694284855505caaf5dbcc2e831e7 Mon Sep 17 00:00:00 2001
From: sophieqgu <37032128+sophieqgu@users.noreply.github.com>
Date: Mon, 2 Feb 2026 03:37:13 -0500
Subject: [PATCH 03/18] fix: Rewards summary change icon color (#25458)
## **Description**
Change previous season summary icon colors
## **Changelog**
CHANGELOG entry: Change Rewards season summary icon colors
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Low risk visual-only updates: icon tint classes and an SVG asset are
adjusted with no changes to rewards data fetching or control flow.
>
> **Overview**
> Updates the Previous Season rewards summary tiles to use themed icon
colors by adding `twClassName` to the Rocket (tier) and People
(referrals) icons.
>
> Replaces `metamask-rewards-points-alternative.svg` with a new 24x24
orange-stroked version (was 14x14 gray).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c4855ac46c04b05d9dbb7774ccf27566afccd600. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../components/PreviousSeason/PreviousSeasonLevel.tsx | 6 +++++-
.../PreviousSeason/PreviousSeasonReferralDetails.tsx | 6 +++++-
app/images/rewards/metamask-rewards-points-alternative.svg | 4 ++--
3 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx
index a2871b17e53..4e623dd303d 100644
--- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx
+++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx
@@ -36,7 +36,11 @@ const PreviousSeasonLevel: React.FC = () => {
>
{/* Tier Image */}
-
+
diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx
index ec8be1ffb65..7dce9260c34 100644
--- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx
+++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx
@@ -54,7 +54,11 @@ const PreviousSeasonReferralDetails = () => {
loadingHeight={72}
>
-
+
{totalReferees}
diff --git a/app/images/rewards/metamask-rewards-points-alternative.svg b/app/images/rewards/metamask-rewards-points-alternative.svg
index 936341681cb..662ff3580e5 100644
--- a/app/images/rewards/metamask-rewards-points-alternative.svg
+++ b/app/images/rewards/metamask-rewards-points-alternative.svg
@@ -1,3 +1,3 @@
-