From f2b07b68c53d132a1cd38c1a32d57813e82f0fc4 Mon Sep 17 00:00:00 2001 From: Aslau Mario-Daniel Date: Thu, 21 May 2026 03:23:44 +0300 Subject: [PATCH 01/14] feat(predict): Bottom Sheet Keyboard Fix cp-7.78.0 (#30483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Predict Buy bottom-sheet introduced in [#28779](https://github.com/MetaMask/metamask-mobile/pull/28779) opened with the custom keypad already rendered behind the rest of the sheet content. The root cause was a single `isInputFocused` boolean inside `usePredictBuyInputState` that was hard-coded to `true` on first render and overloaded across **five** unrelated behaviours: 1. Mounting `PredictKeypad`. 2. Highlighting the amount-display "active" state. 3. Hiding `PredictBuyBottomContent` while the keypad is up. 4. Disabling `PredictFeeSummary`. 5. Deferring the mm_pay relay-config side effects (`updatePendingAmount` / `setPayToken`) inside `PredictPayWithAnyTokenInfo`. Because the only "off switch" was `setIsInputFocused(false)` (driven from a `Done` button that doesn't exist in sheet mode) the previous PR worked around the issue with three `isSheetMode ? false : isInputFocused` ternaries — Bugbot flagged this on #28779. The workarounds also produced a confusing situation where bottom content and keypad rendered simultaneously on first sheet open. This PR separates the conflated meanings, parameterises the initial state, and removes the workarounds. No behaviour change for the legacy full-screen flow. ### What changed **`usePredictBuyInputState` ([hooks/usePredictBuyInputState.ts](app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts))** - Accepts an options object: `{ initialKeypadOpen = true }`. - Renamed state + setter: `isInputFocused` → `isKeypadOpen`, `setIsInputFocused` → `setIsKeypadOpen`. - Default value preserves legacy behaviour; sheet mode opts out. **`PredictBuyWithAnyToken` ([PredictBuyWithAnyToken.tsx](app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx))** - Calls the hook as `usePredictBuyInputState({ initialKeypadOpen: !isSheetMode })` so the sheet opens with the keypad collapsed. - Removed the three `isSheetMode ? false : isInputFocused` patches. - Renders `PredictBuyBottomContent` at the call site via `{(isSheetMode || !isKeypadOpen) && }` instead of relying on the component to bail internally. Keeps the legacy "hide while typing" behaviour while letting the sheet show the bottom content (and Confirm) at all times. - Passes the new self-documenting `shouldDeferRelaySetup={!isSheetMode && isKeypadOpen}` prop to `PredictPayWithAnyTokenInfo`. Same effective value as before, but the prop name now describes its actual purpose. **`PredictPayWithAnyTokenInfo` ([components/PredictPayWithAnyTokenInfo](app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx))** - Renamed prop: `isInputFocused` → `shouldDeferRelaySetup`. This is the genuine separation: the prop is **not** about UI keypad state, it's about pausing `updatePendingAmount` / `setPayToken` calls. Legacy mode still defers until the user taps Done; sheet mode never defers (relay must update on every keystroke since there's no Done and the user can tap Confirm with the keypad still open — preventing underfunded deposits). - Added a JSDoc comment explaining the contract. **`PredictBuyBottomContent` ([components/PredictBuyBottomContent](app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx))** - Removed the `isInputFocused` prop and the `if (isInputFocused) return null` early return. Component is now purely structural; visibility is the parent's responsibility. **`PredictKeypad` and `PredictBuyAmountSection`** - Mechanical prop renames: `isInputFocused` → `isKeypadOpen`, `setIsInputFocused` → `setIsKeypadOpen`. **Legacy `PredictBuyPreview` ([views/PredictBuyPreview](app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx))** - Local `useState(true)` renamed to `isKeypadOpen` for consistency with the rest of the tree. No behaviour change (still initialises `true`, still gates `renderBottomContent`). **Tests** - All prop/state references renamed. - New coverage on `usePredictBuyInputState` for `initialKeypadOpen: false` and `initialKeypadOpen: true`. - New assertions in `PredictBuyWithAnyToken.test.tsx` that the hook is called with `initialKeypadOpen: false` in sheet mode and `initialKeypadOpen: true` in non-sheet mode. - `PredictBuyBottomContent.test.tsx`: removed the now-irrelevant `isInputFocused is true / false` describe blocks since visibility is caller-controlled. - `PredictPayWithAnyTokenInfo.test.tsx`: updated 43 prop references and renamed two test descriptions to reflect the new "relay deferral" intent. - `PredictBuyPreview.test.tsx`: updated `renderBottomContent` describe block descriptions. ### Net behavioural effect (sheet mode) - Sheet opens → keypad hidden, amount display shows `$0` (inactive), quick amounts + pay-with row + fee summary + Confirm button visible (Confirm disabled until amount > 0). - Tap amount display → keypad opens at the bottom of the sheet, stacked **below** the Confirm button (does not overlap). - Tap a quick amount → value set, keypad closes. - Tap Confirm with keypad still open → places order (relay setup is up-to-date because `shouldDeferRelaySetup` is `false` in sheet mode). - Auto-blur on banner display still works. Legacy full-screen flow: unchanged. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: PRED-707 (follow-up to [#28779](https://github.com/MetaMask/metamask-mobile/pull/28779)) ## **Manual testing steps** ```gherkin Feature: Predict buy bottom-sheet keypad initial state Scenario: Sheet opens with keypad collapsed (flag ON) Given the predictBottomSheet feature flag is enabled And the user is on a prediction market details page When user taps a "Yes" or "No" outcome button Then a bottom sheet opens with the buy preview content And the custom numeric keypad is NOT visible And the amount display shows "$0" And the quick amount buttons ($20 / $50 / $100 / $250) are visible And the Pay with row, fee summary and Confirm button are visible And the Confirm button is disabled Scenario: Tapping the amount opens the keypad Given the buy bottom sheet is open with no amount entered And the keypad is hidden When user taps the amount display Then the custom keypad becomes visible at the bottom of the sheet And the keypad does NOT cover the Confirm button And the amount display shows the active highlight Scenario: Tapping a quick amount sets value and closes the keypad Given the keypad is open And the user has typed "$73" When user taps the "$50" quick amount button Then the amount becomes "$50" And the keypad closes And haptic feedback fires (Light impact) And the Confirm button is enabled Scenario: Confirming with keypad still open Given the keypad is open And the user has typed "$25" When user taps the Confirm button Then the order is placed successfully And the relay was configured for $25 (no underfunded deposit) And the sheet closes Scenario: Banner display auto-blurs the keypad in sheet mode Given the buy bottom sheet is open And the keypad is open When an order_failed or price_changed banner appears Then the keypad auto-closes And the banner + Retry CTA are visible without needing to tap Done Scenario: Legacy full-screen flow is unchanged (flag OFF) Given the predictBottomSheet feature flag is disabled And the user navigates to the BuyPreview screen When the screen mounts Then the keypad is open by default (matching previous behaviour) And tapping Done closes the keypad and reveals the bottom content And the relay is configured once on Done (deferred during typing) ``` ## **Screenshots/Recordings** ### **Before** Sheet opens with the keypad rendered behind the bottom content; bottom content and keypad are both visible simultaneously on first paint. (See screenshot in PR comments — keypad sits below `Confirm` even though the user has not yet tapped the amount display.) ### **After** Sheet opens with the keypad hidden; bottom content (quick amounts, pay with row, fee summary, Confirm) is the only thing visible. Tapping the amount display reveals the keypad; tapping a quick amount or Confirm collapses it. https://github.com/user-attachments/assets/af89b78a-5103-48eb-b2c9-346ea64cd463 ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example > N/A — pure refactor of UI state semantics, no new code paths. ## **Pre-merge reviewer checklist** - [x] 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** > Changes how the Predict buy keypad state is tracked and used to gate bottom content and mm_pay relay setup; mistakes could surface as incorrect UI visibility or misconfigured deposit/payment amounts during checkout. Scope is mostly contained to Predict buy views and covered by updated tests. > > **Overview** > Fixes the Predict buy bottom-sheet keypad initial state by splitting the previously overloaded `isInputFocused` flag into a dedicated `isKeypadOpen` state, including a new `initialKeypadOpen` option in `usePredictBuyInputState` so sheet mode can start closed. > > Updates `PredictBuyWithAnyToken`/`PredictKeypad`/`PredictBuyAmountSection` to use `isKeypadOpen` for keypad mounting and active styling, moves bottom-content visibility control to the parent (removing `PredictBuyBottomContent`’s internal early-return), and introduces `shouldDeferRelaySetup` (replacing `isInputFocused`) to explicitly gate `PredictPayWithAnyTokenInfo`’s relay-configuration side effects. > > Refactors legacy `PredictBuyPreview` to the same `isKeypadOpen` naming and updates/adds tests to assert the new initialization, visibility behavior, and relay deferral propagation. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 98d3d4dd1fb15c581bbb92472bf951513f646b60. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PredictKeypad/PredictKeypad.test.tsx | 44 +++--- .../PredictKeypad/PredictKeypad.tsx | 28 ++-- .../PredictBuyPreview.test.tsx | 4 +- .../PredictBuyPreview/PredictBuyPreview.tsx | 10 +- .../PredictBuyWithAnyToken.test.tsx | 134 ++++++++++++++--- .../PredictBuyWithAnyToken.tsx | 138 +++++++++--------- .../PredictBuyAmountSection.test.tsx | 38 ++--- .../PredictBuyAmountSection.tsx | 6 +- .../PredictBuyBottomContent.test.tsx | 43 +----- .../PredictBuyBottomContent.tsx | 7 - .../PredictPayWithAnyTokenInfo.test.tsx | 90 ++++++------ .../PredictPayWithAnyTokenInfo.tsx | 20 ++- .../hooks/usePredictBuyInputState.test.ts | 28 +++- .../hooks/usePredictBuyInputState.ts | 22 ++- 14 files changed, 349 insertions(+), 263 deletions(-) diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx index e16c12dff40b..8ee4c9b1af02 100644 --- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx +++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx @@ -18,12 +18,12 @@ describe('PredictKeypad', () => { mockOnChange = null; }); const defaultProps = { - isInputFocused: true, + isKeypadOpen: true, currentValue: 1, currentValueUSDString: '1.00', setCurrentValue: jest.fn(), setCurrentValueUSDString: jest.fn(), - setIsInputFocused: jest.fn(), + setIsKeypadOpen: jest.fn(), }; beforeEach(() => { @@ -35,32 +35,26 @@ describe('PredictKeypad', () => { }); describe('Rendering', () => { - it('renders keypad when input is focused', () => { - // Arrange - const props = { ...defaultProps, isInputFocused: true }; + it('renders keypad when keypad is open', () => { + const props = { ...defaultProps, isKeypadOpen: true }; - // Act const { getByText } = render(); - // Assert - expect(getByText('$20')).toBeTruthy(); - expect(getByText('$50')).toBeTruthy(); - expect(getByText('$100')).toBeTruthy(); - expect(getByText('Done')).toBeTruthy(); + expect(getByText('$20')).toBeOnTheScreen(); + expect(getByText('$50')).toBeOnTheScreen(); + expect(getByText('$100')).toBeOnTheScreen(); + expect(getByText('Done')).toBeOnTheScreen(); }); - it('does not render keypad when input is not focused', () => { - // Arrange - const props = { ...defaultProps, isInputFocused: false }; + it('does not render keypad when keypad is closed', () => { + const props = { ...defaultProps, isKeypadOpen: false }; - // Act const { queryByText } = render(); - // Assert - expect(queryByText('$20')).toBeNull(); - expect(queryByText('$50')).toBeNull(); - expect(queryByText('$100')).toBeNull(); - expect(queryByText('Done')).toBeNull(); + expect(queryByText('$20')).not.toBeOnTheScreen(); + expect(queryByText('$50')).not.toBeOnTheScreen(); + expect(queryByText('$100')).not.toBeOnTheScreen(); + expect(queryByText('Done')).not.toBeOnTheScreen(); }); }); @@ -117,7 +111,7 @@ describe('PredictKeypad', () => { fireEvent.press(getByText('Done')); // Assert - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); it('exposes handleAmountPress handler through ref', () => { @@ -130,7 +124,7 @@ describe('PredictKeypad', () => { ref.current?.handleAmountPress(); // Assert - expect(props.setIsInputFocused).toHaveBeenCalledWith(true); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(true); }); it('exposes handleKeypadAmountPress handler through ref', () => { @@ -157,7 +151,7 @@ describe('PredictKeypad', () => { ref.current?.handleDonePress(); // Assert - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); }); @@ -184,7 +178,7 @@ describe('PredictKeypad', () => { expect(props.setCurrentValueUSDString).toHaveBeenCalledWith('25'); expect(props.setCurrentValue).toHaveBeenCalledWith(25); - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); it('handles empty string after removing decimal point', () => { @@ -212,7 +206,7 @@ describe('PredictKeypad', () => { ref.current?.handleDonePress(); expect(props.setCurrentValueUSDString).not.toHaveBeenCalled(); - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); }); diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx index f86e9b450e71..be44445c5e6b 100644 --- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx +++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx @@ -8,12 +8,12 @@ import Button, { import Keypad from '../../../../Base/Keypad'; interface PredictKeypadProps { - isInputFocused: boolean; + isKeypadOpen: boolean; currentValue: number; currentValueUSDString: string; setCurrentValue: (value: number) => void; setCurrentValueUSDString: (value: string) => void; - setIsInputFocused: (focused: boolean) => void; + setIsKeypadOpen: (open: boolean) => void; hideHeader?: boolean; } @@ -26,12 +26,12 @@ export interface PredictKeypadHandles { const PredictKeypad = forwardRef( ( { - isInputFocused, + isKeypadOpen, currentValue, currentValueUSDString, setCurrentValue, setCurrentValueUSDString, - setIsInputFocused, + setIsKeypadOpen, hideHeader = false, }, ref, @@ -39,8 +39,8 @@ const PredictKeypad = forwardRef( const tw = useTailwind(); const handleAmountPress = useCallback(() => { - setIsInputFocused(true); - }, [setIsInputFocused]); + setIsKeypadOpen(true); + }, [setIsKeypadOpen]); const handleKeypadAmountPress = useCallback( (amount: number) => { @@ -58,9 +58,9 @@ const PredictKeypad = forwardRef( setCurrentValueUSDString(cleanedValue); setCurrentValue(parseFloat(cleanedValue) || 0); } - setIsInputFocused(false); + setIsKeypadOpen(false); }, [ - setIsInputFocused, + setIsKeypadOpen, currentValueUSDString, setCurrentValueUSDString, setCurrentValue, @@ -93,9 +93,9 @@ const PredictKeypad = forwardRef( adjustedValue = value.replace('.', ''); } - // Set focus flag immediately - if (!isInputFocused) { - setIsInputFocused(true); + // Open the keypad immediately on any keystroke + if (!isKeypadOpen) { + setIsKeypadOpen(true); } // Enforce 9-digit limit (ignoring non-digits). Block the change if exceeded. @@ -129,14 +129,14 @@ const PredictKeypad = forwardRef( }, [ currentValue, - isInputFocused, + isKeypadOpen, setCurrentValue, setCurrentValueUSDString, - setIsInputFocused, + setIsKeypadOpen, ], ); - if (!isInputFocused) return null; + if (!isKeypadOpen) return null; return ( diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx index 7b3d98cf7d84..93b0821800fd 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx @@ -2207,7 +2207,7 @@ describe('PredictBuyPreview', () => { }); describe('renderBottomContent visibility', () => { - it('returns null when isInputFocused is true', () => { + it('returns null when keypad is open', () => { mockBalance = 1000; mockBalanceLoading = false; @@ -2219,7 +2219,7 @@ describe('PredictBuyPreview', () => { ).not.toBeOnTheScreen(); }); - it('renders bottom content when isInputFocused is false', () => { + it('renders bottom content when keypad is closed', () => { mockBalance = 1000; mockBalanceLoading = false; diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index 0f2c8d9ff916..f5493f8fc494 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -197,7 +197,7 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { const [currentValue, setCurrentValue] = useState(0); const [currentValueUSDString, setCurrentValueUSDString] = useState(''); - const [isInputFocused, setIsInputFocused] = useState(true); + const [isKeypadOpen, setIsKeypadOpen] = useState(true); const [isUserInputChange, setIsUserInputChange] = useState(false); const [isFeeBreakdownVisible, setIsFeeBreakdownVisible] = useState(false); const previousValueRef = useRef(0); @@ -487,7 +487,7 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { keypadRef.current?.handleAmountPress()} - isActive={isInputFocused} + isActive={isKeypadOpen} hasError={isInsufficientBalance} /> @@ -605,7 +605,7 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { }; const renderBottomContent = () => { - if (isInputFocused) { + if (isKeypadOpen) { return null; } @@ -669,12 +669,12 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { {renderMinimumBetWarning()} {renderBottomContent()} {isFeeBreakdownVisible && ( diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx index 77fc37099255..bfa38645c8ff 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx @@ -14,7 +14,7 @@ const mockResetOrderNotFilled = jest.fn(); const mockClearBuyErrorBanner = jest.fn(); const mockSetCurrentValue = jest.fn(); const mockSetCurrentValueUSDString = jest.fn(); -const mockSetIsInputFocused = jest.fn(); +const mockSetIsKeypadOpen = jest.fn(); const mockSetIsUserInputChange = jest.fn(); const mockSetIsConfirming = jest.fn(); const mockHandleRetryWithBestPrice = jest.fn(); @@ -133,19 +133,22 @@ jest.mock('./hooks/usePredictBuyAvailableBalance', () => ({ }), })); +const mockUsePredictBuyInputState = jest.fn((..._args: unknown[]) => ({ + currentValue: 20, + setCurrentValue: mockSetCurrentValue, + currentValueUSDString: '$20.00', + setCurrentValueUSDString: mockSetCurrentValueUSDString, + isKeypadOpen: false, + setIsKeypadOpen: mockSetIsKeypadOpen, + isUserInputChange: true, + setIsUserInputChange: mockSetIsUserInputChange, + isConfirming: false, + setIsConfirming: mockSetIsConfirming, +})); + jest.mock('./hooks/usePredictBuyInputState', () => ({ - usePredictBuyInputState: () => ({ - currentValue: 20, - setCurrentValue: mockSetCurrentValue, - currentValueUSDString: '$20.00', - setCurrentValueUSDString: mockSetCurrentValueUSDString, - isInputFocused: false, - setIsInputFocused: mockSetIsInputFocused, - isUserInputChange: true, - setIsUserInputChange: mockSetIsUserInputChange, - isConfirming: false, - setIsConfirming: mockSetIsConfirming, - }), + usePredictBuyInputState: (...args: unknown[]) => + mockUsePredictBuyInputState(...args), })); jest.mock('./hooks/usePredictBuyInfo', () => ({ @@ -317,15 +320,18 @@ jest.mock('../../components/PredictOrderRetrySheet', () => { )); }); +const mockPredictPayWithAnyTokenInfo = jest.fn(); + jest.mock('./components/PredictPayWithAnyTokenInfo', () => { const { Text } = jest.requireActual('react-native'); - return function MockPredictPayWithAnyTokenInfo({ - currentValue, - }: { + return function MockPredictPayWithAnyTokenInfo(props: { currentValue: number; - isInputFocused: boolean; + shouldDeferRelaySetup: boolean; }) { - return {currentValue}; + mockPredictPayWithAnyTokenInfo(props); + return ( + {props.currentValue} + ); }; }); @@ -544,6 +550,14 @@ describe('PredictBuyWithAnyToken', () => { ).not.toBeOnTheScreen(); }); + it('initialises usePredictBuyInputState with initialKeypadOpen=false', () => { + renderWithProvider(); + + expect(mockUsePredictBuyInputState).toHaveBeenCalledWith({ + initialKeypadOpen: false, + }); + }); + it('renders PredictQuickAmounts inside bottom content', () => { renderWithProvider(); @@ -564,12 +578,12 @@ describe('PredictBuyWithAnyToken', () => { expect(screen.getByTestId('predict-keypad')).toBeOnTheScreen(); }); - it('sets isInputFocused to false when quick amount is tapped', () => { + it('closes the keypad when a quick amount is tapped', () => { renderWithProvider(); fireEvent.press(screen.getByTestId('quick-amount-20')); - expect(mockSetIsInputFocused).toHaveBeenCalledWith(false); + expect(mockSetIsKeypadOpen).toHaveBeenCalledWith(false); expect(mockSetCurrentValue).toHaveBeenCalledWith(20); expect(mockSetCurrentValueUSDString).toHaveBeenCalledWith('20'); }); @@ -640,6 +654,14 @@ describe('PredictBuyWithAnyToken', () => { }); describe('non-sheet mode', () => { + it('initialises usePredictBuyInputState with initialKeypadOpen=true', () => { + renderWithProvider(); + + expect(mockUsePredictBuyInputState).toHaveBeenCalledWith({ + initialKeypadOpen: true, + }); + }); + it('does NOT render the banner even if buyErrorBanner is set', () => { mockBuyErrorBanner = { variant: 'order_failed', @@ -670,6 +692,78 @@ describe('PredictBuyWithAnyToken', () => { }); }); + describe('shouldDeferRelaySetup propagation', () => { + const sheetProps = { + mode: 'sheet' as const, + market: { id: 'market-1' }, + outcome: { id: 'outcome-1' }, + outcomeToken: { id: 'token-1', title: 'Yes', price: 0.62 }, + entryPoint: 'market_details', + onClose: jest.fn(), + } as unknown as PredictBuyPreviewProps; + + const mockHookReturnWithKeypadOpen = (isKeypadOpen: boolean) => ({ + currentValue: 20, + setCurrentValue: mockSetCurrentValue, + currentValueUSDString: '$20.00', + setCurrentValueUSDString: mockSetCurrentValueUSDString, + isKeypadOpen, + setIsKeypadOpen: mockSetIsKeypadOpen, + isUserInputChange: true, + setIsUserInputChange: mockSetIsUserInputChange, + isConfirming: false, + setIsConfirming: mockSetIsConfirming, + }); + + it('passes shouldDeferRelaySetup=false in sheet mode when keypad is closed', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(false), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: false }), + ); + }); + + it('passes shouldDeferRelaySetup=false in sheet mode even when keypad is open', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(true), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: false }), + ); + }); + + it('passes shouldDeferRelaySetup=false in non-sheet mode when keypad is closed', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(false), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: false }), + ); + }); + + it('passes shouldDeferRelaySetup=true in non-sheet mode when keypad is open', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(true), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: true }), + ); + }); + }); + describe('CTA button modes', () => { const sheetProps = { mode: 'sheet' as const, diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index 0ef11d2a8109..d3022883b773 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -165,21 +165,21 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { setCurrentValue, currentValueUSDString, setCurrentValueUSDString, - isInputFocused, - setIsInputFocused, + isKeypadOpen, + setIsKeypadOpen, isUserInputChange, setIsUserInputChange, isConfirming, setIsConfirming, - } = usePredictBuyInputState(); + } = usePredictBuyInputState({ initialKeypadOpen: !isSheetMode }); const handleQuickAmount = useCallback( (amount: number) => { setCurrentValue(amount); setCurrentValueUSDString(amount.toString()); - setIsInputFocused(false); + setIsKeypadOpen(false); }, - [setCurrentValue, setCurrentValueUSDString, setIsInputFocused], + [setCurrentValue, setCurrentValueUSDString, setIsKeypadOpen], ); const handleFeesInfoPress = useCallback(() => { @@ -334,14 +334,14 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { previousValueRef.current = currentValue; }, [currentValue, isUserInputChange, clearBuyErrorBanner]); - // When the banner appears in sheet mode, blur the amount input so the keypad - // collapses and the Retry CTA + banner are immediately visible without the - // user having to dismiss the keyboard. + // When the banner appears in sheet mode, close the keypad so the Retry CTA + // + banner are immediately visible without the user having to dismiss the + // keyboard. useEffect(() => { - if (isSheetMode && isBannerActive && isInputFocused) { - setIsInputFocused(false); + if (isSheetMode && isBannerActive && isKeypadOpen) { + setIsKeypadOpen(false); } - }, [isSheetMode, isBannerActive, isInputFocused, setIsInputFocused]); + }, [isSheetMode, isBannerActive, isKeypadOpen, setIsKeypadOpen]); const handleBuyButtonPress = useCallback(() => { if (isPaymentSelectorNavigationLocked) { @@ -433,7 +433,7 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { { { {!isSheetMode && ( )} - - {isSheetMode && ( - - )} - {payWithAnyTokenEnabled && isSheetMode && ( - + {isSheetMode && ( + + )} + {payWithAnyTokenEnabled && isSheetMode && ( + + )} + {/* Always enabled when rendered: in legacy mode the parent only + mounts PredictBuyBottomContent while the keypad is closed; in + sheet mode the fee summary is always actionable. */} + - )} - - {isSheetMode && buyErrorBanner && ( - + )} + - )} - - + + )} {isSheetMode && ( )} @@ -582,7 +584,7 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx index b371304903fe..fdadcfe21e02 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx @@ -71,7 +71,7 @@ describe('PredictBuyAmountSection', () => { { { { { { { { { { { { { { { { { { { ; - isInputFocused: boolean; + isKeypadOpen: boolean; isBalanceLoading: boolean; isBalancePulsing: boolean; availableBalanceDisplay: string; @@ -32,7 +32,7 @@ interface PredictBuyAmountSectionProps { const PredictBuyAmountSection = ({ currentValueUSDString, keypadRef, - isInputFocused, + isKeypadOpen, isBalanceLoading, isBalancePulsing, availableBalanceDisplay, @@ -75,7 +75,7 @@ const PredictBuyAmountSection = ({ onPress={() => !isPlacingOrder && keypadRef.current?.handleAmountPress() } - isActive={isInputFocused && !isPlacingOrder} + isActive={isKeypadOpen && !isPlacingOrder} hasError={false} /> diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx index ca84d50fcf92..46266e4e0f14 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx @@ -34,25 +34,10 @@ describe('PredictBuyBottomContent', () => { jest.clearAllMocks(); }); - describe('when isInputFocused is true', () => { - it('returns null and does not render anything', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.queryByText(/Disclaimer text/)).not.toBeOnTheScreen(); - expect(screen.queryByTestId('children-content')).not.toBeOnTheScreen(); - }); - }); - - describe('when isInputFocused is false', () => { + describe('rendering', () => { it('renders children content', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(screen.getByTestId('children-content')).toBeOnTheScreen(); @@ -60,9 +45,7 @@ describe('PredictBuyBottomContent', () => { it('renders disclaimer text', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(screen.getByText(/Disclaimer text/)).toBeOnTheScreen(); @@ -70,9 +53,7 @@ describe('PredictBuyBottomContent', () => { it('renders learn more link', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(screen.getByText(/Learn more/)).toBeOnTheScreen(); @@ -80,9 +61,7 @@ describe('PredictBuyBottomContent', () => { it('opens Polymarket TOS URL when learn more is pressed', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); const learnMoreLink = screen.getByText(/Learn more/); @@ -108,9 +87,7 @@ describe('PredictBuyBottomContent', () => { ); renderWithProvider( - - {multipleChildren} - , + {multipleChildren}, ); expect(screen.getByTestId('child-1')).toBeOnTheScreen(); @@ -121,9 +98,7 @@ describe('PredictBuyBottomContent', () => { describe('Linking behavior', () => { it('calls Linking.openURL with correct URL', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); const learnMoreLink = screen.getByText(/Learn more/); @@ -137,9 +112,7 @@ describe('PredictBuyBottomContent', () => { it('opens URL only when learn more is pressed', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(Linking.openURL).not.toHaveBeenCalled(); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx index 316da14465ca..e507c9de80a7 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx @@ -12,23 +12,16 @@ import { Linking } from 'react-native'; import { strings } from '../../../../../../../../locales/i18n'; interface PredictBuyBottomContentProps { - isInputFocused: boolean; - hideBorder?: boolean; children: React.ReactNode; } const PredictBuyBottomContent = ({ - isInputFocused, hideBorder = false, children, }: PredictBuyBottomContentProps) => { const tw = useTailwind(); - if (isInputFocused) { - return null; - } - return ( { , ); @@ -149,7 +149,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -163,7 +163,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -177,7 +177,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -204,7 +204,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -229,7 +229,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -254,7 +254,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -281,7 +281,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -299,7 +299,7 @@ describe('PredictPayWithAnyTokenInfo', () => { preview={createMockPreview({ maxAmountSpent: 99.99, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -323,7 +323,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -333,28 +333,28 @@ describe('PredictPayWithAnyTokenInfo', () => { }); describe('deposit amount gating', () => { - it('does not commit deposit amount while input is focused', () => { + it('does not commit deposit amount while relay setup is deferred', () => { mockActiveTransactionMeta = { id: 'tx-1' }; render( , ); expect(mockUpdatePendingAmount).not.toHaveBeenCalled(); }); - it('commits deposit amount when input loses focus', () => { + it('commits deposit amount when relay setup deferral is released', () => { mockActiveTransactionMeta = { id: 'tx-1' }; const { rerender } = render( , ); @@ -364,7 +364,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -378,7 +378,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -391,7 +391,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -399,7 +399,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -414,7 +414,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -429,7 +429,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -444,7 +444,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -455,7 +455,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -465,7 +465,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -482,7 +482,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -497,7 +497,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -512,7 +512,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -527,7 +527,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -551,7 +551,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -569,7 +569,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -594,7 +594,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -610,7 +610,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -626,7 +626,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -642,7 +642,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -658,7 +658,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -674,7 +674,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -696,7 +696,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -711,7 +711,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -729,7 +729,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -754,7 +754,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -779,7 +779,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -798,7 +798,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -813,7 +813,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -830,7 +830,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -847,7 +847,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -865,7 +865,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx index 3a7013abb42c..a43c4af2dbb5 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx @@ -15,13 +15,21 @@ import { getPredictBuyAllInCost } from '../../../../utils/orders'; interface PredictPayWithAnyTokenInfoProps { currentValue: number; preview?: OrderPreview | null; - isInputFocused: boolean; + /** + * When true, defers the mm_pay relay-config side effects + * (`updatePendingAmount` / `setPayToken`). The legacy full-screen flow + * sets this while the keypad is open and only releases it on Done so the + * relay isn't reconfigured on every keystroke. The bottom-sheet flow + * keeps it false because there is no Done affordance and the user can + * tap Confirm while the keypad is still open. + */ + shouldDeferRelaySetup: boolean; } const PredictPayWithAnyTokenInfo = ({ currentValue, preview, - isInputFocused, + shouldDeferRelaySetup, }: PredictPayWithAnyTokenInfoProps) => { const transactionMeta = useTransactionMetadataRequest(); @@ -33,7 +41,7 @@ const PredictPayWithAnyTokenInfo = ({ ); }; @@ -41,7 +49,7 @@ const PredictPayWithAnyTokenInfo = ({ function PredictPayWithAnyTokenInfoInner({ currentValue, preview, - isInputFocused, + shouldDeferRelaySetup, }: PredictPayWithAnyTokenInfoProps) { const [depositAmount, setDepositAmount] = useState(''); @@ -66,8 +74,8 @@ function PredictPayWithAnyTokenInfoInner({ !isPredictBalanceSelected && !!fees && currentValue >= MINIMUM_BET && - !isInputFocused, - [isPredictBalanceSelected, fees, currentValue, isInputFocused], + !shouldDeferRelaySetup, + [isPredictBalanceSelected, fees, currentValue, shouldDeferRelaySetup], ); const computedDepositAmount = useMemo(() => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts index 43c1a909ecc6..507043cac8e4 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts @@ -94,21 +94,37 @@ describe('usePredictBuyInputState', () => { }); }); - describe('isInputFocused', () => { - it('initializes to true', () => { + describe('isKeypadOpen', () => { + it('initializes to true by default', () => { const { result } = renderHook(() => usePredictBuyInputState()); - expect(result.current.isInputFocused).toBe(true); + expect(result.current.isKeypadOpen).toBe(true); }); - it('updates isInputFocused via setIsInputFocused', () => { + it('honours initialKeypadOpen=false from caller options', () => { + const { result } = renderHook(() => + usePredictBuyInputState({ initialKeypadOpen: false }), + ); + + expect(result.current.isKeypadOpen).toBe(false); + }); + + it('honours initialKeypadOpen=true from caller options', () => { + const { result } = renderHook(() => + usePredictBuyInputState({ initialKeypadOpen: true }), + ); + + expect(result.current.isKeypadOpen).toBe(true); + }); + + it('updates isKeypadOpen via setIsKeypadOpen', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { - result.current.setIsInputFocused(false); + result.current.setIsKeypadOpen(false); }); - expect(result.current.isInputFocused).toBe(false); + expect(result.current.isKeypadOpen).toBe(false); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts index a65dd0058226..214b171a7bff 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts @@ -1,7 +1,13 @@ import { SetStateAction, useCallback, useRef, useState } from 'react'; import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -export const usePredictBuyInputState = () => { +interface UsePredictBuyInputStateOptions { + initialKeypadOpen?: boolean; +} + +export const usePredictBuyInputState = ({ + initialKeypadOpen = true, +}: UsePredictBuyInputStateOptions = {}) => { const { clearOrderError } = usePredictActiveOrder(); const [currentValue, setCurrentValueState] = useState(0); @@ -13,14 +19,14 @@ export const usePredictBuyInputState = () => { currentValue ? currentValue.toString() : '', ); - const [isInputFocused, setIsInputFocusedState] = useState(true); + const [isKeypadOpen, setIsKeypadOpenState] = useState(initialKeypadOpen); const shouldSyncCurrentValueRef = useRef(false); const shouldClearAmountErrorRef = useRef(false); - const shouldSyncInputFocusRef = useRef(false); + const shouldSyncKeypadOpenRef = useRef(false); - const setIsInputFocused = useCallback((nextIsInputFocused: boolean) => { - shouldSyncInputFocusRef.current = true; - setIsInputFocusedState(nextIsInputFocused); + const setIsKeypadOpen = useCallback((nextIsKeypadOpen: boolean) => { + shouldSyncKeypadOpenRef.current = true; + setIsKeypadOpenState(nextIsKeypadOpen); }, []); const [isUserInputChange, setIsUserInputChange] = useState(false); @@ -56,8 +62,8 @@ export const usePredictBuyInputState = () => { setCurrentValue, currentValueUSDString, setCurrentValueUSDString, - isInputFocused, - setIsInputFocused, + isKeypadOpen, + setIsKeypadOpen, isUserInputChange, setIsUserInputChange, isConfirming, From 67b629f4def7fee6e037d9eafae79719da17f885 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Thu, 21 May 2026 02:54:36 -0300 Subject: [PATCH 02/14] feat: implement Pay With Perps section (#30124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the **Perps** section to the redesigned _Pay with_ bottom sheet. On `perpsDepositAndOrder` confirmations, a **Perps account** row now renders above the Crypto section with an inline **Add** button that routes to the standalone Perps deposit confirmation. Visibility mirrors the existing legacy-modal rule (`perpsDepositAndOrder` only — `perpsDeposit` is filtered out to avoid a recursive "deposit to deposit" loop). The Perps row is an **Add CTA, not a selection row**. Payment-source state for perps flows lives in a dual-state machinery (`PerpsController.selectedPaymentToken` for the UX choice + `TransactionPayController.payToken` for the real on-chain funding source) that the legacy modal already orchestrates — there is no on-row "selected token" to display. All changes are dark-launched behind `MM_DEV_PAY_WITH_BOTTOM_SHEET` and have no effect in production. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1362 ## **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** https://github.com/user-attachments/assets/a6e692da-a2b1-4c92-8555-c4abcea2b2fa ### **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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 confirmations payment-selection and navigation flows (including multi-pop dismissal logic) for `perpsDepositAndOrder`, which could cause incorrect pay token selection or route-dismissal issues despite being dev-flag gated. > > **Overview** > Adds a new **Perps** section to the redesigned `Pay with` bottom sheet for `perpsDepositAndOrder`, showing a `Perps account` row with inline `Add` that launches the Perps deposit confirmation and a tap that selects Perps balance. > > Updates Perps pay-token selection to support lightweight `{address, chainId}` inputs, adjusts the Crypto section so checkmarks/selected rows don’t conflict when Perps balance or fiat payment *implicitly owns* selection, and wires the Perps order screen to open the new bottom sheet when enabled. > > Improves picker dismissal/navigation robustness: `PayWithModal` can now atomically pop multiple routes (`dismissOnSelectCount`) to avoid Android double-pop crashes, and `useDismissOnPaymentChange` now guards against dismissing when the route isn’t focused. Adds/updates unit tests and English strings for the new Perps labels. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 94d8917a271d1a00d4aeece66f29db73bae5ab28. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Goktug Poyraz --- .../Views/PerpsOrderView/PerpsPayRow.tsx | 5 + .../hooks/usePerpsBalanceTokenFilter.test.ts | 67 +++++ .../Perps/hooks/usePerpsBalanceTokenFilter.ts | 5 + .../UI/Perps/hooks/usePerpsPaymentToken.ts | 9 +- app/components/UI/Perps/routes/index.tsx | 9 + .../modals/pay-with-modal/pay-with-modal.tsx | 46 +++- .../confirmations/hooks/pay/sections/index.ts | 1 + .../sections/usePayWithCryptoSection.test.tsx | 100 +++++++ .../pay/sections/usePayWithCryptoSection.ts | 50 +++- .../sections/usePayWithPerpsSection.test.tsx | 243 ++++++++++++++++++ .../pay/sections/usePayWithPerpsSection.tsx | 111 ++++++++ .../pay/useDismissOnPaymentChange.test.ts | 39 +++ .../hooks/pay/useDismissOnPaymentChange.ts | 5 + .../hooks/pay/usePayWithSections.test.ts | 50 ++++ .../hooks/pay/usePayWithSections.ts | 13 +- locales/languages/en.json | 3 + 16 files changed, 731 insertions(+), 25 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx create mode 100644 app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx index ca9dde719c25..d9b9c8146d18 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx @@ -46,6 +46,7 @@ import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { Hex } from '@metamask/utils'; import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; +import { isPayWithBottomSheetEnabled } from '../../../../Views/confirmations/utils/transaction-pay'; const tokenIconStyles = StyleSheet.create({ iconSmall: { @@ -122,6 +123,10 @@ export const PerpsPayRow = ({ [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR, }); + if (isPayWithBottomSheetEnabled()) { + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET); + return; + } navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); }, [canEdit, navigation, setConfirmationMetric, track]); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index 33613b35b3ef..f856a682d118 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -16,6 +16,7 @@ import { useNavigation } from '@react-navigation/native'; import useApprovalRequest from '../../../Views/confirmations/hooks/useApprovalRequest'; import { selectPerpsAccountState } from '../selectors/perpsController'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), @@ -42,6 +43,10 @@ jest.mock('images/perps-pay-token-icon.png', () => ({ uri: 'perps-pay-token-icon-uri', })); +jest.mock('../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual('../../../Views/confirmations/utils/transaction-pay'), + isPayWithBottomSheetEnabled: jest.fn(() => false), +})); jest.mock('../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', () => jest.fn( () => (value: { toNumber: () => number }) => @@ -70,6 +75,10 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< const mockUseApprovalRequest = useApprovalRequest as jest.MockedFunction< typeof useApprovalRequest >; +const mockIsPayWithBottomSheetEnabled = + isPayWithBottomSheetEnabled as jest.MockedFunction< + typeof isPayWithBottomSheetEnabled + >; describe('usePerpsBalanceTokenFilter', () => { const chainId = '0xa4b1'; @@ -80,6 +89,7 @@ describe('usePerpsBalanceTokenFilter', () => { beforeEach(() => { jest.clearAllMocks(); + mockIsPayWithBottomSheetEnabled.mockReturnValue(false); mockUseTransactionMetadataRequest.mockReturnValue(undefined); mockUseIsPerpsBalanceSelected.mockReturnValue(false); mockUseSelector.mockImplementation( @@ -401,5 +411,62 @@ describe('usePerpsBalanceTokenFilter', () => { expect(mockOnPerpsPaymentTokenChange).toHaveBeenCalledWith(null); } }); + + it('omits the synthetic perps balance row when the new Pay With bottom sheet is enabled', () => { + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xabc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(false); + expect((output[0] as AssetType).address).toBe('0xabc'); + }); + + it('still applies the allowlist filter when the new Pay With bottom sheet is enabled', () => { + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + const allowlistKey = `${chainId}.0xusdc`.toLowerCase(); + mockUseSelector.mockImplementation( + (selector: (state: unknown) => unknown) => { + if (selector === selectPerpsAccountState) + return { spendableBalance: '100.00' }; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) + return [allowlistKey]; + return []; + }, + ); + const inputTokens: AssetType[] = [ + { + address: '0xusdc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '500', + } as AssetType, + { + address: '0xother', + chainId, + symbol: 'OTHER', + name: 'Other', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(false); + expect((output[0] as AssetType).address).toBe('0xusdc'); + }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 86ee09a367bd..10605edf3413 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -12,6 +12,7 @@ import { HighlightedItem, type TokenListItem, } from '../../../Views/confirmations/types/token'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; import { selectPerpsAccountState } from '../selectors/perpsController'; @@ -108,6 +109,10 @@ export function usePerpsBalanceTokenFilter(): ( return mappedTokens; } + if (isPayWithBottomSheetEnabled()) { + return mappedTokens; + } + const highlightedAction: HighlightedItem = { position: 'outside_of_asset_list', icon: PERPS_BALANCE_ICON_URI, diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts index 6b5b62e1b32b..546d3043c315 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts @@ -5,15 +5,20 @@ import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/u import Engine from '../../../../core/Engine'; import { parsePayWithToken } from '../utils/parsePayWithToken'; +export type PerpsPaymentTokenInput = + | AssetType + | { address: string; chainId: string } + | null; + export interface UsePerpsPaymentTokenResult { - onPaymentTokenChange: (token: AssetType | null) => void; + onPaymentTokenChange: (token: PerpsPaymentTokenInput) => void; } export function usePerpsPaymentToken(): UsePerpsPaymentTokenResult { const { setPayToken } = useTransactionPayToken(); const onPaymentTokenChange = useCallback( - (token: AssetType | null) => { + (token: PerpsPaymentTokenInput) => { const parsed = token === null || token === undefined ? null : parsePayWithToken(token); diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 48869fb4d55a..9270428dd1c2 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -44,6 +44,7 @@ import { HIP3DebugView } from '../Debug'; import PerpsCrossMarginWarningBottomSheet from '../components/PerpsCrossMarginWarningBottomSheet'; import PerpsSelectProviderView from '../Views/PerpsSelectProviderView'; import { PayWithModal } from '../../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; +import { PayWithBottomSheet } from '../../../Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; /* eslint-disable-next-line */ import { NavigationContext } from '@react-navigation/core'; @@ -443,6 +444,14 @@ const PerpsScreenStack = () => { ...transparentModalScreenOptions, }} /> + {/* Order redirect screen - handles one-click trade from token details */} 1, PayWithModal owns navigation on close by dispatching + * `StackActions.pop(N)` atomically instead of relying on the legacy + * `BottomSheet`'s built-in `navigation.goBack()`. Set to 2 by the new Pay + * With bottom sheet's "Other assets" launcher so picking a token pops both + * this modal AND the bottom sheet underneath in a single navigator + * dispatch — avoids the Android view-hierarchy race that crashes with + * `IllegalStateException` on two adjacent pops. + */ + dismissOnSelectCount?: number; +} + export function PayWithModal() { + const navigation = useNavigation(); + const { dismissOnSelectCount = 1 } = useParams({}); const transactionMeta = useTransactionMetadataRequest(); const hideNetworkFilter = hasTransactionType( transactionMeta, @@ -51,13 +68,6 @@ export function PayWithModal() { const requiredTokens = useTransactionPayRequiredTokens(); const fiatPayment = useTransactionPayFiatPayment(); const fiatHighlightedActions = useFiatPaymentHighlightedActions(); - /** - * Suppress fiat highlighted items in the modal when the new Pay With - * bottom sheet is enabled. In that mode the Bank/Card section is the single - * source of truth for fiat payment methods, while this modal continues to - * serve as the crypto/tokens picker via the "Other assets" entry point. - * Remove this gate at CONF-1313 GA along with the env util. - */ const effectiveFiatHighlightedActions = useMemo( () => (isPayWithBottomSheetEnabled() ? [] : fiatHighlightedActions), [fiatHighlightedActions], @@ -89,6 +99,14 @@ export function PayWithModal() { bottomSheetRef.current?.onCloseBottomSheet(onClosed); }, []); + const handleClose = useCallback(() => { + if (dismissOnSelectCount > 1) { + close(() => navigation.goBack()); + } else { + close(); + } + }, [close, dismissOnSelectCount, navigation]); + const wrapHighlightedItemCallbacks = useCallback( (items: TokenListItem[]): TokenListItem[] => items.map((item) => { @@ -114,6 +132,10 @@ export function PayWithModal() { const handleTokenSelect = useCallback( (token: AssetType) => { const onClosed = async () => { + if (dismissOnSelectCount > 1) { + navigation.dispatch(StackActions.pop(dismissOnSelectCount)); + } + if ( hasTransactionType(transactionMeta, [TransactionType.musdConversion]) ) { @@ -171,8 +193,10 @@ export function PayWithModal() { }, [ close, + dismissOnSelectCount, isPredictContext, isWithdraw, + navigation, onMusdPaymentTokenChange, onPerpsPaymentTokenChange, onPredictPaymentTokenChange, @@ -245,13 +269,9 @@ export function PayWithModal() { isFullscreen ref={bottomSheetRef} keyboardAvoidingViewEnabled={false} + shouldNavigateBack={dismissOnSelectCount <= 1} > - close()} - /> + ({ jest.mock('../../../../../../util/navigation/navUtils'); jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); jest.mock('../../transactions/useTransactionMetadataRequest'); +jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); jest.mock('../useLastUsedPaymentMethod'); jest.mock('../usePayWithPreferredToken'); jest.mock('../usePayWithSelectedToken'); @@ -71,6 +75,8 @@ describe('usePayWithCryptoSection', () => { const usePayWithPreferredTokenMock = jest.mocked(usePayWithPreferredToken); const usePayWithSelectedTokenMock = jest.mocked(usePayWithSelectedToken); const useLastUsedPaymentMethodMock = jest.mocked(useLastUsedPaymentMethod); + const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); const useTransactionPayFiatPaymentMock = jest.mocked( useTransactionPayFiatPayment, ); @@ -79,6 +85,7 @@ describe('usePayWithCryptoSection', () => { const goBackMock = jest.fn(); const selectTokenMock = jest.fn(); const setPayTokenMock = jest.fn(); + const onPerpsPaymentTokenChangeMock = jest.fn(); const isLastUsedMock = jest.fn().mockReturnValue(false); beforeEach(() => { @@ -87,6 +94,7 @@ describe('usePayWithCryptoSection', () => { useNavigationMock.mockReturnValue({ navigate: navigateMock, goBack: goBackMock, + isFocused: jest.fn().mockReturnValue(true), } as never); useParamsMock.mockReturnValue({}); useTransactionMetadataRequestMock.mockReturnValue(undefined); @@ -111,6 +119,10 @@ describe('usePayWithCryptoSection', () => { lastUsedToken: undefined, isLastUsed: isLastUsedMock, }); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPerpsPaymentTokenChangeMock, + }); useTransactionPayFiatPaymentMock.mockReturnValue(undefined); useTransactionPayTokenMock.mockReturnValue({ payToken: TOKEN_MOCK, @@ -257,6 +269,42 @@ describe('usePayWithCryptoSection', () => { expect(result.current?.rows[1].icon).toEqual(expect.any(Object)); }); + it('does not mark the preferred token row as selected on perpsDepositAndOrder flows when Perps balance is the implicit default', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'crypto-preferred-token', + isSelected: false, + trailingElement: 'none', + }), + ); + }); + + it('still marks the preferred token row as selected on perpsDepositAndOrder flows when the user explicitly picked the preferred token via "Other assets"', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'crypto-preferred-token', + isSelected: true, + trailingElement: 'checkmark', + }), + ); + }); + it('does not select the preferred token row when another token is selected', () => { const distinctSelectedToken = { ...TOKEN_MOCK, @@ -399,6 +447,7 @@ describe('usePayWithCryptoSection', () => { address: TOKEN_MOCK.address, chainId: TOKEN_MOCK.chainId, }); + expect(onPerpsPaymentTokenChangeMock).not.toHaveBeenCalled(); expect(goBackMock).toHaveBeenCalledTimes(1); }); @@ -434,6 +483,56 @@ describe('usePayWithCryptoSection', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + it('routes the preferred-row tap through onPerpsPaymentTokenChange on perpsDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPerpsPaymentTokenChangeMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(setPayTokenMock).not.toHaveBeenCalled(); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('hides the user-selected token row when Perps balance is the implicit default on perpsDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + const distinctSelectedToken = { + ...TOKEN_MOCK, + address: SELECTED_TOKEN_MOCK.address, + symbol: SELECTED_TOKEN_MOCK.symbol, + }; + usePayWithPreferredTokenMock.mockReturnValue({ + hasTokens: true, + preferredToken: TOKEN_MOCK, + selectedToken: distinctSelectedToken, + }); + usePayWithSelectedTokenMock.mockReturnValue({ + isSelectedDistinctFromAutomatic: true, + selectedToken: SELECTED_TOKEN_MOCK, + selectToken: selectTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const selectedRow = result.current?.rows.find( + (row) => row.id === 'crypto-selected-token', + ); + + expect(selectedRow).toBeUndefined(); + }); + it('does not assign a tap handler to the user-selected token row', () => { const distinctSelectedToken = { ...TOKEN_MOCK, @@ -469,6 +568,7 @@ describe('usePayWithCryptoSection', () => { expect(navigateMock).toHaveBeenCalledWith( Routes.CONFIRMATION_PAY_WITH_MODAL, + { dismissOnSelectCount: 2 }, ); }); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts index 176ee227457c..598176271c00 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; import { BigNumber } from 'bignumber.js'; +import { TransactionType } from '@metamask/transaction-controller'; import { Icon, IconColor, @@ -16,6 +17,8 @@ import { PayWithRowConfig, PayWithSectionConfig, } from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { hasTransactionType } from '../../../utils/transaction'; import { isMatchingPayToken, resolvePreferredPayToken, @@ -26,6 +29,7 @@ import { usePayWithPreferredToken } from '../usePayWithPreferredToken'; import { usePayWithSelectedToken } from '../usePayWithSelectedToken'; import { useTransactionPayFiatPayment } from '../useTransactionPayData'; import { useTransactionPayToken } from '../useTransactionPayToken'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; interface PayWithCryptoSectionParams { @@ -63,24 +67,47 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { selectedToken: selectedTokenDisplay, } = usePayWithSelectedToken({ preferredToken: resolvedPreferredToken }); const { setPayToken } = useTransactionPayToken(); + const { onPaymentTokenChange: onPerpsPaymentTokenChange } = + usePerpsPaymentToken(); const { isLastUsed } = useLastUsedPaymentMethod(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + const isPerpsBalanceImplicitlySelected = + isPerpsDepositAndOrder && isPerpsBalanceSelected; const fiatPayment = useTransactionPayFiatPayment(); const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); + const isDedicatedSectionOwningSelection = + isPerpsBalanceImplicitlySelected || hasFiatPaymentSelected; const handleOtherAssetsPress = useCallback(() => { - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { + dismissOnSelectCount: 2, + }); }, [navigation]); const handlePreferredTokenPress = useCallback(() => { if (!preferredToken) { return; } - setPayToken({ + const target = { address: preferredToken.address, chainId: preferredToken.chainId, - }); + }; + if (isPerpsDepositAndOrder) { + onPerpsPaymentTokenChange(target); + } else { + setPayToken(target); + } navigation.goBack(); - }, [navigation, preferredToken, setPayToken]); + }, [ + isPerpsDepositAndOrder, + navigation, + onPerpsPaymentTokenChange, + preferredToken, + setPayToken, + ]); const preferredTokenBalance = useMemo( () => formatFiat(new BigNumber(preferredToken?.balanceUsd ?? '0')), @@ -100,8 +127,17 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { const rows: PayWithRowConfig[] = []; if (preferredToken) { + // When a dedicated section "owns" the selection (Perps balance is the + // implicit default in a perpsDepositAndOrder flow, OR a fiat payment + // method has been picked), the Crypto section's preferred-token row must + // not render a misleading checkmark, and the user-selected-token row is + // hidden below. When the user explicitly picks a crypto token via "Other + // assets" in a perps flow, `PerpsController` also stores it as + // `selectedPaymentToken`, and we honor that selection with a checkmark + // (handled by `isPerpsBalanceImplicitlySelected` being false in that + // case). const isPreferredTokenSelected = - !hasFiatPaymentSelected && + !isDedicatedSectionOwningSelection && isMatchingPayToken(selectedToken, preferredToken); rows.push({ @@ -126,7 +162,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { if ( isSelectedDistinctFromAutomatic && selectedTokenDisplay && - !hasFiatPaymentSelected + !isDedicatedSectionOwningSelection ) { rows.push({ id: 'crypto-selected-token', @@ -174,8 +210,8 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }, [ handleOtherAssetsPress, handlePreferredTokenPress, - hasFiatPaymentSelected, hasTokens, + isDedicatedSectionOwningSelection, isLastUsed, isSelectedDistinctFromAutomatic, preferredToken, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx new file mode 100644 index 000000000000..d72f2ae0e41f --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx @@ -0,0 +1,243 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useNavigation } from '@react-navigation/native'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../../../constants/navigation/Routes'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perpsController'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { usePayWithPerpsSection } from './usePayWithPerpsSection'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { balance?: string }) => { + const translations: Record = { + 'confirm.pay_with_bottom_sheet.perps': 'Perps', + 'confirm.pay_with_bottom_sheet.perps_account': 'Perps account', + 'confirm.pay_with_bottom_sheet.add': 'Add', + 'confirm.pay_with_bottom_sheet.available_balance': `${ + params?.balance ?? '' + } available`, + }; + return translations[key] ?? key; + }, +})); +jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); +jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsTrading'); +jest.mock('../../useApprovalRequest'); +jest.mock('../../transactions/useTransactionMetadataRequest'); + +describe('usePayWithPerpsSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useNavigationMock = jest.mocked(useNavigation); + const useFiatFormatterMock = jest.mocked(useFiatFormatter); + const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePerpsTradingMock = jest.mocked(usePerpsTrading); + const useApprovalRequestMock = jest.mocked(useApprovalRequest); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + + const navigateMock = jest.fn(); + const goBackMock = jest.fn(); + const onPaymentTokenChangeMock = jest.fn(); + const depositWithConfirmationMock = jest.fn(); + const onRejectMock = jest.fn(); + const formatFiatMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + formatFiatMock.mockImplementation( + (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + ); + + useNavigationMock.mockReturnValue({ + navigate: navigateMock, + goBack: goBackMock, + } as never); + + useFiatFormatterMock.mockReturnValue(formatFiatMock as never); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + + useSelectorMock.mockImplementation((selector) => { + if (selector === selectPerpsAccountState) { + return { spendableBalance: '500' }; + } + return undefined; + }); + + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + } as never); + + usePerpsTradingMock.mockReturnValue({ + depositWithConfirmation: depositWithConfirmationMock.mockResolvedValue({ + result: Promise.resolve('ok'), + }), + } as never); + + useApprovalRequestMock.mockReturnValue({ + onReject: onRejectMock, + } as never); + }); + + it('returns null when the transaction type is not perpsDepositAndOrder', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.perpsDeposit, + txParams: {}, + } as never); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toBeNull(); + }); + + it('returns null when there is no transaction metadata', () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toBeNull(); + }); + + it('returns the perps section config with a single perps account row when the transaction type is perpsDepositAndOrder', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toEqual( + expect.objectContaining({ + id: 'perps', + title: 'Perps', + testID: 'pay-with-section-perps', + }), + ); + expect(result.current?.rows).toHaveLength(1); + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'perps-balance', + title: 'Perps account', + subtitle: '$500.00 available', + isSelected: true, + testID: 'pay-with-perps-section-balance-row', + }), + ); + }); + + it('marks the row as selected when perps balance is the active payment method', () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: true, + trailingElement: expect.any(Object), + }), + ); + }); + + it('marks the row as not selected when a crypto token is chosen instead', () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: false, + trailingElement: expect.any(Object), + }), + ); + }); + + it('treats a missing spendable balance as zero', () => { + useSelectorMock.mockImplementation((selector) => { + if (selector === selectPerpsAccountState) { + return null; + } + return undefined; + }); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0].subtitle).toBe('$0.00 available'); + }); + + it('selects perps balance as payment token and dismisses the sheet when the row is pressed', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPaymentTokenChangeMock).toHaveBeenCalledWith(null); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('rejects approval, triggers deposit confirmation, and navigates with perps header when Add is pressed', async () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => Promise } } + | undefined; + + await act(async () => { + await trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(depositWithConfirmationMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + }); + + it('does not navigate when deposit confirmation rejects', async () => { + depositWithConfirmationMock.mockRejectedValueOnce(new Error('user-cancel')); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => Promise } } + | undefined; + + await act(async () => { + await trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(depositWithConfirmationMock).toHaveBeenCalledTimes(1); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('keeps the result reference stable across renders when nothing changes', () => { + const { result, rerender } = renderHook(() => usePayWithPerpsSection()); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx new file mode 100644 index 000000000000..27e59426ab01 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useMemo } from 'react'; +import { Image } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../locales/i18n'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perpsController'; +import { PERPS_BALANCE_ICON_URI } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { + PayWithRowConfig, + PayWithSectionConfig, +} from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { hasTransactionType } from '../../../utils/transaction'; + +export const PAY_WITH_PERPS_SECTION_TEST_ID = 'pay-with-section-perps'; +export const PAY_WITH_PERPS_BALANCE_ROW_TEST_ID = + 'pay-with-perps-section-balance-row'; + +export function usePayWithPerpsSection(): PayWithSectionConfig | null { + const navigation = useNavigation(); + const transactionMeta = useTransactionMetadataRequest(); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + const perpsAccount = useSelector(selectPerpsAccountState); + const { onPaymentTokenChange } = usePerpsPaymentToken(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const { depositWithConfirmation } = usePerpsTrading(); + const { onReject } = useApprovalRequest(); + + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + + const balance = useMemo( + () => formatFiat(new BigNumber(perpsAccount?.spendableBalance ?? '0')), + [formatFiat, perpsAccount?.spendableBalance], + ); + + const handleSelect = useCallback(() => { + onPaymentTokenChange(null); + navigation.goBack(); + }, [navigation, onPaymentTokenChange]); + + const handleAdd = useCallback(async () => { + onReject(); + try { + await depositWithConfirmation(); + navigation.navigate( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + } catch { + // Deposit flow handles errors (e.g. user rejection or missing network). + } + }, [depositWithConfirmation, navigation, onReject]); + + return useMemo(() => { + if (!isPerpsDepositAndOrder) { + return null; + } + + const row: PayWithRowConfig = { + id: 'perps-balance', + icon: React.createElement(Image, { + source: { uri: PERPS_BALANCE_ICON_URI }, + style: { width: 24, height: 24 }, + }), + title: strings('confirm.pay_with_bottom_sheet.perps_account'), + subtitle: strings('confirm.pay_with_bottom_sheet.available_balance', { + balance, + }), + isSelected: isPerpsBalanceSelected, + trailingElement: ( + + ), + onPress: handleSelect, + testID: PAY_WITH_PERPS_BALANCE_ROW_TEST_ID, + }; + + return { + id: 'perps', + title: strings('confirm.pay_with_bottom_sheet.perps'), + testID: PAY_WITH_PERPS_SECTION_TEST_ID, + rows: [row], + }; + }, [ + balance, + handleAdd, + handleSelect, + isPerpsBalanceSelected, + isPerpsDepositAndOrder, + ]); +} diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts index 82205a1406e2..8534c17efde0 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts @@ -41,15 +41,18 @@ describe('useDismissOnPaymentChange', () => { useTransactionPayFiatPayment, ); const goBackMock = jest.fn(); + const isFocusedMock = jest.fn().mockReturnValue(true); const setPayTokenMock: jest.MockedFn< ReturnType['setPayToken'] > = jest.fn(); beforeEach(() => { jest.resetAllMocks(); + isFocusedMock.mockReturnValue(true); useNavigationMock.mockReturnValue({ goBack: goBackMock, + isFocused: isFocusedMock, } as never); useTransactionPayTokenMock.mockReturnValue({ @@ -268,4 +271,40 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); }); + + describe('focus guard (defers dismissal when an overlapping route is on top)', () => { + it('does not call goBack when the screen is not focused even if the pay token changes', () => { + isFocusedMock.mockReturnValue(false); + + const { rerender } = renderHook(() => useDismissOnPaymentChange()); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); + + it('latches when defeating an unfocused change, so it does not re-fire after re-focus', () => { + isFocusedMock.mockReturnValue(false); + + const { rerender } = renderHook(() => useDismissOnPaymentChange()); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + isFocusedMock.mockReturnValue(true); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts index 474bfe81974d..09393fa2f4e1 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts @@ -46,6 +46,11 @@ export function useDismissOnPaymentChange(): void { return; } + if (!navigation.isFocused()) { + isDismissingRef.current = true; + return; + } + isDismissingRef.current = true; navigation.goBack(); }, [navigation, payToken, selectedPaymentMethodId]); diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts index eb95e992cd1c..e537d2c79b17 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -2,10 +2,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { PayWithSectionConfig } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; import { usePayWithFiatSection } from './sections/usePayWithFiatSection'; +import { usePayWithPerpsSection } from './sections/usePayWithPerpsSection'; import { usePayWithSections } from './usePayWithSections'; jest.mock('./sections/usePayWithCryptoSection'); jest.mock('./sections/usePayWithFiatSection'); +jest.mock('./sections/usePayWithPerpsSection'); const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { id: 'crypto', @@ -19,6 +21,18 @@ const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { ], }; +const PERPS_SECTION_MOCK: PayWithSectionConfig = { + id: 'perps', + title: 'Perps', + rows: [ + { + id: 'perps-balance', + icon: 'Perps', + title: 'Perps account', + }, + ], +}; + const BANK_CARD_SECTION_MOCK: PayWithSectionConfig = { id: 'bank-card', title: 'Bank and card', @@ -34,12 +48,14 @@ const BANK_CARD_SECTION_MOCK: PayWithSectionConfig = { describe('usePayWithSections', () => { const usePayWithCryptoSectionMock = jest.mocked(usePayWithCryptoSection); const usePayWithFiatSectionMock = jest.mocked(usePayWithFiatSection); + const usePayWithPerpsSectionMock = jest.mocked(usePayWithPerpsSection); beforeEach(() => { jest.resetAllMocks(); usePayWithCryptoSectionMock.mockReturnValue(null); usePayWithFiatSectionMock.mockReturnValue(null); + usePayWithPerpsSectionMock.mockReturnValue(null); }); it('returns empty sections array when no section is visible', () => { @@ -56,6 +72,14 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([CRYPTO_SECTION_MOCK]); }); + it('returns the visible perps section', () => { + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([PERPS_SECTION_MOCK]); + }); + it('returns the visible bank-card section when only bank-card is available', () => { usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -64,6 +88,18 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([BANK_CARD_SECTION_MOCK]); }); + it('returns perps section before crypto section when both are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + it('renders bank-card before crypto when both sections are visible', () => { usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -76,6 +112,20 @@ describe('usePayWithSections', () => { ]); }); + it('renders perps, bank-card, then crypto when all three sections are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + BANK_CARD_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + it('returns the same sections reference across renders', () => { const { result, rerender } = renderHook(() => usePayWithSections()); const first = result.current.sections; diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts index d8dac8eef974..8c83cf436484 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts @@ -1,20 +1,27 @@ import { useMemo } from 'react'; import { PayWithSectionConfig } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; -import { usePayWithCryptoSection, usePayWithFiatSection } from './sections'; +import { + usePayWithCryptoSection, + usePayWithFiatSection, + usePayWithPerpsSection, +} from './sections'; export interface UsePayWithSectionsResult { sections: PayWithSectionConfig[]; } export function usePayWithSections(): UsePayWithSectionsResult { + const perpsSection = usePayWithPerpsSection(); const bankCardSection = usePayWithFiatSection(); const cryptoSection = usePayWithCryptoSection(); return useMemo( () => ({ - sections: [bankCardSection, cryptoSection].filter(isPayWithSectionConfig), + sections: [perpsSection, bankCardSection, cryptoSection].filter( + isPayWithSectionConfig, + ), }), - [bankCardSection, cryptoSection], + [bankCardSection, cryptoSection, perpsSection], ); } diff --git a/locales/languages/en.json b/locales/languages/en.json index 748ebceb013d..6acfcb45e100 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7132,6 +7132,9 @@ "last_used": "Last used", "bank_and_card": "Bank and card", "crypto": "Crypto", + "perps": "Perps", + "perps_account": "Perps account", + "add": "Add", "available_balance": "{{balance}} available", "other_assets": "Other assets", "other_assets_description": "Select from your tokens" From 87f9bfd5d57809812da5c9dd99020d01311a72f1 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 21 May 2026 14:15:41 +0800 Subject: [PATCH 03/14] feat(perps): add perps slippage controls (#30125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add user-configurable max slippage **and** live estimated slippage to the Perps order screen for market orders, with a client-side block when the estimate exceeds the user's cap. Matches HyperLiquid's native UX (max slippage applies to market orders placed from the order form only). **What this PR ships** - New `Max slippage` row on the order form, visible on market orders only. Tapping opens a config bottom sheet with quick-pick presets (0.5%, 2%, 3%) and a keypad-driven custom sheet (range 0.1%–10%, 10 bps step). - Max slippage persisted globally as a user preference in `PerpsController` state (basis points, default 300 bps = 3%). - **Live estimated slippage** computed from the HyperLiquid L2 order book via VWAP: walks asks (BUY) or bids (SELL) up to the requested USD notional and reports the weighted-average fill price's distance from the mid-price. Displayed in the slippage row as `Est: X% / Max: Y%`. - **Client-side block** on market orders whose estimated slippage exceeds the configured cap. Submit is blocked, a toast explains why, and a `slippage_limit_blocked_order` event fires. - User-configured `maxSlippageBps` is wired through to the HyperLiquid order: market-order limit price = `currentPrice * (1 ± maxSlippageBps / 10000)`. The first commit on this branch fixed a trap where the order screen set bps while the limit-price math read a separate (always-undefined) `slippage` decimal field, so the user's setting silently fell back to a hardcoded 3%. The second commit collapses both fields into a single `maxSlippageBps`. - MetaMetrics: `slippage_config_opened`, `slippage_config_changed`, and new `slippage_limit_blocked_order` interaction types; `max_slippage_pct`, `max_slippage_source` (`default` / `user_configured`), and `estimated_slippage_pct` properties. All slippage values are stored internally as basis points (integers). Display converts to percentage only at the UI boundary. **How slippage is applied (parity with HyperLiquid native)** | Path | Our value | HyperLiquid native | |---|---|---| | Market order — order form | User-configured `maxSlippageBps`, default 300 bps (3%) | User-configurable | | Limit order | None — user-provided limit price used directly | Same | | TP/SL trigger (market execution) | Hardcoded 1000 bps (10%) via `DefaultTpslSlippageBps` | 1000 bps (10%) | | Close position (market) | Hardcoded 300 bps (3%) via `DefaultMarketSlippageBps` | 800 bps (8%) | Close-position slippage is hardcoded and **out of scope** for this story per Jira §4: "Slippage on position close / add margin flows" is explicitly excluded. Alignment to HL's 8% can be a small follow-up if desired. **Acceptance criteria coverage** - AC1 — Estimated slippage shown ✓ (VWAP from live order book) - AC2 — Max slippage tap-to-configure with bottom sheet ✓ - AC3 — Setting persists across sessions ✓ - AC4 — Default 3% ✓ - AC5 — Order blocked when estimate exceeds max ✓ (client-side, with toast + analytics event) - AC6 — Order success rate guardrail — to verify post-rollout via Mixpanel **Out of scope (per Jira §4)** - Per-asset / per-leverage overrides. - TP/SL slippage configuration (execution slippage hardcoded; no user control). - Slippage configuration on position close / add margin flows. - Any change to order routing — this PR is UI + persisted config + a client-side guard. ## **Changelog** CHANGELOG entry: Added estimated slippage and a configurable max slippage preference for perps market orders, with submission blocked when the estimate exceeds the configured cap. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-1043 ## **Manual testing steps** ```gherkin Feature: Perps slippage visualization, configuration, and block Scenario: user sees estimated slippage on market order Given user is on the perps order screen with a market order and amount entered When the order form renders Then a "Slippage" row shows `Est: X% / Max: 3%` (X computed from live book depth) And the value updates as the order amount changes Scenario: user configures max slippage via preset Given user is on the perps order screen When user taps the "Slippage" row Then a bottom sheet opens with quick-pick chips (0.5%, 2%, 3%) and an Edit chip When user selects "2%" and taps Set Then the row updates to `Est: X% / Max: 2%` and the value persists across sessions Scenario: user configures max slippage via custom keypad Given the slippage bottom sheet is open When user taps the Edit chip Then a custom-value sheet opens with `−`/`+` controls and a numeric keypad When user enters a value outside 0.1–10% range Then an error message appears and Set is disabled When user enters a valid value (e.g. 5) and taps Set Then the row updates to `Est: X% / Max: 5%` and persists Scenario: market order uses configured slippage Given max slippage is configured to 5% When user places a market BUY at $50,000 spot Then the HyperLiquid order is submitted with limit price ≈ $50,000 * 1.05 = $52,500 Scenario: order blocked when estimated slippage exceeds max Given user has set max slippage to 0.1% And the order size is large enough that estimated slippage > 0.1% When user taps Place Order Then the order is NOT submitted And a toast explains "Estimated slippage exceeds your max" And a `slippage_limit_blocked_order` MetaMetrics event fires Scenario: limit order ignores max slippage Given user is on the perps order screen with a limit order When the form renders Then the Slippage row is hidden And the order uses the user-provided limit price untouched ``` ## **Screenshots/Recordings** ### **Before** Slippage UI was a single `Max slippage` row inside the leverage/details box with an inline text-input bottom sheet — no preset chips, no DS chip styling, and the user-configured value did not reach HyperLiquid (silent 3% fallback). ### **After** | Order form (default 3%) | Main slippage sheet | |---|---| | ![order-default](https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/30125/01-order-form-default.png) | ![main-sheet](https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/30125/02-main-slippage-sheet.png) | | Slippage row sits above Fees with `Est: 0% / Max: 3% ✏️`. BodySM size hierarchy on Margin / Liquidation / Slippage / Fees. Flex spacer pins the info block to the bottom of the scroll view. | DS `ButtonFilter` chips `0.5% / 2% / 3%` (3% active), trailing edit pill, `Set` footer. | | Custom slippage sheet | Order form after Set 5% | |---|---| | ![custom-sheet](https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/30125/03-custom-slippage-sheet.png) | ![order-5pct](https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/30125/04-order-form-custom.png) | | Title `Use custom slippage`. Centered value with blinking cursor, evenly spaced `−` / `+` `ButtonIcon` controls, full keypad, `Cancel` / `Set` footer. Snaps and clamps to 10–1000 bps in 10 bps steps. | Row updates to `Est: 0% / Max: 5% ✏️`. Persistence verified via recipe: controller persists 500 bps after Set. | ## **Validation Recipe**
recipe.json — slippage UI visibility, config sheet, persistence, default value ```json { "title": "TAT-1043: Slippage visualization and configuration", "schema_version": 1, "description": "Validates slippage UI on perps market order: default 3% max slippage, slippage row visible, config sheet opens and changes value, persistence of the user's selection.", "validate": { "workflow": { "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade"], "setup": [ { "id": "setup-nav-home", "action": "navigate", "target": "PerpsHomeView" } ], "entry": "ensure-testnet", "nodes": { "ensure-testnet": { "action": "call", "ref": "perps/setup-testnet", "next": "check-default-slippage" }, "check-default-slippage": { "action": "eval_sync", "expression": "(function(){var ctrl=Engine.context.PerpsController;var val=ctrl.getMaxSlippage();return JSON.stringify({defaultBps:val===undefined?300:val,isDefault:val===undefined||val===300})})()", "assert": { "operator": "eq", "field": "isDefault", "value": true }, "next": "clear-btc-position" }, "clear-btc-position": { "action": "eval_async", "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'BTC'}).then(function(){return JSON.stringify({cleared:true})})})", "assert": { "operator": "eq", "field": "cleared", "value": true }, "next": "wait-btc-clear" }, "wait-btc-clear": { "action": "wait", "duration_ms": 2000, "next": "nav-to-btc" }, "nav-to-btc": { "action": "wait_for", "test_id": "perps-market-row-item-BTC", "timeout_ms": 8000, "next": "press-btc-row" }, "press-btc-row": { "action": "press", "test_id": "perps-market-row-item-BTC", "next": "wait-side-button" }, "wait-side-button": { "action": "wait_for", "test_id": "perps-market-details-long-button", "timeout_ms": 8000, "next": "press-long" }, "press-long": { "action": "press", "test_id": "perps-market-details-long-button", "next": "wait-amount" }, "wait-amount": { "action": "wait_for", "test_id": "perps-amount-display-touchable", "timeout_ms": 10000, "next": "press-amount" }, "press-amount": { "action": "press", "test_id": "perps-amount-display-touchable", "next": "wait-keypad" }, "wait-keypad": { "action": "wait_for", "test_id": "perps-order-view-keypad", "timeout_ms": 5000, "next": "clear-keypad" }, "clear-keypad": { "action": "clear_keypad", "count": 8, "next": "type-amount" }, "type-amount": { "action": "type_keypad", "value": "10", "next": "press-done" }, "press-done": { "action": "press", "test_id": "perps-order-view-keypad-done", "next": "wait-order-form" }, "wait-order-form": { "action": "wait_for", "test_id": "perps-order-view-place-order-button", "timeout_ms": 15000, "next": "wait-slippage-row" }, "wait-slippage-row": { "action": "wait_for", "test_id": "perps-order-view-slippage-value", "timeout_ms": 10000, "next": "screenshot-row" }, "screenshot-row": { "action": "screenshot", "filename": "evidence-slippage-visible.png", "note": "Market order form showing slippage row (3% default)", "next": "check-max-display" }, "check-max-display": { "action": "wait_for", "test_id": "perps-order-view-slippage-row", "timeout_ms": 5000, "next": "open-config" }, "open-config": { "action": "press", "test_id": "perps-order-view-slippage-row", "next": "wait-config-sheet" }, "wait-config-sheet": { "action": "wait_for", "test_id": "perps-slippage-config-input", "timeout_ms": 5000, "next": "screenshot-config" }, "screenshot-config": { "action": "screenshot", "filename": "evidence-slippage-config-sheet.png", "note": "Slippage config bottom sheet open with input field and quick-pick presets", "next": "change-value" }, "change-value": { "action": "set_input", "test_id": "perps-slippage-config-input", "value": "5", "next": "save-value" }, "save-value": { "action": "press", "test_id": "perps-slippage-config-save", "next": "wait-sheet-close" }, "wait-sheet-close": { "action": "wait", "duration_ms": 1000, "next": "verify-persisted" }, "verify-persisted": { "action": "eval_sync", "expression": "(function(){var ctrl=Engine.context.PerpsController;var val=ctrl.getMaxSlippage();return JSON.stringify({bps:val,isPersisted:val===500})})()", "assert": { "operator": "eq", "field": "isPersisted", "value": true }, "next": "screenshot-updated" }, "screenshot-updated": { "action": "screenshot", "filename": "evidence-slippage-changed.png", "note": "Order form now shows max slippage updated to 5% after config change", "next": "restore-default" }, "restore-default": { "action": "eval_sync", "expression": "(function(){Engine.context.PerpsController.setMaxSlippage(300);var val=Engine.context.PerpsController.getMaxSlippage();return JSON.stringify({restored:val===300})})()", "assert": { "operator": "eq", "field": "restored", "value": true }, "next": "done" }, "done": { "action": "end", "status": "pass" } }, "teardown": [ { "id": "teardown-restore-slippage", "action": "eval_sync", "expression": "(function(){Engine.context.PerpsController.setMaxSlippage(300);return JSON.stringify({clean:true})})()", "assert": { "operator": "not_null" } }, { "id": "teardown-nav-home", "action": "navigate", "target": "PerpsHomeView" } ] } } } ```
## **Validation Logs** Command: ```bash IOS_SIMULATOR=mm-3 node scripts/perps/agentic/validate-recipe.js .task/feat/tat-1043-0513-225508/artifacts/recipe.json ```
Full output (all steps passed) ``` [check-default-slippage] result: {"defaultBps":300,"isDefault":true} PASS [clear-btc-position] result: {"cleared":true} PASS [nav-to-btc] result: {"visible":true} PASS [press-btc-row] result: {"ok":true} PASS [wait-side-button] result: {"visible":true} PASS [press-long] result: {"ok":true} PASS [wait-amount] result: {"visible":true} PASS [press-amount] result: {"ok":true} PASS [wait-keypad] result: {"visible":true} PASS [clear-keypad] result: {"ok":true,"deleted":8} PASS [type-amount] result: {"ok":true,"value":"10"} PASS [press-done] result: {"ok":true} PASS [wait-order-form] result: {"visible":true} PASS [wait-slippage-row] result: {"visible":true} PASS [screenshot-row] PASS [check-max-display] result: {"visible":true} PASS [open-config] result: {"ok":true} PASS [wait-config-sheet] result: {"visible":true} PASS [screenshot-config] PASS [change-value] result: {"ok":true,"value":"5"} PASS [save-value] result: {"ok":true} PASS [wait-sheet-close] PASS [verify-persisted] result: {"bps":500,"isPersisted":true} PASS [screenshot-updated] PASS [restore-default] result: {"restored":true} PASS Recipe: PASS ```
## **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. #### Performance checks (if applicable) - [x] I've tested on Android - [x] I've tested with a power user scenario - [x] I've instrumented key operations with Sentry traces for production performance metrics ## **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] > **High Risk** > High risk because it changes market-order execution parameters (limit-price buffering) and introduces a new client-side block path that can prevent order submission; it also adds persisted controller state used during trading. > > **Overview** > Adds a **Slippage** row to the Perps order form (market orders only) showing live VWAP-based estimated slippage from the L2 order book alongside a user-configurable max cap, and opens a new bottom-sheet flow (quick-picks + custom keypad) to update that cap. > > Persists `maxSlippageBps` in `PerpsController` with new `getMaxSlippage`/`setMaxSlippage` actions (clamped/snapped to bounds), wires the cap through order placement/editing to HyperLiquid via `maxSlippageBps` (with backward-compatible normalization of deprecated decimal `slippage`), and blocks `placeOrder` when the estimate exceeds the configured cap (toast + new MetaMetrics properties/events). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit df3225cc4b5996bc8c179dced959c8bd1aaf1887. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/UI/Perps/Perps.testIds.ts | 30 ++ .../PerpsOrderView/PerpsOrderView.styles.ts | 9 + .../PerpsOrderView/PerpsOrderView.test.tsx | 142 +++++++++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 173 ++++++++++- .../PerpsBottomSheetTooltip.types.ts | 3 +- .../content/contentRegistry.ts | 1 + .../PerpsCustomSlippageBottomSheet.styles.ts | 57 ++++ .../PerpsCustomSlippageBottomSheet.tsx | 228 ++++++++++++++ .../PerpsSlippageBottomSheet.styles.ts | 31 ++ .../PerpsSlippageBottomSheet.test.tsx | 278 ++++++++++++++++++ .../PerpsSlippageBottomSheet.tsx | 174 +++++++++++ .../PerpsSlippageBottomSheet/index.ts | 1 + .../UI/Perps/constants/slippageConfig.test.ts | 65 ++++ .../UI/Perps/constants/slippageConfig.ts | 24 ++ .../Perps/hooks/usePerpsEstimatedSlippage.ts | 68 +++++ .../Perps/hooks/usePerpsMaxSlippage.test.ts | 56 ++++ .../UI/Perps/hooks/usePerpsMaxSlippage.ts | 50 ++++ .../Perps/utils/slippageCalculation.test.ts | 154 ++++++++++ .../UI/Perps/utils/slippageCalculation.ts | 86 ++++++ .../PerpsController-method-action-types.ts | 22 ++ app/controllers/perps/PerpsController.ts | 47 ++- app/controllers/perps/constants/eventNames.ts | 14 + .../perps/constants/perpsConfig.ts | 24 ++ app/controllers/perps/index.ts | 1 + .../providers/HyperLiquidProvider.test.ts | 54 +++- .../perps/providers/HyperLiquidProvider.ts | 58 ++-- app/controllers/perps/types/index.ts | 8 +- .../perps/utils/orderCalculations.ts | 17 +- docs/perps/perps-architecture.md | 7 + docs/perps/perps-review-antipatterns.md | 2 +- locales/languages/en.json | 20 ++ 31 files changed, 1846 insertions(+), 58 deletions(-) create mode 100644 app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx create mode 100644 app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx create mode 100644 app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts create mode 100644 app/components/UI/Perps/constants/slippageConfig.test.ts create mode 100644 app/components/UI/Perps/constants/slippageConfig.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts create mode 100644 app/components/UI/Perps/utils/slippageCalculation.test.ts create mode 100644 app/components/UI/Perps/utils/slippageCalculation.ts diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 16c8017e8af6..ae2c4bc363ca 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -561,9 +561,39 @@ export const PerpsOrderViewSelectorsIDs = { // Row touchables that open bottom sheets LEVERAGE_ROW: 'perps-order-view-leverage-row', LIMIT_PRICE_ROW: 'perps-order-view-limit-price-row', + // Slippage + SLIPPAGE_ROW: 'perps-order-view-slippage-row', + SLIPPAGE_VALUE: 'perps-order-view-slippage-value', SERVICE_INTERRUPTION_BANNER: 'perps-order-view-service-interruption-banner', }; +// ======================================== +// PERPS SLIPPAGE CONFIG BOTTOM SHEET SELECTORS +// ======================================== + +export const PerpsSlippageConfigSelectorsIDs = { + SET: 'perps-slippage-config-set', + EDIT_CHIP: 'perps-slippage-config-edit-chip', +} as const; + +export const getPerpsSlippageConfigSelector = { + preset: (pct: number) => `perps-slippage-config-preset-${pct}`, +}; + +// ======================================== +// PERPS CUSTOM SLIPPAGE BOTTOM SHEET SELECTORS +// ======================================== + +export const PerpsCustomSlippageBottomSheetSelectorsIDs = { + DISPLAY: 'perps-custom-slippage-display', + DECREMENT: 'perps-custom-slippage-decrement', + INCREMENT: 'perps-custom-slippage-increment', + KEYPAD: 'perps-custom-slippage-keypad', + CANCEL: 'perps-custom-slippage-cancel', + SET: 'perps-custom-slippage-set', + ERROR: 'perps-custom-slippage-error', +} as const; + // ======================================== // PERPS LIMIT PRICE BOTTOM SHEET SELECTORS // ======================================== diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts index 595ce1abf6a9..03f4932a0a9d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts @@ -60,6 +60,11 @@ const createStyles = (colors: Colors) => alignItems: 'center', flex: 1, }, + slippageValueRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, infoIcon: { marginLeft: 0, padding: 10, // Increases touch target from 20x20 to 40x40 for better accessibility @@ -71,6 +76,10 @@ const createStyles = (colors: Colors) => paddingHorizontal: 16, borderRadius: 12, }, + infoSectionSpacer: { + flex: 1, + minHeight: 16, + }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 9253a52719a0..ee83bd91b3de 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -57,6 +57,8 @@ import { usePerpsLivePrices, usePerpsTopOfBook, } from '../../hooks/stream'; +import { usePerpsEstimatedSlippage } from '../../hooks/usePerpsEstimatedSlippage'; +import { usePerpsMaxSlippage } from '../../hooks/usePerpsMaxSlippage'; import { PerpsStreamManager, PerpsStreamProvider, @@ -702,6 +704,21 @@ jest.mock( }, ); +jest.mock('../../hooks/usePerpsEstimatedSlippage', () => ({ + usePerpsEstimatedSlippage: jest.fn(() => ({ + estimatedSlippageBps: null, + isReady: false, + })), +})); + +jest.mock('../../hooks/usePerpsMaxSlippage', () => ({ + usePerpsMaxSlippage: jest.fn(() => ({ + maxSlippageBps: 300, + maxSlippageSource: 'default', + setMaxSlippage: jest.fn(), + })), +})); + // Test setup const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -4231,4 +4248,129 @@ describe('PerpsOrderView', () => { }); }); }); + + describe('slippage block on submit', () => { + beforeEach(() => { + // Earlier tests in the file mutate shared mocks (order form, validation, + // toasts, slippage hooks) without restoring them. Reset every mock the + // block path reads so this suite is self-contained regardless of run order. + (usePerpsEstimatedSlippage as jest.Mock).mockReturnValue({ + estimatedSlippageBps: null, + isReady: false, + }); + (usePerpsMaxSlippage as jest.Mock).mockReturnValue({ + maxSlippageBps: 300, + maxSlippageSource: 'default', + setMaxSlippage: jest.fn(), + }); + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'ETH', + amount: '11', + leverage: 3, + direction: 'long', + type: 'market', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '11', + positionSize: '0.0037', + }, + }); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + }); + + it('blocks placeOrder when estimated slippage exceeds the configured cap', async () => { + const mockPlaceOrder = jest.fn().mockResolvedValue({ success: true }); + (usePerpsOrderExecution as jest.Mock).mockImplementation(() => ({ + placeOrder: mockPlaceOrder, + isPlacing: false, + })); + + const mockValidationError = jest.fn(() => ({ + id: 'slippage-block-toast', + })); + const mockShowToast = jest.fn(); + (usePerpsToasts as jest.Mock).mockReturnValue({ + showToast: mockShowToast, + PerpsToastOptions: { + formValidation: { + orderForm: { + limitPriceRequired: {}, + validationError: mockValidationError, + }, + }, + orderManagement: { + market: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + limit: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + shared: { submitting: jest.fn() }, + }, + positionManagement: { tpsl: { updateTPSLError: jest.fn() } }, + dataFetching: { + market: { error: { marketDataUnavailable: jest.fn() } }, + }, + accountManagement: { + deposit: { + inProgress: jest.fn(), + takingLonger: {}, + tradeCanceled: {}, + error: {}, + }, + }, + }, + }); + + (usePerpsEstimatedSlippage as jest.Mock).mockReturnValue({ + estimatedSlippageBps: 500, // 5% + isReady: true, + }); + (usePerpsMaxSlippage as jest.Mock).mockReturnValue({ + maxSlippageBps: 100, // 1% — estimate exceeds the cap + maxSlippageSource: 'user_configured', + setMaxSlippage: jest.fn(), + }); + + render(, { wrapper: TestWrapper }); + + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + await act(async () => { + fireEvent.press(placeOrderButton); + }); + + // The critical AC invariant: an order whose estimated slippage exceeds + // the configured cap must NOT reach the order execution path. (The toast + // copy and event payload are verified separately by the slippage agentic + // recipe and the `eventNames` constants tests.) + expect(mockPlaceOrder).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index b2ef1246832a..8cf6cef43904 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -78,6 +78,7 @@ import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip import PerpsFeesDisplay from '../../components/PerpsFeesDisplay'; import PerpsLeverageBottomSheet from '../../components/PerpsLeverageBottomSheet'; import PerpsLimitPriceBottomSheet from '../../components/PerpsLimitPriceBottomSheet'; +import PerpsSlippageBottomSheet from '../../components/PerpsSlippageBottomSheet'; import PerpsOICapWarning from '../../components/PerpsOICapWarning'; import PerpsOrderHeader from '../../components/PerpsOrderHeader'; import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet'; @@ -86,7 +87,6 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, DECIMAL_PRECISION_CONFIG, - ORDER_SLIPPAGE_CONFIG, PERPS_CONSTANTS, getPerpsDisplaySymbol, calculateMarginRequired, @@ -95,7 +95,9 @@ import { type OrderParams, type OrderType, type Position, + ORDER_SLIPPAGE_CONFIG, } from '@metamask/perps-controller'; +import { bpsToPercent } from '../../constants/slippageConfig'; import { PerpsOrderProvider, usePerpsOrderContext, @@ -120,6 +122,8 @@ import { usePerpsTopOfBook, } from '../../hooks/stream'; import { usePerpsConnection } from '../../hooks/usePerpsConnection'; +import { usePerpsEstimatedSlippage } from '../../hooks/usePerpsEstimatedSlippage'; +import { usePerpsMaxSlippage } from '../../hooks/usePerpsMaxSlippage'; import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; @@ -373,9 +377,17 @@ const PerpsOrderViewContentBase: React.FC = ({ const [isLeverageVisible, setIsLeverageVisible] = useState(false); const [isLimitPriceVisible, setIsLimitPriceVisible] = useState(false); const [isOrderTypeVisible, setIsOrderTypeVisible] = useState(false); + const [isSlippageVisible, setIsSlippageVisible] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); const [shouldOpenLimitPrice, setShouldOpenLimitPrice] = useState(false); + // Max slippage from persisted controller state via hook so the component + // never reaches into PerpsController directly (perps anti-pattern rule). + // The hook also exposes the source (default vs user-configured) for + // MetaMetrics and a setter that refreshes the read on save. + const { maxSlippageBps, maxSlippageSource, setMaxSlippage } = + usePerpsMaxSlippage(); + const isPayRowVisible = Boolean( isTradeWithAnyTokenEnabled && activeTransactionMeta, ); @@ -512,9 +524,49 @@ const PerpsOrderViewContentBase: React.FC = ({ const shouldBlockBecauseOfFeesLoading = hasCustomTokenSelected && isPayTotalsLoading; + const isMarketOrder = orderForm.type === 'market'; + // Simple boolean calculation - no need for expensive memoization const hasValidAmount = parseFloat(orderForm.amount) > 0; + // Live VWAP slippage estimate for market orders; limit orders skip it. + const orderUsdAmount = useMemo( + () => parseFloat(orderForm.amount) || 0, + [orderForm.amount], + ); + const { estimatedSlippageBps } = usePerpsEstimatedSlippage({ + symbol: orderForm.asset, + sizeUsd: orderUsdAmount, + isBuy: orderForm.direction === 'long', + // Gate on `isInitialized` so the order-book subscription waits until the + // perps providers are wired; otherwise the subscription becomes a no-op + // and the estimate stays null until the screen remounts. + enabled: isMarketOrder && hasValidAmount && isInitialized, + }); + // Keep the estimate nullable so the row can render a `--` placeholder when + // the L2 book has not produced data yet (per the perps anti-pattern doc: + // never default unavailable data to `0`). When the estimate is unknown the + // user-configured cap still flows through to HyperLiquid as the limit-price + // buffer, so we surface "estimate pending" without blocking the order. + // Numeric percent for analytics and comparisons; formatted string for UI so + // the row never shows `3.333333%` noise. + const estimatedSlippagePct: number | null = useMemo( + () => + typeof estimatedSlippageBps === 'number' + ? bpsToPercent(estimatedSlippageBps) + : null, + [estimatedSlippageBps], + ); + const estimatedSlippagePctDisplay: string | null = useMemo( + () => + estimatedSlippagePct === null ? null : estimatedSlippagePct.toFixed(2), + [estimatedSlippagePct], + ); + const exceedsMaxSlippage = + isMarketOrder && + typeof estimatedSlippageBps === 'number' && + estimatedSlippageBps > maxSlippageBps; + // Get rewards state using the new hook const rewardsState = usePerpsRewards({ feeResults, @@ -924,6 +976,30 @@ const PerpsOrderViewContentBase: React.FC = ({ return; } + // Bail out before the pay-with-any-token deposit branch so an + // excessive-slippage order never starts a deposit/signature flow. + if (exceedsMaxSlippage && typeof estimatedSlippageBps === 'number') { + const estPct = bpsToPercent(estimatedSlippageBps); + const maxPct = bpsToPercent(maxSlippageBps); + showToast( + PerpsToastOptions.formValidation.orderForm.validationError( + strings('perps.slippage.exceeds_max', { + est: estPct.toFixed(2), + max: maxPct.toFixed(2), + }), + ), + ); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SLIPPAGE_LIMIT_BLOCKED_ORDER, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT]: maxPct, + [PERPS_EVENT_PROPERTY.ESTIMATED_SLIPPAGE_PCT]: estPct, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE]: maxSlippageSource, + }); + return; + } + // Check if deposit is needed first (when custom token is selected) const needsDeposit = isTradeWithAnyTokenEnabled && @@ -1087,8 +1163,8 @@ const PerpsOrderViewContentBase: React.FC = ({ priceAtCalculation: effectivePrice, // Price snapshot when size was calculated (for slippage validation) maxSlippageBps: orderForm.type === 'limit' - ? ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps // 1% for limit orders - : ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, // 3% for market orders + ? ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps // 1% for limit orders (fixed) + : maxSlippageBps, // User-configured for market orders (already in bps) // Only add TP/SL/Limit if they are truthy and/or not empty strings ...(orderForm.type === 'limit' && orderForm.limitPrice ? { price: orderForm.limitPrice } @@ -1214,6 +1290,10 @@ const PerpsOrderViewContentBase: React.FC = ({ onDepositConfirm, handleDepositConfirm, fromTokenDetails, + maxSlippageBps, + maxSlippageSource, + estimatedSlippageBps, + exceedsMaxSlippage, ], ); @@ -1576,20 +1656,22 @@ const PerpsOrderViewContentBase: React.FC = ({ )} + {/* Spacer pushes the info section to the bottom of the scroll view */} + {!isInputFocused && } + {/* Info Section */} 0 ? 16 : -16 }, + { marginBottom: orderValidation.errors.length > 0 ? 16 : 8 }, // eslint-disable-next-line react-native/no-inline-styles { marginTop: isInputFocused ? 16 : 0 }, ]} > - + {strings('perps.order.margin')} = ({ @@ -1619,7 +1701,7 @@ const PerpsOrderViewContentBase: React.FC = ({ - + {strings('perps.order.liquidation_price')} = ({ @@ -1648,9 +1730,57 @@ const PerpsOrderViewContentBase: React.FC = ({ : PERPS_CONSTANTS.FallbackDataDisplay} + {isMarketOrder && ( + { + setIsSlippageVisible(true); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SLIPPAGE_CONFIG_OPENED, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT]: + bpsToPercent(maxSlippageBps), + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE]: maxSlippageSource, + }); + }} + > + + + {strings('perps.slippage.slippage')} + + + + {estimatedSlippagePctDisplay === null + ? strings('perps.slippage.row_format_pending', { + value: bpsToPercent(maxSlippageBps), + }) + : strings('perps.slippage.row_format', { + est: estimatedSlippagePctDisplay, + value: bpsToPercent(maxSlippageBps), + })} + + + + + + )} - + {strings('perps.order.fees')} = ({ }) } testID={PerpsOrderViewSelectorsIDs.FEES_VALUE} - variant={TextVariant.BodyMD} + variant={TextVariant.BodySM} /> )} @@ -1692,7 +1822,7 @@ const PerpsOrderViewContentBase: React.FC = ({ {strings('perps.estimated_points')} @@ -1966,6 +2096,25 @@ const PerpsOrderViewContentBase: React.FC = ({ asset={orderForm.asset} direction={orderForm.direction} /> + {/* Slippage Config Bottom Sheet */} + setIsSlippageVisible(false)} + onSave={(valueBps) => { + setMaxSlippage(valueBps); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SLIPPAGE_CONFIG_CHANGED, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT]: bpsToPercent(valueBps), + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE]: + PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE.USER_CONFIGURED, + [PERPS_EVENT_PROPERTY.SETTING_TYPE]: + PERPS_EVENT_VALUE.SETTING_TYPE.SLIPPAGE, + }); + }} + /> {selectedTooltip && ( + StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + displayRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + paddingVertical: 24, + }, + displayCenter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + displayValue: { + fontSize: 40, + lineHeight: 48, + color: colors.text.default, + fontWeight: '500', + }, + displaySuffix: { + fontSize: 40, + lineHeight: 48, + color: colors.text.default, + fontWeight: '500', + marginLeft: 4, + }, + cursor: { + width: 2, + height: 36, + marginHorizontal: 2, + backgroundColor: colors.primary.default, + }, + keypadContainer: { + marginTop: 8, + }, + errorText: { + textAlign: 'center', + marginTop: 4, + marginBottom: 8, + }, + footerContainer: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingBottom: 16, + gap: 8, + }, + footerButton: { + flex: 1, + }, + }); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx new file mode 100644 index 000000000000..6b5ef9a20f00 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx @@ -0,0 +1,228 @@ +import { + ButtonIcon, + ButtonIconSize, + ButtonIconVariant, + IconName, +} from '@metamask/design-system-react-native'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Animated, View } from 'react-native'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useTheme } from '../../../../../util/theme'; +import Keypad from '../../../../Base/Keypad'; +import { + PERPS_SLIPPAGE_MAX_BPS, + PERPS_SLIPPAGE_MIN_BPS, + PERPS_SLIPPAGE_STEP_BPS, + bpsToPercent, + percentToBps, +} from '../../constants/slippageConfig'; +import { PerpsCustomSlippageBottomSheetSelectorsIDs } from '../../Perps.testIds'; +import { createStyles } from './PerpsCustomSlippageBottomSheet.styles'; + +interface PerpsCustomSlippageBottomSheetProps { + isVisible: boolean; + currentValueBps: number; + onClose: () => void; + onSave: (valueBps: number) => void; +} + +const MIN_PCT = bpsToPercent(PERPS_SLIPPAGE_MIN_BPS); +const MAX_PCT = bpsToPercent(PERPS_SLIPPAGE_MAX_BPS); +const STEP_PCT = bpsToPercent(PERPS_SLIPPAGE_STEP_BPS); + +function snapToStep(pct: number): number { + const snappedBps = + Math.round(percentToBps(pct) / PERPS_SLIPPAGE_STEP_BPS) * + PERPS_SLIPPAGE_STEP_BPS; + return bpsToPercent(snappedBps); +} + +function clampToRange(pct: number): number { + return Math.min(MAX_PCT, Math.max(MIN_PCT, pct)); +} + +const PerpsCustomSlippageBottomSheet: React.FC< + PerpsCustomSlippageBottomSheetProps +> = ({ isVisible, currentValueBps, onClose, onSave }) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const bottomSheetRef = useRef(null); + const cursorOpacity = useRef(new Animated.Value(1)).current; + + const [draftValue, setDraftValue] = useState( + bpsToPercent(currentValueBps).toString(), + ); + + useEffect(() => { + if (isVisible) { + setDraftValue(bpsToPercent(currentValueBps).toString()); + bottomSheetRef.current?.onOpenBottomSheet(); + Animated.loop( + Animated.sequence([ + Animated.timing(cursorOpacity, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(cursorOpacity, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + ]), + ).start(); + } else { + cursorOpacity.stopAnimation(); + cursorOpacity.setValue(1); + } + // Stop the cursor animation when the component unmounts so the loop does + // not keep running on an orphaned Animated.Value. + return () => { + cursorOpacity.stopAnimation(); + }; + }, [isVisible, currentValueBps, cursorOpacity]); + + const parsedDraft = Number.parseFloat(draftValue); + const draftIsEmpty = draftValue.trim() === '' || draftValue === '.'; + const draftIsFiniteNumber = Number.isFinite(parsedDraft); + const draftIsInRange = + draftIsFiniteNumber && parsedDraft >= MIN_PCT && parsedDraft <= MAX_PCT; + const showError = !draftIsEmpty && !draftIsInRange; + + const handleKeypadChange = useCallback( + ({ value }: { value: string; valueAsNumber: number }) => { + setDraftValue(value); + }, + [], + ); + + const adjustBy = useCallback( + (deltaPct: number) => { + const basePct = draftIsFiniteNumber ? parsedDraft : MIN_PCT; + const next = snapToStep(clampToRange(basePct + deltaPct)); + setDraftValue(next.toString()); + }, + [draftIsFiniteNumber, parsedDraft], + ); + + const handleDecrement = useCallback(() => adjustBy(-STEP_PCT), [adjustBy]); + const handleIncrement = useCallback(() => adjustBy(STEP_PCT), [adjustBy]); + + const handleSet = useCallback(() => { + if (!draftIsInRange) return; + const finalPct = snapToStep(clampToRange(parsedDraft)); + onSave(percentToBps(finalPct)); + }, [draftIsInRange, parsedDraft, onSave]); + + const footerButtonProps = [ + { + label: strings('perps.slippage.cancel'), + testID: PerpsCustomSlippageBottomSheetSelectorsIDs.CANCEL, + variant: ButtonVariants.Secondary, + size: ButtonSize.Lg, + onPress: onClose, + }, + { + label: strings('perps.slippage.set'), + testID: PerpsCustomSlippageBottomSheetSelectorsIDs.SET, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + onPress: handleSet, + isDisabled: !draftIsInRange, + }, + ]; + + if (!isVisible) return null; + + return ( + + + + {strings('perps.slippage.use_custom_title')} + + + + + + + + {draftValue || '0'} + + % + + = MAX_PCT - 1e-9} + testID={PerpsCustomSlippageBottomSheetSelectorsIDs.INCREMENT} + accessibilityLabel={strings('perps.slippage.increment_label')} + /> + + + {showError && ( + + {strings('perps.slippage.out_of_range', { + min: `${MIN_PCT}`, + max: `${MAX_PCT}`, + })} + + )} + + + + + + + + + ); +}; + +PerpsCustomSlippageBottomSheet.displayName = 'PerpsCustomSlippageBottomSheet'; + +export default memo(PerpsCustomSlippageBottomSheet); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts new file mode 100644 index 000000000000..12967b20bfa0 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +export const createStyles = (_colors: Theme['colors']) => + StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + description: { + marginTop: 8, + marginBottom: 16, + }, + chipRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + chip: { + flex: 1, + height: 48, + borderRadius: 999, + paddingHorizontal: 8, + }, + editChip: { + height: 48, + width: 64, + borderRadius: 999, + paddingHorizontal: 8, + }, + }); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx new file mode 100644 index 000000000000..b8a1b56035c7 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import PerpsSlippageBottomSheet from './PerpsSlippageBottomSheet'; +import { PerpsSlippageConfigSelectorsIDs } from '../../Perps.testIds'; + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + const translations: Record = { + 'perps.slippage.config_title': 'Set slippage', + 'perps.slippage.config_description': + "Your transaction won't go through if the price shifts beyond this threshold.", + 'perps.slippage.set': 'Set', + 'perps.slippage.cancel': 'Cancel', + 'perps.slippage.use_custom_title': 'Use custom slippage', + }; + if (key === 'perps.slippage.out_of_range' && params) { + return `Must be between ${params.min}% and ${params.max}%`; + } + return translations[key] || key; + }), +})); + +const { mockTheme } = jest.requireActual('../../../../../util/theme'); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => mockTheme, +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactModule = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ReactModule.forwardRef( + ({ children }: { children: React.ReactNode }, _ref: unknown) => + ReactModule.createElement(View, null, children), + ), + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View } = jest.requireActual('react-native'); + return function MockBottomSheetHeader({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return function MockBottomSheetFooter({ + buttonPropsArray, + }: { + buttonPropsArray: { + label: string; + onPress: () => void; + isDisabled?: boolean; + testID?: string; + }[]; + }) { + return ( + + {buttonPropsArray.map((btn) => ( + + {btn.label} + + ))} + + ); + }; + }, +); + +jest.mock('@metamask/design-system-react-native', () => { + const { TouchableOpacity, Text, View } = jest.requireActual('react-native'); + return { + ButtonBaseSize: { Sm: 'sm', Md: 'md', Lg: 'lg' }, + ButtonFilter: ({ + children, + onPress, + testID, + isActive, + startIconName, + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + isActive?: boolean; + startIconName?: string; + }) => ( + + {startIconName ? {`icon:${startIconName}`} : null} + {children} + + ), + ButtonIcon: ({ + iconName, + onPress, + testID, + }: { + iconName: string; + onPress?: () => void; + testID?: string; + }) => ( + + {`icon:${iconName}`} + + ), + ButtonIconSize: { Sm: 'sm', Md: 'md', Lg: 'lg' }, + ButtonIconVariant: { + Default: 'default', + Filled: 'filled', + Floating: 'floating', + }, + IconName: { Edit: 'Edit', Add: 'Add', Minus: 'Minus' }, + View, + }; +}); + +jest.mock('./PerpsCustomSlippageBottomSheet', () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + isVisible, + currentValueBps, + onClose, + onSave, + }: { + isVisible: boolean; + currentValueBps: number; + onClose: () => void; + onSave: (bps: number) => void; + }) => + isVisible ? ( + + {`current:${currentValueBps}`} + + onSave(450)} + /> + + ) : null, + }; +}); + +jest.mock('../../../../../component-library/components/Texts/Text', () => { + const { Text } = jest.requireActual('react-native'); + const MockText = ({ children, testID, ...rest }: Record) => ( + + {children as React.ReactNode} + + ); + MockText.displayName = 'MockText'; + return { + __esModule: true, + default: MockText, + TextColor: { + Alternative: 'Alternative', + Error: 'Error', + Default: 'Default', + Inverse: 'Inverse', + }, + TextVariant: { + HeadingMD: 'HeadingMD', + BodySM: 'BodySM', + BodyLGMedium: 'BodyLGMedium', + }, + }; +}); + +const defaultProps = { + isVisible: true, + currentValueBps: 300, // 3% (preset) + onClose: jest.fn(), + onSave: jest.fn(), +}; + +describe('PerpsSlippageBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when not visible', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders three preset chips and an edit chip when value matches a preset', () => { + render(); + expect( + screen.getByTestId('perps-slippage-config-preset-0.5'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('perps-slippage-config-preset-2'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('perps-slippage-config-preset-3'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ).toBeOnTheScreen(); + }); + + it('saves preset bps value when preset chip is pressed and Set tapped', () => { + render(); + fireEvent.press(screen.getByTestId('perps-slippage-config-preset-2')); + fireEvent.press(screen.getByTestId(PerpsSlippageConfigSelectorsIDs.SET)); + expect(defaultProps.onSave).toHaveBeenCalledWith(200); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('opens custom slippage sheet when edit chip is pressed', () => { + render(); + fireEvent.press( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ); + expect(screen.getByTestId('mock-custom-slippage-sheet')).toBeOnTheScreen(); + }); + + it('marks the edit chip as selected when current value is custom', () => { + render( + , + ); + const chip = screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP); + expect(chip.props.accessibilityState?.selected).toBe(true); + }); + + it('commits a value chosen via the custom sheet on Set', () => { + render(); + fireEvent.press( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ); + // Custom sheet mock saves 450 bps + fireEvent.press(screen.getByTestId('mock-custom-save-450')); + // Back on main sheet; Set commits 450 + fireEvent.press(screen.getByTestId(PerpsSlippageConfigSelectorsIDs.SET)); + expect(defaultProps.onSave).toHaveBeenCalledWith(450); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('returns to main sheet when custom sheet is cancelled', () => { + render(); + fireEvent.press( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ); + fireEvent.press(screen.getByTestId('mock-custom-cancel')); + expect( + screen.queryByTestId('mock-custom-slippage-sheet'), + ).not.toBeOnTheScreen(); + expect( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx new file mode 100644 index 000000000000..ad8d6ee73f6f --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx @@ -0,0 +1,174 @@ +import { + ButtonBaseSize, + ButtonFilter, + IconName, +} from '@metamask/design-system-react-native'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useTheme } from '../../../../../util/theme'; +import { + PERPS_SLIPPAGE_QUICK_PICKS_BPS, + bpsToPercent, +} from '../../constants/slippageConfig'; +import { + PerpsSlippageConfigSelectorsIDs, + getPerpsSlippageConfigSelector, +} from '../../Perps.testIds'; +import PerpsCustomSlippageBottomSheet from './PerpsCustomSlippageBottomSheet'; +import { createStyles } from './PerpsSlippageBottomSheet.styles'; + +interface PerpsSlippageBottomSheetProps { + isVisible: boolean; + currentValueBps: number; + onClose: () => void; + onSave: (valueBps: number) => void; +} + +function matchesPreset(bps: number): boolean { + return PERPS_SLIPPAGE_QUICK_PICKS_BPS.includes(bps); +} + +const PerpsSlippageBottomSheet: React.FC = ({ + isVisible, + currentValueBps, + onClose, + onSave, +}) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const bottomSheetRef = useRef(null); + + const [selectedBps, setSelectedBps] = useState(currentValueBps); + const [isCustomOpen, setIsCustomOpen] = useState(false); + + useEffect(() => { + if (isVisible) { + setSelectedBps(currentValueBps); + setIsCustomOpen(false); + } + }, [isVisible, currentValueBps]); + + const isCustom = !matchesPreset(selectedBps); + + const handlePresetPress = useCallback((bps: number) => { + setSelectedBps(bps); + }, []); + + const handleOpenCustom = useCallback(() => { + setIsCustomOpen(true); + }, []); + + const handleCustomClose = useCallback(() => { + setIsCustomOpen(false); + }, []); + + const handleCustomSave = useCallback((bps: number) => { + setSelectedBps(bps); + setIsCustomOpen(false); + }, []); + + const handleSet = useCallback(() => { + onSave(selectedBps); + onClose(); + }, [onSave, onClose, selectedBps]); + + const footerButtonProps = [ + { + label: strings('perps.slippage.set'), + testID: PerpsSlippageConfigSelectorsIDs.SET, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + onPress: handleSet, + }, + ]; + + if (!isVisible) return null; + + if (isCustomOpen) { + return ( + + ); + } + + const customLabel = isCustom ? `${bpsToPercent(selectedBps)}%` : undefined; + + return ( + + + + {strings('perps.slippage.config_title')} + + + + + + {strings('perps.slippage.config_description')} + + + + {PERPS_SLIPPAGE_QUICK_PICKS_BPS.map((bps) => { + const pct = bpsToPercent(bps); + const isSelected = !isCustom && selectedBps === bps; + return ( + handlePresetPress(bps)} + testID={getPerpsSlippageConfigSelector.preset(pct)} + style={styles.chip} + > + {`${pct}%`} + + ); + })} + + + {isCustom && customLabel ? customLabel : ''} + + + + + + + ); +}; + +PerpsSlippageBottomSheet.displayName = 'PerpsSlippageBottomSheet'; + +export default memo(PerpsSlippageBottomSheet); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts new file mode 100644 index 000000000000..70922a2e4bf8 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts @@ -0,0 +1 @@ +export { default } from './PerpsSlippageBottomSheet'; diff --git a/app/components/UI/Perps/constants/slippageConfig.test.ts b/app/components/UI/Perps/constants/slippageConfig.test.ts new file mode 100644 index 000000000000..9b4ccfe0c32a --- /dev/null +++ b/app/components/UI/Perps/constants/slippageConfig.test.ts @@ -0,0 +1,65 @@ +import { + PERPS_SLIPPAGE_DEFAULT_BPS, + PERPS_SLIPPAGE_MIN_BPS, + PERPS_SLIPPAGE_MAX_BPS, + PERPS_SLIPPAGE_STEP_BPS, + PERPS_SLIPPAGE_QUICK_PICKS_BPS, + bpsToPercent, + percentToBps, +} from './slippageConfig'; + +describe('slippageConfig constants', () => { + it('exports expected default values', () => { + expect(PERPS_SLIPPAGE_DEFAULT_BPS).toBe(300); + expect(PERPS_SLIPPAGE_MIN_BPS).toBe(10); + expect(PERPS_SLIPPAGE_MAX_BPS).toBe(1000); + expect(PERPS_SLIPPAGE_STEP_BPS).toBe(10); + }); + + it('exports quick-pick presets 0.5%, 2%, 3%', () => { + expect(PERPS_SLIPPAGE_QUICK_PICKS_BPS).toEqual([50, 200, 300]); + }); + + it('quick-pick presets are within valid range', () => { + for (const bps of PERPS_SLIPPAGE_QUICK_PICKS_BPS) { + expect(bps).toBeGreaterThanOrEqual(PERPS_SLIPPAGE_MIN_BPS); + expect(bps).toBeLessThanOrEqual(PERPS_SLIPPAGE_MAX_BPS); + } + }); +}); + +describe('bpsToPercent', () => { + it('converts 300 bps to 3%', () => { + expect(bpsToPercent(300)).toBe(3); + }); + + it('converts 10 bps to 0.1%', () => { + expect(bpsToPercent(10)).toBe(0.1); + }); + + it('converts 1000 bps to 10%', () => { + expect(bpsToPercent(1000)).toBe(10); + }); + + it('converts 0 bps to 0%', () => { + expect(bpsToPercent(0)).toBe(0); + }); +}); + +describe('percentToBps', () => { + it('converts 3% to 300 bps', () => { + expect(percentToBps(3)).toBe(300); + }); + + it('converts 0.1% to 10 bps', () => { + expect(percentToBps(0.1)).toBe(10); + }); + + it('converts 10% to 1000 bps', () => { + expect(percentToBps(10)).toBe(1000); + }); + + it('rounds to nearest integer', () => { + expect(percentToBps(3.456)).toBe(346); + }); +}); diff --git a/app/components/UI/Perps/constants/slippageConfig.ts b/app/components/UI/Perps/constants/slippageConfig.ts new file mode 100644 index 000000000000..8fd0c151a52d --- /dev/null +++ b/app/components/UI/Perps/constants/slippageConfig.ts @@ -0,0 +1,24 @@ +import { + MAX_SLIPPAGE_BOUNDS, + ORDER_SLIPPAGE_CONFIG, +} from '@metamask/perps-controller'; + +/** + * Slippage configuration constants for the perps order entry surface. + * All values in basis points (1 bps = 0.01%). + * Range 10–1000 bps (0.1%–10%) in 10 bps (0.1%) steps. + */ +export const PERPS_SLIPPAGE_DEFAULT_BPS = + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; +export const PERPS_SLIPPAGE_MIN_BPS = MAX_SLIPPAGE_BOUNDS.MinBps; +export const PERPS_SLIPPAGE_MAX_BPS = MAX_SLIPPAGE_BOUNDS.MaxBps; +export const PERPS_SLIPPAGE_STEP_BPS = MAX_SLIPPAGE_BOUNDS.StepBps; + +/** Quick-pick presets in basis points (0.5%, 2%, 3%) */ +export const PERPS_SLIPPAGE_QUICK_PICKS_BPS = [50, 200, 300]; + +/** Convert bps to percent for display */ +export const bpsToPercent = (bps: number): number => bps / 100; + +/** Convert percent to bps for storage */ +export const percentToBps = (pct: number): number => Math.round(pct * 100); diff --git a/app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts b/app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts new file mode 100644 index 000000000000..199079b0416d --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { usePerpsLiveOrderBook } from './stream/usePerpsLiveOrderBook'; +import { calculateEstimatedSlippageBps } from '../utils/slippageCalculation'; +import { PERFORMANCE_CONFIG } from '@metamask/perps-controller'; + +export interface UsePerpsEstimatedSlippageOptions { + /** Asset symbol (e.g. 'BTC'). */ + symbol: string; + /** USD notional to fill. Pass undefined / 0 to disable the calc. */ + sizeUsd: number | undefined; + /** true = BUY (sweeps asks), false = SELL (sweeps bids). */ + isBuy: boolean; + /** + * Disable the subscription entirely (e.g. for limit orders). + * Defaults to true. + */ + enabled?: boolean; +} + +export interface UsePerpsEstimatedSlippageReturn { + /** Estimated slippage in bps, or null when the book is loading or too shallow. */ + estimatedSlippageBps: number | null; + /** True once the underlying order book subscription has produced data. */ + isReady: boolean; +} + +/** + * Estimates the slippage in basis points a market order would incur given the + * live HyperLiquid order book and the requested USD size. Combines the L2 book + * subscription with the pure VWAP calc helper so the order screen can show a + * "Est: X%" value and block submission when the estimate exceeds the user cap. + * + * @param options - Symbol, USD size, direction, and an optional enable flag. + * @returns Estimated slippage in bps and a readiness flag. + */ +export function usePerpsEstimatedSlippage({ + symbol, + sizeUsd, + isBuy, + enabled = true, +}: UsePerpsEstimatedSlippageOptions): UsePerpsEstimatedSlippageReturn { + // Throttle the L2 book at `SlippageEstimateThrottleMs`. The slippage row + // needs sub-second updates while the user types, which is faster than the + // generic order-form price guideline; the downstream `useMemo` keeps each + // tick cheap (one VWAP walk). + const { orderBook } = usePerpsLiveOrderBook({ + symbol, + enabled: enabled && Boolean(symbol), + levels: PERFORMANCE_CONFIG.SlippageEstimateBookLevels, + throttleMs: PERFORMANCE_CONFIG.SlippageEstimateThrottleMs, + }); + + const estimatedSlippageBps = useMemo(() => { + if (!enabled || !sizeUsd || sizeUsd <= 0) { + return null; + } + return calculateEstimatedSlippageBps({ + orderBook, + sizeUsd, + isBuy, + }); + }, [orderBook, sizeUsd, isBuy, enabled]); + + return { + estimatedSlippageBps, + isReady: orderBook !== null, + }; +} diff --git a/app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts new file mode 100644 index 000000000000..ddf6d1ed5738 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { usePerpsMaxSlippage } from './usePerpsMaxSlippage'; +import Engine from '../../../../core/Engine'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + getMaxSlippage: jest.fn(), + setMaxSlippage: jest.fn(), + }, + }, +})); + +const mockController = Engine.context.PerpsController as unknown as { + getMaxSlippage: jest.Mock; + setMaxSlippage: jest.Mock; +}; + +describe('usePerpsMaxSlippage', () => { + beforeEach(() => { + mockController.getMaxSlippage.mockReset(); + mockController.setMaxSlippage.mockReset(); + }); + + it('returns the controller value with `user_configured` source', () => { + mockController.getMaxSlippage.mockReturnValue(500); + const { result } = renderHook(() => usePerpsMaxSlippage()); + expect(result.current.maxSlippageBps).toBe(500); + expect(result.current.maxSlippageSource).toBe('user_configured'); + }); + + it('falls back to the controller default with `default` source when unset', () => { + mockController.getMaxSlippage.mockReturnValue(undefined); + const { result } = renderHook(() => usePerpsMaxSlippage()); + expect(result.current.maxSlippageBps).toBe(300); + expect(result.current.maxSlippageSource).toBe('default'); + }); + + it('persists a new value and refreshes the read', () => { + mockController.getMaxSlippage.mockReturnValue(undefined); + const { result } = renderHook(() => usePerpsMaxSlippage()); + + expect(result.current.maxSlippageBps).toBe(300); + expect(result.current.maxSlippageSource).toBe('default'); + + mockController.getMaxSlippage.mockReturnValue(450); + + act(() => { + result.current.setMaxSlippage(450); + }); + + expect(mockController.setMaxSlippage).toHaveBeenCalledWith(450); + expect(result.current.maxSlippageBps).toBe(450); + expect(result.current.maxSlippageSource).toBe('user_configured'); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts new file mode 100644 index 000000000000..9e29abbacf47 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts @@ -0,0 +1,50 @@ +import { useCallback, useMemo, useState } from 'react'; +import Engine from '../../../../core/Engine'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import { PERPS_SLIPPAGE_DEFAULT_BPS } from '../constants/slippageConfig'; + +type MaxSlippageSource = + (typeof PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE)[keyof typeof PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE]; + +export interface UsePerpsMaxSlippageReturn { + /** Resolved max slippage in basis points (falls back to the documented default). */ + maxSlippageBps: number; + /** Indicates whether the value comes from a persisted user choice or the default. */ + maxSlippageSource: MaxSlippageSource; + /** Persist a new max-slippage value (basis points). */ + setMaxSlippage: (bps: number) => void; +} + +/** + * Reads the user's persisted max slippage out of `PerpsController` so the + * order screen never reaches across the controller boundary directly. Returns + * both the resolved bps value and the source (default vs user-configured) so + * callers can pass `max_slippage_source` to MetaMetrics without re-running the + * lookup. Exposes a `setMaxSlippage` helper that bumps an internal revision + * counter, which forces the memoised reads to refresh after a save. + */ +export function usePerpsMaxSlippage(): UsePerpsMaxSlippageReturn { + const [revision, setRevision] = useState(0); + + const setMaxSlippage = useCallback((bps: number) => { + Engine.context.PerpsController?.setMaxSlippage(bps); + setRevision((current) => current + 1); + }, []); + + return useMemo(() => { + const stored = Engine.context.PerpsController?.getMaxSlippage?.(); + const maxSlippageBps = stored ?? PERPS_SLIPPAGE_DEFAULT_BPS; + const maxSlippageSource: MaxSlippageSource = + stored === undefined + ? PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE.DEFAULT + : PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE.USER_CONFIGURED; + return { + maxSlippageBps, + maxSlippageSource, + setMaxSlippage, + }; + // Engine.context read is intentionally not a hook dep; the revision + // counter forces the memo to re-run after `setMaxSlippage` writes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revision, setMaxSlippage]); +} diff --git a/app/components/UI/Perps/utils/slippageCalculation.test.ts b/app/components/UI/Perps/utils/slippageCalculation.test.ts new file mode 100644 index 000000000000..29335bc27683 --- /dev/null +++ b/app/components/UI/Perps/utils/slippageCalculation.test.ts @@ -0,0 +1,154 @@ +import type { OrderBookData, OrderBookLevel } from '@metamask/perps-controller'; +import { calculateEstimatedSlippageBps } from './slippageCalculation'; + +const level = (price: number, size: number): OrderBookLevel => ({ + price: String(price), + size: String(size), + total: String(size), + notional: String(price * size), + totalNotional: String(price * size), +}); + +const buildBook = ( + midPrice: number, + asks: OrderBookLevel[], + bids: OrderBookLevel[], +): OrderBookData => ({ + midPrice: String(midPrice), + asks, + bids, + spread: '0', + spreadPercentage: '0', + lastUpdated: 0, + maxTotal: '0', +}); + +describe('calculateEstimatedSlippageBps', () => { + it('returns null when the order book is null', () => { + expect( + calculateEstimatedSlippageBps({ + orderBook: null, + sizeUsd: 1000, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns null when sizeUsd is non-positive', () => { + const book = buildBook(100, [level(101, 10)], [level(99, 10)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 0, + isBuy: true, + }), + ).toBeNull(); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: -50, + isBuy: false, + }), + ).toBeNull(); + }); + + it('returns null when midPrice is not finite', () => { + const book = buildBook(NaN, [level(101, 10)], [level(99, 10)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 100, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns null when the targeted side has no levels', () => { + const book = buildBook(100, [], [level(99, 10)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 100, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns null when the book is too shallow to fill the request', () => { + const book = buildBook(100, [level(101, 1)], [level(99, 1)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 10_000, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns 0 bps for a buy that fills entirely at the first ask level above mid', () => { + // Mid 100, ask 100 means the VWAP equals mid → no slippage. + const book = buildBook(100, [level(100, 100)], [level(99, 100)]); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1000, + isBuy: true, + }); + expect(result).toBeCloseTo(0, 5); + }); + + it('returns the exact bps for a buy that walks two ask levels', () => { + // Mid 100, asks [100 x 10, 110 x 10]. + // Target base size = sizeUsd / midPrice = 1500 / 100 = 15. + // Walk: 10 @ 100, then 5 @ 110. + // VWAP = (10 * 100 + 5 * 110) / 15 = 1550 / 15 ≈ 103.3333. + // Slippage bps = (103.3333 - 100) / 100 * 10000 ≈ 333.33. + const book = buildBook(100, [level(100, 10), level(110, 10)], []); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1500, + isBuy: true, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeCloseTo(333.333, 2); + }); + + it('returns the exact bps for a sell that walks two bid levels', () => { + // Mid 100, bids [100 x 10, 90 x 10] (descending price). + // Target base size = 1500 / 100 = 15. + // Walk: 10 @ 100, then 5 @ 90. + // VWAP = (10 * 100 + 5 * 90) / 15 = 1450 / 15 ≈ 96.6667. + // Slippage bps = (100 - 96.6667) / 100 * 10000 ≈ 333.33. + const book = buildBook(100, [], [level(100, 10), level(90, 10)]); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1500, + isBuy: false, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeCloseTo(333.333, 2); + }); + + it('skips levels with zero or non-finite size', () => { + // The bad first level should not stop the walk. + const book = buildBook(100, [level(100, 0), level(101, 100)], []); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1000, + isBuy: true, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeGreaterThan(0); + }); + + it('never returns a negative slippage', () => { + // A "favourable" buy where the ask sits below mid still clamps to 0. + const book = buildBook(100, [level(90, 100)], []); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 100, + isBuy: true, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/app/components/UI/Perps/utils/slippageCalculation.ts b/app/components/UI/Perps/utils/slippageCalculation.ts new file mode 100644 index 000000000000..47115133ddc3 --- /dev/null +++ b/app/components/UI/Perps/utils/slippageCalculation.ts @@ -0,0 +1,86 @@ +import { + BASIS_POINTS_DIVISOR, + type OrderBookData, +} from '@metamask/perps-controller'; + +export interface EstimatedSlippageParams { + /** Live order book snapshot (typically from usePerpsLiveOrderBook). */ + orderBook: OrderBookData | null; + /** USD notional to fill. */ + sizeUsd: number; + /** true = BUY (sweeps asks), false = SELL (sweeps bids). */ + isBuy: boolean; +} + +/** + * Estimate slippage in basis points for a market order of `sizeUsd` against + * the current L2 book. Converts the USD size to a target base size + * (`sizeUsd / midPrice`) — matching the provider's execution model — walks + * the relevant side accumulating base size, then returns the VWAP's distance + * from the mid. Returns `null` when the book is missing or too shallow; the + * caller must treat that as "unknown" rather than zero. + * + * @param params - Order book snapshot, USD notional, and direction. + * @returns Estimated slippage in basis points (always non-negative) or null. + */ +export function calculateEstimatedSlippageBps({ + orderBook, + sizeUsd, + isBuy, +}: EstimatedSlippageParams): number | null { + if (!orderBook || !(sizeUsd > 0)) { + return null; + } + + const midPrice = Number(orderBook.midPrice); + if (!Number.isFinite(midPrice) || midPrice <= 0) { + return null; + } + + const levels = isBuy ? orderBook.asks : orderBook.bids; + if (!levels || levels.length === 0) { + return null; + } + + // Mirror the HyperLiquid execution model: the provider derives a fixed base + // size from `usdValue / currentPrice` and submits a limit at the slippage- + // buffered price, so the book walk must accumulate base size rather than + // quote notional. Walking by USD notional underestimates buy slippage and + // overestimates sell slippage versus the real fill. + const targetBaseSize = sizeUsd / midPrice; + if (!Number.isFinite(targetBaseSize) || targetBaseSize <= 0) { + return null; + } + + let filledBaseSize = 0; + let weightedPriceSum = 0; + + for (const level of levels) { + const price = Number(level.price); + const size = Number(level.size); + if (!Number.isFinite(price) || !Number.isFinite(size) || size <= 0) { + continue; + } + + const remainingBase = targetBaseSize - filledBaseSize; + + if (remainingBase <= size) { + // This level finishes the fill — only take the remaining base size. + weightedPriceSum += remainingBase * price; + filledBaseSize += remainingBase; + break; + } + + weightedPriceSum += size * price; + filledBaseSize += size; + } + + if (filledBaseSize < targetBaseSize || filledBaseSize <= 0) { + return null; + } + + const vwap = weightedPriceSum / filledBaseSize; + const slippageBps = + ((vwap - midPrice) / midPrice) * BASIS_POINTS_DIVISOR * (isBuy ? 1 : -1); + return Math.max(0, slippageBps); +} diff --git a/app/controllers/perps/PerpsController-method-action-types.ts b/app/controllers/perps/PerpsController-method-action-types.ts index 3e7ee2f260e2..cdc4c733a413 100644 --- a/app/controllers/perps/PerpsController-method-action-types.ts +++ b/app/controllers/perps/PerpsController-method-action-types.ts @@ -895,6 +895,26 @@ export type PerpsControllerSaveMarketFilterPreferencesAction = { handler: PerpsController['saveMarketFilterPreferences']; }; +/** + * Get the user's max slippage tolerance in basis points. + * + * @returns The configured max slippage bps, or undefined if never set (callers should default to 300 bps / 3%). + */ +export type PerpsControllerGetMaxSlippageAction = { + type: `PerpsController:getMaxSlippage`; + handler: PerpsController['getMaxSlippage']; +}; + +/** + * Set the user's max slippage tolerance in basis points. + * + * @param bps - Max slippage in basis points (e.g. 300 = 3%). Clamped to 10–1000, snapped to step of 10. + */ +export type PerpsControllerSetMaxSlippageAction = { + type: `PerpsController:setMaxSlippage`; + handler: PerpsController['setMaxSlippage']; +}; + /** * Set the selected payment token for the Perps order/deposit flow. * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. @@ -1060,6 +1080,8 @@ export type PerpsControllerMethodActions = | PerpsControllerClearPendingTradeConfigurationAction | PerpsControllerGetMarketFilterPreferencesAction | PerpsControllerSaveMarketFilterPreferencesAction + | PerpsControllerGetMaxSlippageAction + | PerpsControllerSetMaxSlippageAction | PerpsControllerSetSelectedPaymentTokenAction | PerpsControllerResetSelectedPaymentTokenAction | PerpsControllerGetOrderBookGroupingAction diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 92c2d4d2b605..9a5d4cf7bd7f 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -17,6 +17,7 @@ import { } from './constants/eventNames'; import { USDC_SYMBOL } from './constants/hyperLiquidConfig'; import { PerpsMeasurementName } from './constants/performanceMetrics'; +import type { SortOptionId } from './constants/perpsConfig'; import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, @@ -24,8 +25,8 @@ import { PERPS_DISK_CACHE_MARKETS, PERPS_DISK_CACHE_USER_DATA, buildProviderCacheKey, + MAX_SLIPPAGE_BOUNDS, } from './constants/perpsConfig'; -import type { SortOptionId } from './constants/perpsConfig'; import type { PerpsControllerMethodActions } from './PerpsController-method-action-types'; import { PERPS_ERROR_CODES } from './perpsErrorCodes'; import { AggregatedPerpsProvider } from './providers/AggregatedPerpsProvider'; @@ -343,6 +344,9 @@ export type PerpsControllerState = { }; }; + // Max slippage tolerance in basis points (e.g. 300 = 3%). Global user preference. + maxSlippageBps?: number; + // Market filter preferences (network-independent) - includes both sorting and filtering options marketFilterPreferences: { optionId: SortOptionId; @@ -590,6 +594,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + maxSlippageBps: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, marketFilterPreferences: { includeInStateLogs: true, persist: true, @@ -739,6 +749,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshEligibility', 'resetFirstTimeUserState', 'resetSelectedPaymentToken', + 'getMaxSlippage', + 'setMaxSlippage', 'saveMarketFilterPreferences', 'saveOrderBookGrouping', 'savePendingTradeConfiguration', @@ -4816,6 +4828,39 @@ export class PerpsController extends BaseController< }); } + /** + * Get the user's max slippage tolerance in basis points. + * + * @returns The configured max slippage bps, or undefined if never set (callers should default to 300 bps / 3%). + */ + getMaxSlippage(): number | undefined { + return this.state.maxSlippageBps; + } + + /** + * Set the user's max slippage tolerance in basis points. + * + * @param bps - Max slippage in basis points (e.g. 300 = 3%). Clamped to 10–1000, snapped to step of 10. + */ + setMaxSlippage(bps: number): void { + // Reject non-finite input (NaN/Infinity) so it cannot reach the order + // path, where it would poison `getMaxSlippage` and produce a NaN limit + // price. `Math.max(..., NaN)` returns NaN and `??` does not catch it. + if (!Number.isFinite(bps)) { + return; + } + const clamped = Math.min( + MAX_SLIPPAGE_BOUNDS.MaxBps, + Math.max(MAX_SLIPPAGE_BOUNDS.MinBps, bps), + ); + const snapped = + Math.round(clamped / MAX_SLIPPAGE_BOUNDS.StepBps) * + MAX_SLIPPAGE_BOUNDS.StepBps; + this.update((state) => { + state.maxSlippageBps = snapped; + }); + } + /** * Set the selected payment token for the Perps order/deposit flow. * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. diff --git a/app/controllers/perps/constants/eventNames.ts b/app/controllers/perps/constants/eventNames.ts index fae07b1910e5..54af92f8eeb7 100644 --- a/app/controllers/perps/constants/eventNames.ts +++ b/app/controllers/perps/constants/eventNames.ts @@ -159,6 +159,11 @@ export const PERPS_EVENT_PROPERTY = { INITIAL_PAYMENT_METHOD: 'initial_payment_method', NEW_PAYMENT_METHOD: 'new_payment_method', + // Slippage properties + MAX_SLIPPAGE_PCT: 'max_slippage_pct', + MAX_SLIPPAGE_SOURCE: 'max_slippage_source', + ESTIMATED_SLIPPAGE_PCT: 'estimated_slippage_pct', + // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) ABSTRACTION_MODE: 'abstraction_mode', PREVIOUS_ABSTRACTION_MODE: 'previous_abstraction_mode', @@ -329,6 +334,14 @@ export const PERPS_EVENT_VALUE = { PAYMENT_METHOD_CHANGED: 'payment_method_changed', // Deposit + order (pay-with token) cancel CANCEL_TRADE_WITH_TOKEN: 'cancel_trade_with_token', + // Slippage interactions + SLIPPAGE_CONFIG_OPENED: 'slippage_config_opened', + SLIPPAGE_CONFIG_CHANGED: 'slippage_config_changed', + SLIPPAGE_LIMIT_BLOCKED_ORDER: 'slippage_limit_blocked_order', + }, + MAX_SLIPPAGE_SOURCE: { + DEFAULT: 'default', + USER_CONFIGURED: 'user_configured', }, ACTION_TYPE: { START_TRADING: 'start_trading', @@ -412,6 +425,7 @@ export const PERPS_EVENT_VALUE = { }, SETTING_TYPE: { LEVERAGE: 'leverage', + SLIPPAGE: 'slippage', }, SCREEN_NAME: { CONNECTION_ERROR: 'connection_error', diff --git a/app/controllers/perps/constants/perpsConfig.ts b/app/controllers/perps/constants/perpsConfig.ts index 07e13d1d4aa8..bf6471b78ac5 100644 --- a/app/controllers/perps/constants/perpsConfig.ts +++ b/app/controllers/perps/constants/perpsConfig.ts @@ -106,6 +106,16 @@ export const ORDER_SLIPPAGE_CONFIG = { DefaultLimitSlippageBps: 100, } as const; +/** + * Bounds and step for the user-configurable max slippage preference (basis points). + * Shared by the controller (`setMaxSlippage`) and UI (`slippageConfig.ts`). + */ +export const MAX_SLIPPAGE_BOUNDS = { + MinBps: 10, + MaxBps: 1000, + StepBps: 10, +} as const; + /** * Max order amount buffer to reduce "Insufficient margin" rejections from the exchange. * When the user selects 100% (slider or Max), we cap the order at (1 - this) of the @@ -135,6 +145,20 @@ export const PERFORMANCE_CONFIG = { // Prevents WS subscription churn during rapid market switching (#28141) CandleConnectDebounceMs: 500, + // Order-form slippage estimate throttle (milliseconds) + // Updates the estimated-slippage value derived from the live L2 order book + // no more than once per window. Aggressive enough to keep the row reactive + // while the user edits the amount, conservative enough to avoid re-render + // pressure on every book tick. + SlippageEstimateThrottleMs: 250, + + // Order-book levels sampled when estimating slippage + // Number of price levels (per side) walked by `calculateEstimatedSlippageBps` + // to fill the requested USD notional. Matches the L2 sample size used by the + // order-book panel and is enough depth for the typical order sizes we + // surface in the order form. + SlippageEstimateBookLevels: 10, + // Candle WS teardown delay (milliseconds) // When the last subscriber for a cacheKey unsubscribes, wait this long before // tearing down the WS. A subsequent subscribe inside the window cancels the diff --git a/app/controllers/perps/index.ts b/app/controllers/perps/index.ts index b0f9f138142e..64d331b75b3d 100644 --- a/app/controllers/perps/index.ts +++ b/app/controllers/perps/index.ts @@ -409,6 +409,7 @@ export { WITHDRAWAL_CONSTANTS, VALIDATION_THRESHOLDS, ORDER_SLIPPAGE_CONFIG, + MAX_SLIPPAGE_BOUNDS, PERFORMANCE_CONFIG, TP_SL_CONFIG, HYPERLIQUID_ORDER_LIMITS, diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 204bb9e8dafb..74fbb053cd33 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -808,7 +808,7 @@ describe('HyperLiquidProvider', () => { isBuy: true, size: '0.1', orderType: 'market', - slippage: 0.02, // 2% slippage + maxSlippageBps: 200, // 2% } as OrderParams, }; @@ -817,11 +817,15 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(true); // Price is fetched from WebSocket cache (getCachedPrice) or REST API (allMids) as fallback - // Verify market orders use FrontendMarket TIF in edit operations + // Verify market orders use FrontendMarket TIF in edit operations, and + // that the user-configured cap (2% via maxSlippageBps: 200) actually + // moves the submitted limit price. BTC mock price is 50000, so a 2% buy + // buffer should produce a price of 51000. expect(mockClientService.getExchangeClient().modify).toHaveBeenCalledWith( expect.objectContaining({ order: expect.objectContaining({ t: { limit: { tif: 'FrontendMarket' } }, + p: expect.stringMatching(/^51000(\.0+)?$/), }), }), ); @@ -3610,13 +3614,55 @@ describe('HyperLiquidProvider', () => { size: '0.1', orderType: 'market', currentPrice: 50000, - slippage: 0.02, // 2% slippage + maxSlippageBps: 200, // 2% }; const result = await provider.placeOrder(orderParams); expect(result.success).toBe(true); - // Should use 2% slippage instead of default 1% + // 50000 * (1 + 0.02) = 51000 — verifies the user-configured cap reaches + // HyperLiquid as the buffered limit price (regression guard for the + // bps wiring fix). + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + p: expect.stringMatching(/^51000(\.0+)?$/), + }), + ], + }), + ); + }); + + it('normalizes the deprecated decimal `slippage` field to bps', async () => { + // Legacy publisher consumers may still call placeOrder with the + // deprecated decimal `slippage`. The provider must normalize it to + // the same submitted limit price as `maxSlippageBps: 200`. + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + slippage: 0.02, // 2% as decimal + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + p: expect.stringMatching(/^51000(\.0+)?$/), + }), + ], + }), + ); }); it('handles filled order response', async () => { diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 83b76c9ee018..ad444dde9d6b 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -3757,18 +3757,24 @@ export class HyperLiquidProvider implements PerpsProvider { blocklistMarkets: this.#blocklistMarkets, }); - // 2. Calculate final position size with USD reconciliation + // Normalize the deprecated decimal `slippage` to bps once so both the + // price-staleness check and the limit-price calc see the same value. + const normalizedMaxSlippageBps = + params.maxSlippageBps ?? + (typeof params.slippage === 'number' + ? Math.round(params.slippage * BASIS_POINTS_DIVISOR) + : undefined); + const { finalPositionSize } = calculateFinalPositionSize({ usdAmount: params.usdAmount, size: params.size, currentPrice: effectivePrice, priceAtCalculation: params.priceAtCalculation, - maxSlippageBps: params.maxSlippageBps, + maxSlippageBps: normalizedMaxSlippageBps, szDecimals: assetInfo.szDecimals, leverage: params.leverage, }); - // 3. Calculate order price and formatted size const { orderPrice, formattedSize, formattedPrice } = calculateOrderPriceAndSize({ orderType: params.orderType, @@ -3776,7 +3782,7 @@ export class HyperLiquidProvider implements PerpsProvider { finalPositionSize, currentPrice: effectivePrice, limitPrice: params.price, - slippage: params.slippage, + maxSlippageBps: normalizedMaxSlippageBps, szDecimals: assetInfo.szDecimals, }); @@ -3969,35 +3975,21 @@ export class HyperLiquidProvider implements PerpsProvider { dexName: dexName ?? null, }); - // Calculate order parameters using the same logic as placeOrder - let orderPrice: number; - let formattedSize: string; - - if (params.newOrder.orderType === 'market') { - const positionSize = parseFloat(params.newOrder.size); - const slippage = - params.newOrder.slippage ?? - ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000; - orderPrice = params.newOrder.isBuy - ? currentPrice * (1 + slippage) - : currentPrice * (1 - slippage); - formattedSize = formatHyperLiquidSize({ - size: positionSize, - szDecimals: assetInfo.szDecimals, - }); - } else { - if (!params.newOrder.price) { - throw new Error(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); - } - orderPrice = parseFloat(params.newOrder.price); - formattedSize = formatHyperLiquidSize({ - size: parseFloat(params.newOrder.size), - szDecimals: assetInfo.szDecimals, - }); - } - - const formattedPrice = formatHyperLiquidPrice({ - price: orderPrice, + // Calculate order parameters using the same helper as placeOrder so the + // slippage rules stay in one place (bps → decimal, market-only, default). + // Accept the deprecated decimal `slippage` field too, normalizing to bps. + const normalizedMaxSlippageBps = + params.newOrder.maxSlippageBps ?? + (typeof params.newOrder.slippage === 'number' + ? Math.round(params.newOrder.slippage * BASIS_POINTS_DIVISOR) + : undefined); + const { formattedSize, formattedPrice } = calculateOrderPriceAndSize({ + orderType: params.newOrder.orderType, + isBuy: params.newOrder.isBuy, + finalPositionSize: parseFloat(params.newOrder.size), + currentPrice, + limitPrice: params.newOrder.price, + maxSlippageBps: normalizedMaxSlippageBps, szDecimals: assetInfo.szDecimals, }); const assetId = await this.#getAssetIdWithRepair({ diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index e18262e518e6..9b29cdee65b6 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -159,12 +159,18 @@ export type OrderParams = { usdAmount?: string; // USD amount (primary source of truth, provider calculates size from this) priceAtCalculation?: number; // Price snapshot when size was calculated (for slippage validation) maxSlippageBps?: number; // Slippage tolerance in basis points (e.g., 100 = 1%, default if not provided) + /** + * @deprecated Use `maxSlippageBps` instead. Retained for one release so that + * existing publisher consumers (extension, core) that still pass slippage as + * a decimal (e.g. 0.03 for 3%) continue to work; the provider normalizes the + * value to basis points when `maxSlippageBps` is absent. + */ + slippage?: number; // Advanced order features takeProfitPrice?: string; // Take profit price stopLossPrice?: string; // Stop loss price clientOrderId?: string; // Optional client-provided order ID - slippage?: number; // Slippage tolerance for market orders (default: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000 = 3%) grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; // Override grouping (defaults: 'na' without TP/SL, 'normalTpsl' with TP/SL) currentPrice?: number; // Current market price (avoids extra API call if provided) leverage?: number; // Leverage to apply for the order (e.g., 10 for 10x leverage) diff --git a/app/controllers/perps/utils/orderCalculations.ts b/app/controllers/perps/utils/orderCalculations.ts index b5e70f85ce4e..1073a2def4da 100644 --- a/app/controllers/perps/utils/orderCalculations.ts +++ b/app/controllers/perps/utils/orderCalculations.ts @@ -4,6 +4,7 @@ import { formatHyperLiquidPrice, formatHyperLiquidSize, } from './hyperLiquidAdapter'; +import { BASIS_POINTS_DIVISOR } from '../constants/hyperLiquidConfig'; import { MAX_ORDER_MARGIN_BUFFER, ORDER_SLIPPAGE_CONFIG, @@ -58,7 +59,10 @@ export type CalculateOrderPriceAndSizeParams = { finalPositionSize: number; currentPrice: number; limitPrice?: string; - slippage?: number; + // Max slippage in basis points (e.g. 300 = 3%). Only applied to market orders; + // limit orders use limitPrice directly. Falls back to ORDER_SLIPPAGE_CONFIG + // .DefaultMarketSlippageBps when omitted on a market order. + maxSlippageBps?: number; szDecimals: number; }; @@ -314,7 +318,7 @@ export function calculateOrderPriceAndSize( finalPositionSize, currentPrice, limitPrice, - slippage, + maxSlippageBps, szDecimals, } = params; @@ -322,9 +326,12 @@ export function calculateOrderPriceAndSize( let formattedSize: string; if (orderType === 'market') { - // Market orders: add slippage (3% conservative default) - const slippageValue = - slippage ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000; + // Market orders: apply slippage buffer to the live price so HyperLiquid + // receives a worst-case acceptable limit price. Falls back to the + // documented default if the caller does not provide one. + const effectiveBps = + maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; + const slippageValue = effectiveBps / BASIS_POINTS_DIVISOR; orderPrice = isBuy ? currentPrice * (1 + slippageValue) : currentPrice * (1 - slippageValue); diff --git a/docs/perps/perps-architecture.md b/docs/perps/perps-architecture.md index 73e4f0d95db8..50199742038c 100644 --- a/docs/perps/perps-architecture.md +++ b/docs/perps/perps-architecture.md @@ -502,6 +502,13 @@ const prices = useLivePrices({ symbols: allSymbols, throttleMs: 2000 }); // Charts: near real-time (100ms throttle) const prices = useLivePrices({ symbols: ['BTC'], throttleMs: 100 }); + +// Slippage estimator: sub-second so the row reflects the size the user is +// typing. Downstream useMemo keeps per-tick work cheap. +const { orderBook } = usePerpsLiveOrderBook({ + symbol, + throttleMs: PERFORMANCE_CONFIG.SlippageEstimateThrottleMs, +}); ``` 4. **Shared cache** ensures instant data availability for all subscribers diff --git a/docs/perps/perps-review-antipatterns.md b/docs/perps/perps-review-antipatterns.md index ecab07ee1529..c12a5257c4eb 100644 --- a/docs/perps/perps-review-antipatterns.md +++ b/docs/perps/perps-review-antipatterns.md @@ -68,7 +68,7 @@ All provider access must go through `AggregatedPerpsProvider` → `ProviderRoute Single `PerpsAlwaysOnProvider` at wallet root owns lifecycle. All `PerpsConnectionProvider` instances use `manageLifecycle={false}`. - **New `PerpsConnectionProvider` with lifecycle** — adding a `PerpsConnectionProvider` without `manageLifecycle={false}` creates reference-count bugs. Only `PerpsAlwaysOnProvider` manages connect/disconnect. -- **Unthrottled WS → setState** — every WS tick triggers state update. Must use `useLivePrices` with appropriate `throttleMs` (100ms for charts, 2s for lists, 10s for order forms). +- **Unthrottled WS → setState** — every WS tick triggers state update. Must use `useLivePrices` with appropriate `throttleMs` (100ms for charts, 2s for lists, 10s for order forms). Exception: subscriptions that must react to user form input within the same tick (e.g. the L2 order-book subscription in `usePerpsEstimatedSlippage`) can use a sub-second cadence via `PERFORMANCE_CONFIG.SlippageEstimateThrottleMs`; downstream `useMemo` must keep per-tick work cheap so the faster cadence does not cause render pressure. - **Per-component WS subscription** — creating a new WebSocket connection per component instead of using `PerpsStreamManager` shared subscriptions with reference counting. - **WS subscription leak** — subscribing on mount without unsubscribing on unmount or market switch. `PerpsStreamManager` handles ref counting but custom subscriptions must clean up. - **Stale data after async gap** — reading position/order state, awaiting something, then using the stale read. WS updates change state between awaits. Re-read after async boundaries. diff --git a/locales/languages/en.json b/locales/languages/en.json index 6acfcb45e100..86e14a7e06f1 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1469,6 +1469,22 @@ "select_token_to_pay_with": "Please select a token to pay with before placing your order", "initializing": "Initializing order..." }, + "slippage": { + "config_title": "Set slippage", + "config_description": "Your transaction won't go through if the price shifts beyond this threshold.", + "set": "Set", + "cancel": "Cancel", + "custom": "Custom", + "use_custom_title": "Use custom slippage", + "row_format": "Est: {{est}}% / Max: {{value}}%", + "row_format_pending": "Est: -- / Max: {{value}}%", + "input_label": "Max slippage percentage", + "decrement_label": "Decrease slippage", + "increment_label": "Increase slippage", + "out_of_range": "Enter a value between {{min}}% and {{max}}%", + "slippage": "Slippage", + "exceeds_max": "Estimated slippage ({{est}}%) exceeds your max slippage ({{max}}%). Increase the cap or reduce the order size." + }, "price_deviation_warning": { "message": "Price has deviated too much from the spot price. New positions cannot be opened at this time." }, @@ -1924,6 +1940,10 @@ "pay_with": { "title": "Pay with", "content": "Choose which token or balance to use to pay for this trade. You can pay with your Perps balance or select another token from your wallet." + }, + "slippage": { + "title": "Estimated slippage", + "content": "Slippage is the difference between the expected price and the price at which your order is filled. Larger orders or low-liquidity markets may have higher slippage." } }, "connection": { From b4f31a44080d993bd233b48087e186b4a1a7f057 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 21 May 2026 14:19:03 +0800 Subject: [PATCH 04/14] chore(skills): switch to public MetaMask/skills with optional Consensys overlay (#30180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Follow-up to [#29853](https://github.com/MetaMask/metamask-mobile/pull/29853). Switches `yarn skills` to the new **public** [`MetaMask/skills`](https://github.com/MetaMask/skills) repo, with the private [`Consensys/skills`](https://github.com/Consensys/skills) repo available as an optional overlay for internal-only material. Adds a `postinstall` hook so engineers get an auto-updated skill set on every `yarn install` — no manual `yarn skills` needed. ### Why public - Cloud agents (Cursor cloud, Codex cloud, Claude.ai web) can't `git clone` a private repo or set engineer-managed env vars. The public repo solves this with a one-liner curl bootstrap (no SSH, no auth). - New engineers don't need Consensys SSO to start using skills — the public set installs cleanly on its own. - Out-of-the-box auto-sync via `postinstall` (Ramon's ask in `#engineering-mobile`). ### Engineer experience (zero-config) ``` yarn install # postinstall clones MetaMask/skills into .skills-cache/ and installs yarn skills # auto-detects the cache, no env var or shell rc edit needed ``` Override only if you keep a separate clone: ``` export METAMASK_SKILLS_DIR=~/dev/metamask/skills export CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional private overlay ``` ### Changes - `scripts/skills-sync.mts` — multi-source aware wrapper (was bash, ported to node `.mts` for cross-platform consistency). Resolution order: `METAMASK_SKILLS_DIR` → `CONSENSYS_SKILLS_DIR` → auto-fallback to `.skills-cache/metamask-skills`. When the cache is used, injects `METAMASK_SKILLS_DIR=` so the bash sync underneath sees the source. - `scripts/skills-postinstall.mts` **(new)** — auto-clones `MetaMask/skills` into `.skills-cache/metamask-skills` and runs the installer on every `yarn install`. Layered with `CONSENSYS_SKILLS_DIR` when set. Best-effort: skipped on CI, on `SKILLS_SKIP_POSTINSTALL=1`, or when offline. Opt back in on CI with `SKILLS_FORCE_POSTINSTALL=1`. - `.gitignore` — adds `.skills-cache/`, updates comments to reference public canonical. - `package.json` — adds `postinstall` hook + lavamoat `"$root$": true` allowScripts entry; `skills` script uses `tsx scripts/skills-sync.mts` (node 20 has no native TS). - `.skills.local.example` — documents new zero-config default + optional env-var override. ### Companion PRs - [`MetaMask/skills`](https://github.com/MetaMask/skills) (merged) — public source of truth, install CLI, multi-source `sync`, cloud-agent `bootstrap`. - [`Consensys/skills#9`](https://github.com/Consensys/skills/pull/9) — private overlay only; repo retained for future internal material. - [`MetaMask/metamask-extension#42488`](https://github.com/MetaMask/metamask-extension/pull/42488) — parallel extension migration. - [`MetaMask/decisions#162`](https://github.com/MetaMask/decisions/pull/162) — ADR amendment. ### Out of scope - TS rewrite of CLI. - Personal-skills tooling. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: zero-config yarn skills sync Scenario: fresh clone, no env vars Given the engineer cloned this branch and has no METAMASK_SKILLS_DIR set When they run `yarn install` Then `.skills-cache/metamask-skills/` is populated from MetaMask/skills And skills are installed under `.claude/`, `.cursor/`, `.agents/` And `git status --short` reports a clean working tree When they later run `yarn skills` Then the wrapper auto-detects the cache and re-syncs without any env var Scenario: engineer with explicit clone Given METAMASK_SKILLS_DIR points at a separate clone outside the repo When the engineer runs `yarn skills` Then the explicit clone is used in preference to the cache Scenario: federation with private overlay Given METAMASK_SKILLS_DIR and CONSENSYS_SKILLS_DIR both point at valid clones When the engineer runs `yarn skills` Then skills from both sources install And on name collision the Consensys (private) version wins Scenario: CI install skips postinstall by default Given CI=1 is set When the engineer runs `yarn install` Then skills-postinstall.mts exits immediately without writing anything Scenario: CI install opts back in Given CI=1 and SKILLS_FORCE_POSTINSTALL=1 are set When the engineer runs `yarn install` Then skills-postinstall.mts clones the cache and runs the install Scenario: opt out per machine Given SKILLS_SKIP_POSTINSTALL=1 is set When the engineer runs `yarn install` Then skills-postinstall.mts exits immediately Scenario: Windows engineer without WSL/Git Bash Given the engineer runs the wrapper on Windows PowerShell When they run `yarn skills` or `yarn install` Then the node wrappers execute without requiring bash on PATH at the wrapper layer ``` ## **Screenshots/Recordings** No UI change — agent tooling and repo wiring only. ### **Before** N/A ### **After** N/A ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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** > Medium risk because it changes install-time behavior via a new `postinstall` script (runs `git` and writes to `.skills-cache/`) and updates Lavamoat allow-scripts, which could impact developer/CI installs if misconfigured. > > **Overview** > Updates agent-skills tooling to prefer the public `MetaMask/skills` source (optionally layered with `Consensys/skills`) and replaces the old `bash`-only `scripts/skills-sync.sh` with a `tsx` wrapper that auto-detects sources via env vars or `.skills.local`. > > Adds a best-effort `postinstall` hook that clones/updates `MetaMask/skills` into `.skills-cache/` (skipped on CI unless opted in) and updates docs/config templates plus `.gitignore`/Lavamoat settings to support the new zero-config workflow. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ca9187eebc683626d5ff48f39f9285ce727f6b75. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .gitignore | 11 +- .skills.local.example | 24 +++- README.md | 22 ++-- package.json | 4 +- scripts/skills-postinstall.mts | 100 +++++++++++++++++ scripts/skills-sync.mts | 196 +++++++++++++++++++++++++++++++++ scripts/skills-sync.sh | 31 ------ 7 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 scripts/skills-postinstall.mts create mode 100644 scripts/skills-sync.mts delete mode 100755 scripts/skills-sync.sh diff --git a/.gitignore b/.gitignore index 582846d58404..6fa6c4955d71 100644 --- a/.gitignore +++ b/.gitignore @@ -199,13 +199,18 @@ release-test-plan.json release-delta.json release-signoffs.json -# Per-engineer skills config (auto-generated by Consensys/skills sync). +# Per-engineer skills config (auto-generated by MetaMask/skills sync). # Copy `.skills.local.example` to `.skills.local` and edit `SKILLS_DOMAINS=`. .skills.local +# Local cache used by `postinstall` to auto-update skills from the public +# MetaMask/skills repo. Safe to delete — postinstall recreates on next install. +.skills-cache/ + # Agent skills/commands/rules — never tracked. Synced via `yarn skills` from -# Consensys/skills, or hand-authored locally. (See ADR #57.) Only IDE/bugbot -# config under `.cursor/` is tracked via the carve-outs below. +# MetaMask/skills (public) and optionally Consensys/skills (private overlay), +# or hand-authored locally. (See ADR #57.) Only IDE/bugbot config under +# `.cursor/` is tracked via the carve-outs below. .claude/skills/ .claude/commands/ .agents/skills/ diff --git a/.skills.local.example b/.skills.local.example index 4b5b4c4e80a6..04c1476aaeaf 100644 --- a/.skills.local.example +++ b/.skills.local.example @@ -1,12 +1,26 @@ # Template for per-engineer skills config used by `yarn skills`. -# Copy this file to `.skills.local` (gitignored) and set the domains -# you want installed by default. +# Copy this file to `.skills.local` (gitignored). # -# Examples: -# SKILLS_DOMAINS= # interactive prompt each run +# Zero-config default: the `postinstall` hook clones MetaMask/skills into +# `.skills-cache/metamask-skills` on every `yarn install`. `yarn skills` +# auto-detects that cache when no env var is set — nothing to do. +# +# Optional persistent skills config belongs in this file. Environment variables +# with the same names are only for one-off shell or CI overrides and take +# precedence over this file. +# METAMASK_SKILLS_DIR path to MetaMask/skills checkout (public, no auth) +# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay) +# +# Example local setup (only if you want to override the cache): +# METAMASK_SKILLS_DIR=~/dev/metamask/skills +# CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional +# +# Default behavior installs ALL domains. Set SKILLS_DOMAINS to opt out of some: +# SKILLS_DOMAINS= # all (default) # SKILLS_DOMAINS=perps # single domain -# SKILLS_DOMAINS=perps,testing,pr # multiple domains +# SKILLS_DOMAINS=perps,testing,pr-workflow # multiple domains # # Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain `. +# Pick interactively with `yarn skills --select`. # Use `yarn skills --reset` to wipe. SKILLS_DOMAINS= diff --git a/README.md b/README.md index 03e98c17f46c..c59b9b644dfb 100644 --- a/README.md +++ b/README.md @@ -199,25 +199,27 @@ yarn start:android ### AI Agent Skills (`yarn skills`) -AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the [Consensys/skills](https://github.com/Consensys/skills) repo. Per [ADR #57](https://github.com/MetaMask/decisions/pull/162) this content is **not committed here** — `yarn skills` syncs it on demand into local-only paths under `.cursor/`, `.claude/`, and `.agents/`. +AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the [MetaMask/skills](https://github.com/MetaMask/skills) repo, with an optional private overlay from [Consensys/skills](https://github.com/Consensys/skills). Per [ADR #57](https://github.com/MetaMask/decisions/pull/162) this content is **not committed here** — `yarn skills` syncs it on demand into local-only paths under `.cursor/`, `.claude/`, and `.agents/`. -One-time setup: +Zero-config setup: ```bash -git clone git@github.com:Consensys/skills.git ~/path/to/consensys-skills -export CONSENSYS_SKILLS_DIR=~/path/to/consensys-skills # add to your shell rc +yarn install # clones MetaMask/skills into .skills-cache/metamask-skills +yarn skills # syncs all default skills from the cache ``` -Keep that checkout on `main` — `yarn skills` syncs from whatever revision is checked out there. - -Then in this repo: +Optional local configuration: ```bash -yarn skills # interactive prompt -SKILLS_DOMAINS=perps,testing yarn skills # non-interactive +cp .skills.local.example .skills.local +# edit .skills.local to set SKILLS_DOMAINS or override skills source paths +yarn skills --select # interactively pick domains +SKILLS_DOMAINS=perps,testing yarn skills # one-off domain override ``` -If `CONSENSYS_SKILLS_DIR` is unset, `yarn skills` prints the same setup instructions and exits. Skipping it is fine — it only affects agent tooling, not the app build. +Use `.skills.local` for persistent skills configuration. Shell environment variables with the same names are supported for one-off or CI overrides and take precedence. + +Skipping `yarn skills` is fine — it only affects agent tooling, not the app build. ### Git Hooks (Husky) diff --git a/package.json b/package.json index 38acd0c31e8f..a0f876a885aa 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "coverage:analyze": "node scripts/coverage-analysis.js", "coverage:files": "node scripts/coverage-analysis.js --files", "setup": "yarn clean && node scripts/setup.mjs", - "skills": "bash scripts/skills-sync.sh", + "skills": "tsx scripts/skills-sync.mts", + "postinstall": "tsx scripts/skills-postinstall.mts", "setup:ci": "yarn clean:ios && yarn clean:android && node scripts/setup.mjs", "setup:github-ci": "node scripts/setup.mjs --build-on-github-ci", "eas": "eas", @@ -727,6 +728,7 @@ }, "lavamoat": { "allowScripts": { + "$root$": true, "ethereumjs-abi>ethereumjs-util>ethereum-cryptography>keccak": true, "@sentry/react-native>@sentry/cli": true, "@storybook/manager-webpack5>@storybook/core-common>webpack>watchpack>watchpack-chokidar2>chokidar>fsevents": false, diff --git a/scripts/skills-postinstall.mts b/scripts/skills-postinstall.mts new file mode 100644 index 000000000000..0a2d4537f69b --- /dev/null +++ b/scripts/skills-postinstall.mts @@ -0,0 +1,100 @@ +// Auto-update skills on `yarn install`. Best-effort: never fails the install. +// +// - Skipped on CI, or when SKILLS_SKIP_POSTINSTALL=1. +// - Override CI skip with SKILLS_FORCE_POSTINSTALL=1 (for CI jobs that +// actually need skills installed, e.g. agent-driven review bots). +// - Clones https://github.com/MetaMask/skills (public, no auth) into +// .skills-cache/metamask-skills if absent. +// - `git fetch + reset` to origin/main if present. +// - Leaves installation/domain selection to `yarn skills`, which reads +// .skills.local and SKILLS_DOMAINS. +// - All errors swallowed with a one-line warning. Engineers can run +// `yarn skills` manually for interactive feedback. + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { mkdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +if (process.env.SKILLS_SKIP_POSTINSTALL) { + process.exit(0); +} +if (process.env.CI && !process.env.SKILLS_FORCE_POSTINSTALL) { + process.exit(0); +} + +const CACHE_DIR = '.skills-cache/metamask-skills'; +const PUBLIC_REPO = 'https://github.com/MetaMask/skills.git'; + +function warn(msg: string): void { + process.stderr.write( + `skills postinstall: ${msg} (run \`yarn skills\` for details)\n`, + ); +} + +function run(cmd: string, args: string[]): SpawnSyncReturns { + return spawnSync(cmd, args, { stdio: 'ignore' }); +} + +function isGitDir(dir: string): boolean { + try { + return statSync(path.join(dir, '.git')).isDirectory(); + } catch { + return false; + } +} + +// Top-level guard: synchronous calls (mkdirSync, statSync, existsSync) can +// throw on EPERM, read-only filesystem, or unexpected file-vs-dir conflicts. +// Honor the "never fails the install" contract — swallow anything that +// escapes and exit 0. +try { + if (!isGitDir(CACHE_DIR)) { + mkdirSync(path.dirname(CACHE_DIR), { recursive: true }); + const clone = run('git', [ + 'clone', + '--depth', + '1', + '--branch', + 'main', + PUBLIC_REPO, + CACHE_DIR, + ]); + if (clone.status !== 0) { + warn('clone failed (offline?)'); + process.exit(0); + } + } else { + const fetch = run('git', [ + '-C', + CACHE_DIR, + 'fetch', + '--depth', + '1', + 'origin', + 'main', + ]); + if (fetch.status !== 0) { + warn('fetch failed (offline?)'); + process.exit(0); + } + const reset = run('git', [ + '-C', + CACHE_DIR, + 'reset', + '--hard', + 'origin/main', + ]); + if (reset.status !== 0) { + warn('reset failed'); + process.exit(0); + } + } + + // `yarn skills` performs installation with the selected Bash and honors + // .skills.local / SKILLS_DOMAINS. Postinstall only keeps the public cache + // available so that default path works without any local configuration. +} catch (e) { + const msg = e instanceof Error ? e.message : String(e); + warn(`unexpected error: ${msg}`); +} +process.exit(0); diff --git a/scripts/skills-sync.mts b/scripts/skills-sync.mts new file mode 100644 index 000000000000..f30900b92c9f --- /dev/null +++ b/scripts/skills-sync.mts @@ -0,0 +1,196 @@ +// Wrapper for `yarn skills`. Picks a multi-source-aware tools/sync from +// whichever skill repo is configured and delegates. +// +// Source configuration comes from env vars first, then .skills.local. +// Prefer the public MetaMask/skills sync CLI whenever it is available: +// 1. METAMASK_SKILLS_DIR/tools/sync +// 2. .skills-cache/metamask-skills/tools/sync (zero-config default) +// 3. CONSENSYS_SKILLS_DIR/tools/sync (private fallback when no public source exists) +// The public sync still walks every configured source. Cache fallback means +// `yarn skills` works out of the box after `yarn install` without any shell rc +// edit. + +import { spawnSync } from 'child_process'; +import { readFileSync, statSync } from 'fs'; +import path from 'path'; +import { parse } from 'dotenv'; + +const REPO = 'metamask-mobile'; +const CACHE_DIR = path.join(process.cwd(), '.skills-cache', 'metamask-skills'); +const CONFIG_FILE = path.join(process.cwd(), '.skills.local'); +const SOURCE_ENV_KEYS = [ + 'METAMASK_SKILLS_DIR', + 'CONSENSYS_SKILLS_DIR', +] as const; + +function syncIn(dir: string): string | null { + const candidate = path.join(dir, 'tools', 'sync'); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + // ignored + } + return null; +} + +function bashMajorVersion(candidate: string): number | null { + const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' }); + if (result.status !== 0) return null; + + const match = `${result.stdout}${result.stderr}`.match( + /GNU bash, version (\d+)\./u, + ); + return match ? Number(match[1]) : null; +} + +function pickBash(): string | null { + const candidates = [ + process.env.BASH, + 'bash', + '/opt/homebrew/bin/bash', + '/usr/local/bin/bash', + '/bin/bash', + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of new Set(candidates)) { + const major = bashMajorVersion(candidate); + if (major && major >= 4) { + return candidate; + } + } + + return null; +} + +type SkillSourceEnv = Record< + (typeof SOURCE_ENV_KEYS)[number], + string | undefined +>; +type SyncPick = { sync: string }; + +function expandLeadingTilde(value: string | undefined): string | undefined { + if (!value?.startsWith('~')) { + return value; + } + + if (!process.env.HOME) { + return value; + } + + if (value === '~') { + return process.env.HOME; + } + + if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { + return path.join(process.env.HOME, value.slice(2)); + } + + return value; +} + +function loadSkillSourceEnv(): SkillSourceEnv { + const env: SkillSourceEnv = { + METAMASK_SKILLS_DIR: process.env.METAMASK_SKILLS_DIR, + CONSENSYS_SKILLS_DIR: process.env.CONSENSYS_SKILLS_DIR, + }; + + try { + const localConfig = parse(readFileSync(CONFIG_FILE, 'utf8')); + for (const key of SOURCE_ENV_KEYS) { + if (!env[key]) { + env[key] = expandLeadingTilde(localConfig[key]); + } + } + } catch { + // ignored: .skills.local is optional + } + + return env; +} + +function pickSync(sourceEnv: SkillSourceEnv): SyncPick | null { + const publicSync = sourceEnv.METAMASK_SKILLS_DIR + ? syncIn(sourceEnv.METAMASK_SKILLS_DIR) + : null; + if (publicSync) { + return { sync: publicSync }; + } + + const cacheSync = syncIn(CACHE_DIR); + if (cacheSync) { + return { sync: cacheSync }; + } + + if (sourceEnv.CONSENSYS_SKILLS_DIR) { + const privateSync = syncIn(sourceEnv.CONSENSYS_SKILLS_DIR); + if (privateSync) { + return { sync: privateSync }; + } + } + + return null; +} + +const sourceEnv = loadSkillSourceEnv(); +const picked = pickSync(sourceEnv); +if (!picked) { + process.stderr.write( + [ + 'No skills source available.', + '', + 'The postinstall hook normally clones the public skills repo into', + '.skills-cache/metamask-skills automatically. If that did not happen', + '(e.g. you ran the wrapper before `yarn install`, or postinstall was', + 'skipped via CI / SKILLS_SKIP_POSTINSTALL), point at a clone manually', + 'in .skills.local:', + '', + ' git clone https://github.com/MetaMask/skills ~/dev/metamask/skills', + ' echo METAMASK_SKILLS_DIR=~/dev/metamask/skills >> .skills.local', + '', + 'Optional private overlay (Consensys internal, SSH required):', + ' git clone git@github.com:Consensys/skills.git ~/dev/Consensys/skills', + ' echo CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills >> .skills.local', + '', + 'Then re-run `yarn skills`.', + '', + ].join('\n'), + ); + process.exit(1); +} + +const env = { ...process.env }; +for (const key of SOURCE_ENV_KEYS) { + if (!env[key] && sourceEnv[key]) { + env[key] = sourceEnv[key]; + } +} +if (!env.METAMASK_SKILLS_DIR && syncIn(CACHE_DIR)) { + env.METAMASK_SKILLS_DIR = CACHE_DIR; +} + +const bash = pickBash(); +if (!bash) { + process.stderr.write( + [ + 'No supported Bash found.', + '', + '`yarn skills` requires Bash 4+ because the shared skills installer uses', + 'modern Bash features. macOS /bin/bash is 3.2.', + '', + 'Install a current Bash, then re-run `yarn skills`:', + ' brew install bash', + '', + ].join('\n'), + ); + process.exit(1); +} +if (bash.includes(path.sep)) { + env.PATH = `${path.dirname(bash)}${path.delimiter}${env.PATH ?? ''}`; +} + +const result = spawnSync( + bash, + [picked.sync, '--repo', REPO, '--target', process.cwd(), ...process.argv.slice(2)], + { stdio: 'inherit', env }, +); +process.exit(result.status === null ? 1 : result.status); diff --git a/scripts/skills-sync.sh b/scripts/skills-sync.sh deleted file mode 100755 index 46bc9549fce3..000000000000 --- a/scripts/skills-sync.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Wrapper for `yarn skills`. Delegates to Consensys/skills/tools/sync. -# Lives here (not in package.json) to keep yarn's variable handling out of the way. -set -eu - -if [[ -z "${CONSENSYS_SKILLS_DIR:-}" ]]; then - cat >&2 <<'EOF' -CONSENSYS_SKILLS_DIR is not set. - -To set up (one time): - 1. Clone the source repo somewhere on your machine: - git clone git@github.com:Consensys/skills.git ~/path/to/skills - 2. Export the env var (add to your shell rc): - export CONSENSYS_SKILLS_DIR=~/path/to/skills - -Then re-run `yarn skills`. -EOF - exit 1 -fi - -if [[ ! -x "$CONSENSYS_SKILLS_DIR/tools/sync" ]]; then - cat >&2 < Date: Thu, 21 May 2026 01:21:35 -0500 Subject: [PATCH 05/14] fix: prefer selected evm account (#30253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates Perps account resolution so compliance gated Perps flows evaluate the same EVM account the user currently has selected. As part of the compliance work, Perps needs to reliably identify the active wallet address before allowing account specific actions such as deposits, trades, signing, and account state operations. The previous logic derived the address from the selected account group, which could pick a different EVM account than the user facing selected account when multiple EVM accounts exist in the group. The solution adds a selected-account-first lookup across the Perps controller and related services. Perps now reads `AccountsController:getSelectedAccount` when it is an EVM account, and falls back to the existing selected account group behavior when needed. The PR also delegates that action through the Perps controller messenger so the selected account path works at runtime. This keeps Perps address resolution aligned with the active wallet while preserving compatibility with the existing account group fallback path. This also wires Perps cache invalidation to selected-account changes, so switching between EVM accounts in the same account group clears stale user data and preloads data for the newly selected account. ## **Changelog** CHANGELOG entry: Prefer the selected EVM account when resolving Perps account state and compliance-gated actions. ## **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** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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** > Changes how Perps resolves the active wallet address and when it clears/preloads cached user data, which can impact deposits/signing and account-scoped state if the new selection logic misidentifies the active account. > > **Overview** > Perps now resolves the active EVM address by **preferring `AccountsController:getSelectedAccount`** (when it’s an EVM account) and falling back to the selected account group, via new `getSelectedEvmAccountFromMessenger` helpers. > > This selection logic is applied across `PerpsController` and related services (deposit/withdrawal, wallet adapters, rewards, data lake reporting), and market/user-data preloading now also listens to `AccountsController:selectedAccountChange` to clear disk+memory user cache and refresh data on account switches. Tests were added/updated to cover selected-account preference, cache invalidation, and messenger delegation. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b6c41c929a8bb73e3689b2479455cb60ebc02ba5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- app/controllers/perps/PerpsController.test.ts | 84 ++++++++++++ app/controllers/perps/PerpsController.ts | 36 ++---- .../perps/services/AccountService.ts | 8 +- .../perps/services/DataLakeService.ts | 8 +- .../perps/services/DepositService.test.ts | 32 ++++- .../perps/services/DepositService.ts | 10 +- .../services/HyperLiquidWalletService.test.ts | 56 +++++++- .../services/HyperLiquidWalletService.ts | 31 ++--- .../perps/services/MYXWalletService.test.ts | 41 ++++++ .../perps/services/MYXWalletService.ts | 44 ++----- .../services/RewardsIntegrationService.ts | 8 +- app/controllers/perps/types/messenger.ts | 6 + .../perps/utils/accountUtils.test.ts | 121 ++++++++++++++++++ app/controllers/perps/utils/accountUtils.ts | 59 +++++++++ .../perps-controller-messenger/index.test.ts | 60 +++++++++ .../perps-controller-messenger/index.ts | 5 +- 16 files changed, 506 insertions(+), 103 deletions(-) diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts index d1313036d244..9ead43fc73cf 100644 --- a/app/controllers/perps/PerpsController.test.ts +++ b/app/controllers/perps/PerpsController.test.ts @@ -5131,6 +5131,90 @@ describe('PerpsController', () => { expect(() => controller.stopMarketDataPreload()).not.toThrow(); }); + it('clears stale user data cache when the selected account changes', () => { + const selectedAddress = '0x2222222222222222222222222222222222222222'; + const staleAddress = '0x1111111111111111111111111111111111111111'; + const selectedAccount = { + address: selectedAddress, + type: 'eip155:eoa', + id: 'account-2', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Selected', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }; + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [selectedAccount]; + } + return undefined; + }); + const mockMessenger = createMockMessenger({ call: mockCall }); + const localInfrastructure = createMockInfrastructure(); + const localController = new TestablePerpsController({ + messenger: mockMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: localInfrastructure, + }); + localController.testMarkInitialized(); + localController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + localController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [], + orders: [], + accountState: null, + timestamp: Date.now(), + address: staleAddress, + }; + }); + + localController.startMarketDataPreload(); + + const selectedAccountChangeHandler = ( + mockMessenger.subscribe as jest.Mock + ).mock.calls.find( + ([event]) => event === 'AccountsController:selectedAccountChange', + )?.[1] as (() => void) | undefined; + const accountGroupChangeHandler = ( + mockMessenger.subscribe as jest.Mock + ).mock.calls.find( + ([event]) => + event === 'AccountTreeController:selectedAccountGroupChange', + )?.[1] as (() => void) | undefined; + + expect(selectedAccountChangeHandler).toEqual(expect.any(Function)); + expect(accountGroupChangeHandler).toBe(selectedAccountChangeHandler); + + selectedAccountChangeHandler?.(); + + expect(localController.state.cachedUserDataByProvider).toEqual({}); + expect(localInfrastructure.diskCache.removeItem).toHaveBeenCalledWith( + PERPS_DISK_CACHE_USER_DATA, + ); + + localController.stopMarketDataPreload(); + + expect(mockMessenger.unsubscribe).toHaveBeenCalledWith( + 'AccountsController:selectedAccountChange', + selectedAccountChangeHandler, + ); + expect(mockMessenger.unsubscribe).toHaveBeenCalledWith( + 'AccountTreeController:selectedAccountGroupChange', + selectedAccountChangeHandler, + ); + }); + it('hydrates market data from disk at construction time', () => { const diskMarkets = { providerNetworkKey: 'hyperliquid:mainnet', diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 9a5d4cf7bd7f..22d90aabd8be 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -121,7 +121,7 @@ import { LastTransactionResult, TransactionStatus, } from './types/transactionTypes'; -import { getSelectedEvmAccount } from './utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils'; import { ensureError } from './utils/errorUtils'; import { hydrateFromDiskSync, @@ -1167,11 +1167,7 @@ export class PerpsController extends BaseController< // Get current user address for validation let currentAddress: string | null = null; try { - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); currentAddress = evmAccount?.address ?? null; } catch { // Can't determine current account — trust the cache @@ -2228,11 +2224,7 @@ export class PerpsController extends BaseController< currentDepositId = depositId; // Get current account address via messenger (outside of update() for proper typing) - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const accountAddress = evmAccount?.address ?? 'unknown'; this.update((state) => { @@ -3101,13 +3093,9 @@ export class PerpsController extends BaseController< this.messenger.unsubscribe('PerpsController:stateChange', handler); }; - // Watch for account changes via AccountTreeController + // Watch for selected account changes and selected account group changes. const accountChangeHandler = (): void => { - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const currentAddress = evmAccount?.address ?? null; // If any cached entry belongs to a different account, clear all entries. @@ -3140,11 +3128,19 @@ export class PerpsController extends BaseController< } } }; + this.messenger.subscribe( + 'AccountsController:selectedAccountChange', + accountChangeHandler, + ); this.messenger.subscribe( 'AccountTreeController:selectedAccountGroupChange', accountChangeHandler, ); this.#accountChangeUnsubscribe = (): void => { + this.messenger.unsubscribe( + 'AccountsController:selectedAccountChange', + accountChangeHandler, + ); this.messenger.unsubscribe( 'AccountTreeController:selectedAccountGroupChange', accountChangeHandler, @@ -3345,11 +3341,7 @@ export class PerpsController extends BaseController< } // Get current user address - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); if (!evmAccount?.address) { return; } diff --git a/app/controllers/perps/services/AccountService.ts b/app/controllers/perps/services/AccountService.ts index d30738729301..3b4003643ca0 100644 --- a/app/controllers/perps/services/AccountService.ts +++ b/app/controllers/perps/services/AccountService.ts @@ -24,7 +24,7 @@ import type { } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { TransactionStatus } from '../types/transactionTypes'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; /** @@ -124,10 +124,8 @@ export class AccountService { const netAmount = Math.max(0, grossAmount - feeAmount); // Get current account address via messenger - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const evmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); const accountAddress = evmAccount?.address ?? 'unknown'; diff --git a/app/controllers/perps/services/DataLakeService.ts b/app/controllers/perps/services/DataLakeService.ts index 8e7035ea57a4..a713357e7fb3 100644 --- a/app/controllers/perps/services/DataLakeService.ts +++ b/app/controllers/perps/services/DataLakeService.ts @@ -9,7 +9,7 @@ import { import { PerpsTraceNames, PerpsTraceOperations } from '../types'; import type { PerpsPlatformDependencies } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; /** @@ -131,11 +131,7 @@ export class DataLakeService { try { const token = await this.#getBearerToken(); - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount || !token) { this.#deps.debugLogger.log('DataLake API: Missing requirements', { diff --git a/app/controllers/perps/services/DepositService.test.ts b/app/controllers/perps/services/DepositService.test.ts index 7b2dea0bdb10..64bd4182c4c2 100644 --- a/app/controllers/perps/services/DepositService.test.ts +++ b/app/controllers/perps/services/DepositService.test.ts @@ -45,6 +45,8 @@ describe('DepositService', () => { const mockAssetId = 'eip155:42161/erc20:0xTokenAddress/default'; beforeEach(() => { + jest.clearAllMocks(); + mockProvider = createMockHyperLiquidProvider() as unknown as jest.Mocked; @@ -87,8 +89,6 @@ describe('DepositService', () => { } return value; }); - - jest.clearAllMocks(); }); afterEach(() => { @@ -114,6 +114,34 @@ describe('DepositService', () => { }); }); + it('uses the selected EVM account as the transaction sender', async () => { + const selectedAccount = { + ...mockEvmAccount, + address: '0x2222222222222222222222222222222222222222', + }; + const groupAccount = { + ...mockEvmAccount, + address: '0x3333333333333333333333333333333333333333', + }; + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [groupAccount]; + } + return undefined; + }); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.from).toBe(selectedAccount.address); + }); + it('generates unique deposit ID for tracking', async () => { await service.prepareTransaction({ provider: mockProvider, diff --git a/app/controllers/perps/services/DepositService.ts b/app/controllers/perps/services/DepositService.ts index 860964db75da..d1feadcf4757 100644 --- a/app/controllers/perps/services/DepositService.ts +++ b/app/controllers/perps/services/DepositService.ts @@ -9,7 +9,7 @@ import type { PerpsTransactionParams, } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { generateDepositId } from '../utils/idUtils'; import { generateERC20TransferData } from '../utils/transferData'; @@ -76,12 +76,8 @@ export class DepositService { '0x0', ); - // Get EVM account from selected account group via messenger - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + // Get EVM account from selected account, falling back to the selected account group. + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount) { throw new Error( 'No EVM-compatible account found in selected account group', diff --git a/app/controllers/perps/services/HyperLiquidWalletService.test.ts b/app/controllers/perps/services/HyperLiquidWalletService.test.ts index ab052c0b9b5f..89e6ae1264ec 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.test.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.test.ts @@ -118,6 +118,60 @@ describe('HyperLiquidWalletService', () => { expect(typeof walletAdapter.getChainId).toBe('function'); }); + it('prefers the selected EVM account over the selected account group', async () => { + const selectedAccount = { + ...mockEvmAccount, + address: '0x2222222222222222222222222222222222222222', + }; + const groupAccount = { + ...mockEvmAccount, + address: '0x3333333333333333333333333333333333333333', + }; + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [groupAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + return undefined; + }); + + const selectedAdapter = service.createWalletAdapter(); + + expect(selectedAdapter.address).toBe(selectedAccount.address); + + await selectedAdapter.signTypedData({ + domain: { + name: 'HyperLiquid', + version: '1', + chainId: 42161, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + from: selectedAccount.address, + }), + 'V4', + ); + }); + describe('getChainId method', () => { it('should return mainnet chain ID', async () => { expect(walletAdapter.getChainId).toBeDefined(); @@ -386,7 +440,7 @@ describe('HyperLiquidWalletService', () => { }); await expect(service.getCurrentAccountId()).rejects.toThrow( - 'Store error', + 'NO_ACCOUNT_SELECTED', ); }); diff --git a/app/controllers/perps/services/HyperLiquidWalletService.ts b/app/controllers/perps/services/HyperLiquidWalletService.ts index 1f8dbcbbf8ba..ee6688609dbc 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.ts @@ -12,7 +12,10 @@ import type { PerpsTypedMessageParams, } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils'; +import { + getSelectedEvmAccountDetailsFromMessenger, + getSelectedEvmAccountFromMessenger, +} from '../utils/accountUtils'; // Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this // service portable between mobile and the core monorepo. @@ -61,10 +64,8 @@ export class HyperLiquidWalletService { * @returns True for MetaMask hardware keyrings; false for software accounts. */ public isSelectedHardwareWallet(): boolean { - const selectedEvmAccount = findEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const selectedEvmAccount = getSelectedEvmAccountDetailsFromMessenger( + this.#messenger, ); if (!selectedEvmAccount || !hasProperty(selectedEvmAccount, 'metadata')) { return false; @@ -122,12 +123,8 @@ export class HyperLiquidWalletService { }) => Promise; getChainId?: () => Promise; } { - // Get current EVM account via DI accountTree - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + // Get current EVM account via DI messenger + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -152,10 +149,8 @@ export class HyperLiquidWalletService { }): Promise => { // Get FRESH account on every sign to handle account switches // This prevents race conditions where wallet adapter was created with old account - const currentEvmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentEvmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentEvmAccount?.address) { @@ -200,11 +195,7 @@ export class HyperLiquidWalletService { * @returns The CAIP account ID for the current EVM account. */ public async getCurrentAccountId(): Promise { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); diff --git a/app/controllers/perps/services/MYXWalletService.test.ts b/app/controllers/perps/services/MYXWalletService.test.ts index a271209a987c..50c09d6c7487 100644 --- a/app/controllers/perps/services/MYXWalletService.test.ts +++ b/app/controllers/perps/services/MYXWalletService.test.ts @@ -126,6 +126,47 @@ describe('MYXWalletService', () => { expect(address).toBe(mockEvmAccount.address); }); + it('uses the selected EVM account for signer address and signing', async () => { + const selectedAccount = { + ...mockEvmAccount, + address: '0x2222222222222222222222222222222222222222', + }; + const groupAccount = { + ...mockEvmAccount, + address: '0x3333333333333333333333333333333333333333', + }; + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [groupAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + return undefined; + }); + + const signer = service.createEthersSigner(); + + await expect(signer.getAddress()).resolves.toBe(selectedAccount.address); + await signer.signTypedData({ name: 'MYX' }, { Test: [] }, {}); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + from: selectedAccount.address, + }), + 'V4', + ); + }); + it('getAddress() throws when account disappears', async () => { const signer = service.createEthersSigner(); diff --git a/app/controllers/perps/services/MYXWalletService.ts b/app/controllers/perps/services/MYXWalletService.ts index ba3d9b2cd171..a2720fab7f76 100644 --- a/app/controllers/perps/services/MYXWalletService.ts +++ b/app/controllers/perps/services/MYXWalletService.ts @@ -26,7 +26,7 @@ import { import type { PerpsControllerMessenger } from '../PerpsController'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import type { PerpsPlatformDependencies } from '../types'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; export class MYXWalletService { #isTestnet: boolean; @@ -83,21 +83,15 @@ export class MYXWalletService { ) => Promise; provider: null; } { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } return { getAddress: async (): Promise => { - const currentAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -109,10 +103,8 @@ export class MYXWalletService { types: Record, value: Record, ): Promise => { - const currentAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -160,11 +152,7 @@ export class MYXWalletService { message: Record; }) => Promise; } { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } @@ -174,10 +162,8 @@ export class MYXWalletService { account: { address: evmAccount.address }, chain: { id: chainId }, signTypedData: async (args): Promise => { - const currentAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -207,11 +193,7 @@ export class MYXWalletService { } public getUserAddress(): Hex { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } @@ -223,11 +205,7 @@ export class MYXWalletService { } public async getCurrentAccountId(): Promise { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } diff --git a/app/controllers/perps/services/RewardsIntegrationService.ts b/app/controllers/perps/services/RewardsIntegrationService.ts index a74e231f58b3..6717d5da976e 100644 --- a/app/controllers/perps/services/RewardsIntegrationService.ts +++ b/app/controllers/perps/services/RewardsIntegrationService.ts @@ -5,7 +5,7 @@ import { import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { PerpsPlatformDependencies } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; @@ -63,11 +63,7 @@ export class RewardsIntegrationService { */ async calculateUserFeeDiscount(): Promise { try { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount) { this.#deps.debugLogger.log( diff --git a/app/controllers/perps/types/messenger.ts b/app/controllers/perps/types/messenger.ts index d5becee6797b..5489121617ed 100644 --- a/app/controllers/perps/types/messenger.ts +++ b/app/controllers/perps/types/messenger.ts @@ -2,6 +2,10 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, } from '@metamask/account-tree-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, @@ -32,6 +36,7 @@ export type PerpsControllerAllowedActions = | KeyringControllerSignTypedMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction + | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | AuthenticationController.AuthenticationControllerGetBearerTokenAction; @@ -40,6 +45,7 @@ export type PerpsControllerAllowedActions = */ export type PerpsControllerAllowedEvents = | RemoteFeatureFlagControllerStateChangeEvent + | AccountsControllerSelectedAccountChangeEvent | AccountTreeControllerSelectedAccountGroupChangeEvent; /** diff --git a/app/controllers/perps/utils/accountUtils.test.ts b/app/controllers/perps/utils/accountUtils.test.ts index ba604a1e290f..86e3a6d647c2 100644 --- a/app/controllers/perps/utils/accountUtils.test.ts +++ b/app/controllers/perps/utils/accountUtils.test.ts @@ -1,3 +1,5 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState } from '../types'; @@ -5,9 +7,128 @@ import { addSpotBalanceToAccountState, aggregateAccountStates, calculateWeightedReturnOnEquity, + getSelectedEvmAccountDetailsFromMessenger, + getSelectedEvmAccountFromMessenger, getSpotBalance, } from './accountUtils'; +type SelectedEvmAccountMessenger = Parameters< + typeof getSelectedEvmAccountFromMessenger +>[0]; + +const SELECTED_ADDRESS = '0x1111111111111111111111111111111111111111'; +const GROUP_ADDRESS = '0x2222222222222222222222222222222222222222'; +const NON_EVM_ADDRESS = 'bc1qselectedaccount'; + +function buildAccount( + address: string, + id: string, + type: InternalAccount['type'] = 'eip155:eoa', +): InternalAccount { + return { + address, + id, + type, + options: {}, + methods: [], + metadata: { + name: id, + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + scopes: ['eip155:0'], + } as InternalAccount; +} + +function buildMessenger( + call: (actionType: string) => InternalAccount | InternalAccount[], +): SelectedEvmAccountMessenger { + return { call }; +} + +describe('getSelectedEvmAccountFromMessenger', () => { + it('returns selected account details when requested', () => { + const selectedAccount = buildAccount(SELECTED_ADDRESS, 'selected'); + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountDetailsFromMessenger(messenger)).toBe( + selectedAccount, + ); + }); + + it('prefers the selected account over the first evm account in the selected group', () => { + const selectedAccount = buildAccount(SELECTED_ADDRESS, 'selected'); + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: SELECTED_ADDRESS, + }); + }); + + it('falls back to the selected account group when selected account lookup is unavailable', () => { + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + throw new Error('Selected account unavailable'); + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: GROUP_ADDRESS, + }); + }); + + it('falls back to the selected account group when the selected account is not evm', () => { + const selectedAccount = buildAccount( + NON_EVM_ADDRESS, + 'selected', + 'bip122:p2wpkh', + ); + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: GROUP_ADDRESS, + }); + }); +}); + describe('aggregateAccountStates', () => { const fallback: AccountState = { spendableBalance: PERPS_CONSTANTS.FallbackDataDisplay, diff --git a/app/controllers/perps/utils/accountUtils.ts b/app/controllers/perps/utils/accountUtils.ts index 747cdd7620b5..2d50fffd2dcb 100644 --- a/app/controllers/perps/utils/accountUtils.ts +++ b/app/controllers/perps/utils/accountUtils.ts @@ -37,6 +37,65 @@ export function getSelectedEvmAccount( return getEvmAccountFromAccountGroup(accounts); } +type SelectedEvmAccountMessenger = { + call( + actionType: + | 'AccountsController:getSelectedAccount' + | 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ): unknown; +}; + +function isAccountLike( + value: unknown, +): value is InternalAccount | PerpsInternalAccount { + const account = value as { address?: unknown; type?: unknown } | null; + + return ( + typeof value === 'object' && + value !== null && + typeof account?.address === 'string' && + typeof account.type === 'string' + ); +} + +export function getSelectedEvmAccountDetailsFromMessenger( + messenger: SelectedEvmAccountMessenger, +): InternalAccount | PerpsInternalAccount | undefined { + try { + const selectedAccount = messenger.call( + 'AccountsController:getSelectedAccount', + ); + if (isAccountLike(selectedAccount)) { + const evmAccount = findEvmAccount([selectedAccount]); + if (evmAccount) { + return evmAccount; + } + } + } catch { + // Fall back to the selected account group if the direct lookup is unavailable. + } + + try { + const selectedAccountGroup = messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + return Array.isArray(selectedAccountGroup) + ? (findEvmAccount(selectedAccountGroup.filter(isAccountLike)) ?? + undefined) + : undefined; + } catch { + return undefined; + } +} + +export function getSelectedEvmAccountFromMessenger( + messenger: SelectedEvmAccountMessenger, +): { address: string } | undefined { + const evmAccount = getSelectedEvmAccountDetailsFromMessenger(messenger); + + return evmAccount ? { address: evmAccount.address } : undefined; +} + export type ReturnOnEquityInput = { unrealizedPnl: string | number; returnOnEquity: string | number; diff --git a/app/core/Engine/messengers/perps-controller-messenger/index.test.ts b/app/core/Engine/messengers/perps-controller-messenger/index.test.ts index ecadd1fdde8c..08b6c159120b 100644 --- a/app/core/Engine/messengers/perps-controller-messenger/index.test.ts +++ b/app/core/Engine/messengers/perps-controller-messenger/index.test.ts @@ -1,8 +1,30 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { getPerpsControllerMessenger } from '.'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +interface GetSelectedAccountAction { + type: 'AccountsController:getSelectedAccount'; + handler: () => InternalAccount; +} + describe('PerpsController Messenger', () => { + const selectedAccount: InternalAccount = { + id: 'selected-account-id', + address: '0x1111111111111111111111111111111111111111', + type: 'eip155:eoa' as const, + options: {}, + methods: [], + metadata: { + name: 'Selected Account', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + scopes: ['eip155:1'], + }; + it('returns an instance of the perps controller messenger', () => { const baseControllerMessenger = new ExtendedMessenger({ namespace: MOCK_ANY_NAMESPACE, @@ -25,4 +47,42 @@ describe('PerpsController Messenger', () => { expect(typeof result[method]).toBe('function'); }); }); + + it('delegates the selected account action to the perps controller messenger', () => { + const baseControllerMessenger = new ExtendedMessenger< + MockAnyNamespace, + GetSelectedAccountAction + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + baseControllerMessenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + () => selectedAccount, + ); + + const result = getPerpsControllerMessenger(baseControllerMessenger); + + expect(result.call('AccountsController:getSelectedAccount')).toBe( + selectedAccount, + ); + }); + + it('delegates required events to the perps controller messenger', () => { + const baseControllerMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const delegateSpy = jest.spyOn(baseControllerMessenger, 'delegate'); + + getPerpsControllerMessenger(baseControllerMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ]), + }), + ); + }); }); diff --git a/app/core/Engine/messengers/perps-controller-messenger/index.ts b/app/core/Engine/messengers/perps-controller-messenger/index.ts index 445e22622748..6f426f263dc5 100644 --- a/app/core/Engine/messengers/perps-controller-messenger/index.ts +++ b/app/core/Engine/messengers/perps-controller-messenger/index.ts @@ -11,7 +11,8 @@ import { * * PerpsController uses the messenger for all cross-controller communication: * NetworkController, KeyringController, TransactionController, - * RemoteFeatureFlagController, AccountTreeController, AuthenticationController. + * RemoteFeatureFlagController, AccountsController, AccountTreeController, + * AuthenticationController. * The root messenger already registers actions for these controllers, * so the child messenger can call them through the parent. * @@ -40,11 +41,13 @@ export function getPerpsControllerMessenger( 'KeyringController:signTypedMessage', 'TransactionController:addTransaction', 'RemoteFeatureFlagController:getState', + 'AccountsController:getSelectedAccount', 'AccountTreeController:getAccountsFromSelectedAccountGroup', 'AuthenticationController:getBearerToken', ], events: [ 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', 'AccountTreeController:selectedAccountGroupChange', ], messenger, From 66f0cb2fedec81d18fba4db5750e3b590dfdf26d Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Thu, 21 May 2026 09:12:47 +0100 Subject: [PATCH 06/14] fix(analytics): normalise Segment proxy URL to fix invalid-URL error in 2.23.0 cp-7.78.0 (#30463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `@segment/analytics-react-native` 2.23.0 introduced a strict `validateURL` regex via [PR #1157](https://github.com/segmentio/analytics-react-native/pull/1157) that only allows `[a-zA-Z0-9_.-]` in query-param values. The MetaMask Segment proxy URL encodes the write key as standard base64 in a query param (`?b===`), and the trailing `=` padding characters are rejected by this regex. When the URL fails validation `SegmentDestination.getEndpoint()` silently falls back to `https://api.segment.io/v1/b`. Events reach Segment's default endpoint but the proxy write key is only valid through `fn.segmentapis.com`, so they are rejected — causing **no events to appear in Mixpanel**. ## Change Added `normalizeProxyUrl` in `platform-adapter.ts` that strips trailing `=` padding from query-param values before passing the URL to the Segment client config. - Stripping base64 padding is safe: decoders infer it from data length and the proxy server accepts both forms. - The `=` key–value separator is preserved (the regex uses a lookahead `(?=&|$)` to match only padding at the end of a param value). - Contains a `TODO` to remove once upstream fixes the regex to accept all RFC 3986 query characters. ## Test plan - [ ] Run the app in dev mode and verify analytics events appear in Mixpanel - [ ] Verify no "Invalid URL has been passed" errors in the console - [ ] Run unit tests: `yarn jest platform-adapter` Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how the Segment client is configured and could affect where analytics events are sent if the proxy URL is altered incorrectly, though the change is narrowly scoped and covered by unit tests. > > **Overview** > Fixes Segment proxy URL validation failures by normalizing `SEGMENT_PROXY_URL` before passing it to `createClient`, stripping trailing base64 `=` padding from query-param values (while preserving key/value separators). > > Adds focused unit coverage for `normalizeProxyUrl` across common URL shapes (single/double padding, multi-param URLs) and a wiring test to ensure `createPlatformAdapter` passes the normalized `proxy` value into Segment client configuration. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ee00a416e2520dc6bd1b356eed866bf85ad65d80. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: João Santos --- .../platform-adapter.test.ts | 162 +++++++++++++++++- .../analytics-controller/platform-adapter.ts | 23 ++- 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts b/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts index c853fba6c657..bc7bc10d9b4d 100644 --- a/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts +++ b/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts @@ -1,5 +1,6 @@ -import { createPlatformAdapter } from './platform-adapter'; +import { createPlatformAdapter, normalizeProxyUrl } from './platform-adapter'; import { + createClient, type SegmentClient, DestinationPlugin, } from '@segment/analytics-react-native'; @@ -38,6 +39,145 @@ interface GlobalWithSegmentClient { segmentMockClient: SegmentClient; } +const mockCreateClient = createClient as jest.MockedFunction< + typeof createClient +>; + +// Realistic proxy URL shapes used by MetaMask (base64 write-key in query params). +// The actual values are redacted in .js.env; the structural pattern is: +// https://fn.segmentapis.com/v1/b?b=[=|==] +// DEV keys typically produce single `=` padding; PROD keys often produce `==`. +const DEV_PROXY_URL = + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1kZXYta2V5MTIzNA=='; +const PROD_PROXY_URL = + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1wcm9kLWtleUFCQ0Q='; +const MULTI_PARAM_URL = + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdC1rZXkxMjM=&v=2'; + +describe('normalizeProxyUrl', () => { + it('returns undefined when url is undefined', () => { + const result = normalizeProxyUrl(undefined); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when url is an empty string', () => { + const result = normalizeProxyUrl(''); + + expect(result).toBeUndefined(); + }); + + it('returns the URL unchanged when there is no base64 padding', () => { + const url = 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'; + + const result = normalizeProxyUrl(url); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'); + }); + + it('strips a single trailing = from the last query param', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ=', + ); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'); + }); + + it('strips double trailing == from the last query param', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ==', + ); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'); + }); + + it('strips trailing = padding from a mid-URL query param followed by another param', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ==&v=2', + ); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ&v=2'); + }); + + it('preserves = key-value separators in query params', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdGtleQ==', + ); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdGtleQ', + ); + }); + + it('strips padding from multiple params that each carry base64 values', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?a=dGVzdA==&b=dGVzdGtleQ=', + ); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?a=dGVzdA&b=dGVzdGtleQ', + ); + }); + + it('returns the URL unchanged when it has no query string', () => { + const url = 'https://fn.segmentapis.com/v1/b'; + + const result = normalizeProxyUrl(url); + + expect(result).toBe('https://fn.segmentapis.com/v1/b'); + }); + + it('normalises the dev-environment proxy URL (double == padding)', () => { + const result = normalizeProxyUrl(DEV_PROXY_URL); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1kZXYta2V5MTIzNA', + ); + }); + + it('normalises the prod-environment proxy URL (single = padding)', () => { + const result = normalizeProxyUrl(PROD_PROXY_URL); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1wcm9kLWtleUFCQ0Q', + ); + }); + + it('normalises a multi-param proxy URL preserving non-base64 params', () => { + const result = normalizeProxyUrl(MULTI_PARAM_URL); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdC1rZXkxMjM&v=2', + ); + }); + + it('passes Segment validateURL regex after normalisation for dev URL', () => { + const result = normalizeProxyUrl(DEV_PROXY_URL) as string; + + // The Segment regex allows only [a-zA-Z0-9_.-] in query-param values. + // After normalisation no `=` padding should remain in any param value. + const queryString = result.split('?')[1] ?? ''; + const paramValues = queryString + .split('&') + .map((pair) => pair.split('=')[1]); + paramValues.forEach((value) => { + expect(value).toMatch(/^[a-zA-Z0-9_.-]+$/); + }); + }); + + it('passes Segment validateURL regex after normalisation for prod URL', () => { + const result = normalizeProxyUrl(PROD_PROXY_URL) as string; + + const queryString = result.split('?')[1] ?? ''; + const paramValues = queryString + .split('&') + .map((pair) => pair.split('=')[1]); + paramValues.forEach((value) => { + expect(value).toMatch(/^[a-zA-Z0-9_.-]+$/); + }); + }); +}); + describe('createPlatformAdapter', () => { beforeEach(() => { jest.clearAllMocks(); @@ -169,4 +309,24 @@ describe('createPlatformAdapter', () => { expect(adapter1).not.toBe(adapter2); }); }); + + describe('proxy URL normalisation', () => { + // babel-plugin-transform-inline-environment-variables bakes process.env.* + // at compile time, so env vars cannot be mutated at test runtime. The + // SEGMENT_PROXY_URL value is therefore always undefined in the Jest + // environment, which means createClient receives an undefined proxy. The + // normaliseProxyUrl unit tests above already prove the function handles + // all URL shapes. Here we verify the wiring: createClient receives the + // output of normalizeProxyUrl, whatever that resolves to. + it('passes the output of normalizeProxyUrl as the proxy config to createClient', () => { + mockCreateClient.mockClear(); + + createPlatformAdapter(); + + expect(mockCreateClient).toHaveBeenCalledTimes(1); + const calledConfig = mockCreateClient.mock.calls[0][0]; + // The proxy field is present in the config object (value depends on env). + expect(calledConfig).toHaveProperty('proxy'); + }); + }); }); diff --git a/app/core/Engine/controllers/analytics-controller/platform-adapter.ts b/app/core/Engine/controllers/analytics-controller/platform-adapter.ts index a80eb48da0cf..f6135eae590c 100644 --- a/app/core/Engine/controllers/analytics-controller/platform-adapter.ts +++ b/app/core/Engine/controllers/analytics-controller/platform-adapter.ts @@ -15,10 +15,31 @@ import { segmentPersistor } from '../../../../util/analytics/SegmentPersistor'; import Logger from '../../../../util/Logger'; import MetaMetricsPrivacySegmentPlugin from '../../../../util/analytics/privacySegmentPlugin'; +/** + * Strips trailing `=` padding from every query-param value in a URL. + * + * @segment/analytics-react-native ≥2.23.0 introduced a strict `validateURL` + * regex that only allows `[a-zA-Z0-9_.-]` in query-param values, which rejects + * the standard base64 `=` padding characters present in the Segment proxy write + * key. Stripping the padding is safe – base64 decoders always infer it from the + * data length, and the proxy server accepts both forms. + * + * TODO: remove once upstream fixes the regex to accept all RFC 3986 query chars. + * See: https://github.com/segmentio/analytics-react-native/pull/1157 + */ +export const normalizeProxyUrl = ( + url: string | undefined, +): string | undefined => { + if (!url) return undefined; + // Replace any run of `=` that is followed by `&` (next param) or end-of-string + // (end of query). This strips base64 padding without touching `=` separators. + return url.replace(/[=]+(?=&|$)/g, ''); +}; + const getSegmentClient = (): SegmentClient => { const config: Config = { writeKey: process.env.SEGMENT_WRITE_KEY as string, - proxy: process.env.SEGMENT_PROXY_URL as string, + proxy: normalizeProxyUrl(process.env.SEGMENT_PROXY_URL), debug: __DEV__, // Use custom persistor to bridge Segment SDK with app's storage system storePersistor: segmentPersistor, From 381269beb8ee0b8d4cdaac616b8e701e4352ed32 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Thu, 21 May 2026 01:31:38 -0700 Subject: [PATCH 07/14] feat: show RewardsVipBadge in swap page (#30410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Changes - Expose getVipTierForAccount from rewards controller - implement RewardsVipBadge component, which fetches the account's vipTier and displays it - Update Bridge page disclaimers to reflect discounted fees ## **Changelog** CHANGELOG entry: feat: show RewardsVipBadge in swap page ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4465 ## **Manual testing steps** ```gherkin Feature: rewards vip badge Scenario: user is in the VIP program Given they request a swap quote When user receives a quote Then they see the VIP badge and their discounted MM fee Scenario: user is not in the VIP program Given they request a swap quote When user receives a quote Then they don't see the VIP badge ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-05-19 at 2 54 41 PM Screenshot 2026-05-19 at 2 54 50 PM Screenshot 2026-05-19 at 2 55 05 PM ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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 fee-disclaimer logic in the Bridge confirmation flow and adds a new RewardsController API that calls `/vip/fees`, which could affect pricing/UX if the quote fee fields or VIP lookup behave unexpectedly. > > **Overview** > **Bridge footer now surfaces VIP fee discounts.** A new `useFeeDisclaimer` hook derives the disclaimer copy from quote `baseBpsFee` vs `quoteBpsFee`, and `BridgeViewFooter` renders a struck-through base fee plus discounted fee text when applicable. > > **Adds a reusable VIP badge and controller support.** Introduces `RewardsVipBadge` (and tests) that fetches and displays the account VIP tier, exports `formatAccountToCaipAccountId` for CAIP formatting, and adds `RewardsController.getVipTierForAccount` (with shared VIP fees fetch/dedup refactor + action type wiring) to support the badge. > > Also updates selectors/testIDs and i18n strings to support the new disclaimer/badge and adjusts unit tests for discounted/undefined-fee cases. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e1754eedd1d98e74cb0d213b392e7ee0fa29ee1c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Views/BridgeView/BridgeView.testIds.ts | 1 + .../BridgeView/BridgeViewFooter.test.tsx | 109 ++++++++++++++- .../Views/BridgeView/BridgeViewFooter.tsx | 109 ++++++++++----- .../UI/Bridge/hooks/useFeeDisclaimer.ts | 75 ++++++++++ .../UI/Bridge/hooks/useRewards/useRewards.ts | 2 +- .../RewardsVipBadge/RewardsVipBadge.test.tsx | 73 ++++++++++ .../RewardsVipBadge/RewardsVipBadge.tsx | 65 +++++++++ .../components/RewardsVipBadge/index.ts | 1 + .../RewardsController-method-action-types.ts | 6 + .../RewardsController.test.ts | 46 ++++++ .../rewards-controller/RewardsController.ts | 132 +++++++++++------- .../rewards/metamask-rewards-points-vip.svg | 3 + locales/languages/en.json | 4 + 13 files changed, 535 insertions(+), 91 deletions(-) create mode 100644 app/components/UI/Bridge/hooks/useFeeDisclaimer.ts create mode 100644 app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx create mode 100644 app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx create mode 100644 app/components/UI/Rewards/components/RewardsVipBadge/index.ts create mode 100644 app/images/rewards/metamask-rewards-points-vip.svg diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts index 202609b23c2e..fbc9cd23748c 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts @@ -9,6 +9,7 @@ export const BridgeViewSelectorsIDs = { FEE_DISCLAIMER: 'bridge-fee-disclaimer', QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton', MISSING_PRICE_BANNER: 'bridge-missing-price-banner', + APPROVAL_TOOLTIP: 'bridge-approval-text', } as const; export type BridgeViewSelectorsIDsType = typeof BridgeViewSelectorsIDs; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx index 076f5ac3cb4d..dd4d46d57965 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx @@ -10,6 +10,7 @@ import { RequestStatus, type QuoteResponse, MetaMetricsSwapsEventSource, + BRIDGE_MM_FEE_RATE, } from '@metamask/bridge-controller'; import { Hex } from '@metamask/utils'; import { isHardwareAccount } from '../../../../../util/address'; @@ -74,6 +75,16 @@ jest.mock('../../components/ApprovalText', () => { }; }); +jest.mock('../../../Rewards/components/RewardsVipBadge/RewardsVipBadge', () => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + MockReact.createElement(View, { testID: 'rewards-vip-badge' }), + }; +}); + // ─── Helpers ───────────────────────────────────────────────────────────────── const mockLocation = MetaMetricsSwapsEventSource.MainView; @@ -303,6 +314,29 @@ describe('BridgeViewFooter', () => { }); }); + it('shows discounted fee disclaimer with fee percentage when fee is less than base fee', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: { + ...mockQuoteWithMetadata, + quote: { + feeData: { metabridge: { quoteBpsFee: 57.5, baseBpsFee: 90 } }, + }, + }, + })); + + const { getByTestId } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect(getByTestId('rewards-vip-badge')).toBeTruthy(); + expect( + getByTestId(BridgeViewSelectorsIDs.FEE_DISCLAIMER), + ).toHaveTextContent('Includes0.9%0.575% MM fee.'); + }); + }); + it('shows no MM fee disclaimer when dest token is mUSD and fee is zero', async () => { const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; @@ -313,7 +347,14 @@ describe('BridgeViewFooter', () => { isLoading: false, activeQuote: { ...(mockQuoteWithMetadata as unknown as QuoteResponse), - quote: { feeData: { metabridge: { quoteBpsFee: 0 } } }, + quote: { + ...mockQuoteWithMetadata.quote, + destAsset: { + ...mockQuoteWithMetadata.quote.destAsset, + symbol: 'mUSD', + }, + feeData: { metabridge: { quoteBpsFee: 0, baseBpsFee: 87.5 } }, + }, } as unknown as QuoteResponse, })); @@ -354,6 +395,72 @@ describe('BridgeViewFooter', () => { ).toBeTruthy(); }); }); + + it('shows fee disclaimer when fee is undefined', async () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: false, + activeQuote: { + ...(mockQuoteWithMetadata as unknown as QuoteResponse), + quote: { + ...mockQuoteWithMetadata.quote, + destAsset: { + ...mockQuoteWithMetadata.quote.destAsset, + symbol: 'mUSD', + }, + feeData: { + metabridge: { quoteBpsFee: undefined, baseBpsFee: undefined }, + }, + }, + } as unknown as QuoteResponse, + })); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: 12, + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + }, + destToken: { + address: musdAddress, + chainId: '0x1' as Hex, + decimals: 6, + image: '', + name: 'MetaMask USD', + symbol: 'mUSD', + }, + }, + }); + + const { getByText } = renderFooter(testState as DeepPartial); + + await waitFor(() => { + expect( + getByText( + strings('bridge.fee_disclaimer', { + feePercentage: BRIDGE_MM_FEE_RATE, + }), + { + exact: false, + }, + ), + ).toBeTruthy(); + }); + }); }); describe('Approval Disclaimer', () => { diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx index 061bb74f742f..bff4468e7e05 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { Box } from '../../../Box/Box'; -import { FlexDirection, AlignItems } from '../../../Box/box.types'; +import { + FlexDirection, + AlignItems, + JustifyContent, +} from '../../../Box/box.types'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { selectSourceAmount, - selectDestToken, selectSourceToken, selectBridgeControllerState, selectIsSolanaSourced, @@ -17,11 +20,7 @@ import { BannerAlertSeverity } from '../../../../../component-library/components import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; import { isHardwareAccount } from '../../../../../util/address'; import ApprovalTooltip from '../../components/ApprovalText'; -import { - BRIDGE_MM_FEE_RATE, - MetaMetricsSwapsEventSource, -} from '@metamask/bridge-controller'; -import { isNullOrUndefined } from '@metamask/utils'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.tsx'; import { useStyles } from '../../../../../component-library/hooks/useStyles.ts'; import { createStyles } from './BridgeView.styles.ts'; @@ -32,6 +31,9 @@ import { } from '@metamask/design-system-react-native'; import { BridgeViewSelectorsIDs } from './BridgeView.testIds.ts'; import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import RewardsVipBadge from '../../../Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx'; +import { formatAccountToCaipAccountId } from '../../hooks/useRewards/useRewards.ts'; +import { useFeeDisclaimer } from '../../hooks/useFeeDisclaimer'; interface Props { latestSourceBalance: ReturnType; @@ -47,7 +49,6 @@ export const BridgeViewFooter = ({ const { styles } = useStyles(createStyles); const sourceAmount = useSelector(selectSourceAmount); const sourceToken = useSelector(selectSourceToken); - const destToken = useSelector(selectDestToken); const { quotesLastFetched } = useSelector(selectBridgeControllerState); const selectedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, @@ -56,6 +57,8 @@ export const BridgeViewFooter = ({ const { activeQuote, isLoading, blockaidError, needsNewQuote } = useBridgeQuoteDataContext(); + const { showVipBadge, infoText, infoSuffix, baseFeePercentage } = + useFeeDisclaimer({ activeQuote }); const isValidSourceAmount = sourceAmount !== undefined && sourceAmount !== '.' && sourceToken?.decimals; @@ -84,14 +87,10 @@ export const BridgeViewFooter = ({ return null; } - // TODO: remove this once controller types are updated - // @ts-expect-error: controller types are not up to date yet - const quoteBpsFee = activeQuote?.quote?.feeData?.metabridge?.quoteBpsFee; - const feePercentage = !isNullOrUndefined(quoteBpsFee) - ? quoteBpsFee / 100 - : BRIDGE_MM_FEE_RATE; - - const hasFee = activeQuote && feePercentage > 0; + const caipAccountId = + selectedAddress && sourceToken?.chainId + ? formatAccountToCaipAccountId(selectedAddress, sourceToken.chainId) + : null; const approval = activeQuote?.approval && sourceAmount && sourceToken @@ -121,28 +120,64 @@ export const BridgeViewFooter = ({ latestSourceBalance={latestSourceBalance} transactionActiveAbTests={transactionActiveAbTests} /> - - - {hasFee - ? strings('bridge.fee_disclaimer', { - feePercentage, - }) - : strings('bridge.no_mm_fee_disclaimer', { - destTokenSymbol: destToken?.symbol, - })} - {approval - ? ` ${strings('bridge.approval_needed', approval)}` - : ''}{' '} - + + + {showVipBadge && caipAccountId ? ( + + ) : null} + + + {infoText} + + + {baseFeePercentage && ( + + {baseFeePercentage} + + )} + + {infoSuffix && ( + + {infoSuffix} + + )} + + {approval && ( - + + + {strings('bridge.approval_needed', approval)} + + + )} diff --git a/app/components/UI/Bridge/hooks/useFeeDisclaimer.ts b/app/components/UI/Bridge/hooks/useFeeDisclaimer.ts new file mode 100644 index 000000000000..d5d21851bb73 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useFeeDisclaimer.ts @@ -0,0 +1,75 @@ +import { BRIDGE_MM_FEE_RATE, QuoteResponse } from '@metamask/bridge-controller'; +import { isNullOrUndefined } from '@metamask/utils'; +import { useMemo } from 'react'; +import { strings } from '../../../../../locales/i18n'; + +/** + * Checks if the fee is discounted and returns the appropriate strings to display in the fee disclaimer. + +* @param activeQuote - The active quote from the bridge controller + * @returns An object containing the following properties: + * - showVipBadge: boolean - Whether to show the VIP badge + * - infoText: string - The text to display in the fee disclaimer + * - infoSuffix: string - The suffix to display in the fee disclaimer + * - baseFeePercentage: string - The base fee percentage to display in the fee disclaimer + */ +export const useFeeDisclaimer = ({ + activeQuote, +}: { + activeQuote?: QuoteResponse | null; +}) => { + // @ts-expect-error: controller types are not up to date yet + const baseBpsFee = activeQuote?.quote.feeData.metabridge?.baseBpsFee; + const baseFeePercentage = !isNullOrUndefined(baseBpsFee) + ? baseBpsFee / 100 + : BRIDGE_MM_FEE_RATE; + // TODO: remove this once controller types are updated + // @ts-expect-error: controller types are not up to date yet + const quoteBpsFee = activeQuote?.quote.feeData.metabridge?.quoteBpsFee; + const feePercentage = !isNullOrUndefined(quoteBpsFee) + ? quoteBpsFee / 100 + : BRIDGE_MM_FEE_RATE; + + const hasFee = activeQuote && feePercentage > 0; + + const isDiscounted = + activeQuote && + Boolean(baseBpsFee) && + Boolean(quoteBpsFee) && + baseBpsFee > quoteBpsFee; + + const infoText = useMemo(() => { + if (isDiscounted) { + return strings('bridge.fee_includes'); + } + + if (hasFee) { + return strings('bridge.fee_disclaimer', { + feePercentage, + }); + } + + if (!activeQuote) { + return undefined; + } + + return strings('bridge.no_mm_fee_disclaimer', { + destTokenSymbol: activeQuote.quote.destAsset.symbol, + }); + }, [isDiscounted, hasFee, activeQuote, feePercentage]); + + return { + showVipBadge: isDiscounted, + infoText, + infoSuffix: isDiscounted + ? strings('bridge.fee_percentage_meta_mask', { + feePercentage, + }) + : undefined, + baseFeePercentage: isDiscounted + ? strings('bridge.fee_percentage', { + feePercentage: baseFeePercentage, + }) + : undefined, + }; +}; diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts index 73a0ed0bc1d4..3f38c210c0c6 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts @@ -76,7 +76,7 @@ interface UseRewardsResult { /** * Formats an address to CAIP-10 account ID */ -const formatAccountToCaipAccountId = ( +export const formatAccountToCaipAccountId = ( address: string, chainId: string, ): CaipAccountId | null => { diff --git a/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx new file mode 100644 index 000000000000..acd4a8a2362b --- /dev/null +++ b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import RewardsVipBadge from './RewardsVipBadge'; +import { CaipAccountId } from '@metamask/utils'; + +const mockGetVipTierForAccount = jest.fn(); +jest.mock('../../../../../core/Engine', () => ({ + context: { + RewardsController: { + getVipTierForAccount: (accountId: CaipAccountId) => + mockGetVipTierForAccount(accountId), + }, + }, +})); +describe('RewardsVipBadge', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders vip badge', async () => { + mockGetVipTierForAccount.mockResolvedValueOnce(1); + const { getByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(getByTestId('rewards-vip-badge')).toHaveTextContent('VIP 1'); + }); + }); + + it('renders nothing if vip tier is not found', async () => { + mockGetVipTierForAccount.mockResolvedValueOnce(null); + const { queryByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(queryByTestId('rewards-vip-badge')).toBeNull(); + }); + }); + + it('renders nothing if vip tier is 0', async () => { + mockGetVipTierForAccount.mockResolvedValueOnce(0); + const { queryByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(queryByTestId('rewards-vip-badge')).toBeNull(); + }); + }); + + it('renders nothing if getting vip tier fails', async () => { + mockGetVipTierForAccount.mockRejectedValueOnce( + new Error('Failed to get vip tier'), + ); + const { queryByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(queryByTestId('rewards-vip-badge')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx new file mode 100644 index 000000000000..afaf4f166b02 --- /dev/null +++ b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; +import FoxRewardIcon from '../../../../../images/rewards/metamask-rewards-points-vip.svg'; +import { strings } from '../../../../../../locales/i18n'; +import { CaipAccountId } from '@metamask/utils'; +import { + Box, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import Engine from '../../../../../core/Engine'; +import LinearGradient from 'react-native-linear-gradient'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +interface RewardsVipBadgeProps { + accountId: CaipAccountId; +} + +const RewardsVipBadge: React.FC = ({ accountId }) => { + const tw = useTailwind(); + + const [vipTier, setVipTier] = useState(null); + + useEffect(() => { + Engine.context.RewardsController.getVipTierForAccount(accountId) + .then((result) => { + setVipTier(result); + }) + .catch((error) => { + console.warn('Error fetching vip tier:', error); + setVipTier(null); + }); + }, [accountId]); + + if (!vipTier) return null; + + return ( + + + + + + + {strings('rewards.vip.badge_label', { + tier: vipTier.toString(), + })} + + + + + + ); +}; + +export default RewardsVipBadge; diff --git a/app/components/UI/Rewards/components/RewardsVipBadge/index.ts b/app/components/UI/Rewards/components/RewardsVipBadge/index.ts new file mode 100644 index 000000000000..716f27255279 --- /dev/null +++ b/app/components/UI/Rewards/components/RewardsVipBadge/index.ts @@ -0,0 +1 @@ +export { default } from './RewardsVipBadge'; diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index 1c4d393ddbf9..8b2232f8a4c9 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -170,6 +170,11 @@ export type RewardsControllerGetOptInStatusAction = { handler: RewardsController['getOptInStatus']; }; +export type RewardsControllerGetVipTierForAccountAction = { + type: `RewardsController:getVipTierForAccount`; + handler: RewardsController['getVipTierForAccount']; +}; + /** * Get perps fee discount for an account. * @@ -825,6 +830,7 @@ export type RewardsControllerMethodActions = | RewardsControllerGetHasAccountOptedInAction | RewardsControllerCheckOptInStatusAgainstCacheAction | RewardsControllerGetOptInStatusAction + | RewardsControllerGetVipTierForAccountAction | RewardsControllerGetPerpsDiscountForAccountAction | RewardsControllerGetPointsEventsAction | RewardsControllerGetPointsEventsIfChangedAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 6adb968bb4e2..4296e3b4ee94 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3855,6 +3855,52 @@ describe('RewardsController', () => { }); }); + describe('getVipTierForAccount', () => { + it('returns null when disabled via isDisabled callback', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); + + const result = + await disabledController.getVipTierForAccount(CAIP_ACCOUNT_1); + + expect(result).toBeNull(); + }); + + it('returns null for accounts the controller has never seen (unhydrated)', async () => { + const result = await controller.getVipTierForAccount(CAIP_ACCOUNT_2); + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null when the account has no linked subscription (unhydrated)', async () => { + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: null, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + }, + isDisabled: () => false, + }); + + const result = await controller.getVipTierForAccount(CAIP_ACCOUNT_1); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + }); + describe('isRewardsFeatureEnabled', () => { it('returns true when not disabled', () => { const result = controller.isRewardsFeatureEnabled(); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 83fbf2136e18..d1baa4fcca02 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -491,6 +491,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOptInStatus', 'getPerpsTradingCampaignParticipantOutcome', 'getPerpsDiscountForAccount', + 'getVipTierForAccount', 'getPointsEvents', 'getPointsEventsIfChanged', 'getPointsEventsLastUpdated', @@ -1703,6 +1704,65 @@ export class RewardsController extends BaseController< } } + async #getVipFeesForSubscriptionId( + subscriptionId: string, + ): Promise { + // Deduplicate concurrent fetches: if there's already an in-flight + // request for this subscriptionId, await it instead of firing another. + let inFlight = this.#vipFeesFetchInFlight.get(subscriptionId); + if (!inFlight) { + inFlight = this.#withAuthRetry( + () => + this.messenger.call('RewardsDataService:getVipFees', subscriptionId), + subscriptionId, + ).then((vipFeeResponse): VipFeesResponseDto | 0 => { + // Tier-0 response: backend says no VIP fees — return sentinel 0 + // without caching so the next call re-checks. + if (!vipFeeResponse?.fees || vipFeeResponse.vipTier <= 0) { + return 0; + } + this.update((state) => { + // Promote the subscription's VIP flag so the rest of the app + // reflects the user's VIP status without waiting for a full refresh. + const subState = state.subscriptions[subscriptionId]; + if (subState) { + subState.features = { + ...subState.features, + vip: { enabled: true }, + }; + } + }); + return vipFeeResponse; + }); + this.#vipFeesFetchInFlight.set(subscriptionId, inFlight); + const cleanup = () => this.#vipFeesFetchInFlight.delete(subscriptionId); + inFlight.then(cleanup, cleanup); + } + + const result = await inFlight; + if (result === 0) return 0; + const feeResponse = result as VipFeesResponseDto; + if (!feeResponse.fees) return null; + return feeResponse; + } + + async getVipTierForAccount(account: CaipAccountId): Promise { + const rewardsEnabled = this.isRewardsFeatureEnabled(); + if (!rewardsEnabled) return null; + + const subscriptionId = this.getActualSubscriptionId(account); + if (!subscriptionId) return null; + + const subscription = this.state.subscriptions[subscriptionId]; + if (!subscription) return null; + + const vipFeesResponse = + await this.#getVipFeesForSubscriptionId(subscriptionId); + + if (!vipFeesResponse) return null; + return vipFeesResponse.vipTier; + } + /** * Get perps fee discount for an account. * @@ -1753,8 +1813,7 @@ export class RewardsController extends BaseController< ): Promise { if (!Number.isFinite(baseFeeBips) || baseFeeBips <= 0) return null; - const accountState = this.#getAccountState(account); - const subscriptionId = accountState?.subscriptionId; + const subscriptionId = this.getActualSubscriptionId(account); if (!subscriptionId) return null; const subscription = this.state.subscriptions[subscriptionId]; @@ -1768,57 +1827,26 @@ export class RewardsController extends BaseController< ) { builderFeeBipsRaw = cached.hyperliquidBuilderFeeBips; } else { - // Deduplicate concurrent fetches: if there's already an in-flight - // request for this subscriptionId, await it instead of firing another. - let inFlight = this.#vipFeesFetchInFlight.get(subscriptionId); - if (!inFlight) { - inFlight = this.#withAuthRetry( - () => - this.messenger.call( - 'RewardsDataService:getVipFees', - subscriptionId, - ), - subscriptionId, - ).then((vipFeeResponse): VipFeesResponseDto | 0 => { - // Tier-0 response: backend says no VIP fees — return sentinel 0 - // without caching so the next call re-checks. - if ( - !vipFeeResponse?.fees || - vipFeeResponse.vipTier <= 0 || - !vipFeeResponse.fees.hyperliquid?.builderFeeBips - ) { - return 0; - } - const rawBips = vipFeeResponse.fees.hyperliquid.builderFeeBips; - const next: VipPerpsFeesState = { - hyperliquidBuilderFeeBips: rawBips, - lastFetched: Date.now(), - }; - this.update((state) => { - state.vipPerpsFees[subscriptionId] = next; - // Promote the subscription's VIP flag so the rest of the app - // reflects the user's VIP status without waiting for a full refresh. - const subState = state.subscriptions[subscriptionId]; - if (subState) { - subState.features = { - ...subState.features, - vip: { enabled: true }, - }; - } - }); - return vipFeeResponse; - }); - this.#vipFeesFetchInFlight.set(subscriptionId, inFlight); - const cleanup = () => this.#vipFeesFetchInFlight.delete(subscriptionId); - inFlight.then(cleanup, cleanup); - } - try { - const result = await inFlight; - if (result === 0) return 0; - const feeResponse = result as VipFeesResponseDto; - if (!feeResponse.fees) return null; - builderFeeBipsRaw = feeResponse.fees.hyperliquid.builderFeeBips; + const vipFeeResponse = + await this.#getVipFeesForSubscriptionId(subscriptionId); + if (vipFeeResponse === 0) { + return 0; + } + if (!vipFeeResponse?.fees) { + return null; + } + if (!vipFeeResponse.fees.hyperliquid?.builderFeeBips) { + return 0; + } + builderFeeBipsRaw = vipFeeResponse.fees.hyperliquid.builderFeeBips; + const next: VipPerpsFeesState = { + hyperliquidBuilderFeeBips: builderFeeBipsRaw, + lastFetched: Date.now(), + }; + this.update((state) => { + state.vipPerpsFees[subscriptionId] = next; + }); } catch (error) { Logger.log( 'RewardsController: VIP fees fetch failed; returning no discount:', diff --git a/app/images/rewards/metamask-rewards-points-vip.svg b/app/images/rewards/metamask-rewards-points-vip.svg new file mode 100644 index 000000000000..3fbe48f220a2 --- /dev/null +++ b/app/images/rewards/metamask-rewards-points-vip.svg @@ -0,0 +1,3 @@ + + + diff --git a/locales/languages/en.json b/locales/languages/en.json index 86e14a7e06f1..35d6289839a0 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7453,6 +7453,9 @@ "title": "Bridge", "submitting_transaction": "Submitting", "fetching_quote": "Fetching quote", + "fee_includes": "Includes", + "fee_percentage": "{{feePercentage}}%", + "fee_percentage_meta_mask": "{{feePercentage}}% MM fee.", "fee_disclaimer": "Includes {{feePercentage}}% MetaMask fee.", "no_mm_fee": "No MM fee", "token_suspicious": "Suspicious", @@ -8480,6 +8483,7 @@ "main_title": "Rewards", "vip": { "bps_unit": "bps", + "badge_label": "VIP {{tier}}", "swaps_label": "Swaps", "perps_label": "Perps", "tier_benefits_title": "Tier benefits", From de493701be4eb39b70dabab1d51b0f8607e0c6c0 Mon Sep 17 00:00:00 2001 From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> Date: Thu, 21 May 2026 11:33:55 +0200 Subject: [PATCH 08/14] feat(carousel): enhance analytics tracking for carousel interactions (#30458) ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/GE-245 ## **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** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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** > Adjusts when and how carousel analytics events fire (display/select naming and frequency), which can impact engagement metrics and downstream dashboards. UI/navigation behavior is mostly unchanged but click handler signature changes touch multiple components. > > **Overview** > Updates carousel analytics so **`Banner Display`** is emitted only when a *non-empty* slide becomes the current card (instead of for every visible slide), and standardizes event `name` to use `variableName` with fallback to Contentful `id`. > > Refactors slide click handling to pass the full `CarouselSlide` object through `StackCard`/props and uses the derived analytics name for **`Banner Select`** (including Solana-specific `action` properties), and adds targeted unit tests covering the new display/select tracking behavior. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 860e897802da8c4d7c94a21b156490c95f9aa9da. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Carousel/StackCard/StackCard.tsx | 2 +- .../UI/Carousel/StackCard/StackCard.types.ts | 4 +- app/components/UI/Carousel/index.test.tsx | 108 ++++++++++++++++++ app/components/UI/Carousel/index.tsx | 94 ++++++++------- 4 files changed, 166 insertions(+), 42 deletions(-) diff --git a/app/components/UI/Carousel/StackCard/StackCard.tsx b/app/components/UI/Carousel/StackCard/StackCard.tsx index e254476500e2..9d2800eba0b9 100644 --- a/app/components/UI/Carousel/StackCard/StackCard.tsx +++ b/app/components/UI/Carousel/StackCard/StackCard.tsx @@ -77,7 +77,7 @@ export const StackCard: React.FC = ({ pressed && 'bg-default-pressed', ) } - onPress={() => onSlideClick(slide.id, slide.navigation)} + onPress={() => onSlideClick(slide)} > {/* Animated pressed background overlay for next card */} {!isCurrentCard && ( diff --git a/app/components/UI/Carousel/StackCard/StackCard.types.ts b/app/components/UI/Carousel/StackCard/StackCard.types.ts index d1c8e6b0cf8b..e7c2e864a9f4 100644 --- a/app/components/UI/Carousel/StackCard/StackCard.types.ts +++ b/app/components/UI/Carousel/StackCard/StackCard.types.ts @@ -1,5 +1,5 @@ import { Animated } from 'react-native'; -import { CarouselSlide, NavigationAction } from '../types'; +import { CarouselSlide } from '../types'; export interface StackCardProps { slide: CarouselSlide; @@ -11,6 +11,6 @@ export interface StackCardProps { nextCardScale: Animated.Value; nextCardTranslateY: Animated.Value; nextCardBgOpacity: Animated.Value; - onSlideClick: (slideId: string, navigation: NavigationAction) => void; + onSlideClick: (slide: CarouselSlide) => void; onTransitionToNextCard?: () => void; } diff --git a/app/components/UI/Carousel/index.test.tsx b/app/components/UI/Carousel/index.test.tsx index 0767247312b2..5d697779d6bb 100644 --- a/app/components/UI/Carousel/index.test.tsx +++ b/app/components/UI/Carousel/index.test.tsx @@ -25,6 +25,11 @@ import { SolScope } from '@metamask/keyring-api'; import { setContentPreviewToken } from '../../../actions/notification/helpers'; import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { + AnalyticsEventBuilder, + type AnalyticsTrackingEvent, +} from '../../../util/analytics/AnalyticsEventBuilder'; +import type { UseAnalyticsHook } from '../../../components/hooks/useAnalytics/useAnalytics.types'; const makeMockState = () => ({ @@ -110,6 +115,22 @@ const mockReduxHooks = (state?: RootState) => { .mockImplementation((selector) => selector(state ?? makeMockState())); }; +const mockAnalyticsTracking = () => { + const mockTrackEvent = jest.fn< + ReturnType, + Parameters + >(); + + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), + ); + + return mockTrackEvent; +}; + beforeEach(() => { jest.clearAllMocks(); jest.mocked(useAnalytics).mockReturnValue(createMockUseAnalyticsHook()); @@ -292,6 +313,93 @@ describe('Carousel Navigation', () => { }); }); +describe('Carousel Analytics', () => { + it('tracks Banner Display with the Contentful id when variableName is blank', async () => { + const mockTrackEvent = mockAnalyticsTracking(); + const slide = createMockSlide({ + id: 'contentful-empty-variable-name', + variableName: '', + }); + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: [slide], + }); + + render(); + + await waitFor(() => { + const displayEvents = mockTrackEvent.mock.calls + .map(([event]) => event) + .filter((event) => event.name === 'Banner Display'); + + expect(displayEvents).toEqual([ + expect.objectContaining>({ + name: 'Banner Display', + properties: { name: 'contentful-empty-variable-name' }, + }), + ]); + }); + }); + + it('tracks Banner Display only for the current displayed slide', async () => { + const mockTrackEvent = mockAnalyticsTracking(); + const slides = [ + createMockSlide({ + id: 'current-slide', + variableName: 'current', + }), + createMockSlide({ + id: 'stacked-slide', + variableName: 'stacked', + }), + ]; + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: slides, + }); + + render(); + + await waitFor(() => { + const displayEvents = mockTrackEvent.mock.calls + .map(([event]) => event) + .filter((event) => event.name === 'Banner Display'); + + expect(displayEvents).toEqual([ + expect.objectContaining>({ + name: 'Banner Display', + properties: { name: 'current' }, + }), + ]); + }); + }); + + it('tracks Banner Select with the variableName', async () => { + const mockTrackEvent = mockAnalyticsTracking(); + const slide = createMockSlide({ + id: 'contentful-card-banner', + variableName: 'card', + }); + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: [slide], + }); + + const { findByTestId } = render(); + + fireEvent.press( + await findByTestId('carousel-slide-contentful-card-banner'), + ); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining>({ + name: 'Banner Select', + properties: { name: 'card' }, + }), + ); + }); +}); + describe('Carousel Slide Dismissal', () => { it('triggers transition animation when close button is clicked', async () => { const dismissibleSlide = createMockSlide({ diff --git a/app/components/UI/Carousel/index.tsx b/app/components/UI/Carousel/index.tsx index 3f98b12a30b6..617d3e316bc0 100644 --- a/app/components/UI/Carousel/index.tsx +++ b/app/components/UI/Carousel/index.tsx @@ -9,7 +9,7 @@ import React, { import { Dimensions, Animated, Linking } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; -import { CarouselProps, CarouselSlide, NavigationAction } from './types'; +import { CarouselProps, CarouselSlide } from './types'; import { dismissBanner } from '../../../reducers/banners'; import { StackCard } from './StackCard'; import { StackCardEmpty } from './StackCardEmpty'; @@ -54,6 +54,16 @@ const SCREEN_WIDTH = Dimensions.get('window').width; const BANNER_WIDTH = SCREEN_WIDTH - 32; const BANNER_HEIGHT = 100; +function getSlideVariableName(slide: Pick) { + return slide.variableName; +} + +function getSlideAnalyticsName( + slide: Pick, +) { + return getSlideVariableName(slide) || slide.id; +} + function orderByCardPlacement(slides: CarouselSlide[]): CarouselSlide[] { const placed: (CarouselSlide | undefined)[] = []; const unplaced: CarouselSlide[] = []; @@ -188,8 +198,10 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const applyLocalNavigation = useCallback( (s: CarouselSlide): CarouselSlide => { + const variableName = getSlideVariableName(s); + // fund → open buy flow - if (s.variableName === 'fund') { + if (variableName === 'fund') { return { ...s, navigation: { @@ -200,7 +212,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { } ///: BEGIN:ONLY_INCLUDE_IF(solana) // solana → open add-account flow (if we don't already redirect below) - if (s.variableName === 'solana') { + if (variableName === 'solana') { return { ...s, navigation: { @@ -231,7 +243,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { // Get base slides const patch = (s: CarouselSlide): CarouselSlide => { const withNav = applyLocalNavigation(s); - if (withNav.variableName === 'fund' && isZeroBalance) { + if (getSlideVariableName(withNav) === 'fund' && isZeroBalance) { return { ...withNav, undismissable: withNav.undismissable || true }; } return withNav; @@ -280,7 +292,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { ///: BEGIN:ONLY_INCLUDE_IF(solana) if ( - slide.variableName === 'solana' && + getSlideVariableName(slide) === 'solana' && selectedAccount?.type === SolAccountType.DataAccount ) { return false; @@ -294,7 +306,9 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { // keep the empty card in visibleSlides so the animation completes if (dismissingLastCardRef.current && filtered.length === 0) { // Re-add the empty card so the animation completes - const emptyCards = slidesConfig.filter((s) => s.variableName === 'empty'); + const emptyCards = slidesConfig.filter( + (s) => getSlideVariableName(s) === 'empty', + ); return emptyCards.length > 0 ? emptyCards : []; } @@ -313,6 +327,8 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { visibleSlides.length - 1, ); const currentSlide = visibleSlides[safeActiveSlideIndex]; + const currentSlideId = currentSlide?.id; + const currentSlideVariableName = currentSlide?.variableName; const nextSlide = visibleSlides[safeActiveSlideIndex + 1]; // Next card in stack const hasNextSlide = !!nextSlide; @@ -388,11 +404,13 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { }; const handleSlideClick = useCallback( - (slideId: string, navigation: NavigationAction) => { + (slide: CarouselSlide) => { + const slideName = getSlideAnalyticsName(slide); + const { navigation } = slide; const extraProperties: Record = {}; ///: BEGIN:ONLY_INCLUDE_IF(solana) - const isSolanaBanner = slideId === 'solana'; + const isSolanaBanner = slideName === 'solana'; if (isSolanaBanner && lastSelectedSolanaAccount) { extraProperties.action = 'redirect-solana-account'; } else if (isSolanaBanner && !lastSelectedSolanaAccount) { @@ -403,11 +421,12 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { trackEvent( createEventBuilder({ category: 'Banner Select', - properties: { - name: slideId, + }) + .addProperties({ + name: slideName, ...extraProperties, - }, - }).build(), + }) + .build(), ); ///: BEGIN:ONLY_INCLUDE_IF(solana) @@ -446,7 +465,8 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { setIsTransitioning(true); // Check if next card is the empty card (last non-empty slide being dismissed) - const isNextCardEmpty = nextSlide?.variableName === 'empty'; + const isNextCardEmpty = + nextSlide && getSlideVariableName(nextSlide) === 'empty'; // Set flag to keep empty card visible during dismissal animation if (isNextCardEmpty) { @@ -540,7 +560,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const renderCard = useCallback( (slide: CarouselSlide, isCurrentCard: boolean) => { - const isEmptyCard = slide.variableName === 'empty'; + const isEmptyCard = getSlideVariableName(slide) === 'empty'; if (isEmptyCard) { return ( @@ -590,33 +610,29 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { ], ); - // Track banner display events when visible slides change - useEffect(() => { - visibleSlides.forEach((slide: CarouselSlide) => { - trackEvent( - createEventBuilder({ - category: BANNER_EVENT_DISPLAY, - properties: { - name: slide.variableName ?? slide.id, - }, - }).build(), - ); - }); - }, [visibleSlides, trackEvent, createEventBuilder]); - - // Track current slide display + // Track a banner display only when a real banner becomes the current card. useEffect(() => { - if (currentSlide) { - trackEvent( - createEventBuilder({ - category: BANNER_EVENT_DISPLAY, - properties: { - name: currentSlide.variableName ?? currentSlide.id, - }, - }).build(), - ); + if (!currentSlideId || currentSlideVariableName === 'empty') { + return; } - }, [currentSlide, trackEvent, createEventBuilder]); + + const slideAnalyticsName = currentSlideVariableName || currentSlideId; + + trackEvent( + createEventBuilder({ + category: BANNER_EVENT_DISPLAY, + }) + .addProperties({ + name: slideAnalyticsName, + }) + .build(), + ); + }, [ + currentSlideId, + currentSlideVariableName, + trackEvent, + createEventBuilder, + ]); if ( !isCarouselVisible || From 5db4b6cfd1230067c8c0addeffc3d0cfcddd5dd2 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Thu, 21 May 2026 10:34:09 +0100 Subject: [PATCH 09/14] chore: add CLAUDE.md to wrap AGENTS.md (#30395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Repo contains an AGENTS.md file, but Claude Code doesn't pull that in automatically - meaning that users of Claude Code miss a lot of that context. This adds a CLAUDE.md file that just pulls that AGENTS.md file in. ## **Changelog** CHANGELOG entry: add claude.md file to wrap agents.md ## **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** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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: documentation-only change that doesn’t affect runtime code or build behavior. > > **Overview** > Adds a new `CLAUDE.md` file that simply references `@AGENTS.md`, making the existing agent instructions discoverable to Claude Code without duplicating content. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 64bcd1ae66f839e247fe8b6b668571541003dd27. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..43c994c2d361 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 229a1559b7147f10a1032b784197f0b397bf63bd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 11:35:04 +0200 Subject: [PATCH 10/14] test(snaps): Re-enable BIP-44 test (#30493) ## **Description** Re-enable the BIP-44 test after disabling device synchronization across all Snaps tests. ## **Changelog** CHANGELOG entry: null --- > [!NOTE] > **Low Risk** > Low risk: only changes test execution by re-enabling an existing smoke spec, with no production code impact. > > **Overview** > Re-enables the `BIP-44 Snap Tests` smoke suite by removing `describe.skip`, so the BIP-44 Snap connection/signing/entropy tests run again in CI. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 20422fbcd3455091d826890e4e5971a04339e585. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- tests/smoke/snaps/test-snap-bip-44.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smoke/snaps/test-snap-bip-44.spec.ts b/tests/smoke/snaps/test-snap-bip-44.spec.ts index 625d1edaefaa..fe5bb154a0bd 100644 --- a/tests/smoke/snaps/test-snap-bip-44.spec.ts +++ b/tests/smoke/snaps/test-snap-bip-44.spec.ts @@ -8,7 +8,7 @@ import Assertions from '../../framework/Assertions'; jest.setTimeout(150_000); -describe.skip(SmokeSnaps('BIP-44 Snap Tests'), () => { +describe(SmokeSnaps('BIP-44 Snap Tests'), () => { it('can connect to BIP-44 snap', async () => { await withFixtures( { From 83098a27588bb39f40eec7424e26badd0a0faaee Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Thu, 21 May 2026 11:18:38 +0100 Subject: [PATCH 11/14] chore: build android in prod (#30506) ## **Description** ## **Changelog** CHANGELOG entry: ## **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** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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 workflow-only change that expands allowed `environment` inputs to include `prod`; impact is limited to CI behavior for Android builds. > > **Overview** > Enables building Android in the `prod` track by adding `prod` to the `environment` input contract for `.github/workflows/build-android.yml` (both `workflow_call` and manual `workflow_dispatch`). > > Updates the input validation case statement and the dispatch choice options to accept `prod`, preventing builds from failing input validation when targeting production. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0070f395c56f24f042f8e09b9b6aa3f59ea9e821. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/workflows/build-android.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 23d7b5a51cba..508d5c5cd92c 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -8,7 +8,7 @@ on: required: true type: string environment: - description: 'Build environment / track. Must be one of: exp, beta, rc.' + description: 'Build environment / track. Must be one of: exp, beta, rc, prod.' required: true type: string upload_to_sentry: @@ -43,9 +43,10 @@ on: required: true type: choice options: - - exp - - beta + - prod - rc + - beta + - exp default: rc upload_to_sentry: description: 'Upload JS source maps and native debug symbols to Sentry during the build (requires Sentry auth in the build environment)' @@ -75,8 +76,8 @@ jobs: ENVIRONMENT: ${{ inputs.environment }} run: | case "$ENVIRONMENT" in - exp|beta|rc) echo "✅ environment=$ENVIRONMENT is allowed" ;; - *) echo "::error::Invalid environment '$ENVIRONMENT'. Must be one of: exp, beta, rc"; exit 1 ;; + exp|beta|rc|prod) echo "✅ environment=$ENVIRONMENT is allowed" ;; + *) echo "::error::Invalid environment '$ENVIRONMENT'. Must be one of: exp, beta, rc, prod"; exit 1 ;; esac generate-build-version: From b76275a34177bbe6cfcb0885498e199ca3aa3548 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 21 May 2026 11:52:45 +0100 Subject: [PATCH 12/14] fix: Perps withdraw back-swipe toast cp-7.78.0 (#30504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the Perps withdraw confirmation flow so user-initiated confirmation rejection, including iOS back-swipe, does not show the retryable "withdrawal wasn't started" error toast. ## Root Cause PR #30299 added an `addTransactionBatch` catch path that navigates back and shows the withdrawal-start failure toast for every rejected batch initialization. Back-swiping the confirmation rejects the approval with a user-rejected error, so that normal cancellation path was being treated as a real initialization failure. ## Changes - Detect user-rejected errors before showing the withdrawal-start failure toast. - Keep the existing retry toast behavior for real `addTransactionBatch` failures. - Add regression coverage for user rejection so it does not call `goBack`, build the retry toast, or show the toast. Fixes #30485. --- > [!NOTE] > **Low Risk** > Low risk: scoped to Perps withdraw error handling and adds a regression test; behavior only changes for user-rejected/cancel paths. > > **Overview** > Prevents Perps withdraw confirmation cancellations (user-rejected errors) from being treated as `addTransactionBatch` failures: on rejection, the hook now rethrows without calling `navigation.goBack()` or showing the retryable “withdrawalStartFailed” toast. > > Adds a regression test covering a `providerErrors.userRejectedRequest()` rejection to ensure no back navigation or toast is triggered, while keeping the existing retry-toast behavior for real batch initialization failures. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c23f2c36aa0469447b4cce532c4c871160c9be07. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../usePerpsWithdrawConfirmation.test.ts | 18 ++++++++ .../hooks/usePerpsWithdrawConfirmation.ts | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts index d15f5762a5b1..9daf60ed8a1e 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts @@ -2,6 +2,7 @@ import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { useNavigation } from '@react-navigation/native'; import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { providerErrors } from '@metamask/rpc-errors'; import { renderHook, act, waitFor } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { selectSelectedInternalAccountAddress } from '../../../../selectors/accountsController'; @@ -193,6 +194,23 @@ describe('usePerpsWithdrawConfirmation', () => { expect(mockShowToast).toHaveBeenCalledWith(mockWithdrawalStartFailedToast); }); + it('does not show the start failure toast when the user rejects the confirmation', async () => { + const error = providerErrors.userRejectedRequest(); + mockAddTransactionBatch.mockRejectedValueOnce(error); + + const { result } = renderHook(() => usePerpsWithdrawConfirmation()); + + await expect( + act(async () => { + await result.current.withdrawWithConfirmation(); + }), + ).rejects.toThrow(error.message); + + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockWithdrawalStartFailed).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + it('swallows retry failures after showing another retryable error toast', async () => { mockAddTransactionBatch .mockRejectedValueOnce(new Error('batch failed')) diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts index 4f746df4beb9..77d313d60d45 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts @@ -14,8 +14,47 @@ import { ARBITRUM_USDC } from '../../../Views/confirmations/constants/perps'; import { RootState } from '../../../../reducers'; import Routes from '../../../../constants/navigation/Routes'; import { ensureError } from '../../../../util/errorUtils'; +import { containsUserRejectedError } from '../../../../util/middlewares'; import usePerpsToasts from './usePerpsToasts'; +interface ErrorLike { + code?: unknown; + message?: unknown; +} + +function getErrorLike(error: unknown): ErrorLike | undefined { + return typeof error === 'object' && error !== null + ? (error as ErrorLike) + : undefined; +} + +function getErrorCode(error: unknown): number | undefined { + const code = getErrorLike(error)?.code; + + if (typeof code === 'number') { + return code; + } + + if (typeof code === 'string') { + const numericCode = Number(code); + return Number.isNaN(numericCode) ? undefined : numericCode; + } + + return undefined; +} + +function getErrorMessage(error: unknown, fallbackMessage: string): string { + const message = getErrorLike(error)?.message; + return typeof message === 'string' ? message : fallbackMessage; +} + +function isUserRejectedError(error: unknown, fallbackMessage: string): boolean { + return containsUserRejectedError( + getErrorMessage(error, fallbackMessage), + getErrorCode(error), + ); +} + /** * Hook that triggers the Perps "withdraw to any token" confirmation flow. * @@ -69,6 +108,10 @@ export function usePerpsWithdrawConfirmation() { 'usePerpsWithdrawConfirmation.withdrawWithConfirmation', ); + if (isUserRejectedError(error, errorObj.message)) { + throw errorObj; + } + navigation.goBack(); showToast( PerpsToastOptions.accountManagement.withdrawal.withdrawalStartFailed( From 1635a36869baaa8a62b76c14df505fa017215362 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 21 May 2026 12:56:00 +0200 Subject: [PATCH 13/14] fix(perps): apply safe area top inset directly to TPSL header cp-7.78.0 (#30503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The TPSL (Take Profit / Stop Loss) page header was intermittently positioned too high, overlapping the status bar / notch area. This occurred both when creating a TPSL from the order screen and when editing a TPSL for an open position. **Root cause:** The screen relied on the shimmed `SafeAreaView` with `edges={['top', 'bottom']}` to apply the top safe area inset. The shim (`SafeAreaViewWithHookTopInset`) turns off the native top inset and re-applies it via a hook-based `paddingTop`. With the `transparentModal` presentation mode used by this screen, the hook-based top inset was intermittently not applied, causing the header to render too high. **Fix:** Follow the same proven pattern used by `PerpsOrderHeader` — apply the top inset directly to the header view using `useSafeAreaInsets()`, and only use `SafeAreaView` for the bottom edge. This is deterministic and does not depend on the shimmed SafeAreaView lifecycle for transparent modals. ## **Changelog** CHANGELOG entry: Fixed TPSL page header overlapping the status bar area ## **Related issues** Fixes: TAT-3213 ## **Manual testing steps** ```gherkin Feature: TPSL page header alignment Scenario: user creates a TPSL from the order screen Given the user is on the Perps order screen with an asset selected When user taps the TPSL / Auto close button Then the TPSL page opens with the header properly below the status bar / notch area Scenario: user edits a TPSL for an open position Given the user has an open Perps position with or without existing TP/SL values When user taps the Auto close / Edit TPSL button on the position Then the TPSL page opens with the header properly below the status bar / notch area Scenario: user repeatedly opens and closes the TPSL page Given the user is on the Perps order screen When user opens and closes the TPSL page multiple times Then the header is consistently aligned below the safe area every time ``` ## **Screenshots/Recordings** ### **Before** https://consensys.slack.com/archives/C092T3GPHQD/p1779353500168619 ### **After** Simulator Screenshot - iPhone 17
Pro Max - 2026-05-21 at 11 40 56 Header is consistently positioned below the safe area. ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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 UI layout change limited to safe-area handling in the Perps TPSL screen; no business logic or data flow changes. > > **Overview** > Fixes an intermittent layout issue where the Perps TPSL header could render under the status bar/notch. > > `PerpsTPSLView` now uses `useSafeAreaInsets()` to add top padding directly on the header, and updates `SafeAreaView` to only apply the bottom safe area (`edges={['bottom']}`) so top inset behavior is deterministic in modal presentation. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f3dd563a409ac90ae220955bc0e08fc155b21c58. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index c6d5d1fbac78..6909e34635ce 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx @@ -6,7 +6,10 @@ import { TouchableOpacity, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { strings } from '../../../../../../locales/i18n'; import { @@ -75,6 +78,7 @@ const PerpsTPSLView: React.FC = () => { const [isUpdating, setIsUpdating] = useState(false); const { colors } = useTheme(); const styles = createStyles(colors); + const { top: topInset } = useSafeAreaInsets(); const scrollViewRef = useRef(null); @@ -447,11 +451,16 @@ const PerpsTPSLView: React.FC = () => { return ( {/* Simple header with back button and title */} - + 0 ? { paddingTop: 16 + topInset } : undefined, + ]} + > Date: Thu, 21 May 2026 13:14:37 +0200 Subject: [PATCH 14/14] refactor(analytics): PR E1 rename addTraitsToUser to identify in mobile-core-ux files (#30473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Part of the analytics cleanup workstream (#26686). Renames `.addTraitsToUser()` → `.identify()` across all mobile-core-ux-owned Settings components and network hooks, and migrates `AccountActions.tsx` from the deprecated `useMetrics` to `useAnalytics`. Also removes stale `MetaMetrics`/`MetaMetrics.events` mocks from `AccountsMenu.test.tsx`. Files touched: - `BlockaidSettings.tsx`, `DisplayNFTMediaSettings`, `BatchAccountBalanceSettings`, `AutoDetectTokensSettings`, `AutoDetectNFTSettings` — rename `.addTraitsToUser` → `.identify` - `useNetworkOperations.ts`, `useAddPopularNetwork.ts`, `NetworkManager/index.tsx` — rename `.addTraitsToUser` → `.identify` - `AccountActions.tsx` — migrate `useMetrics` → `useAnalytics` - Corresponding test files updated to use `identify` override in `createMockUseAnalyticsHook` - `AccountsMenu.test.tsx` — remove stale `jest.mock('../../../core/Analytics', ...)` and `jest.mock('../../../core/Analytics/MetaMetrics.events', ...)` ## **Changelog** CHANGELOG entry: null ## **Related issues** Closes: #26821 Refs: #26686 ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** N/A — no UI changes. ### **After** N/A — no UI changes. ## **Pre-merge author checklist** - [x] I've followed the [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)) ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, test affected areas) - [ ] I confirm that this PR addresses what is claimed in the PR title - [ ] I confirm that I've manually reviewed the changes if not manually tested Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Low risk refactor limited to analytics hook usage and test mocks, with minimal runtime behavior change beyond calling the new `identify` alias. > > **Overview** > Updates mobile-core-ux components and network-related hooks to use `useAnalytics().identify` instead of the deprecated `addTraitsToUser` for updating analytics user traits (settings toggles and network add/remove flows). > > Migrates `AccountActions` off `useMetrics` to `useAnalytics`, and cleans up related unit tests by updating mocked hook return values and removing stale `MetaMetrics`/`MetaMetrics.events` mocks in `AccountsMenu.test.tsx`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 367f37e920fef4d5094183b647e5be47ee59d2fb. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/NetworkManager/index.test.tsx | 2 +- app/components/UI/NetworkManager/index.tsx | 6 ++--- .../Views/AccountActions/AccountActions.tsx | 4 ++-- .../Views/AccountsMenu/AccountsMenu.test.tsx | 23 ------------------- .../hooks/useNetworkOperations.test.ts | 2 +- .../hooks/useNetworkOperations.ts | 10 ++++---- .../AutoDetectNFTSettings/index.test.tsx | 2 +- .../Settings/AutoDetectNFTSettings/index.tsx | 6 ++--- .../AutoDetectTokensSettings/index.test.tsx | 2 +- .../AutoDetectTokensSettings/index.tsx | 6 ++--- .../BatchAccountBalanceSettings/index.tsx | 6 ++--- .../DisplayNFTMediaSettings/index.test.tsx | 2 +- .../DisplayNFTMediaSettings/index.tsx | 4 ++-- .../Sections/BlockaidSettings.tsx | 4 ++-- .../useAddPopularNetwork.test.ts | 2 +- .../useAddPopularNetwork.ts | 6 ++--- 16 files changed, 32 insertions(+), 55 deletions(-) diff --git a/app/components/UI/NetworkManager/index.test.tsx b/app/components/UI/NetworkManager/index.test.tsx index 7a175d0e3e71..21b7406d6b76 100644 --- a/app/components/UI/NetworkManager/index.test.tsx +++ b/app/components/UI/NetworkManager/index.test.tsx @@ -122,7 +122,7 @@ jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder: mockCreateEventBuilder, - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, }), })); diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index 10c172d7a93d..6903223b4929 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -83,7 +83,7 @@ const NetworkManager = () => { const navigation = useNavigation(); const { colors } = useTheme(); const { styles } = useStyles(createStyles, { colors }); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const { disableNetwork, enabledNetworksByNamespace } = useNetworkEnablement(); const enabledNetworks = useMemo(() => { @@ -296,11 +296,11 @@ const NetworkManager = () => { NetworkController.removeNetwork(chainId); disableNetwork(showConfirmDeleteModal.caipChainId); - addTraitsToUser(removeItemFromChainIdList(chainId)); + identify(removeItemFromChainIdList(chainId)); setShowConfirmDeleteModal(initialShowConfirmDeleteModal); } - }, [showConfirmDeleteModal, disableNetwork, addTraitsToUser]); + }, [showConfirmDeleteModal, disableNetwork, identify]); const cancelButtonProps: ButtonProps = useMemo( () => ({ diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx index e894e05b1d85..221d77a8b3e6 100644 --- a/app/components/Views/AccountActions/AccountActions.tsx +++ b/app/components/Views/AccountActions/AccountActions.tsx @@ -27,7 +27,7 @@ import Logger from '../../../util/Logger'; import { protectWalletModalVisible } from '../../../actions/user'; import Routes from '../../../constants/navigation/Routes'; import { AccountActionsBottomSheetSelectorsIDs } from './AccountActionsBottomSheet.testIds'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { isHardwareAccount, isHDOrFirstPartySnapAccount, @@ -64,7 +64,7 @@ const AccountActions = () => { const sheetRef = useRef(null); const { navigate } = useNavigation(); const dispatch = useDispatch(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { networkSupporting7702Present } = useEIP7702Networks( selectedAccount.address, ); diff --git a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx index 5e2f63e78af0..d7ecb0e412f5 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx @@ -50,29 +50,6 @@ jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ }), })); -jest.mock('../../../core/Analytics', () => ({ - MetaMetrics: { - getInstance: () => ({ - trackEvent: mockTrackEvent, - }), - }, -})); - -jest.mock('../../../core/Analytics/MetaMetrics.events', () => ({ - EVENT_NAME: { - CARD_HOME_CLICKED: 'Card Home Clicked', - SETTINGS_VIEWED: 'Settings Viewed', - SETTINGS_ABOUT: 'About MetaMask', - NAVIGATION_TAPS_SEND_FEEDBACK: 'Send Feedback', - NAVIGATION_TAPS_GET_HELP: 'Get Help', - NAVIGATION_TAPS_LOGOUT: 'Logout', - QR_SCANNER_OPENED: 'QR Scanner Opened', - RAMPS_BUTTON_CLICKED: 'Ramps Button Clicked', - NOTIFICATIONS_MENU_OPENED: 'Notifications Menu Opened', - NOTIFICATIONS_ACTIVATED: 'Notifications Activated', - }, -})); - jest.mock('../../../core/Analytics/MetricsEventBuilder', () => ({ MetricsEventBuilder: { createEventBuilder: jest.fn(() => ({ diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts index 82aa44c975d0..7f310f62dfa6 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts @@ -84,7 +84,7 @@ const mockCreateEventBuilder = jest.fn(() => ({ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent, - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, createEventBuilder: mockCreateEventBuilder, }), })); diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts index 9fb763781948..a1d542c19812 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts @@ -84,7 +84,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const providerConfig = useSelector(selectProviderConfig); const isAllNetworks = useSelector(selectIsAllNetworks); const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); - const { trackEvent, addTraitsToUser, createEventBuilder } = useAnalytics(); + const { trackEvent, identify, createEventBuilder } = useAnalytics(); // ---- Handle network add/update ------------------------------------------ const handleNetworkUpdate = useCallback( @@ -204,7 +204,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { await NetworkController.addNetwork({ ...networkConfig, } as unknown as AddNetworkFields); - addTraitsToUser(addItemToChainIdList(networkConfig.chainId)); + identify(addItemToChainIdList(networkConfig.chainId)); } if (!skipPostSaveNavigation) { @@ -221,7 +221,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { navigation, networkConfigurations, trackEvent, - addTraitsToUser, + identify, createEventBuilder, ], ); @@ -396,11 +396,11 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const { NetworkController } = Engine.context; NetworkController.removeNetwork(hexChainId); - addTraitsToUser(removeItemFromChainIdList(hexChainId)); + identify(removeItemFromChainIdList(hexChainId)); navigation.goBack(); }, - [navigation, networkConfigurations, providerConfig, addTraitsToUser], + [navigation, networkConfigurations, providerConfig, identify], ); // ---- Navigate to edit --------------------------------------------------- diff --git a/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx b/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx index 2cce94563c6d..8ce0f4270e3c 100644 --- a/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx +++ b/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx @@ -59,7 +59,7 @@ describe('AutoDetectNFTSettings', () => { jest.mocked(useAnalytics).mockReturnValue( createMockUseAnalyticsHook({ trackEvent: mockTrackEvent, - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, }), ); (useNavigation as jest.Mock).mockImplementation(() => mockNavigation); diff --git a/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx b/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx index 813ba091532c..bf777a2fb46c 100644 --- a/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx +++ b/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx @@ -23,7 +23,7 @@ import createStyles from './index.styles'; import { NFT_AUTO_DETECT_MODE_SECTION } from './index.constants'; const AutoDetectNFTSettings = () => { - const { trackEvent, addTraitsToUser, createEventBuilder } = useAnalytics(); + const { trackEvent, identify, createEventBuilder } = useAnalytics(); const theme = useTheme(); const { colors } = theme; const styles = createStyles(); @@ -38,7 +38,7 @@ const AutoDetectNFTSettings = () => { } PreferencesController.setUseNftDetection(value); - addTraitsToUser({ + identify({ ...(value && { [UserProfileProperty.ENABLE_OPENSEA_API]: value ? UserProfileProperty.ON @@ -57,7 +57,7 @@ const AutoDetectNFTSettings = () => { .build(), ); }, - [addTraitsToUser, trackEvent, createEventBuilder], + [identify, trackEvent, createEventBuilder], ); return ( diff --git a/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx b/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx index 8ebb24a41baa..ba8ef470db1a 100644 --- a/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx +++ b/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx @@ -44,7 +44,7 @@ describe('AssetSettings', () => { jest .mocked(useAnalytics) .mockReturnValue( - createMockUseAnalyticsHook({ addTraitsToUser: mockAddTraitsToUser }), + createMockUseAnalyticsHook({ identify: mockAddTraitsToUser }), ); }); diff --git a/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx b/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx index 33692785b599..cef02e14dd25 100644 --- a/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx +++ b/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx @@ -26,20 +26,20 @@ const AutoDetectTokensSettings = () => { const theme = useTheme(); const { colors } = theme; const { styles } = useStyles(styleSheet, {}); - const { addTraitsToUser } = useAnalytics(); + const { identify } = useAnalytics(); const isTokenDetectionEnabled = useSelector(selectUseTokenDetection); const toggleTokenDetection = useCallback( (value: boolean) => { Engine.context.PreferencesController.setUseTokenDetection(value); - addTraitsToUser({ + identify({ [UserProfileProperty.TOKEN_DETECTION]: value ? UserProfileProperty.ON : UserProfileProperty.OFF, }); }, - [addTraitsToUser], + [identify], ); return ( diff --git a/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx b/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx index 49aaf6ed8901..5bb9b3bea45a 100644 --- a/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx +++ b/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx @@ -25,7 +25,7 @@ const BatchAccountBalanceSettings = () => { const theme = useTheme(); const { colors } = theme; const { styles } = useStyles(styleSheet, {}); - const { addTraitsToUser } = useAnalytics(); + const { identify } = useAnalytics(); const isMultiAccountBalancesEnabled = useSelector( selectIsMultiAccountBalancesEnabled, @@ -36,13 +36,13 @@ const BatchAccountBalanceSettings = () => { PreferencesController.setIsMultiAccountBalancesEnabled( multiAccountBalancesEnabled, ); - addTraitsToUser({ + identify({ [UserProfileProperty.MULTI_ACCOUNT_BALANCE]: multiAccountBalancesEnabled ? UserProfileProperty.ON : UserProfileProperty.OFF, }); }, - [PreferencesController, addTraitsToUser], + [PreferencesController, identify], ); return ( diff --git a/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx b/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx index 4cd6db81cbda..d36a1dc2cbaf 100644 --- a/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx +++ b/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx @@ -43,7 +43,7 @@ describe('DisplayNFTMediaSettings', () => { jest .mocked(useAnalytics) .mockReturnValue( - createMockUseAnalyticsHook({ addTraitsToUser: mockAddTraitsToUser }), + createMockUseAnalyticsHook({ identify: mockAddTraitsToUser }), ); }); diff --git a/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx b/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx index 6f0c3ff27278..caf0ab57dc2e 100644 --- a/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx +++ b/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx @@ -21,7 +21,7 @@ const DisplayNFTMediaSettings = () => { const theme = useTheme(); const { colors } = theme; const { styles } = useStyles(styleSheet, {}); - const { addTraitsToUser } = useAnalytics(); + const { identify } = useAnalytics(); const displayNftMedia = useSelector(selectDisplayNftMedia); @@ -39,7 +39,7 @@ const DisplayNFTMediaSettings = () => { [UserProfileProperty.NFT_AUTODETECTION]: UserProfileProperty.OFF, }), }; - addTraitsToUser(traits); + identify(traits); }; return ( diff --git a/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx b/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx index 32875c72f789..f3c4104e1933 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx @@ -21,7 +21,7 @@ import { SECURITY_ALERTS_URL } from '../../../../../constants/urls'; const BlockaidSettings = () => { const theme = useTheme(); const { colors } = useTheme(); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const styles = createStyles(); const securityAlertsEnabled = useSelector(selectIsSecurityAlertsEnabled); @@ -41,7 +41,7 @@ const BlockaidSettings = () => { .build(), ); - addTraitsToUser({ + identify({ [UserProfileProperty.SECURITY_PROVIDERS]: newSecurityAlertsEnabledState ? 'blockaid' : '', diff --git a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts index 53e4f433cf79..cc0ff82b4d99 100644 --- a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts +++ b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts @@ -72,7 +72,7 @@ describe('useAddPopularNetwork', () => { build: jest.fn().mockReturnValue({ event: 'test' }), }), }), - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, }); (useNetworkEnablement as jest.Mock).mockReturnValue({ enableNetwork: mockEnableNetwork, diff --git a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts index 429d97fbdceb..7ccf9fad97cd 100644 --- a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts +++ b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts @@ -36,7 +36,7 @@ interface UseAddPopularNetworkResult { */ export const useAddPopularNetwork = (): UseAddPopularNetworkResult => { const dispatch = useDispatch(); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const networkConfigurationByChainId = useSelector( selectEvmNetworkConfigurationsByChainId, ); @@ -116,7 +116,7 @@ export const useAddPopularNetwork = (): UseAddPopularNetworkResult => { ], }); - addTraitsToUser(addItemToChainIdList(hexChainId)); + identify(addItemToChainIdList(hexChainId)); networkClientId = addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] @@ -138,7 +138,7 @@ export const useAddPopularNetwork = (): UseAddPopularNetworkResult => { networkConfigurationByChainId, trackEvent, createEventBuilder, - addTraitsToUser, + identify, enableNetwork, dispatch, ],