Skip to content

Commit c602ef7

Browse files
abretonc7sclaudesahar-fehri
authored
feat(perps): add lightweight position display on token details page (MetaMask#25685)
## **Description** This PR optimizes the token details page by implementing a lightweight way to display open perps positions without requiring full PerpsController initialization, and adds one-click Long/Short trading buttons that navigate directly to the order confirmation flow. **Problem:** Previously, checking if a user had an open position on a token required initializing the entire PerpsController with WebSocket subscriptions, causing slow page loads. Additionally, cached position data could become stale when users closed positions in the perps environment. There was also no way to initiate a perps trade directly from the token details page. **Solution:** Added a `readOnly` mode pattern that allows querying perps data (positions, account state, markets) via lightweight HTTP API calls without WebSocket, wallet, or full controller initialization. Combined with a `PerpsCacheInvalidator` service that ensures cached data is refreshed when positions change. Added Long/Short buttons on token details that navigate through a new `PerpsOrderRedirect` screen to handle WebSocket initialization and order creation seamlessly. ### Key Changes **New Hook:** - `usePerpsPositionForAsset` - Fetches position data via single HTTP API call with 30-second client-side cache, subscribes to cache invalidation events **ReadOnly Mode (Controller/Provider):** - `getPositions({ readOnly: true, userAddress })` - Query positions without full init - `getAccountState({ readOnly: true, userAddress })` - Query account state without full init - Uses `createStandaloneInfoClient` for lightweight HTTP-only queries **Cache Invalidation Service:** - `PerpsCacheInvalidator` - Singleton service for loosely-coupled cache invalidation - Hooks subscribe to invalidation events (`positions`, `accountState`) - Services (TradingService, AccountService) call `invalidate()` after successful operations - Ensures position display is always accurate even after user actions in perps **One-Click Trade from Token Details:** - New `PerpsOrderRedirect` screen - a redirect/loader that initializes the WebSocket connection inside the Perps stack, calls `depositWithOrder()`, then navigates to the confirmation screen - `usePerpsActions` hook - provides `handlePerpsAction(direction)` callback for Long/Short buttons, navigates to `PerpsOrderRedirect` with direction and asset params - `AssetOverviewContent` integrates Long/Short buttons via `usePerpsActions` when a perps market exists for the token **Dynamic Button Layout:** - Token details action buttons (Buy, Sell, Swap, Bridge, etc.) now include Long/Short when a perps market exists - Buttons dynamically overflow into a "More" menu when there are too many actions **Documentation:** - Added "ReadOnly Mode (Lightweight Queries)" section to `docs/perps/perps-architecture.md` - Added "Cache Invalidation" subsection documenting the pattern ### Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ PerpsCacheInvalidator │ │ (Singleton service) │ ├─────────────────────────────────────────────────────────────────┤ │ - invalidate('positions') → notifies position subscribers │ │ - invalidate('accountState') → notifies account subscribers │ │ - subscribe(type, callback) → returns unsubscribe function │ └─────────────────────────────────────────────────────────────────┘ ▲ ▲ │ subscribe │ invalidate │ │ ┌─────────┴─────────┐ ┌──────────┴──────────┐ │ usePerpsPosition │ │ TradingService │ │ ForAsset │ │ (closePosition, │ │ │ │ placeOrder) │ │ (30s cache + │ │ │ │ invalidation) │ │ AccountService │ └───────────────────┘ │ (withdraw) │ └─────────────────────┘ Order Redirect Flow (Long/Short buttons): ┌────────────────┐ navigate ┌──────────────────┐ WS ready ┌─────────────────┐ │ Token Details │ ──────────────→ │PerpsOrderRedirect│ ────────────→ │ Confirmation │ │ (Long/Short) │ │ (Perps stack) │ depositWith │ Screen │ │ │ │ - init WebSocket │ Order() │ │ └────────────────┘ │ - show loader │ └─────────────────┘ └──────────────────┘ │ on error ▼ goBack() + toast ``` ## **Changelog** CHANGELOG entry: Added lightweight position display and one-click Long/Short trading on token details page for perps-enabled assets ## **Related issues** Fixes: [TAT-2427](https://consensyssoftware.atlassian.net/browse/TAT-2427) ## **Manual testing steps** ```gherkin Feature: Perps position display on token details Scenario: User views token with open perps position Given user has an open perps position for ETH And user is on the wallet home screen When user navigates to ETH token details page Then user should see their open position displayed And the page should load quickly without full perps initialization Scenario: User taps on position to view details Given user is viewing token details with an open position displayed When user taps on the position card Then user should be navigated to the perps market detail page And the full perps environment should load Scenario: User with no perps position Given user does not have any perps positions When user navigates to any token details page Then no perps position card should be displayed And no perps API calls should be made (if user is not eligible) Scenario: Position display updates after closing position Given user has an open perps position for ETH And user is viewing the ETH token details page And user sees their open position displayed When user navigates to perps and closes their ETH position And user navigates back to the ETH token details page Then the position card should no longer be displayed And the cache should have been invalidated automatically Scenario: User taps Long button on token with perps market Given user is viewing ETH token details And ETH has a perps market available When user taps the "Long" button Then a loader screen should appear ("Preparing order...") And the WebSocket connection should initialize And depositWithOrder should be called And user should be navigated to the order confirmation screen Scenario: User taps Short button on token with perps market Given user is viewing ETH token details And ETH has a perps market available When user taps the "Short" button Then a loader screen should appear ("Preparing order...") And the WebSocket connection should initialize And depositWithOrder should be called And user should be navigated to the order confirmation screen Scenario: Long/Short buttons not shown for token without perps market Given user is viewing a token details page for a token without a perps market (e.g. USDC) Then no Long or Short buttons should be displayed in the action bar Scenario: Order redirect handles error gracefully Given user taps Long or Short on a perps-enabled token And the depositWithOrder call fails Then user should see an error toast notification And user should be navigated back to the token details page ``` ## **Screenshots/Recordings** ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/245b905f-e9ab-4b65-a96b-49f81fe51afe ## **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 - [ ] 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. [TAT-2427]: https://consensyssoftware.atlassian.net/browse/TAT-2427?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new read-only provider/controller code paths and cache invalidation wiring, plus a new navigation redirect that triggers `depositWithOrder`; mistakes could lead to stale UI or incorrect trade/confirmation routing, but changes are scoped and covered by new tests. > > **Overview** > Adds a *read-only perps discovery path* so token details can query perps `markets`/`positions`/`accountState` via lightweight HTTP (no full controller/WebSocket init) by extending `PerpsController`/`HyperLiquidProvider` with `readOnly` + `userAddress` params and a standalone info client. > > Introduces `PerpsCacheInvalidator` (and platform dependency plumbing) and wires it into `TradingService` and `AccountService` to invalidate read-only caches after successful state-changing actions, enabling hooks like the new `usePerpsPositionForAsset` (30s TTL + invalidation subscriptions) to stay fresh. > > Updates token details (`AssetOverviewContent`) to display either a compact `PerpsPositionCard` or a `PerpsDiscoveryBanner` based on the read-only position lookup, adds geo-eligibility modal handling for Long/Short, and implements a one-click trade flow via new `PerpsOrderRedirect` route/screen that waits for perps connection, calls `depositWithOrder`, then `StackActions.replace`s into redesigned confirmations (or toasts + goes back on failure). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4a032ad. 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> Co-authored-by: sahar-fehri <sahar.fehri@consensys.net>
1 parent a0a4047 commit c602ef7

29 files changed

Lines changed: 3234 additions & 77 deletions

app/components/UI/AssetOverview/TokenOverview.testIds.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const TokenOverviewSelectorsIDs = {
1111
ADD_BUTTON: 'token-add-button',
1212
CLAIM_BUTTON: 'claim-banner-claim-eth-button',
1313
UNSTAKING_BANNER: 'unstaking-banner',
14+
PERPS_POSITION_CARD: 'perps-position-card-touchable',
15+
PERPS_DISCOVERY_BANNER: 'perps-discovery-banner',
1416
LONG_BUTTON: 'token-long-button',
1517
SHORT_BUTTON: 'token-short-button',
1618
MORE_BUTTON: 'token-more-button',
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import React from 'react';
2+
import { render, waitFor } from '@testing-library/react-native';
3+
import {
4+
useNavigation,
5+
useRoute,
6+
StackActions,
7+
} from '@react-navigation/native';
8+
import PerpsOrderRedirect from './PerpsOrderRedirect';
9+
import { usePerpsConnection } from '../hooks/usePerpsConnection';
10+
import { usePerpsTrading } from '../hooks/usePerpsTrading';
11+
import usePerpsToasts from '../hooks/usePerpsToasts';
12+
import Routes from '../../../../constants/navigation/Routes';
13+
import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig';
14+
15+
jest.mock('@react-navigation/native', () => ({
16+
...jest.requireActual('@react-navigation/native'),
17+
useNavigation: jest.fn(),
18+
useRoute: jest.fn(),
19+
StackActions: {
20+
replace: jest.fn(),
21+
},
22+
}));
23+
24+
jest.mock('../hooks/usePerpsConnection', () => ({
25+
usePerpsConnection: jest.fn(),
26+
}));
27+
28+
jest.mock('../hooks/usePerpsTrading', () => ({
29+
usePerpsTrading: jest.fn(),
30+
}));
31+
32+
jest.mock('../hooks/usePerpsToasts', () => ({
33+
__esModule: true,
34+
default: jest.fn(),
35+
}));
36+
37+
const MockPerpsLoader = jest.fn((_props: Record<string, unknown>) => null);
38+
jest.mock('../components/PerpsLoader', () => ({
39+
__esModule: true,
40+
default: (props: Record<string, unknown>) => MockPerpsLoader(props),
41+
}));
42+
43+
jest.mock('../../../../util/Logger', () => ({
44+
log: jest.fn(),
45+
error: jest.fn(),
46+
}));
47+
48+
jest.mock('../../../../util/errorUtils', () => ({
49+
ensureError: jest.fn((e) => (e instanceof Error ? e : new Error(String(e)))),
50+
}));
51+
52+
const mockNavigate = jest.fn();
53+
const mockGoBack = jest.fn();
54+
const mockDispatch = jest.fn();
55+
const mockDepositWithOrder = jest.fn();
56+
const mockShowToast = jest.fn();
57+
const mockToastOptions = {
58+
accountManagement: {
59+
oneClickTrade: {
60+
txCreationFailed: { variant: 'error', label: 'Failed' },
61+
},
62+
},
63+
};
64+
65+
const mockUseNavigation = jest.mocked(useNavigation);
66+
const mockUseRoute = jest.mocked(useRoute);
67+
const mockUsePerpsConnection = jest.mocked(usePerpsConnection);
68+
const mockUsePerpsTrading = jest.mocked(usePerpsTrading);
69+
const mockUsePerpsToasts = jest.mocked(usePerpsToasts);
70+
71+
describe('PerpsOrderRedirect', () => {
72+
beforeEach(() => {
73+
jest.clearAllMocks();
74+
75+
mockUseNavigation.mockReturnValue({
76+
navigate: mockNavigate,
77+
goBack: mockGoBack,
78+
dispatch: mockDispatch,
79+
} as never);
80+
81+
mockUseRoute.mockReturnValue({
82+
key: 'test',
83+
name: 'PerpsOrderRedirect',
84+
params: { direction: 'long', asset: 'ETH' },
85+
} as never);
86+
87+
mockUsePerpsTrading.mockReturnValue({
88+
depositWithOrder: mockDepositWithOrder,
89+
} as never);
90+
91+
mockUsePerpsToasts.mockReturnValue({
92+
showToast: mockShowToast,
93+
PerpsToastOptions: mockToastOptions,
94+
} as never);
95+
});
96+
97+
it('renders loader with preparing message', () => {
98+
// Arrange
99+
mockUsePerpsConnection.mockReturnValue({
100+
isConnected: false,
101+
isInitialized: false,
102+
} as never);
103+
104+
// Act
105+
render(<PerpsOrderRedirect />);
106+
107+
// Assert
108+
expect(MockPerpsLoader).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
message: 'Preparing order...',
111+
fullScreen: false,
112+
}),
113+
);
114+
});
115+
116+
it('does not call depositWithOrder when WebSocket is not ready', () => {
117+
// Arrange
118+
mockUsePerpsConnection.mockReturnValue({
119+
isConnected: false,
120+
isInitialized: false,
121+
} as never);
122+
123+
// Act
124+
render(<PerpsOrderRedirect />);
125+
126+
// Assert
127+
expect(mockDepositWithOrder).not.toHaveBeenCalled();
128+
});
129+
130+
it('calls depositWithOrder and navigates to confirmation on success', async () => {
131+
// Arrange
132+
mockUsePerpsConnection.mockReturnValue({
133+
isConnected: true,
134+
isInitialized: true,
135+
} as never);
136+
137+
mockDepositWithOrder.mockResolvedValue(undefined);
138+
139+
const mockReplaceAction = { type: 'REPLACE' };
140+
(StackActions.replace as jest.Mock).mockReturnValue(mockReplaceAction);
141+
142+
// Act
143+
render(<PerpsOrderRedirect />);
144+
145+
// Assert
146+
await waitFor(() => {
147+
expect(mockDepositWithOrder).toHaveBeenCalledTimes(1);
148+
});
149+
150+
await waitFor(() => {
151+
expect(StackActions.replace).toHaveBeenCalledWith(
152+
Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
153+
{
154+
direction: 'long',
155+
asset: 'ETH',
156+
showPerpsHeader:
157+
CONFIRMATION_HEADER_CONFIG.ShowPerpsHeaderForDepositAndTrade,
158+
},
159+
);
160+
expect(mockDispatch).toHaveBeenCalledWith(mockReplaceAction);
161+
});
162+
});
163+
164+
it('shows toast and goes back on depositWithOrder failure', async () => {
165+
// Arrange
166+
mockUsePerpsConnection.mockReturnValue({
167+
isConnected: true,
168+
isInitialized: true,
169+
} as never);
170+
171+
mockDepositWithOrder.mockRejectedValue(new Error('Order failed'));
172+
173+
// Act
174+
render(<PerpsOrderRedirect />);
175+
176+
// Assert
177+
await waitFor(() => {
178+
expect(mockShowToast).toHaveBeenCalledWith(
179+
mockToastOptions.accountManagement.oneClickTrade.txCreationFailed,
180+
);
181+
expect(mockGoBack).toHaveBeenCalled();
182+
});
183+
});
184+
185+
it('uses short direction from route params', async () => {
186+
// Arrange
187+
mockUseRoute.mockReturnValue({
188+
key: 'test',
189+
name: 'PerpsOrderRedirect',
190+
params: { direction: 'short', asset: 'BTC' },
191+
} as never);
192+
193+
mockUsePerpsConnection.mockReturnValue({
194+
isConnected: true,
195+
isInitialized: true,
196+
} as never);
197+
198+
mockDepositWithOrder.mockResolvedValue(undefined);
199+
200+
const mockReplaceAction = { type: 'REPLACE' };
201+
(StackActions.replace as jest.Mock).mockReturnValue(mockReplaceAction);
202+
203+
// Act
204+
render(<PerpsOrderRedirect />);
205+
206+
// Assert
207+
await waitFor(() => {
208+
expect(StackActions.replace).toHaveBeenCalledWith(
209+
Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
210+
expect.objectContaining({
211+
direction: 'short',
212+
asset: 'BTC',
213+
}),
214+
);
215+
});
216+
});
217+
218+
it('does not call depositWithOrder twice on re-render', async () => {
219+
// Arrange
220+
mockUsePerpsConnection.mockReturnValue({
221+
isConnected: true,
222+
isInitialized: true,
223+
} as never);
224+
225+
mockDepositWithOrder.mockResolvedValue(undefined);
226+
(StackActions.replace as jest.Mock).mockReturnValue({ type: 'REPLACE' });
227+
228+
// Act
229+
const { rerender } = render(<PerpsOrderRedirect />);
230+
rerender(<PerpsOrderRedirect />);
231+
232+
// Assert
233+
await waitFor(() => {
234+
expect(mockDepositWithOrder).toHaveBeenCalledTimes(1);
235+
});
236+
});
237+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import {
3+
useNavigation,
4+
useRoute,
5+
RouteProp,
6+
StackActions,
7+
} from '@react-navigation/native';
8+
import {
9+
Box,
10+
BoxAlignItems,
11+
BoxJustifyContent,
12+
} from '@metamask/design-system-react-native';
13+
import Routes from '../../../../constants/navigation/Routes';
14+
import { usePerpsConnection } from '../hooks/usePerpsConnection';
15+
import { usePerpsTrading } from '../hooks/usePerpsTrading';
16+
import usePerpsToasts from '../hooks/usePerpsToasts';
17+
import PerpsLoader from '../components/PerpsLoader';
18+
import Logger from '../../../../util/Logger';
19+
import { ensureError } from '../../../../util/errorUtils';
20+
import {
21+
PERPS_CONSTANTS,
22+
CONFIRMATION_HEADER_CONFIG,
23+
} from '../constants/perpsConfig';
24+
import type { PerpsNavigationParamList } from '../types/navigation';
25+
26+
type RouteParams = RouteProp<PerpsNavigationParamList, 'PerpsOrderRedirect'>;
27+
28+
/**
29+
* PerpsOrderRedirect
30+
*
31+
* A redirect screen that handles navigation from Token Details to the Perps order confirmation.
32+
* This screen:
33+
* 1. Waits for the WebSocket connection to be established (via PerpsConnectionProvider)
34+
* 2. Calls depositWithOrder() to create the pending transaction
35+
* 3. Navigates to the confirmation screen with the transaction ready
36+
*
37+
* This is necessary because Token Details is outside the Perps stack, so the WebSocket
38+
* is not initialized there. By navigating to this screen first, we ensure the WebSocket
39+
* is ready before calling depositWithOrder().
40+
*/
41+
const PerpsOrderRedirect: React.FC = () => {
42+
const navigation = useNavigation();
43+
const route = useRoute<RouteParams>();
44+
const { direction, asset } = route.params;
45+
46+
const { isConnected, isInitialized } = usePerpsConnection();
47+
const { depositWithOrder } = usePerpsTrading();
48+
const { showToast, PerpsToastOptions } = usePerpsToasts();
49+
50+
const hasStartedRef = useRef(false);
51+
useEffect(() => {
52+
// Wait for WebSocket to be ready
53+
if (!isConnected || !isInitialized) return;
54+
// Prevent double execution
55+
if (hasStartedRef.current) return;
56+
hasStartedRef.current = true;
57+
58+
Logger.log('[PerpsOrderRedirect] Starting depositWithOrder', {
59+
direction,
60+
asset,
61+
});
62+
63+
depositWithOrder()
64+
.then(() => {
65+
Logger.log(
66+
'[PerpsOrderRedirect] depositWithOrder resolved, navigating to confirmation',
67+
);
68+
// Replace current screen with confirmation (no back to loader)
69+
navigation.dispatch(
70+
StackActions.replace(
71+
Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
72+
{
73+
direction,
74+
asset,
75+
showPerpsHeader:
76+
CONFIRMATION_HEADER_CONFIG.ShowPerpsHeaderForDepositAndTrade,
77+
},
78+
),
79+
);
80+
})
81+
.catch((error: unknown) => {
82+
const err = ensureError(error);
83+
Logger.error(err, {
84+
feature: PERPS_CONSTANTS.FeatureName,
85+
message: 'Failed to start one-click trade from asset details',
86+
});
87+
showToast(
88+
PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed,
89+
);
90+
// Go back to token details on failure
91+
navigation.goBack();
92+
});
93+
}, [
94+
isConnected,
95+
isInitialized,
96+
direction,
97+
asset,
98+
depositWithOrder,
99+
navigation,
100+
showToast,
101+
PerpsToastOptions,
102+
]);
103+
104+
// Match PerpsLoadingSkeleton layout ("Connecting to Perps") so both loaders look the same: top-aligned, centered, pt-20
105+
return (
106+
<Box
107+
twClassName="flex-1 bg-default pt-20"
108+
alignItems={BoxAlignItems.Center}
109+
justifyContent={BoxJustifyContent.Start}
110+
>
111+
<PerpsLoader message="Preparing order..." fullScreen={false} />
112+
</Box>
113+
);
114+
};
115+
116+
export default PerpsOrderRedirect;

app/components/UI/Perps/__mocks__/serviceMocks.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export const createMockInfrastructure =
5757
rewards: {
5858
getFeeDiscount: jest.fn().mockResolvedValue(0),
5959
},
60+
61+
// === Cache Invalidation ===
62+
cacheInvalidator: {
63+
invalidate: jest.fn(),
64+
invalidateAll: jest.fn(),
65+
},
6066
}) as unknown as jest.Mocked<PerpsPlatformDependencies>;
6167

6268
/**

0 commit comments

Comments
 (0)