Skip to content

Commit 03f318a

Browse files
abretonc7sclaude
andauthored
fix: cherry-pick 429 rate limiting fix with coin naming convention (#25443)
## **Description** Cherry-pick of commit 425beae (Nick's 429 rate limiting fix) to release/7.62.2 branch. This hotfix addresses HyperLiquid WebSocket rate limiting issues (429 errors) that occur during rapid market switching or TP/SL updates. The fix introduces cache-first patterns to reduce API weight and avoid hitting rate limits. **Key changes:** - Add optional `position` parameter to `updatePositionTPSL` to skip REST API fetch when WebSocket data is available - Implement `getOrFetchPrice` helper for WebSocket-first price retrieval (0 weight vs 20 weight) - Add atomic cache getter `getOrdersCacheIfInitialized()` to prevent race conditions - Add `getOrFetchFills` for cache-first fills retrieval - Move `positionOpenedTimestamp` calculation into `useHasExistingPosition` hook - Add `currentPositionRef` sync in PerpsMarketDetailsView to prevent stale closure issues **Conflict resolution notes:** The main branch uses `symbol` naming convention while release/7.62.2 uses `coin`. All conflicts were resolved by keeping the `coin` naming convention while adopting the cache-first optimization patterns. ## **Changelog** CHANGELOG entry: Fixed rate limiting issues (429 errors) when rapidly switching markets or updating TP/SL orders ## **Related issues** Fixes: Rate limiting issues on HyperLiquid API during rapid market navigation ## **Manual testing steps** ```gherkin Feature: Rate limiting prevention for Perps Scenario: User rapidly switches between markets Given user has the Perps feature enabled And user is viewing a market details page When user rapidly navigates between different markets (BTC -> ETH -> SOL -> BTC) Then no 429 rate limit errors should appear And market data should load correctly for each market Scenario: User updates TP/SL via stop loss prompt banner Given user has an open position without stop loss And the stop loss prompt banner is visible When user taps "Set Stop Loss" on the banner Then the stop loss should be set successfully And no rate limit errors should occur Scenario: User edits existing TP/SL on a position Given user has an open position with TP/SL set When user navigates to modify TP/SL And user updates the stop loss price Then the update should succeed without 429 errors ``` ## **Screenshots/Recordings** ### **Before** N/A - Bug fix for rate limiting, no visual changes ### **After** N/A - Bug fix for rate limiting, no visual changes ## **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 perps trading/provider logic (`updatePositionTPSL`, order/price/fill retrieval) and async UI state around stop-loss actions, so regressions could impact TP/SL updates or stale-data handling, but changes are bounded and add explicit fallbacks/tests. > > **Overview** > **Goal:** reduce HyperLiquid REST API weight (and 429s) during rapid market switching and TP/SL operations by preferring WebSocket caches. > > Adds cache-first primitives in `HyperLiquidSubscriptionService` (fills cache + atomic `getOrdersCacheIfInitialized`) and `HyperLiquidProvider` (`getOrFetchPrice`, `getOrFetchFills`), and refactors provider call sites to use these helpers with stricter invalid-price validation and single-DEX REST fallbacks. > > Updates TP/SL flow to pass live WebSocket `position` into `updatePositionTPSL` (avoiding a REST positions fetch), uses cached orders to cancel existing TP/SL when available, and surfaces partial-failure feedback in `PerpsOrderView` when order succeeds but TP/SL update fails. > > Moves `positionOpenedTimestamp` derivation into `useHasExistingPosition` (WebSocket fills first, REST historical fallback), and hardens `PerpsMarketDetailsView` stop-loss banner interactions against stale closures/market switches; the banner UI switches from a `Switch` to a "Set" button with a delayed success checkmark + fade-out. Tests and test IDs are updated accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0edee81. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cb0aa6e commit 03f318a

16 files changed

Lines changed: 1585 additions & 313 deletions

app/components/UI/Perps/Perps.testIds.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,8 @@ export const PerpsTutorialSelectorsIDs = {
472472
export const PerpsStopLossPromptSelectorsIDs = {
473473
CONTAINER: 'perps-stop-loss-prompt-container',
474474
ADD_MARGIN_BUTTON: 'perps-stop-loss-prompt-add-margin-button',
475-
TOGGLE: 'perps-stop-loss-prompt-toggle',
475+
SET_STOP_LOSS_BUTTON: 'perps-stop-loss-prompt-set-button',
476+
SUCCESS_ICON: 'perps-stop-loss-prompt-success-icon',
476477
LOADING: 'perps-stop-loss-prompt-loading',
477478
} as const;
478479

app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx

Lines changed: 73 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -252,21 +252,28 @@ jest.mock('../../hooks/usePerpsOpenOrders', () => ({
252252
usePerpsOpenOrders: () => mockUsePerpsOpenOrdersImpl(),
253253
}));
254254

255-
const mockUsePerpsOrderFillsImpl = jest.fn<
255+
const mockUsePerpsLiveFillsImpl = jest.fn<
256256
ReturnType<
257-
typeof import('../../hooks/usePerpsOrderFills').usePerpsOrderFills
257+
typeof import('../../hooks/stream/usePerpsLiveFills').usePerpsLiveFills
258258
>,
259259
[]
260260
>(() => ({
261-
orderFills: [],
262-
isLoading: false,
263-
error: null,
264-
refresh: jest.fn(),
265-
isRefreshing: false,
261+
fills: [],
262+
isInitialLoading: false,
266263
}));
267264

268-
jest.mock('../../hooks/usePerpsOrderFills', () => ({
269-
usePerpsOrderFills: () => mockUsePerpsOrderFillsImpl(),
265+
jest.mock('../../hooks/stream/usePerpsLiveFills', () => ({
266+
usePerpsLiveFills: () => mockUsePerpsLiveFillsImpl(),
267+
}));
268+
269+
// Mock Engine for REST fallback tests
270+
const mockGetOrderFills = jest.fn();
271+
jest.mock('../../../../../core/Engine', () => ({
272+
context: {
273+
PerpsController: {
274+
getOrderFills: (...args: unknown[]) => mockGetOrderFills(...args),
275+
},
276+
},
270277
}));
271278

272279
// Mock for usePerpsMarkets that can be modified per test
@@ -568,6 +575,7 @@ describe('PerpsMarketDetailsView', () => {
568575
error: null,
569576
existingPosition: null,
570577
refreshPosition: jest.fn(),
578+
positionOpenedTimestamp: undefined,
571579
});
572580

573581
// Reset navigation mocks
@@ -600,12 +608,9 @@ describe('PerpsMarketDetailsView', () => {
600608
};
601609

602610
// Reset order fills mock to default
603-
mockUsePerpsOrderFillsImpl.mockReturnValue({
604-
orderFills: [],
605-
isLoading: false,
606-
error: null,
607-
refresh: jest.fn(),
608-
isRefreshing: false,
611+
mockUsePerpsLiveFillsImpl.mockReturnValue({
612+
fills: [],
613+
isInitialLoading: false,
609614
});
610615
});
611616

@@ -834,6 +839,7 @@ describe('PerpsMarketDetailsView', () => {
834839
},
835840
},
836841
refreshPosition: jest.fn(),
842+
positionOpenedTimestamp: undefined,
837843
});
838844

839845
const { getByTestId, queryByText, queryByTestId } = renderWithProvider(
@@ -924,6 +930,7 @@ describe('PerpsMarketDetailsView', () => {
924930
error: null,
925931
existingPosition: null,
926932
refreshPosition: mockRefreshPosition, // No-op function for WebSocket positions
933+
positionOpenedTimestamp: undefined,
927934
});
928935

929936
const { getByTestId } = renderWithProvider(
@@ -975,6 +982,7 @@ describe('PerpsMarketDetailsView', () => {
975982
},
976983
},
977984
refreshPosition: mockRefreshPosition,
985+
positionOpenedTimestamp: undefined,
978986
});
979987

980988
const { getByTestId } = renderWithProvider(
@@ -1011,6 +1019,7 @@ describe('PerpsMarketDetailsView', () => {
10111019
error: null,
10121020
existingPosition: null,
10131021
refreshPosition: mockRefreshPosition,
1022+
positionOpenedTimestamp: undefined,
10141023
});
10151024

10161025
const { getByTestId } = renderWithProvider(
@@ -1075,6 +1084,7 @@ describe('PerpsMarketDetailsView', () => {
10751084
error: null,
10761085
existingPosition: null,
10771086
refreshPosition: mockRefreshPosition,
1087+
positionOpenedTimestamp: undefined,
10781088
});
10791089

10801090
const { getByTestId } = renderWithProvider(
@@ -1692,10 +1702,13 @@ describe('PerpsMarketDetailsView', () => {
16921702
});
16931703
});
16941704

1695-
describe('Position opened timestamp calculation', () => {
1696-
it('computes position opened timestamp from order fills data', () => {
1697-
// Arrange
1698-
const timestamp = Date.now();
1705+
describe('Position opened timestamp', () => {
1706+
// Note: The timestamp calculation logic has been moved to useHasExistingPosition hook
1707+
// These tests verify the component correctly uses the hook's positionOpenedTimestamp
1708+
1709+
it('uses positionOpenedTimestamp from useHasExistingPosition hook', () => {
1710+
// Arrange - Hook provides the timestamp
1711+
const timestamp = Date.now() - 5 * 60 * 1000;
16991712
mockUseHasExistingPosition.mockReturnValue({
17001713
hasPosition: true,
17011714
isLoading: false,
@@ -1718,51 +1731,50 @@ describe('PerpsMarketDetailsView', () => {
17181731
},
17191732
},
17201733
refreshPosition: jest.fn(),
1734+
positionOpenedTimestamp: timestamp, // Hook now provides this
17211735
});
17221736

1723-
mockUsePerpsOrderFillsImpl.mockReturnValue({
1724-
orderFills: [
1725-
{
1726-
orderId: 'order-1',
1727-
symbol: 'BTC',
1728-
side: 'buy',
1729-
direction: 'Open Long',
1730-
timestamp: timestamp - 2000,
1731-
size: '0.3',
1732-
price: '43000',
1733-
pnl: '0',
1734-
fee: '0.001',
1735-
feeToken: 'USDC',
1736-
},
1737-
{
1738-
orderId: 'order-2',
1739-
symbol: 'BTC',
1740-
side: 'buy',
1741-
direction: 'Open Long',
1742-
timestamp,
1743-
size: '0.5',
1744-
price: '44000',
1745-
pnl: '0',
1746-
fee: '0.001',
1747-
feeToken: 'USDC',
1748-
},
1749-
{
1750-
orderId: 'order-3',
1751-
symbol: 'ETH',
1752-
side: 'sell',
1753-
direction: 'Open Short',
1754-
timestamp: timestamp - 1000,
1755-
size: '1.0',
1756-
price: '3000',
1757-
pnl: '0',
1758-
fee: '0.001',
1759-
feeToken: 'USDC',
1760-
},
1761-
],
1737+
// Act
1738+
const { getByTestId } = renderWithProvider(
1739+
<PerpsConnectionProvider>
1740+
<PerpsMarketDetailsView />
1741+
</PerpsConnectionProvider>,
1742+
{
1743+
state: initialState,
1744+
},
1745+
);
1746+
1747+
// Assert - component renders with position data
1748+
expect(
1749+
getByTestId(PerpsMarketDetailsViewSelectorsIDs.CONTAINER),
1750+
).toBeTruthy();
1751+
});
1752+
1753+
it('handles undefined positionOpenedTimestamp from hook', () => {
1754+
// Arrange - Hook returns undefined timestamp (e.g., new position)
1755+
mockUseHasExistingPosition.mockReturnValue({
1756+
hasPosition: true,
17621757
isLoading: false,
17631758
error: null,
1764-
refresh: jest.fn(),
1765-
isRefreshing: false,
1759+
existingPosition: {
1760+
symbol: 'BTC',
1761+
size: '0.5',
1762+
entryPrice: '44000',
1763+
positionValue: '22000',
1764+
unrealizedPnl: '50',
1765+
marginUsed: '500',
1766+
leverage: { type: 'isolated', value: 5 },
1767+
liquidationPrice: '40000',
1768+
maxLeverage: 20,
1769+
returnOnEquity: '1.14',
1770+
cumulativeFunding: {
1771+
allTime: '0',
1772+
sinceOpen: '0',
1773+
sinceChange: '0',
1774+
},
1775+
},
1776+
refreshPosition: jest.fn(),
1777+
positionOpenedTimestamp: undefined, // No timestamp available yet
17661778
});
17671779

17681780
// Act
@@ -1775,11 +1787,10 @@ describe('PerpsMarketDetailsView', () => {
17751787
},
17761788
);
17771789

1778-
// Assert
1790+
// Assert - component still renders correctly
17791791
expect(
17801792
getByTestId(PerpsMarketDetailsViewSelectorsIDs.CONTAINER),
17811793
).toBeTruthy();
1782-
expect(mockUsePerpsOrderFillsImpl).toHaveBeenCalled();
17831794
});
17841795
});
17851796

0 commit comments

Comments
 (0)