Skip to content

Commit e2ca72f

Browse files
runway-github[bot]abretonc7sclaude
authored
chore(runway): cherry-pick fix(perps): add spotMeta caching to reduce API calls on HIP-3 markets (#25511)
- 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core Perps trading/connectivity paths (HIP-3 collateral checks, transfers, connection lifecycle), so caching or error-wrapping regressions could impact order placement or diagnostics despite being largely additive and well-tested. > > **Overview** > Reduces redundant HyperLiquid API calls by adding a session-scoped `spotMeta` cache in `HyperLiquidProvider` and routing HIP-3 collateral checks/USDC↔USDH flows through it, with cache cleared on disconnect. > > Standardizes error handling by expanding `ensureError` (better undefined/null handling + optional context) and updating Perps connection/provider/stream logging and Sentry capture to use it. > > Introduces a global singleton signing readiness cache (`TradingReadinessCache`/`PerpsSigningCache`) with in-flight locks to prevent repeated signing prompts, plus comprehensive unit tests and updated HyperLiquid provider test mocks/types (`SpotMetaResponse`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 90f96a1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [b5c386c](b5c386c) --------- Co-authored-by: Arthur Breton <arthur.breton@consensys.net> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
1 parent c6e1142 commit e2ca72f

10 files changed

Lines changed: 1260 additions & 73 deletions

app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,10 @@ const createMockInfoClient = (overrides: Record<string, unknown> = {}) => ({
236236
]),
237237
spotMeta: jest.fn().mockResolvedValue({
238238
tokens: [
239-
{ name: 'USDC', tokenId: '0xdef456' },
240-
{ name: 'USDT', tokenId: '0x789abc' },
239+
{ name: 'USDC', tokenId: '0xdef456', index: 0 },
240+
{ name: 'USDT', tokenId: '0x789abc', index: 1 },
241241
],
242+
universe: [],
242243
}),
243244
...overrides,
244245
});
@@ -6024,7 +6025,8 @@ describe('HyperLiquidProvider', () => {
60246025
mockClientService.getInfoClient = jest.fn().mockReturnValue(
60256026
createMockInfoClient({
60266027
spotMeta: jest.fn().mockResolvedValue({
6027-
tokens: [{ name: 'USDC', tokenId: '0xabc123' }],
6028+
tokens: [{ name: 'USDC', tokenId: '0xabc123', index: 0 }],
6029+
universe: [],
60286030
}),
60296031
}),
60306032
);
@@ -6114,7 +6116,8 @@ describe('HyperLiquidProvider', () => {
61146116
it('calls getUsdcTokenId to get correct token', async () => {
61156117
// Arrange
61166118
const mockSpotMeta = jest.fn().mockResolvedValue({
6117-
tokens: [{ name: 'USDC', tokenId: '0xspecific' }],
6119+
tokens: [{ name: 'USDC', tokenId: '0xspecific', index: 0 }],
6120+
universe: [],
61186121
});
61196122
mockClientService.getInfoClient = jest
61206123
.fn()
@@ -6224,9 +6227,10 @@ describe('HyperLiquidProvider', () => {
62246227
// Arrange
62256228
const mockSpotMeta = {
62266229
tokens: [
6227-
{ name: 'USDC', tokenId: '0xdef456' },
6228-
{ name: 'USDT', tokenId: '0x789abc' },
6230+
{ name: 'USDC', tokenId: '0xdef456', index: 0 },
6231+
{ name: 'USDT', tokenId: '0x789abc', index: 1 },
62296232
],
6233+
universe: [],
62306234
};
62316235
mockClientService.getInfoClient = jest.fn().mockReturnValue(
62326236
createMockInfoClient({
@@ -6246,7 +6250,8 @@ describe('HyperLiquidProvider', () => {
62466250
it('throws error when USDC token not found in metadata', async () => {
62476251
// Arrange
62486252
const mockSpotMeta = {
6249-
tokens: [{ name: 'USDT', tokenId: '0x789abc' }],
6253+
tokens: [{ name: 'USDT', tokenId: '0x789abc', index: 0 }],
6254+
universe: [],
62506255
};
62516256
mockClientService.getInfoClient = jest.fn().mockReturnValue(
62526257
createMockInfoClient({

app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
import type {
5555
SDKOrderParams,
5656
MetaResponse,
57+
SpotMetaResponse,
5758
PerpsAssetCtx,
5859
FrontendOrder,
5960
} from '../../types/hyperliquid-types';
@@ -288,6 +289,10 @@ export class HyperLiquidProvider implements IPerpsProvider {
288289
// Pending promise to deduplicate concurrent getValidatedDexs() calls
289290
private pendingValidatedDexsPromise: Promise<(string | null)[]> | null = null;
290291

292+
// Session cache for spot metadata (contains USDC/USDH token info for HIP-3 collateral checks)
293+
// Pre-fetched when needed to avoid API failures during order placement
294+
private cachedSpotMeta: SpotMetaResponse | null = null;
295+
291296
// Cache for USDC token ID from spot metadata
292297
private cachedUsdcTokenId?: string;
293298

@@ -889,6 +894,35 @@ export class HyperLiquidProvider implements IPerpsProvider {
889894
return meta;
890895
}
891896

897+
/**
898+
* Fetch spot metadata with session-based caching
899+
* Contains token info (USDC, USDH indices) needed for HIP-3 collateral checks
900+
* @returns SpotMetaResponse with tokens and universe data
901+
*/
902+
private async getCachedSpotMeta(): Promise<SpotMetaResponse> {
903+
if (this.cachedSpotMeta) {
904+
this.deps.debugLogger.log('[getCachedSpotMeta] Using cached spotMeta', {
905+
tokensCount: this.cachedSpotMeta.tokens.length,
906+
universeCount: this.cachedSpotMeta.universe.length,
907+
});
908+
return this.cachedSpotMeta;
909+
}
910+
911+
const infoClient = this.clientService.getInfoClient();
912+
const spotMeta = await infoClient.spotMeta();
913+
914+
this.cachedSpotMeta = spotMeta;
915+
this.deps.debugLogger.log(
916+
'[getCachedSpotMeta] Fetched and cached spotMeta',
917+
{
918+
tokensCount: spotMeta.tokens.length,
919+
universeCount: spotMeta.universe.length,
920+
},
921+
);
922+
923+
return spotMeta;
924+
}
925+
892926
/**
893927
* Fetch perpDexs data with TTL-based caching
894928
* Returns deployerFeeScale info needed for dynamic fee calculation
@@ -1082,10 +1116,9 @@ export class HyperLiquidProvider implements IPerpsProvider {
10821116
return this.cachedUsdcTokenId;
10831117
}
10841118

1085-
const infoClient = this.clientService.getInfoClient();
1086-
const spotMeta = await infoClient.spotMeta();
1119+
const spotMeta = await this.getCachedSpotMeta();
10871120

1088-
const usdcToken = spotMeta.tokens.find((t) => t.name === 'USDC');
1121+
const usdcToken = spotMeta.tokens.find((tok) => tok.name === 'USDC');
10891122
if (!usdcToken) {
10901123
throw new Error('USDC token not found in spot metadata');
10911124
}
@@ -1104,11 +1137,10 @@ export class HyperLiquidProvider implements IPerpsProvider {
11041137
*/
11051138
private async isUsdhCollateralDex(dexName: string): Promise<boolean> {
11061139
const meta = await this.getCachedMeta({ dexName });
1107-
const infoClient = this.clientService.getInfoClient();
1108-
const spotMeta = await infoClient.spotMeta();
1140+
const spotMeta = await this.getCachedSpotMeta();
11091141

11101142
const collateralToken = spotMeta.tokens.find(
1111-
(t: { index: number }) => t.index === meta.collateralToken,
1143+
(tok: { index: number }) => tok.index === meta.collateralToken,
11121144
);
11131145

11141146
const isUsdh = collateralToken?.name === USDH_CONFIG.TOKEN_NAME;
@@ -1216,7 +1248,10 @@ export class HyperLiquidProvider implements IPerpsProvider {
12161248

12171249
return { success: false, error: `Transfer failed: ${result.status}` };
12181250
} catch (error) {
1219-
const errorMsg = error instanceof Error ? error.message : String(error);
1251+
const errorMsg = ensureError(
1252+
error,
1253+
'HyperLiquidProvider.transferUSDCToPerps',
1254+
).message;
12201255
this.deps.debugLogger.log(
12211256
'HyperLiquidProvider: USDC transfer to spot failed',
12221257
{
@@ -1234,8 +1269,7 @@ export class HyperLiquidProvider implements IPerpsProvider {
12341269
private async swapUsdcToUsdh(
12351270
amount: number,
12361271
): Promise<{ success: boolean; filledSize?: number; error?: string }> {
1237-
const infoClient = this.clientService.getInfoClient();
1238-
const spotMeta = await infoClient.spotMeta();
1272+
const spotMeta = await this.getCachedSpotMeta();
12391273

12401274
// Find USDH and USDC tokens by name
12411275
const usdhToken = spotMeta.tokens.find(
@@ -1277,6 +1311,7 @@ export class HyperLiquidProvider implements IPerpsProvider {
12771311
);
12781312

12791313
// Get current mid price
1314+
const infoClient = this.clientService.getInfoClient();
12801315
const allMids = await infoClient.allMids();
12811316
const pairKey = `@${usdhUsdcPair.index}`;
12821317
const usdhPrice = parseFloat(allMids[pairKey] || '1');
@@ -1373,7 +1408,10 @@ export class HyperLiquidProvider implements IPerpsProvider {
13731408

13741409
return { success: true, filledSize };
13751410
} catch (error) {
1376-
const errorMsg = error instanceof Error ? error.message : String(error);
1411+
const errorMsg = ensureError(
1412+
error,
1413+
'HyperLiquidProvider.swapUSDCToUSDH',
1414+
).message;
13771415
this.deps.debugLogger.log('HyperLiquidProvider: USDC→USDH swap error', {
13781416
error: errorMsg,
13791417
});
@@ -1699,7 +1737,7 @@ export class HyperLiquidProvider implements IPerpsProvider {
16991737
* Map HyperLiquid API errors to standardized PERPS_ERROR_CODES
17001738
*/
17011739
private mapError(error: unknown): Error {
1702-
const message = error instanceof Error ? error.message : String(error);
1740+
const message = ensureError(error, 'HyperLiquidProvider.mapError').message;
17031741

17041742
for (const [pattern, code] of Object.entries(this.ERROR_MAPPINGS)) {
17051743
if (message.toLowerCase().includes(pattern.toLowerCase())) {
@@ -1708,7 +1746,7 @@ export class HyperLiquidProvider implements IPerpsProvider {
17081746
}
17091747

17101748
// Return original error to preserve stack trace for unmapped errors
1711-
return error instanceof Error ? error : new Error(String(error));
1749+
return ensureError(error, 'HyperLiquidProvider.mapError');
17121750
}
17131751

17141752
/**
@@ -6457,7 +6495,10 @@ export class HyperLiquidProvider implements IPerpsProvider {
64576495
// Clear session caches (ensures fresh state on reconnect/account switch)
64586496
this.referralCheckCache.clear();
64596497
this.builderFeeCheckCache.clear();
6498+
// NOTE: DexAbstractionCache is global and NOT cleared on disconnect
6499+
// to prevent repeated signing requests across reconnections
64606500
this.cachedMetaByDex.clear();
6501+
this.cachedSpotMeta = null;
64616502
this.perpDexsCache = { data: null, timestamp: 0 };
64626503

64636504
// Clear pending promise trackers to prevent memory leaks and ensure clean state

app/components/UI/Perps/providers/PerpsConnectionProvider.tsx

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export const PerpsConnectionProvider: React.FC<
100100
try {
101101
await PerpsConnectionManager.connect();
102102
} catch (err) {
103-
Logger.error(err as Error, {
103+
Logger.error(ensureError(err, 'PerpsConnectionProvider.connect'), {
104104
message: 'PerpsConnectionProvider: Error during connect',
105105
context: 'PerpsConnectionProvider.connect',
106106
});
@@ -128,7 +128,7 @@ export const PerpsConnectionProvider: React.FC<
128128
try {
129129
await PerpsConnectionManager.disconnect();
130130
} catch (err) {
131-
Logger.error(err as Error, {
131+
Logger.error(ensureError(err, 'PerpsConnectionProvider.disconnect'), {
132132
message: 'PerpsConnectionProvider: Error during disconnect',
133133
context: 'PerpsConnectionProvider.disconnect',
134134
});
@@ -166,10 +166,13 @@ export const PerpsConnectionProvider: React.FC<
166166
// Use the existing reconnectWithNewContext method from the singleton
167167
await PerpsConnectionManager.reconnectWithNewContext(options);
168168
} catch (err) {
169-
Logger.error(err as Error, {
170-
message: 'PerpsConnectionProvider: Error during reconnect',
171-
context: 'PerpsConnectionProvider.reconnectWithNewContext',
172-
});
169+
Logger.error(
170+
ensureError(err, 'PerpsConnectionProvider.reconnectWithNewContext'),
171+
{
172+
message: 'PerpsConnectionProvider: Error during reconnect',
173+
context: 'PerpsConnectionProvider.reconnectWithNewContext',
174+
},
175+
);
173176
}
174177
// Always update state after reconnection attempt
175178
const state = PerpsConnectionManager.getConnectionState();
@@ -185,11 +188,14 @@ export const PerpsConnectionProvider: React.FC<
185188
try {
186189
await PerpsConnectionManager.connect();
187190
} catch (err) {
188-
Logger.error(err as Error, {
189-
message: 'PerpsConnectionProvider: Error in lifecycle onConnect',
190-
context:
191-
'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect',
192-
});
191+
Logger.error(
192+
ensureError(err, 'PerpsConnectionProvider.lifecycle.onConnect'),
193+
{
194+
message: 'PerpsConnectionProvider: Error in lifecycle onConnect',
195+
context:
196+
'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect',
197+
},
198+
);
193199
}
194200
const state = PerpsConnectionManager.getConnectionState();
195201
setConnectionState(state);
@@ -198,11 +204,14 @@ export const PerpsConnectionProvider: React.FC<
198204
try {
199205
await PerpsConnectionManager.disconnect();
200206
} catch (err) {
201-
Logger.error(err as Error, {
202-
message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect',
203-
context:
204-
'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect',
205-
});
207+
Logger.error(
208+
ensureError(err, 'PerpsConnectionProvider.lifecycle.onDisconnect'),
209+
{
210+
message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect',
211+
context:
212+
'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect',
213+
},
214+
);
206215
}
207216
const state = PerpsConnectionManager.getConnectionState();
208217
setConnectionState(state);

app/components/UI/Perps/providers/PerpsStreamManager.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import performance from 'react-native-performance';
44
import Engine from '../../../../core/Engine';
55
import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
66
import Logger from '../../../../util/Logger';
7+
import { ensureError } from '../../../../util/errorUtils';
78
import {
89
trace,
910
endTrace,
@@ -398,7 +399,7 @@ class PriceStreamChannel extends StreamChannel<Record<string, PriceUpdate>> {
398399
this.cleanupPrewarm();
399400
};
400401
} catch (error) {
401-
Logger.error(error instanceof Error ? error : new Error(String(error)), {
402+
Logger.error(ensureError(error, 'PriceStreamChannel.prewarm'), {
402403
context: 'PriceStreamChannel.prewarm',
403404
});
404405
// Return no-op cleanup function
@@ -1269,9 +1270,12 @@ class MarketDataChannel extends StreamChannel<PerpsMarketData[]> {
12691270
public prewarm(): () => void {
12701271
// Fetch data immediately to populate cache
12711272
this.fetchMarketData().catch((error) => {
1272-
Logger.error(error instanceof Error ? error : new Error(String(error)), {
1273-
context: 'MarketDataChannel.prewarm',
1274-
});
1273+
Logger.error(
1274+
ensureError(error, 'PerpsStreamManager.fetchMarketData.background'),
1275+
{
1276+
context: 'MarketDataChannel.prewarm',
1277+
},
1278+
);
12751279
});
12761280

12771281
// No cleanup needed for REST data

app/components/UI/Perps/services/PerpsConnectionManager.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -560,36 +560,31 @@ class PerpsConnectionManagerClass {
560560
this.clearConnectionTimeout();
561561

562562
// Capture exception with connection context
563-
captureException(
564-
error instanceof Error ? error : new Error(String(error)),
565-
{
566-
tags: {
567-
component: 'PerpsConnectionManager',
568-
action: 'connection_connection',
569-
operation: 'connection_management',
563+
captureException(ensureError(error, 'PerpsConnectionManager.connect'), {
564+
tags: {
565+
component: 'PerpsConnectionManager',
566+
action: 'connection_connection',
567+
operation: 'connection_management',
568+
provider: 'hyperliquid',
569+
},
570+
extra: {
571+
connectionContext: {
570572
provider: 'hyperliquid',
571-
},
572-
extra: {
573-
connectionContext: {
574-
provider: 'hyperliquid',
575-
timestamp: new Date().toISOString(),
576-
isTestnet:
577-
Engine.context.PerpsController?.getCurrentNetwork?.() ===
578-
'testnet',
579-
},
573+
timestamp: new Date().toISOString(),
574+
isTestnet:
575+
Engine.context.PerpsController?.getCurrentNetwork?.() ===
576+
'testnet',
580577
},
581578
},
582-
);
579+
});
583580

584581
traceData = {
585582
success: false,
586-
error: error instanceof Error ? error.message : 'Unknown error',
583+
error: ensureError(error, 'PerpsConnectionManager.connect').message,
587584
};
588585

589586
// Set error state for UI
590-
this.setError(
591-
error instanceof Error ? error : new Error(String(error)),
592-
);
587+
this.setError(ensureError(error, 'PerpsConnectionManager.connect'));
593588
DevLogger.log('PerpsConnectionManager: Connection failed', error);
594589
throw error;
595590
} finally {
@@ -811,7 +806,9 @@ class PerpsConnectionManagerClass {
811806
};
812807

813808
// Set error state for UI - this is critical for reliability
814-
this.setError(error instanceof Error ? error : new Error(String(error)));
809+
this.setError(
810+
ensureError(error, 'PerpsConnectionManager.reconnectWithNewContext'),
811+
);
815812
DevLogger.log(
816813
'PerpsConnectionManager: Reconnection with new context failed',
817814
error,

0 commit comments

Comments
 (0)