From c89ed6cbe67d22e4894e37a7624f4978092f2791 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:37:44 +0800 Subject: [PATCH 01/13] feat(perps): improved limit order view with bid ask mid values (#24069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adjusts the limit price preset buttons in the PerpsLimitPriceBottomSheet to include Mid/Bid/Ask prices alongside reduced percentage options. **Changes:** - Long orders now show: Mid, Bid, -1%, -2% (previously: -1%, -2%, -5%, -10%) - Short orders now show: Mid, Ask, +1%, +2% (previously: +1%, +2%, +5%, +10%) - Added MetaMetrics tracking for limit price input method (`PERPS_UI_INTERACTION` with `input_method`: preset/percentage_button/keyboard) Uses the optimized `usePerpsTopOfBook` hook for bid/ask data instead of full orderbook subscription. ## **Changelog** CHANGELOG entry: Updated limit price presets to include Mid and Bid/Ask buttons for quicker price selection ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2114 ## **Manual testing steps** ```gherkin Feature: Limit price presets Scenario: User sets limit price using Mid button for long order Given user is on the order form with direction set to Long When user taps "Limit" order type and opens limit price modal Then user sees preset buttons: Mid, Bid, -1%, -2% When user taps "Mid" button Then limit price is set to the current mid price Scenario: User sets limit price using Bid button for long order Given user is on the limit price modal with direction set to Long When user taps "Bid" button Then limit price is set to the current best bid price Scenario: User sets limit price using Ask button for short order Given user is on the order form with direction set to Short When user taps "Limit" order type and opens limit price modal Then user sees preset buttons: Mid, Ask, +1%, +2% When user taps "Ask" button Then limit price is set to the current best ask price Scenario: MetaMetrics tracks input method Given user is on the limit price modal When user taps a preset button (Mid/Bid/Ask) and confirms Then PERPS_UI_INTERACTION event is sent with input_method: "preset" When user taps a percentage button and confirms Then PERPS_UI_INTERACTION event is sent with input_method: "percentage_button" When user types via keypad and confirms Then PERPS_UI_INTERACTION event is sent with input_method: "keyboard" ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/43e9ebc6-5bf3-4296-83c7-b2a55bebf7d8 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds Mid/Bid/Ask preset buttons with top-of-book data and input-method tracking to the perps limit price modal, while reducing percentage presets to 1% and 2%. > > - **Perps UI**: > - Add `Mid`, `Bid`, `Ask` preset buttons in `PerpsLimitPriceBottomSheet` using live mid and `usePerpsTopOfBook` bid/ask. > - Reduce percentage presets to `±1%` and `±2%`; format/validation logic unchanged. > - **Analytics**: > - Track limit-price input method via `usePerpsEventTracking` (`preset`, `percentage_button`, `keyboard`) on confirm (`PERPS_UI_INTERACTION`). > - **Config**: > - Update `LIMIT_PRICE_CONFIG` (`PRESET_PERCENTAGES: [1,2]`, `LONG_PRESETS: [-1,-2]`, `SHORT_PRESETS: [1,2]`). > - **i18n**: > - Add `perps.order.limit_price_modal.mid_price`; change labels to "Bid"/"Ask". > - **Tests**: > - Expand tests to mock `usePerpsTopOfBook` and assert Mid/Bid/Ask and percentage button behaviors. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9721347fc04f49b0d26d9938586c329c087a0293. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsLimitPriceBottomSheet.test.tsx | 112 +++++++++++--- .../PerpsLimitPriceBottomSheet.tsx | 143 ++++++++++++++++-- .../UI/Perps/constants/perpsConfig.ts | 8 +- locales/languages/en.json | 5 +- 4 files changed, 227 insertions(+), 41 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx index b9706d954b89..8ab4571fee00 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx @@ -107,6 +107,10 @@ jest.mock('bignumber.js', () => ({ // Mock stream hooks jest.mock('../../hooks/stream', () => ({ usePerpsLivePrices: jest.fn(() => ({})), + usePerpsTopOfBook: jest.fn(() => ({ + bestBid: '2995', + bestAsk: '3005', + })), })); // Mock usePerpsConnection hook @@ -114,6 +118,32 @@ jest.mock('../../hooks/index', () => ({ usePerpsConnection: jest.fn(), })); +// Mock usePerpsEventTracking hook +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(() => ({ + track: jest.fn(), + })), +})); + +// Mock eventNames constants +jest.mock('../../constants/eventNames', () => ({ + PerpsEventProperties: { + INTERACTION_TYPE: 'interaction_type', + SETTING_TYPE: 'setting_type', + INPUT_METHOD: 'input_method', + ASSET: 'asset', + DIRECTION: 'direction', + }, + PerpsEventValues: { + INTERACTION_TYPE: { SETTING_CHANGED: 'setting_changed' }, + INPUT_METHOD: { + PRESET: 'preset', + PERCENTAGE_BUTTON: 'percentage_button', + KEYBOARD: 'keyboard', + }, + }, +})); + // Mock Keypad component from Base // Mock BottomSheet components jest.mock( @@ -329,9 +359,11 @@ describe('PerpsLimitPriceBottomSheet', () => { jest.clearAllMocks(); mockUseTheme.mockReturnValue(mockTheme); - // Mock usePerpsLivePrices hook to return empty by default - const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + // Mock stream hooks + const { usePerpsLivePrices, usePerpsTopOfBook } = + jest.requireMock('../../hooks/stream'); usePerpsLivePrices.mockReturnValue({}); + usePerpsTopOfBook.mockReturnValue({ bestBid: '2995', bestAsk: '3005' }); // Mock usePerpsConnection hook const { usePerpsConnection } = jest.requireMock('../../hooks/index'); @@ -451,43 +483,83 @@ describe('PerpsLimitPriceBottomSheet', () => { }); describe('Quick Action Buttons', () => { - it('displays direction-specific preset buttons for long orders', () => { - // Act + it('displays Mid, Bid, and percentage preset buttons for long orders', () => { render(); - // Assert - Long orders show negative percentages (buy below market) + expect( + screen.getByText('perps.order.limit_price_modal.mid_price'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.order.limit_price_modal.bid_price'), + ).toBeOnTheScreen(); expect(screen.getByText('-1%')).toBeOnTheScreen(); expect(screen.getByText('-2%')).toBeOnTheScreen(); - expect(screen.getByText('-5%')).toBeOnTheScreen(); - expect(screen.getByText('-10%')).toBeOnTheScreen(); }); - it('displays direction-specific preset buttons for short orders', () => { - // Act + it('displays Mid, Ask, and percentage preset buttons for short orders', () => { render( , ); - // Assert - Short orders show positive percentages (sell above market) + expect( + screen.getByText('perps.order.limit_price_modal.mid_price'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.order.limit_price_modal.ask_price'), + ).toBeOnTheScreen(); expect(screen.getByText('+1%')).toBeOnTheScreen(); expect(screen.getByText('+2%')).toBeOnTheScreen(); - expect(screen.getByText('+5%')).toBeOnTheScreen(); - expect(screen.getByText('+10%')).toBeOnTheScreen(); }); - it('calculates price based on current market price for long orders', () => { - // Act + it('sets price when Mid button is pressed', () => { + render(); + + const midButton = screen.getByText( + 'perps.order.limit_price_modal.mid_price', + ); + fireEvent.press(midButton); + + // Verify limit price was updated to current price (3000) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000'); + }); + + it('sets price when Bid button is pressed for long orders', () => { + render(); + + const bidButton = screen.getByText( + 'perps.order.limit_price_modal.bid_price', + ); + fireEvent.press(bidButton); + + // Verify limit price was updated to bid price (2995 from mock) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('2995'); + }); + + it('sets price when Ask button is pressed for short orders', () => { + render( + , + ); + + const askButton = screen.getByText( + 'perps.order.limit_price_modal.ask_price', + ); + fireEvent.press(askButton); + + // Verify limit price was updated to ask price (3005 from mock) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3005'); + }); + + it('sets price when percentage button is pressed for long orders', () => { render(); const onePercentButton = screen.getByText('-1%'); fireEvent.press(onePercentButton); - // Assert - Button exists and is pressable - expect(onePercentButton).toBeOnTheScreen(); + // Verify limit price was updated (BigNumber mock returns base price) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000'); }); - it('calculates price based on current market price for short orders', () => { - // Act + it('sets price when percentage button is pressed for short orders', () => { render( , ); @@ -495,8 +567,8 @@ describe('PerpsLimitPriceBottomSheet', () => { const onePercentButton = screen.getByText('+1%'); fireEvent.press(onePercentButton); - // Assert - Button exists and is pressable - expect(onePercentButton).toBeOnTheScreen(); + // Verify limit price was updated (BigNumber mock returns base price) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000'); }); }); diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx index fb1298f5b0bf..33acf906346e 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx @@ -23,12 +23,18 @@ import { } from '../../utils/formatUtils'; import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; import { createStyles } from './PerpsLimitPriceBottomSheet.styles'; -import { usePerpsLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream'; import { PERPS_CONSTANTS, LIMIT_PRICE_CONFIG, } from '../../constants/perpsConfig'; import { BigNumber } from 'bignumber.js'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; interface PerpsLimitPriceBottomSheetProps { isVisible: boolean; @@ -46,7 +52,7 @@ interface PerpsLimitPriceBottomSheetProps { * Modal for setting limit order prices with direction-specific presets * * Features: - * - Direction-aware presets (Long: -1%, -2%, -5%, -10% | Short: +1%, +2%, +5%, +10%) + * - Direction-aware presets (Long: Mid, Bid, -1%, -2% | Short: Mid, Ask, +1%, +2%) * - Custom keypad for price input * - Real-time current market price display * - Automatic preset calculation based on current price @@ -72,6 +78,12 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ // Initialize with initial limit price or empty to show placeholder const [limitPrice, setLimitPrice] = useState(initialLimitPrice || ''); + // Track input method for MetaMetrics (preset = Mid/Bid/Ask, percentage_button = %, keyboard = manual) + const [inputMethod, setInputMethod] = useState(null); + + // MetaMetrics tracking + const { track } = usePerpsEventTracking(); + // Get real-time price data with 1000ms throttle for limit price bottom sheet // Only subscribe when visible const priceData = usePerpsLivePrices({ @@ -85,8 +97,15 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ ? parseFloat(currentPriceData.price) : passedCurrentPrice; + // Get top of book (bid/ask) data for Bid/Ask preset buttons + // Note: Mid price comes from currentPrice above (from allMids stream) + const topOfBook = usePerpsTopOfBook({ symbol: isVisible ? asset : '' }); + const bidPrice = topOfBook?.bestBid; + const askPrice = topOfBook?.bestAsk; + useEffect(() => { if (isVisible) { + setInputMethod(null); // Reset input method tracking for new session bottomSheetRef.current?.onOpenBottomSheet(); // Start cursor blinking animation @@ -114,6 +133,19 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ const handleConfirm = () => { // Remove any formatting (commas, dollar signs) before passing the value const cleanPrice = limitPrice.replace(/[$,]/g, ''); + + // Track limit price input method + if (inputMethod) { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.SETTING_CHANGED, + [PerpsEventProperties.SETTING_TYPE]: 'limit_price', + [PerpsEventProperties.INPUT_METHOD]: inputMethod, + [PerpsEventProperties.ASSET]: asset, + [PerpsEventProperties.DIRECTION]: direction, + }); + } + // Only call onConfirm; parent controls visibility. Avoid calling onClose here // to distinguish between confirm vs dismiss (onClose used for cancel/dismiss). onConfirm(cleanPrice); @@ -127,6 +159,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ return; // Ignore input that would exceed 9 digits } setLimitPrice(value || ''); + setInputMethod(PerpsEventValues.INPUT_METHOD.KEYBOARD); }, [], ); @@ -334,23 +367,69 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY} - {/* Quick percentage buttons - Direction-specific presets using config */} + {/* Quick preset buttons - Mid/Bid/Ask + percentage presets */} + {/* Mid price button - uses currentPrice which is the mid price from allMids stream */} + { + if (currentPrice) { + setLimitPrice( + formatWithSignificantDigits(currentPrice, 4).value.toString(), + ); + setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET); + } + }} + > + + {strings('perps.order.limit_price_modal.mid_price')} + + + {direction === 'long' ? ( - // For long orders: buy below market using LONG_PRESETS + // For long orders: Mid, Bid, -1%, -2% <> - {LIMIT_PRICE_CONFIG.LONG_PRESETS.map((percentage) => ( - + {/* Bid price button */} + { + const price = bidPrice || currentPriceData?.price; + if (price) { setLimitPrice( formatWithSignificantDigits( - parseFloat(calculatePriceForPercentage(percentage)), + parseFloat(price), 4, ).value.toString(), - ) + ); + setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET); } + }} + > + + {strings('perps.order.limit_price_modal.bid_price')} + + + + {/* Percentage presets */} + {LIMIT_PRICE_CONFIG.LONG_PRESETS.map((percentage) => ( + { + const calculatedPrice = + calculatePriceForPercentage(percentage); + if (calculatedPrice) { + setLimitPrice( + formatWithSignificantDigits( + parseFloat(calculatedPrice), + 4, + ).value.toString(), + ); + setInputMethod( + PerpsEventValues.INPUT_METHOD.PERCENTAGE_BUTTON, + ); + } + }} > {percentage > 0 ? '+' : ''} @@ -360,15 +439,49 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ ))} ) : ( - // For short orders: sell above market using SHORT_PRESETS + // For short orders: Mid, Ask, +1%, +2% <> + {/* Ask price button */} + { + const price = askPrice || currentPriceData?.price; + if (price) { + setLimitPrice( + formatWithSignificantDigits( + parseFloat(price), + 4, + ).value.toString(), + ); + setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET); + } + }} + > + + {strings('perps.order.limit_price_modal.ask_price')} + + + + {/* Percentage presets */} {LIMIT_PRICE_CONFIG.SHORT_PRESETS.map((percentage) => ( - setLimitPrice(calculatePriceForPercentage(percentage)) - } + onPress={() => { + const calculatedPrice = + calculatePriceForPercentage(percentage); + if (calculatedPrice) { + setLimitPrice( + formatWithSignificantDigits( + parseFloat(calculatedPrice), + 4, + ).value.toString(), + ); + setInputMethod( + PerpsEventValues.INPUT_METHOD.PERCENTAGE_BUTTON, + ); + } + }} > {percentage > 0 ? '+' : ''} diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index f2d078023036..fd85daed253f 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -219,15 +219,15 @@ export const TP_SL_VIEW_CONFIG = { */ export const LIMIT_PRICE_CONFIG = { // Preset percentage options for quick selection - PRESET_PERCENTAGES: [1, 2, 5, 10], // Available as both positive and negative + PRESET_PERCENTAGES: [1, 2], // Available as both positive and negative // Modal opening delay when switching to limit order (milliseconds) // Allows order type modal to close smoothly before opening limit price modal MODAL_OPEN_DELAY: 300, - // Direction-specific preset configurations - LONG_PRESETS: [-1, -2, -5, -10], // Buy below market for long orders - SHORT_PRESETS: [1, 2, 5, 10], // Sell above market for short orders + // Direction-specific preset configurations (Mid/Bid/Ask buttons handled separately) + LONG_PRESETS: [-1, -2], // Buy below market for long orders + SHORT_PRESETS: [1, 2], // Sell above market for short orders } as const; /** diff --git a/locales/languages/en.json b/locales/languages/en.json index 2b7db4eec996..9121c08f6aea 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1212,8 +1212,9 @@ "market_price": "Market price: {{price}}", "market": "Market", "current_price": "Current price", - "ask_price": "Ask price", - "bid_price": "Bid price", + "mid_price": "Mid", + "ask_price": "Ask", + "bid_price": "Bid", "difference_from_market": "Difference from market:", "limit_price_above": "Limit price is above current price", "limit_price_below": "Limit price is below current price" From 1ebae6857a7bc857947a9aa7102585e3aaa273fd Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:45:33 +0000 Subject: [PATCH 02/13] chore: Add how to use the sourcemaps for better performance benchmarking (#23805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** With sourcemaps applied it will be easier to read the cpu traces! ## **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. ## **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] > Documents converting `.cpuprofile` with sourcemaps and where to obtain them for clearer Chrome tracing. > > - **Docs** (`docs/readme/release-build-profiler.md`): > - Add instructions to convert `.cpuprofile` with sourcemaps using `--sourcemap-path` in `react-native-release-profiler`. > - Note where to obtain sourcemaps from Bitrise artifacts (`Android_Sourcemaps_prodRelease.zip`) and to unzip before use. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 582aa4267bffa60f269f9148f13be26e7590529e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- docs/readme/release-build-profiler.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/readme/release-build-profiler.md b/docs/readme/release-build-profiler.md index 75ec8400adb3..aed8bc69434d 100644 --- a/docs/readme/release-build-profiler.md +++ b/docs/readme/release-build-profiler.md @@ -37,6 +37,14 @@ Chrome's tracing UI expects a JSON trace. Convert the `.cpuprofile` first: yarn react-native-release-profiler --local /path/to/profile.cpuprofile ``` +To have sourcemaps on the tracing, to be easier to identify the processes that are happening, convirt the `.cpuprofile` with this argument: + +```bash +yarn react-native-release-profiler --local /path/to/profile.cpuprofile --sourcemap-path /path/to/sourcemaps +``` + +You can find the sourcemaps at the artifcacts generated when running `release_rc_builds_to_store_pipeline` in bitrise, under the name `Android_Sourcemaps_prodRelease.zip`, download and unzip it. + Then open Chrome and load the generated JSON: - Navigate to `chrome://tracing` → Load → select the JSON file. From 348086606c704f7750c36176a7e779d03840d9cc Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:19:30 +0800 Subject: [PATCH 03/13] fix(perps): universal fiat formatting in transaction history (#24096) ## **Description** Fixes incorrect decimal precision for entry price display in trade transaction history. The entry price was using `formatPositiveFiat()` which applies minimal view formatting (2 decimals max), causing small prices like `$0.028...` to display as `$0.03`. Now uses `formatPerpsFiat()` with `PRICE_RANGES_UNIVERSAL` config which follows the decimal rules from `docs/perps/perps-rules-decimals.md`: - Values < $0.01: 4 significant digits, max 6 decimals - Values $0.01-$10: 5 significant digits, min 2, max 6 decimals - Higher values: Appropriate precision based on magnitude ## **Changelog** CHANGELOG entry: Fixed entry price decimal precision in Perps trade history ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2202 ## **Manual testing steps** ```gherkin Feature: Entry price decimal display Scenario: User views trade transaction with small entry price Given user has completed a trade on a low-price token (e.g., MON at ~$0.028) When user navigates to Perps > Transactions > Trades tab And user taps on a trade transaction Then Entry price should display with proper decimal precision (e.g., "$0.02826" not "$0.03") ``` ## **Screenshots/Recordings** ### **Before** Entry price shows `$0.03` (rounded, losing precision) image ### **After** image Entry price shows proper precision following universal decimal rules ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Use `formatPerpsFiat` with `PRICE_RANGES_UNIVERSAL` to display entry/close price with correct precision in perps position transaction details. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9bf0cffeb641a044c5550278884719df8eba02bf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsTransactionsView/PerpsPositionTransactionView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 96ad227fa4a3..2c59753eaaab 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -30,8 +30,10 @@ import { PerpsTransaction, } from '../../types/transactionHistory'; import { + formatPerpsFiat, formatPositiveFiat, formatTransactionDate, + PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { styleSheet } from './PerpsPositionTransactionView.styles'; @@ -103,7 +105,9 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.action === 'Closed' ? strings('perps.transactions.position.close_price') : strings('perps.transactions.position.entry_price'), - value: formatPositiveFiat(transaction.fill.entryPrice), + value: formatPerpsFiat(transaction.fill.entryPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }), }, ].filter(Boolean); From e87b3a422629754053eabaaa9b793306341cba05 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:40:35 +0800 Subject: [PATCH 04/13] fix(perps): date display formatting in chart (#24103) ## **Description** This PR adjusts the date format on the Perps price chart x-axis from "6 Nov" style to "11/6" (MM/DD numeric format) as requested in TAT-2171. **Changes:** - Changed date format from "day month" (e.g., "6 Nov") to "MM/DD" (e.g., "11/6") for clearer and more compact display - Updated all date formatting cases in the `formatTimestamp` function: - DayOfMonth tick type (used for daily candles) - Hour/Minute tick types (used for intraday intervals) - Fallback logic for various time spans - The shorter numeric format naturally reduces label overlap on the x-axis - Crosshair tooltip retains the detailed "Nov 17 00:15" format for precision ## **Changelog** CHANGELOG entry: Fixed date format display on Perps price chart x-axis from "6 Nov" to "11/6" format ## **Related issues** Fixes: [TAT-2171](https://consensyssoftware.atlassian.net/browse/TAT-2171) ## **Manual testing steps** ```gherkin Feature: Perps chart date format Scenario: User views the price chart with 1-hour candle interval Given user is on a Perps market details page with price chart visible When user selects 1h candle interval Then x-axis labels show dates in "11/6" format (not "6 Nov") And labels for non-today dates show "11/17 00:15" format Scenario: User views the price chart with 1-day candle interval Given user is on a Perps market details page with price chart visible When user selects 1d candle interval Then x-axis labels show dates in "11/6" format And labels do not overlap Scenario: User taps on chart to view crosshair details Given user is on a Perps market details page with price chart visible When user taps and holds on a candle Then crosshair tooltip shows detailed format "Nov 17 00:15" ``` ## **Screenshots/Recordings** ### **Before** image ### **After** image ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Updates chart x-axis date formatting to MM/DD (and MM/DD HH:MM for intraday when not today) across tick types and fallback paths. > > - **Perps chart x-axis formatting** (`app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx`) > - Updates `window.formatTimestamp` to use numeric `MM/DD` for `DayOfMonth` tick marks. > - For `Hour`/`Minute`, shows `MM/DD HH:MM` when not today; time-only when today. > - Aligns fallback formatting (visible-range branches and final fallback) to `MM/DD` and `MM/DD HH:MM` patterns. > - Crosshair labels remain detailed (short month, day, time) for precision. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2d04b095b8f55b941643ee7693b96dbc62d82b37. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [TAT-2171]: https://consensyssoftware.atlassian.net/browse/TAT-2171?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../TradingViewChartTemplate.tsx | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx b/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx index 4c60593b1f50..3f8a71807bb7 100644 --- a/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx +++ b/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx @@ -217,41 +217,41 @@ export const createTradingViewChartTemplate = ( timeZone: userTimezone }); case 'DayOfMonth': - // Always show day + month for DayOfMonth tick type (e.g., 1D candles) - // Format: "17 Nov" (day before month) - const day = date.toLocaleString('en-US', { - day: 'numeric', + // Always show month/day for DayOfMonth tick type (e.g., 1D candles) + // Format: "11/6" (MM/DD numeric format) + const dayOfMonthMonth = date.toLocaleString('en-US', { + month: 'numeric', timeZone: userTimezone }); - const month = date.toLocaleString('en-US', { - month: 'short', + const dayOfMonthDay = date.toLocaleString('en-US', { + day: 'numeric', timeZone: userTimezone }); - return day + ' ' + month; + return dayOfMonthMonth + '/' + dayOfMonthDay; case 'Hour': case 'Minute': // Show date + time if not today, otherwise just time if (!window.isToday(date, userTimezone)) { - // Format: "17 Nov 00:15" - const day = date.toLocaleString('en-US', { - day: 'numeric', + // Format: "11/17 00:15" (MM/DD + time) + const hourMinuteMonth = date.toLocaleString('en-US', { + month: 'numeric', timeZone: userTimezone }); - const month = date.toLocaleString('en-US', { - month: 'short', + const hourMinuteDay = date.toLocaleString('en-US', { + day: 'numeric', timeZone: userTimezone }); - const timeStr = date.toLocaleString('en-US', { - hour: '2-digit', + const timeStr = date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone }); - return day + ' ' + month + ' ' + timeStr; + return hourMinuteMonth + '/' + hourMinuteDay + ' ' + timeStr; } else { // Show time only for today - return date.toLocaleString('en-US', { - hour: '2-digit', + return date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone @@ -281,26 +281,26 @@ export const createTradingViewChartTemplate = ( if (timeSpanHours <= 24) { // Less than 24 hours: show date + time if not today, otherwise just time if (!window.isToday(date, userTimezone)) { - // Format: "17 Nov 00:15" - const day = date.toLocaleString('en-US', { - day: 'numeric', + // Format: "11/17 00:15" (MM/DD + time) + const fb24Month = date.toLocaleString('en-US', { + month: 'numeric', timeZone: userTimezone }); - const month = date.toLocaleString('en-US', { - month: 'short', + const fb24Day = date.toLocaleString('en-US', { + day: 'numeric', timeZone: userTimezone }); - const timeStr = date.toLocaleString('en-US', { - hour: '2-digit', + const fb24TimeStr = date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone }); - return day + ' ' + month + ' ' + timeStr; + return fb24Month + '/' + fb24Day + ' ' + fb24TimeStr; } else { // Show time only for today - return date.toLocaleString('en-US', { - hour: '2-digit', + return date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone @@ -309,65 +309,65 @@ export const createTradingViewChartTemplate = ( } else if (timeSpanHours <= 24 * 7) { // Less than a week: show date + time if not today, otherwise just time if (!window.isToday(date, userTimezone)) { - // Format: "17 Nov 00:15" - const day = date.toLocaleString('en-US', { - day: 'numeric', + // Format: "11/17 00:15" (MM/DD + time) + const fbWeekMonth = date.toLocaleString('en-US', { + month: 'numeric', timeZone: userTimezone }); - const month = date.toLocaleString('en-US', { - month: 'short', + const fbWeekDay = date.toLocaleString('en-US', { + day: 'numeric', timeZone: userTimezone }); - const timeStr = date.toLocaleString('en-US', { - hour: '2-digit', + const fbWeekTimeStr = date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone }); - return day + ' ' + month + ' ' + timeStr; + return fbWeekMonth + '/' + fbWeekDay + ' ' + fbWeekTimeStr; } else { // Show time only for today - return date.toLocaleString('en-US', { - hour: '2-digit', + return date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone }); } } else { - // Longer ranges: always show day + month (e.g., "17 Nov") + // Longer ranges: always show month/day (e.g., "11/6") // This is especially important for 1D candles - const day = date.toLocaleString('en-US', { - day: 'numeric', + const fbLongMonth = date.toLocaleString('en-US', { + month: 'numeric', timeZone: userTimezone }); - const month = date.toLocaleString('en-US', { - month: 'short', + const fbLongDay = date.toLocaleString('en-US', { + day: 'numeric', timeZone: userTimezone }); - return day + ' ' + month; + return fbLongMonth + '/' + fbLongDay; } } } // Final fallback: show date and time - // Format: "17 Nov 00:15" - const day = date.toLocaleString('en-US', { - day: 'numeric', + // Format: "11/17 00:15" (MM/DD + time) + const finalMonth = date.toLocaleString('en-US', { + month: 'numeric', timeZone: userTimezone }); - const month = date.toLocaleString('en-US', { - month: 'short', + const finalDay = date.toLocaleString('en-US', { + day: 'numeric', timeZone: userTimezone }); - const timeStr = date.toLocaleString('en-US', { - hour: '2-digit', + const finalTimeStr = date.toLocaleString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone }); - return day + ' ' + month + ' ' + timeStr; + return finalMonth + '/' + finalDay + ' ' + finalTimeStr; } }; From fece8413b611844960153322d7d3fe2d2e7e6ee5 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:54:53 +0800 Subject: [PATCH 05/13] feat(perps): always show see all link on details screen (#24099) ## **Description** Adds "See all" button to the Recent Activity section in market details view even when there's no activity. Previously the button only appeared when trades existed, preventing users from navigating to the full activity view. ## **Changelog** CHANGELOG entry: Fixed "See all" button visibility in Perps Recent Activity section ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2222 ## **Manual testing steps** ```gherkin Feature: Recent Activity See All button Scenario: User views market details with no recent activity Given user navigates to a Perps market details page And the user has no recent trades for that market When user views the Recent Activity section Then "See all" button should be visible next to "Recent activity" header And tapping "See all" navigates to Activity > Trades tab ``` ## **Screenshots/Recordings** ### **Before** "See all" button hidden when no recent activity ### **After** "See all" button always visible, allowing navigation to full activity view ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Always render the "See all" button in the Perps market Recent activity header when not loading, and update tests to target it via testID. > > - **UI**: > - `app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx` > - Show `see-all-button` whenever `!isLoading` (removed `trades.length > 0` condition). > - Add `testID="see-all-button"` to the header button. > - **Tests**: > - `app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx` > - Update assertions to use `queryByTestId('see-all-button')`/`getByTestId('see-all-button')` instead of text queries. > - Change empty state expectation to render the See all button. > - Adjust navigation test to press the `see-all-button`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6d1ec7855937d447ec9e8c090e16b3e05b579a41. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsMarketTradesList.test.tsx | 10 +++++----- .../PerpsMarketTradesList/PerpsMarketTradesList.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx index f154163cb5b7..4b113f1ace9d 100644 --- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx @@ -210,7 +210,7 @@ describe('PerpsMarketTradesList', () => { render(); - expect(screen.queryByText('See all')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('see-all-button')).not.toBeOnTheScreen(); }); }); @@ -237,7 +237,7 @@ describe('PerpsMarketTradesList', () => { expect(screen.getByText('Recent activity')).toBeOnTheScreen(); }); - it('does not render See all button when empty', () => { + it('renders See all button when empty', () => { mockUsePerpsLiveFills.mockReturnValue({ fills: [], isInitialLoading: false, @@ -245,7 +245,7 @@ describe('PerpsMarketTradesList', () => { render(); - expect(screen.queryByText('See all')).not.toBeOnTheScreen(); + expect(screen.getByTestId('see-all-button')).toBeOnTheScreen(); }); }); @@ -272,7 +272,7 @@ describe('PerpsMarketTradesList', () => { render(); expect(screen.getByText('Recent activity')).toBeOnTheScreen(); - expect(screen.getByText('See all')).toBeOnTheScreen(); + expect(screen.getByTestId('see-all-button')).toBeOnTheScreen(); }); it('renders trade subtitles correctly', () => { @@ -347,7 +347,7 @@ describe('PerpsMarketTradesList', () => { render(); - const seeAllButton = screen.getByText('See all'); + const seeAllButton = screen.getByTestId('see-all-button'); fireEvent.press(seeAllButton); expect(mockNavigate).toHaveBeenCalledTimes(1); diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx index 1e0eba09cd37..730ddc955dd0 100644 --- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx @@ -137,8 +137,8 @@ const PerpsMarketTradesList: React.FC = ({ {strings('perps.market.recent_trades')} - {!isLoading && trades.length > 0 && ( - + {!isLoading && ( + {strings('perps.home.see_all')} From 32c71f1b2183baf0492ec692366523927aaf3e50 Mon Sep 17 00:00:00 2001 From: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:07:52 +0100 Subject: [PATCH 06/13] test: added bs config params (#23969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **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. ## **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] > Adds multiple Appium capability settings and reduces `bstackPageSource` sampling in the BrowserStack config. > > - **E2E / BrowserStack config (`e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts`)**: > - Add Appium capability settings: `settings[actionAcknowledgmentTimeout]`, `settings[ignoreUnimportantViews]`, `settings[waitForSelectorTimeout]`, `waitForQuiescence`, `animationCoolOffTimeout`, `reduceMotion`, `customSnapshotTimeout`, `waitForIdleTimeout`, `disableWindowAnimation`, `skipDeviceInitialization`. > - Adjust `appium:bstackPageSource`: `samplesX` 15→3, `samplesY` 15→3, `maxDepth` 75→15. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 41eac814d2bf1e3814ffd9fa6d26cbeca257fe21. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../browserstack/BrowserStackConfigBuilder.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts b/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts index c9c00497e75a..277b01429120 100644 --- a/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts +++ b/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts @@ -70,14 +70,24 @@ export class BrowserStackConfigBuilder { 'appium:app': appBsUrl, 'appium:autoAcceptAlerts': true, 'appium:fullReset': true, + 'appium:settings[actionAcknowledgmentTimeout]': 3000, + 'appium:settings[ignoreUnimportantViews]': true, 'appium:settings[snapshotMaxDepth]': 62, + 'appium:settings[waitForSelectorTimeout]': 1000, 'appium:includeSafariInWebviews': true, 'appium:chromedriverAutodownload': true, + 'appium:waitForQuiescence': false, // Don't wait for app idle + 'appium:animationCoolOffTimeout': 0, // Skip animation wait + 'appium:reduceMotion': true, // Reduce iOS animations + 'appium:customSnapshotTimeout': 15, // Snapshot timeout in seconds" + 'appium:waitForIdleTimeout': 0, // Don't wait for idle + 'appium:disableWindowAnimation': true, // Disable animations + 'appium:skipDeviceInitialization': true, // Skip init (faster startup) 'appium:bstackPageSource': { enable: true, - samplesX: 15, - samplesY: 15, - maxDepth: 75, + samplesX: 3, + samplesY: 3, + maxDepth: 15, }, }, }; From 73ab28102166710ec29b8a057e7e7feeb78dcdee Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:16:07 +0800 Subject: [PATCH 07/13] fix(sdk): disable android native sdk (#23658) ## **Description** **Changes:** - Remove the native SDK library (`nativesdk.aar`) and MessageService from Android manifest - Remove NativeSDKPackage registration from MainApplication - Disable SDK initialization for Android platform - Remove deeplink handler for Android SDK binding - Delete all TypeScript code in `AndroidSDK/` directory ## **Changelog** CHANGELOG entry: null ## **Related issues** ## **Manual testing steps** ```gherkin Feature: Android SDK disabled Scenario: App launches without Android SDK Given the MetaMask app is installed on Android When user opens the app Then the app should launch without crash And SDK Connect should work via deeplinks and QR codes And no Android native SDK binding should occur ``` ## **Screenshots/Recordings** ### **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. ## **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] > Disables Android native SDK integration, removes related code and deeplinks, and routes SDK Connect via deeplinks only. > > - **Android (native)**: > - Remove `../libs/nativesdk.aar` dependency and `MessageService` from `AndroidManifest.xml`. > - Stop registering `NativeSDKPackage` in `MainApplication.kt`. > - **SDKConnect**: > - Android SDK support disabled: `bindAndroidSDK()` no-op, `isAndroidSDKBound()` always false, `loadDappConnections()` returns `{}`, `getAndroidConnections()` returns `undefined`, `addDappConnection()` no-op. > - Init no longer starts Android native service; `removeChannel` only uses deeplinking service for dapp connections. > - **Deeplinking**: > - Remove handling/tests for `ACTIONS.ANDROID_SDK`; keep `CONNECT`/`MMSDK` flows. > - **Refactor/cleanup**: > - Delete `app/core/SDKConnect/AndroidSDK/**` code and tests. > - Move shared utilities/types: introduce `SDKConnect/dapp-sdk-types`, relocate `getDefaultBridgeParams`, update imports across tests/services. > - Update wait utilities to use new types and maintain Android binding wait behavior via disabled path. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 86b884dfdfa2e3aec000ef73a671cf17adb45001. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- android/app/build.gradle | 1 - android/app/src/main/AndroidManifest.xml | 6 - .../main/java/io/metamask/MainApplication.kt | 4 +- android/libs/nativesdk.aar | Bin 53813 -> 0 bytes .../BackgroundBridge/BackgroundBridge.test.js | 2 +- .../__tests__/handleMetaMaskDeeplink.test.ts | 18 - .../__tests__/handleUniversalLink.test.ts | 1 - .../handlers/legacy/handleMetaMaskDeeplink.ts | 12 - .../AndroidNativeSDKEventHandler.ts | 27 - .../SDKConnect/AndroidSDK/AndroidService.ts | 502 ------------------ .../AndroidService/sendMessage.test.ts | 143 ----- .../AndroidSDK/AndroidService/sendMessage.ts | 132 ----- .../AndroidSDK/addDappConnection.test.ts | 40 -- .../AndroidSDK/addDappConnection.ts | 18 - .../AndroidSDK/bindAndroidSDK.test.ts | 46 -- .../SDKConnect/AndroidSDK/bindAndroidSDK.ts | 21 - .../AndroidSDK/loadDappConnections.test.ts | 43 -- .../AndroidSDK/loadDappConnections.ts | 21 - .../ConnectionManagement/removeChannel.ts | 2 +- .../InitializationManagement/init.ts | 7 - app/core/SDKConnect/SDKConnect.test.ts | 58 +- app/core/SDKConnect/SDKConnect.ts | 20 +- .../DeeplinkProtocolService.test.ts | 2 +- .../DeeplinkProtocolService.ts | 4 +- .../{AndroidSDK => }/dapp-sdk-types.ts | 0 .../getDefaultBridgeParams.ts | 4 +- app/core/SDKConnect/utils/wait.util.test.ts | 2 +- app/core/SDKConnect/utils/wait.util.ts | 2 +- 28 files changed, 39 insertions(+), 1099 deletions(-) delete mode 100644 android/libs/nativesdk.aar delete mode 100644 app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/AndroidService.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/addDappConnection.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts delete mode 100644 app/core/SDKConnect/AndroidSDK/loadDappConnections.ts rename app/core/SDKConnect/{AndroidSDK => }/dapp-sdk-types.ts (100%) rename app/core/SDKConnect/{AndroidSDK => }/getDefaultBridgeParams.ts (93%) diff --git a/android/app/build.gradle b/android/app/build.gradle index 4f06959781de..a66dba67dd71 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -371,7 +371,6 @@ android { dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation(files("../libs/ecies.aar")) - implementation(files("../libs/nativesdk.aar")) implementation("com.facebook.react:react-android") implementation 'org.apache.commons:commons-compress:1.22' androidTestImplementation 'androidx.test:core:1.5.0' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e398c99dbf18..9d30b90cb861 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -184,11 +184,5 @@ android:resource="@xml/filepaths" /> - - - diff --git a/android/app/src/main/java/io/metamask/MainApplication.kt b/android/app/src/main/java/io/metamask/MainApplication.kt index cc96024a9a78..442d52188e34 100644 --- a/android/app/src/main/java/io/metamask/MainApplication.kt +++ b/android/app/src/main/java/io/metamask/MainApplication.kt @@ -27,7 +27,6 @@ import cl.json.ShareApplication import io.branch.rnbranch.RNBranchModule import io.metamask.nativeModules.PreventScreenshotPackage import io.metamask.nativeModules.RCTMinimizerPackage -import io.metamask.nativesdk.NativeSDKPackage import io.metamask.nativeModules.RNTar.RNTarPackage import io.metamask.nativeModules.NotificationPackage @@ -43,9 +42,8 @@ class MainApplication : Application(), ShareApplication, ReactApplication { // Add all our custom packages packages.add(PreventScreenshotPackage()) packages.add(RCTMinimizerPackage()) - packages.add(NativeSDKPackage()) packages.add(RNTarPackage()) - packages.add(NotificationPackage()) + packages.add(NotificationPackage()) return packages } diff --git a/android/libs/nativesdk.aar b/android/libs/nativesdk.aar deleted file mode 100644 index 990866e39ab3d803b998a44892ad3e897731b2ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53813 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 L0ALJd3;_5q?t6>i diff --git a/app/core/BackgroundBridge/BackgroundBridge.test.js b/app/core/BackgroundBridge/BackgroundBridge.test.js index 05330ff0bced..e9bf96948afb 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.test.js +++ b/app/core/BackgroundBridge/BackgroundBridge.test.js @@ -1,4 +1,4 @@ -import getDefaultBridgeParams from '../SDKConnect/AndroidSDK/getDefaultBridgeParams'; +import getDefaultBridgeParams from '../SDKConnect/getDefaultBridgeParams'; import BackgroundBridge from './BackgroundBridge'; import Engine from '../Engine'; import { getPermittedAccounts } from '../Permissions'; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts index 6cabf78ff67a..0ebf3e6d4b06 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts @@ -104,24 +104,6 @@ describe('handleMetaMaskProtocol', () => { expect(handled).toHaveBeenCalled(); }); - describe('when url starts with ${PREFIXES.METAMASK}${ACTIONS.ANDROID_SDK}', () => { - beforeEach(() => { - url = `${PREFIXES.METAMASK}${ACTIONS.ANDROID_SDK}`; - }); - - it('calls bindAndroidSDK', () => { - handleMetaMaskDeeplink({ - handled, - params, - url, - origin, - wcURL, - }); - - expect(mockBindAndroidSDK).toHaveBeenCalled(); - }); - }); - describe('when params.comm is "deeplinking"', () => { beforeEach(() => { url = `${PREFIXES.METAMASK}${ACTIONS.CONNECT}`; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts index 6b15a0e3cb70..391d0d1064ec 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts @@ -112,7 +112,6 @@ describe('handleUniversalLink', () => { describe('SDK Actions', () => { const testCases = [ - { action: ACTIONS.ANDROID_SDK }, { action: ACTIONS.CONNECT }, { action: ACTIONS.MMSDK }, ] as const; diff --git a/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts b/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts index 39e337c0ce48..f28aa3d3bf71 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts @@ -28,18 +28,6 @@ export function handleMetaMaskDeeplink({ }) { handled(); - if (url.startsWith(`${PREFIXES.METAMASK}${ACTIONS.ANDROID_SDK}`)) { - DevLogger.log( - `DeeplinkManager:: metamask launched via android sdk deeplink`, - ); - SDKConnect.getInstance() - .bindAndroidSDK() - .catch((err) => { - Logger.error(err, 'DeepLinkManager failed to connect'); - }); - return; - } - if (url.startsWith(`${PREFIXES.METAMASK}${ACTIONS.CONNECT}`)) { if (params.redirect && origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { SDKConnect.getInstance().state.navigation?.navigate( diff --git a/app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts b/app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts deleted file mode 100644 index 36cb256631a8..000000000000 --- a/app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NativeEventEmitter, NativeModules } from 'react-native'; - -import { EventType } from '@metamask/sdk-communication-layer'; - -export default class AndroidSDKEventHandler extends NativeEventEmitter { - constructor() { - super(NativeModules.RCTDeviceEventEmitter); - } - - onMessageReceived(callback: (message: string) => void) { - return this.addListener(EventType.MESSAGE, (message) => { - callback(message); - }); - } - - onClientsConnected(callback: (clientInfo: string) => void) { - return this.addListener(EventType.CLIENTS_CONNECTED, (clientInfo) => { - callback(clientInfo); - }); - } - - onClientsDisconnected(callback: (id: string) => void) { - return this.addListener(EventType.CLIENTS_DISCONNECTED, (id) => { - callback(id); - }); - } -} diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService.ts b/app/core/SDKConnect/AndroidSDK/AndroidService.ts deleted file mode 100644 index 4bea7fa1f61c..000000000000 --- a/app/core/SDKConnect/AndroidSDK/AndroidService.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { NetworkController } from '@metamask/network-controller'; -import { EventEmitter2 } from 'eventemitter2'; -import { NativeModules } from 'react-native'; -import Engine from '../../Engine'; -import { RPCQueueManager } from '../RPCQueueManager'; - -import { - EventType, - MessageType, - OriginatorInfo, -} from '@metamask/sdk-communication-layer'; -import Logger from '../../../util/Logger'; -import AppConstants from '../../AppConstants'; - -import { - wait, - waitForAndroidServiceBinding, - waitForKeychainUnlocked, -} from '../utils/wait.util'; - -import BackgroundBridge from '../../BackgroundBridge/BackgroundBridge'; -import { SDKConnect } from '../SDKConnect'; - -import { KeyringController } from '@metamask/keyring-controller'; - -import { PermissionController } from '@metamask/permission-controller'; -import { PROTOCOLS } from '../../../constants/deeplinks'; -import BatchRPCManager from '../BatchRPCManager'; -import { DEFAULT_SESSION_TIMEOUT_MS } from '../SDKConnectConstants'; -import handleCustomRpcCalls from '../handlers/handleCustomRpcCalls'; -import DevLogger from '../utils/DevLogger'; -import AndroidSDKEventHandler from './AndroidNativeSDKEventHandler'; -import sendMessage from './AndroidService/sendMessage'; -import { DappClient, DappConnections } from './dapp-sdk-types'; -import getDefaultBridgeParams from './getDefaultBridgeParams'; -import { AccountsController } from '@metamask/accounts-controller'; -import { toChecksumHexAddress } from '@metamask/controller-utils'; -import Routes from '../../../constants/navigation/Routes'; -import { - Caip25CaveatType, - Caip25EndowmentPermissionName, -} from '@metamask/chain-agnostic-permission'; -import { getDefaultCaip25CaveatValue } from '../../Permissions'; - -export default class AndroidService extends EventEmitter2 { - public communicationClient = NativeModules.CommunicationClient; - public connections: DappConnections = {}; - public rpcQueueManager = new RPCQueueManager(); - public bridgeByClientId: { [clientId: string]: BackgroundBridge } = {}; - public eventHandler: AndroidSDKEventHandler; - public batchRPCManager: BatchRPCManager = new BatchRPCManager('android'); - // To keep track in order to get the associated bridge to handle batch rpc calls - public currentClientId?: string; - - constructor() { - super(); - - this.eventHandler = new AndroidSDKEventHandler(); - this.setupEventListeners() - .then(() => { - DevLogger.log( - `AndroidService::constructor event listeners setup completed`, - ); - // - }) - .catch((err) => { - Logger.log(err, `AndroidService:: error setting up event listeners`); - }); - } - - private async setupEventListeners(): Promise { - try { - // Wait for keychain to be unlocked before handling rpc calls. - const keyringController = ( - Engine.context as { KeyringController: KeyringController } - ).KeyringController; - await waitForKeychainUnlocked({ - keyringController, - context: 'AndroidService::setupEventListener', - }); - - DevLogger.log(`AndroidService::setupEventListeners loading connections`); - const rawConnections = - await SDKConnect.getInstance().loadDappConnections(); - - if (rawConnections) { - Object.values(rawConnections).forEach((connection) => { - DevLogger.log( - `AndroidService::setupEventListeners recover client: ${connection.id}`, - ); - this.connections[connection.id] = { - connected: false, - clientId: connection.id, - originatorInfo: connection.originatorInfo as OriginatorInfo, - validUntil: connection.validUntil, - }; - }); - } else { - DevLogger.log( - `AndroidService::setupEventListeners no previous connections found`, - ); - } - } catch (err) { - console.error(`AndroidService::setupEventListeners error`, err); - } - - this.restorePreviousConnections(); - - this.setupOnClientsConnectedListener(); - this.setupOnMessageReceivedListener(); - - // Bind native module to client - await SDKConnect.getInstance().bindAndroidSDK(); - } - - public getConnections() { - DevLogger.log( - `AndroidService::getConnections`, - JSON.stringify(this.connections, null, 2), - ); - return Object.values(this.connections).filter( - (connection) => connection?.clientId?.length > 0, - ); - } - - private setupOnClientsConnectedListener() { - this.eventHandler.onClientsConnected(async (sClientInfo: string) => { - const clientInfo: DappClient = JSON.parse(sClientInfo); - - DevLogger.log(`AndroidService::clients_connected`, clientInfo); - if (this.connections?.[clientInfo.clientId]) { - // Skip existing client -- bridge has been setup - Logger.log( - `AndroidService::clients_connected - existing client, sending ready`, - ); - - // Update connected state - this.connections[clientInfo.clientId] = { - ...this.connections[clientInfo.clientId], - connected: true, - }; - - this.sendMessage( - { - type: MessageType.READY, - data: { - id: clientInfo?.clientId, - }, - }, - false, - ).catch((err) => { - Logger.log( - `AndroidService::clients_connected - error sending ready message to client ${clientInfo.clientId}`, - err, - ); - }); - return; - } - - await SDKConnect.getInstance().addDappConnection({ - id: clientInfo.clientId, - lastAuthorized: Date.now(), - origin: AppConstants.MM_SDK.ANDROID_SDK, - originatorInfo: clientInfo.originatorInfo, - otherPublicKey: '', - validUntil: Date.now() + DEFAULT_SESSION_TIMEOUT_MS, - }); - - const handleEventAsync = async () => { - const keyringController = ( - Engine.context as { KeyringController: KeyringController } - ).KeyringController; - - await waitForKeychainUnlocked({ - keyringController, - context: 'AndroidService::setupOnClientsConnectedListener', - }); - - try { - if (!this.connections?.[clientInfo.clientId]) { - DevLogger.log( - `AndroidService::clients_connected - new client ${clientInfo.clientId}}`, - this.connections, - ); - // Ask for account permissions - await this.checkPermission({ - originatorInfo: clientInfo.originatorInfo, - channelId: clientInfo.clientId, - }); - - this.setupBridge(clientInfo); - // Save session to SDKConnect - // Save to local connections - this.connections[clientInfo.clientId] = { - connected: true, - clientId: clientInfo.clientId, - originatorInfo: clientInfo.originatorInfo, - validUntil: clientInfo.validUntil, - }; - await SDKConnect.getInstance().addDappConnection({ - id: clientInfo.clientId, - lastAuthorized: Date.now(), - origin: AppConstants.MM_SDK.ANDROID_SDK, - originatorInfo: clientInfo.originatorInfo, - otherPublicKey: '', - validUntil: Date.now() + DEFAULT_SESSION_TIMEOUT_MS, - }); - } - - this.sendMessage( - { - type: MessageType.READY, - data: { - id: clientInfo?.clientId, - }, - }, - false, - ).catch((err) => { - Logger.log( - err, - `AndroidService::clients_connected error sending READY message to client`, - ); - }); - } catch (error) { - Logger.log( - error, - `AndroidService::clients_connected sending jsonrpc error to client - connection rejected`, - ); - this.sendMessage({ - data: { - error, - jsonrpc: '2.0', - }, - name: 'metamask-provider', - }).catch((err) => { - Logger.log( - err, - `AndroidService::clients_connected error failed sending jsonrpc error to client`, - ); - }); - SDKConnect.getInstance().state.navigation?.navigate( - Routes.MODAL.ROOT_MODAL_FLOW, - { - screen: Routes.SDK.RETURN_TO_DAPP_NOTIFICATION, - }, - ); - return; - } - - this.emit(EventType.CLIENTS_CONNECTED); - }; - - handleEventAsync().catch((err) => { - Logger.log( - err, - `AndroidService::clients_connected error handling event`, - ); - }); - }); - } - - private async checkPermission({ - channelId, - }: { - originatorInfo: OriginatorInfo; - channelId: string; - }): Promise { - const permissionsController = ( - Engine.context as { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - PermissionController: PermissionController; - } - ).PermissionController; - - return permissionsController.requestPermissions( - { origin: channelId }, - { - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: getDefaultCaip25CaveatValue(), - }, - ], - }, - }, - ); - } - - private setupOnMessageReceivedListener() { - this.eventHandler.onMessageReceived((jsonMessage: string) => { - const handleEventAsync = async () => { - let parsedMsg: { - id: string; - message: string; - }; - - DevLogger.log(`AndroidService::onMessageReceived`, jsonMessage); - try { - await wait(200); // Extra wait to make sure ui is ready - - await waitForAndroidServiceBinding(); - const keyringController = ( - Engine.context as { KeyringController: KeyringController } - ).KeyringController; - await waitForKeychainUnlocked({ - keyringController, - context: 'AndroidService::setupOnMessageReceivedListener', - }); - } catch (error) { - Logger.log(error, `AndroidService::onMessageReceived error`); - } - - let sessionId: string, - message: string, - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: { id: string; jsonrpc: string; method: string; params: any }; - try { - parsedMsg = JSON.parse(jsonMessage); // handle message and redirect to corresponding bridge - sessionId = parsedMsg.id; - message = parsedMsg.message; - data = JSON.parse(message); - - // Update connected state - this.connections[sessionId] = { - ...this.connections[sessionId], - connected: true, - }; - } catch (error) { - Logger.log( - error, - `AndroidService::onMessageReceived invalid json param`, - ); - this.sendMessage({ - data: { - error, - jsonrpc: '2.0', - }, - name: 'metamask-provider', - }).catch((err) => { - Logger.log( - err, - `AndroidService::onMessageReceived error sending jsonrpc error message to client ${sessionId}`, - ); - }); - return; - } - - let bridge = this.bridgeByClientId[sessionId]; - - if (!bridge) { - console.warn( - `AndroidService:: Bridge not found for client`, - `sessionId=${sessionId} data.id=${data.id}`, - ); - - try { - // Ask users permissions again - it probably means the channel was removed - await this.checkPermission({ - originatorInfo: this.connections[sessionId]?.originatorInfo ?? {}, - channelId: sessionId, - }); - - // Create new bridge - this.setupBridge(this.connections[sessionId]); - bridge = this.bridgeByClientId[sessionId]; - } catch (err) { - Logger.log( - err, - `AndroidService::onMessageReceived error checking permissions`, - ); - return; - } - } - - const accountsController = ( - Engine.context as { - AccountsController: AccountsController; - } - ).AccountsController; - - const selectedInternalAccountChecksummedAddress = toChecksumHexAddress( - accountsController.getSelectedAccount().address, - ); - - const networkController = ( - Engine.context as { - NetworkController: NetworkController; - } - ).NetworkController; - - const { - configuration: { chainId }, - } = networkController.getNetworkClientById( - networkController.state?.selectedNetworkClientId, - ); - - this.currentClientId = sessionId; - - // Handle custom rpc method - const processedRpc = await handleCustomRpcCalls({ - batchRPCManager: this.batchRPCManager, - selectedChainId: chainId, - selectedAddress: selectedInternalAccountChecksummedAddress, - rpc: { id: data.id, method: data.method, params: data.params }, - }); - - DevLogger.log( - `AndroidService::onMessageReceived processedRpc`, - processedRpc, - ); - this.rpcQueueManager.add({ - id: processedRpc?.id ?? data.id, - method: processedRpc?.method ?? data.method, - }); - bridge.onMessage({ name: 'metamask-provider', data: processedRpc }); - }; - handleEventAsync().catch((err) => { - Logger.log( - err, - `AndroidService::onMessageReceived error handling event`, - ); - }); - }); - } - - private restorePreviousConnections() { - if (Object.keys(this.connections ?? {}).length) { - Object.values(this.connections).forEach((clientInfo) => { - try { - this.setupBridge(clientInfo); - this.sendMessage( - { - type: MessageType.READY, - data: { - id: clientInfo?.clientId, - }, - }, - false, - ).catch((err) => { - Logger.log( - err, - `AndroidService:: error sending jsonrpc error to client ${clientInfo.clientId}`, - ); - }); - } catch (error) { - Logger.log( - error, - `AndroidService:: error setting up bridge for client ${clientInfo.clientId}`, - ); - } - }); - } - } - - private setupBridge(clientInfo: DappClient) { - DevLogger.log( - `AndroidService::setupBridge for id=${clientInfo.clientId} exists=${!!this - .bridgeByClientId[clientInfo.clientId]}}`, - ); - - if (this.bridgeByClientId[clientInfo.clientId]) { - return; - } - - const defaultBridgeParams = getDefaultBridgeParams(clientInfo); - - const bridge = new BackgroundBridge({ - webview: null, - channelId: clientInfo.clientId, - isMMSDK: true, - url: PROTOCOLS.METAMASK + '://' + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN, - isRemoteConn: true, - sendMessage: this.sendMessage.bind(this), - ...defaultBridgeParams, - }); - - this.bridgeByClientId[clientInfo.clientId] = bridge; - } - - async removeConnection(channelId: string) { - try { - if (this.connections[channelId]) { - DevLogger.log( - `AndroidService::remove client ${channelId} exists --- remove bridge`, - ); - delete this.bridgeByClientId[channelId]; - } - delete this.connections[channelId]; - } catch (err) { - Logger.log(err, `AndroidService::remove error`); - } - } - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async sendMessage(message: any, forceRedirect?: boolean) { - return sendMessage(this, message, forceRedirect); - } -} diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts deleted file mode 100644 index b92fbb7036f5..000000000000 --- a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import Logger from '../../../../util/Logger'; -import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; -import Engine from '../../../Engine'; -import { Minimizer } from '../../../NativeModules'; -import { RPC_METHODS } from '../../SDKConnectConstants'; -import handleBatchRpcResponse from '../../handlers/handleBatchRpcResponse'; -import { wait } from '../../utils/wait.util'; -import AndroidService from '../AndroidService'; -import sendMessage from './sendMessage'; - -jest.mock('../../../Engine'); -jest.mock('../../../NativeModules', () => ({ - Minimizer: { - goBack: jest.fn(), - }, -})); -jest.mock('../../../../util/Logger'); -jest.mock('../../utils/wait.util', () => ({ - wait: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('../AndroidService'); -jest.mock('../../handlers/handleBatchRpcResponse', () => jest.fn()); -jest.mock('../../utils/DevLogger'); - -const MOCK_ADDRESS = '0x1'; -const mockInternalAccount = createMockInternalAccount( - MOCK_ADDRESS, - 'Account 1', -); - -describe('sendMessage', () => { - let instance: jest.Mocked; - let message: any; - - const mockGetId = jest.fn(); - const mockRemove = jest.fn(); - const mockIsEmpty = jest.fn().mockReturnValue(true); - const mockGet = jest.fn(); - const mockSendMessage = jest.fn(); - const mockGetById = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - instance = { - rpcQueueManager: { - getId: mockGetId, - remove: mockRemove, - isEmpty: mockIsEmpty, - get: mockGet, - }, - communicationClient: { - sendMessage: mockSendMessage, - }, - batchRPCManager: { - getById: mockGetById, - }, - bridgeByClientId: {}, - currentClientId: 'test-client-id', - } as unknown as jest.Mocked; - - message = { - data: { - id: 'test-id', - result: ['0x1', '0x2'], - }, - }; - - (Engine.context as any) = { - AccountsController: { - getSelectedAccount: jest.fn().mockReturnValue(mockInternalAccount), - }, - }; - }); - - it('should send message with reordered accounts if selectedAddress is in result', async () => { - mockGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS); - - await sendMessage(instance, message); - - expect(mockSendMessage).toHaveBeenCalledWith( - JSON.stringify({ - ...message, - data: { - ...message.data, - result: ['0x1', '0x2'], - }, - }), - ); - }); - - it('should send message without reordering if selectedAddress is not in result', async () => { - const MOCK_ADDRESS_2 = '0x3'; - const mockInternalAccount2 = createMockInternalAccount( - MOCK_ADDRESS_2.toLowerCase(), - 'Account 2', - ); - - (Engine.context as any).AccountsController.getSelectedAccount = jest - .fn() - .mockReturnValue(mockInternalAccount2); - - mockGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS); - - await sendMessage(instance, message); - - expect(mockSendMessage).toHaveBeenCalledWith(JSON.stringify(message)); - }); - - it('should handle multichain rpc call responses separately', async () => { - mockGetId.mockReturnValue('someMethod'); - mockGetById.mockReturnValue(['rpc1', 'rpc2']); - (handleBatchRpcResponse as jest.Mock).mockResolvedValue(true); - - await sendMessage(instance, message); - - expect(handleBatchRpcResponse).toHaveBeenCalled(); - expect(mockRemove).toHaveBeenCalledWith('test-id'); - expect(mockSendMessage).toHaveBeenCalledWith(JSON.stringify(message)); - }); - - it('should not call goBack if rpcQueueManager is not empty', async () => { - mockGetId.mockReturnValue('someMethod'); - mockIsEmpty.mockReturnValue(false); - - await sendMessage(instance, message); - - expect(Minimizer.goBack).not.toHaveBeenCalled(); - }); - - it('should handle error when waiting for empty rpc queue', async () => { - mockGetId.mockReturnValue('someMethod'); - (wait as jest.Mock).mockRejectedValue(new Error('test error')); - - await sendMessage(instance, message); - - expect(Logger.log).toHaveBeenCalledWith( - expect.any(Error), - `AndroidService:: error waiting for empty rpc queue`, - ); - }); -}); diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts deleted file mode 100644 index be7b9bd0915d..000000000000 --- a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { AccountsController } from '@metamask/accounts-controller'; -import Logger from '../../../../util/Logger'; -import Engine from '../../../Engine'; -import { METHODS_TO_DELAY, RPC_METHODS } from '../../SDKConnectConstants'; -import handleBatchRpcResponse from '../../handlers/handleBatchRpcResponse'; -import DevLogger from '../../utils/DevLogger'; -import { wait } from '../../utils/wait.util'; -import AndroidService from '../AndroidService'; -import { - areAddressesEqual, - toFormattedAddress, -} from '../../../../util/address'; - -async function sendMessage( - instance: AndroidService, - message: any, - forceRedirect?: boolean, -) { - const id = message?.data?.id; - let rpcMethod = instance.rpcQueueManager.getId(id); - - const isConnectionResponse = rpcMethod === RPC_METHODS.ETH_REQUESTACCOUNTS; - - if (isConnectionResponse) { - const accountsController = ( - Engine.context as { - AccountsController: AccountsController; - } - ).AccountsController; - - const selectedFormattedAddress = toFormattedAddress( - accountsController.getSelectedAccount().address, - ); - - const formattedAccounts = (message.data.result as string[]).map( - (a: string) => toFormattedAddress(a), - ); - - const isPartOfConnectedAddresses = formattedAccounts.includes( - selectedFormattedAddress, - ); - - if (isPartOfConnectedAddresses) { - // Remove the selectedAddress from the formattedAccounts if it exists - const remainingAccounts = formattedAccounts.filter( - (account) => !areAddressesEqual(account, selectedFormattedAddress), - ); - - // Create the reorderedAccounts array with selectedAddress as the first element - const reorderedAccounts: string[] = [ - selectedFormattedAddress, - ...remainingAccounts, - ]; - - message = { - ...message, - data: { - ...message.data, - result: reorderedAccounts, - }, - }; - } - } - - instance.communicationClient.sendMessage(JSON.stringify(message)); - - DevLogger.log(`AndroidService::sendMessage method=${rpcMethod}`, message); - - // handle multichain rpc call responses separately - const chainRPCs = instance.batchRPCManager.getById(id); - if (chainRPCs) { - const isLastRpcOrError = await handleBatchRpcResponse({ - chainRpcs: chainRPCs, - msg: message, - backgroundBridge: - instance.bridgeByClientId[instance.currentClientId ?? ''], - batchRPCManager: instance.batchRPCManager, - sendMessage: ({ msg }) => instance.sendMessage(msg), - }); - DevLogger.log( - `AndroidService::sendMessage isLastRpc=${isLastRpcOrError}`, - chainRPCs, - ); - - if (!isLastRpcOrError) { - DevLogger.log( - `AndroidService::sendMessage NOT last rpc --- skip goBack()`, - chainRPCs, - ); - instance.rpcQueueManager.remove(id); - // Only continue processing the message and goback if all rpcs in the batch have been handled - return; - } - - // Always set the method to metamask_batch otherwise it may not have been set correctly because of the batch rpc flow. - rpcMethod = RPC_METHODS.METAMASK_BATCH; - DevLogger.log( - `AndroidService::sendMessage chainRPCs=${chainRPCs} COMPLETED!`, - ); - } - - instance.rpcQueueManager.remove(id); - - if (!rpcMethod && forceRedirect !== true) { - DevLogger.log( - `AndroidService::sendMessage no rpc method --- rpcMethod=${rpcMethod} forceRedirect=${forceRedirect} --- skip goBack()`, - ); - return; - } - - try { - if (METHODS_TO_DELAY[rpcMethod]) { - // Add delay to see the feedback modal - await wait(1000); - } - - if (!instance.rpcQueueManager.isEmpty()) { - DevLogger.log( - `AndroidService::sendMessage NOT empty --- skip goBack()`, - instance.rpcQueueManager.get(), - ); - return; - } - - DevLogger.log(`AndroidService::sendMessage empty --- goBack()`); - } catch (error) { - Logger.log(error, `AndroidService:: error waiting for empty rpc queue`); - } -} - -export default sendMessage; diff --git a/app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts b/app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts deleted file mode 100644 index fe4ddc84adbf..000000000000 --- a/app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ConnectionProps } from '../Connection'; -import SDKConnect from '../SDKConnect'; -import addDappConnection from './addDappConnection'; - -jest.mock('../Connection'); -jest.mock('../SDKConnect'); -jest.mock('../utils/DevLogger'); -jest.mock('../../../store/storage-wrapper', () => ({ - setItem: jest.fn().mockResolvedValue(''), -})); -jest.mock('../../../core/AppConstants'); - -describe('addDappConnection', () => { - let mockInstance = {} as unknown as SDKConnect; - const mockEmit = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - mockInstance = { - state: { - connections: {}, - dappConnections: {}, - }, - emit: mockEmit, - } as unknown as SDKConnect; - }); - - it('should add the connection to the instance state', async () => { - const mockConnection = { - id: 'test-id', - } as unknown as ConnectionProps; - - await addDappConnection(mockConnection, mockInstance); - - expect(mockInstance.state.dappConnections[mockConnection.id]).toBe( - mockConnection, - ); - }); -}); diff --git a/app/core/SDKConnect/AndroidSDK/addDappConnection.ts b/app/core/SDKConnect/AndroidSDK/addDappConnection.ts deleted file mode 100644 index 0f059de0e9a4..000000000000 --- a/app/core/SDKConnect/AndroidSDK/addDappConnection.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { updateDappConnection } from '../../../actions/sdk'; -import { store } from '../../../store'; -import { ConnectionProps } from '../Connection'; -import SDKConnect from '../SDKConnect'; -import DevLogger from '../utils/DevLogger'; - -async function addDappConnection( - connection: ConnectionProps, - instance: SDKConnect, -) { - instance.state.dappConnections[connection.id] = connection; - - DevLogger.log(`SDKConnect::addDappConnection`, connection); - - store.dispatch(updateDappConnection(connection.id, connection)); -} - -export default addDappConnection; diff --git a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts b/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts deleted file mode 100644 index a53f94bd6b24..000000000000 --- a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NativeModules, Platform } from 'react-native'; -import SDKConnect from '../SDKConnect'; -import bindAndroidSDK from './bindAndroidSDK'; - -jest.mock('../SDKConnect'); -jest.mock('../../../util/Logger'); - -describe('bindAndroidSDK', () => { - let mockInstance = {} as unknown as SDKConnect; - - beforeEach(() => { - jest.clearAllMocks(); - - NativeModules.CommunicationClient = { - bindService: jest.fn(), - }; - - mockInstance = { - state: { - androidSDKBound: false, - }, - } as unknown as SDKConnect; - }); - - it('should return early if the platform is not android', async () => { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (Platform as any).OS = 'ios'; - - await bindAndroidSDK(mockInstance); - - expect( - NativeModules.CommunicationClient.bindService, - ).not.toHaveBeenCalled(); - }); - - it('should return early if the Android SDK is already bound', async () => { - mockInstance.state.androidSDKBound = true; - - await bindAndroidSDK(mockInstance); - - expect( - NativeModules.CommunicationClient.bindService, - ).not.toHaveBeenCalled(); - }); -}); diff --git a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts b/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts deleted file mode 100644 index fa3ede736667..000000000000 --- a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Logger from '../../../util/Logger'; -import { NativeModules, Platform } from 'react-native'; -import SDKConnect from '../SDKConnect'; - -async function bindAndroidSDK(instance: SDKConnect) { - if (Platform.OS !== 'android') { - return; - } - - if (instance.state.androidSDKBound) return; - - try { - // Always bind native module to client as early as possible otherwise connection may have an invalid status - await NativeModules.CommunicationClient.bindService(); - instance.state.androidSDKBound = true; - } catch (err) { - Logger.log(err, `SDKConnect::bindAndroiSDK failed`); - } -} - -export default bindAndroidSDK; diff --git a/app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts b/app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts deleted file mode 100644 index 1922528a7b91..000000000000 --- a/app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import StorageWrapper from '../../../store/storage-wrapper'; -import loadDappConnections from './loadDappConnections'; - -jest.mock('../../../core/AppConstants'); -jest.mock('../../../store/storage-wrapper', () => ({ - getItem: jest.fn().mockResolvedValue(''), - setItem: jest.fn().mockResolvedValue(''), -})); -jest.mock('../utils/DevLogger'); -jest.mock('../../../store', () => ({ - store: { - getState: jest.fn(() => ({ - sdk: { - connections: {}, - approvedHosts: {}, - }, - })), - }, -})); - -describe('loadDappConnections', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return an empty object if no connections are found', async () => { - const result = await loadDappConnections(); - - expect(result).toStrictEqual({}); - }); - - it('should parse the retrieved connections', async () => { - const mockConnections = {}; - - (StorageWrapper.getItem as jest.Mock).mockResolvedValueOnce( - JSON.stringify(mockConnections), - ); - - const result = await loadDappConnections(); - - expect(result).toStrictEqual(mockConnections); - }); -}); diff --git a/app/core/SDKConnect/AndroidSDK/loadDappConnections.ts b/app/core/SDKConnect/AndroidSDK/loadDappConnections.ts deleted file mode 100644 index cbedc1602811..000000000000 --- a/app/core/SDKConnect/AndroidSDK/loadDappConnections.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { RootState } from '../../../reducers'; -import { store } from '../../../store'; -import { ConnectionProps } from '../Connection'; -import DevLogger from '../utils/DevLogger'; - -async function loadDappConnections(): Promise<{ - [id: string]: ConnectionProps; -}> { - const { sdk } = store.getState() as RootState; - - const dappConnections = sdk.dappConnections || {}; - DevLogger.log( - `SDKConnect::loadDappConnections found ${ - Object.keys(dappConnections).length - }`, - dappConnections, - ); - return dappConnections; -} - -export default loadDappConnections; diff --git a/app/core/SDKConnect/ConnectionManagement/removeChannel.ts b/app/core/SDKConnect/ConnectionManagement/removeChannel.ts index 2e3d8b26a9b0..778a2a35d96c 100644 --- a/app/core/SDKConnect/ConnectionManagement/removeChannel.ts +++ b/app/core/SDKConnect/ConnectionManagement/removeChannel.ts @@ -30,7 +30,7 @@ async function removeChannel({ ); if (isDappConnection) { - instance.state.androidService?.removeConnection(channelId); + // Android SDK disabled for security reasons (pm-security #532, #534) instance.state.deeplinkingService?.removeConnection(channelId); } diff --git a/app/core/SDKConnect/InitializationManagement/init.ts b/app/core/SDKConnect/InitializationManagement/init.ts index fcbdfd0401f3..ef83210461c7 100644 --- a/app/core/SDKConnect/InitializationManagement/init.ts +++ b/app/core/SDKConnect/InitializationManagement/init.ts @@ -1,6 +1,5 @@ import { NavigationContainerRef } from '@react-navigation/native'; import { Platform } from 'react-native'; -import AndroidService from '../AndroidSDK/AndroidService'; import SDKConnect from '../SDKConnect'; import DevLogger from '../utils/DevLogger'; import asyncInit from './asyncInit'; @@ -28,12 +27,6 @@ async function init({ return; } - if (!instance.state.androidSDKStarted && Platform.OS === 'android') { - DevLogger.log(`SDKConnect::init() - starting android service`); - instance.state.androidService = new AndroidService(); - instance.state.androidSDKStarted = true; - } - if (!instance.state.deeplinkingServiceStarted && Platform.OS === 'ios') { DevLogger.log(`SDKConnect::init() - starting deeplinking service`); instance.state.deeplinkingService = new DeeplinkProtocolService(); diff --git a/app/core/SDKConnect/SDKConnect.test.ts b/app/core/SDKConnect/SDKConnect.test.ts index 176bd70c0ee1..f3f2bf0c3e2c 100644 --- a/app/core/SDKConnect/SDKConnect.test.ts +++ b/app/core/SDKConnect/SDKConnect.test.ts @@ -1,8 +1,5 @@ import { OriginatorInfo } from '@metamask/sdk-communication-layer'; import AppConstants from '../AppConstants'; -import addDappConnection from './AndroidSDK/addDappConnection'; -import bindAndroidSDK from './AndroidSDK/bindAndroidSDK'; -import loadDappConnections from './AndroidSDK/loadDappConnections'; import { Connection, ConnectionProps } from './Connection'; import { approveHost, @@ -53,10 +50,6 @@ jest.mock('../NavigationService', () => ({ jest.mock('./Connection'); jest.mock('@react-navigation/native'); jest.mock('@metamask/sdk-communication-layer'); -jest.mock('./AndroidSDK/AndroidService'); -jest.mock('./AndroidSDK/addDappConnection'); -jest.mock('./AndroidSDK/bindAndroidSDK'); -jest.mock('./AndroidSDK/loadDappConnections'); jest.mock('./ConnectionManagement'); jest.mock('./InitializationManagement'); jest.mock('./RPCQueueManager'); @@ -122,18 +115,6 @@ describe('SDKConnect', () => { typeof disapproveChannel >; - const mockBindAndroidSDK = bindAndroidSDK as jest.MockedFunction< - typeof bindAndroidSDK - >; - - const mockLoadDappConnections = loadDappConnections as jest.MockedFunction< - typeof loadDappConnections - >; - - const mockAddDappConnection = addDappConnection as jest.MockedFunction< - typeof addDappConnection - >; - beforeEach(() => { jest.clearAllMocks(); sdkConnect = SDKConnect.getInstance(); @@ -317,36 +298,39 @@ describe('SDKConnect', () => { }); }); - describe('Android SDK Management', () => { + describe('Android SDK Management (Disabled)', () => { + // Android SDK disabled for security reasons (pm-security #532, #534) describe('bindAndroidSDK', () => { - it('should bind the Android SDK', async () => { - await sdkConnect.bindAndroidSDK(); + it('should be a no-op (Android SDK disabled)', async () => { + const result = await sdkConnect.bindAndroidSDK(); + expect(result).toBeUndefined(); + }); + }); - expect(mockBindAndroidSDK).toHaveBeenCalledTimes(1); - expect(mockBindAndroidSDK).toHaveBeenCalledWith(sdkConnect); + describe('isAndroidSDKBound', () => { + it('should always return false (Android SDK disabled)', () => { + expect(sdkConnect.isAndroidSDKBound()).toBe(false); }); }); describe('loadDappConnections', () => { - it('should load Android connections', async () => { - await sdkConnect.loadDappConnections(); + it('should return empty object (Android SDK disabled)', async () => { + const result = await sdkConnect.loadDappConnections(); + expect(result).toEqual({}); + }); + }); - expect(mockLoadDappConnections).toHaveBeenCalledTimes(1); - expect(mockLoadDappConnections).toHaveBeenCalledWith(); + describe('getAndroidConnections', () => { + it('should return undefined (Android SDK disabled)', () => { + expect(sdkConnect.getAndroidConnections()).toBeUndefined(); }); }); describe('addDappConnection', () => { - it('should add an Android connection', async () => { + it('should be a no-op (Android SDK disabled)', async () => { const testConnection = {} as ConnectionProps; - - await sdkConnect.addDappConnection(testConnection); - - expect(mockAddDappConnection).toHaveBeenCalledTimes(1); - expect(mockAddDappConnection).toHaveBeenCalledWith( - testConnection, - sdkConnect, - ); + const result = await sdkConnect.addDappConnection(testConnection); + expect(result).toBeUndefined(); }); }); }); diff --git a/app/core/SDKConnect/SDKConnect.ts b/app/core/SDKConnect/SDKConnect.ts index b3636cd43056..359c941dd779 100644 --- a/app/core/SDKConnect/SDKConnect.ts +++ b/app/core/SDKConnect/SDKConnect.ts @@ -6,10 +6,6 @@ import AppConstants from '../AppConstants'; import { OriginatorInfo } from '@metamask/sdk-communication-layer'; import { NavigationContainerRef } from '@react-navigation/native'; import Engine from '../../core/Engine'; -import AndroidService from './AndroidSDK/AndroidService'; -import addDappConnection from './AndroidSDK/addDappConnection'; -import bindAndroidSDK from './AndroidSDK/bindAndroidSDK'; -import loadDappConnections from './AndroidSDK/loadDappConnections'; import { Connection, ConnectionProps } from './Connection'; import { approveHost, @@ -72,7 +68,6 @@ export interface SDKConnectState { connections: SDKSessions; androidSDKStarted: boolean; androidSDKBound: boolean; - androidService?: AndroidService; deeplinkingServiceStarted: boolean; deeplinkingService?: DeeplinkProtocolService; dappConnections: SDKSessions; @@ -109,7 +104,6 @@ export class SDKConnect { androidSDKStarted: false, androidSDKBound: false, deeplinkingServiceStarted: false, - androidService: undefined, deeplinkingService: undefined, connecting: {}, approvedHosts: {}, @@ -222,26 +216,28 @@ export class SDKConnect { return pause(this); } + // Android SDK disabled for security reasons (pm-security #532, #534) public async bindAndroidSDK() { - return bindAndroidSDK(this); + return; } public isAndroidSDKBound() { - return this.state.androidSDKBound; + return false; } async loadDappConnections(): Promise<{ [id: string]: ConnectionProps; }> { - return loadDappConnections(); + return {}; } getAndroidConnections() { - return this.state.androidService?.getConnections(); + return undefined; } - async addDappConnection(connection: ConnectionProps) { - return addDappConnection(connection, this); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async addDappConnection(_connection: ConnectionProps) { + return; } public async refreshChannel({ channelId }: { channelId: string }) { diff --git a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts index 9b7e991a6891..dea4024abd79 100644 --- a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts +++ b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts @@ -9,7 +9,7 @@ import handleCustomRpcCalls from '../handlers/handleCustomRpcCalls'; import DevLogger from '../utils/DevLogger'; import DeeplinkProtocolService from './DeeplinkProtocolService'; import AppConstants from '../../AppConstants'; -import { DappClient } from '../AndroidSDK/dapp-sdk-types'; +import { DappClient } from '../dapp-sdk-types'; import { createMockInternalAccount } from '../../../util/test/accountsControllerTestUtils'; import { toChecksumHexAddress } from '@metamask/controller-utils'; diff --git a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts index 00fe77dc7467..fbe5e69eb164 100644 --- a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts +++ b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts @@ -8,8 +8,8 @@ import AppConstants from '../../../core/AppConstants'; import Engine from '../../../core/Engine'; import Logger from '../../../util/Logger'; import BackgroundBridge from '../../BackgroundBridge/BackgroundBridge'; -import { DappClient, DappConnections } from '../AndroidSDK/dapp-sdk-types'; -import getDefaultBridgeParams from '../AndroidSDK/getDefaultBridgeParams'; +import { DappClient, DappConnections } from '../dapp-sdk-types'; +import getDefaultBridgeParams from '../getDefaultBridgeParams'; import BatchRPCManager from '../BatchRPCManager'; import RPCQueueManager from '../RPCQueueManager'; import SDKConnect from '../SDKConnect'; diff --git a/app/core/SDKConnect/AndroidSDK/dapp-sdk-types.ts b/app/core/SDKConnect/dapp-sdk-types.ts similarity index 100% rename from app/core/SDKConnect/AndroidSDK/dapp-sdk-types.ts rename to app/core/SDKConnect/dapp-sdk-types.ts diff --git a/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts b/app/core/SDKConnect/getDefaultBridgeParams.ts similarity index 93% rename from app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts rename to app/core/SDKConnect/getDefaultBridgeParams.ts index 284c6b7dc659..df7b59457069 100644 --- a/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts +++ b/app/core/SDKConnect/getDefaultBridgeParams.ts @@ -1,6 +1,6 @@ import { ImageSourcePropType } from 'react-native'; -import AppConstants from '../../AppConstants'; -import getRpcMethodMiddleware from '../../RPCMethods/RPCMethodMiddleware'; +import AppConstants from '../AppConstants'; +import getRpcMethodMiddleware from '../RPCMethods/RPCMethodMiddleware'; import { DappClient } from './dapp-sdk-types'; const getDefaultBridgeParams = (clientInfo: DappClient) => ({ diff --git a/app/core/SDKConnect/utils/wait.util.test.ts b/app/core/SDKConnect/utils/wait.util.test.ts index be01a136b3d1..72e0f7f3fe66 100644 --- a/app/core/SDKConnect/utils/wait.util.test.ts +++ b/app/core/SDKConnect/utils/wait.util.test.ts @@ -1,5 +1,5 @@ import { KeyringController } from '@metamask/keyring-controller'; -import { DappClient } from '../AndroidSDK/dapp-sdk-types'; +import { DappClient } from '../dapp-sdk-types'; import { Connection } from '../Connection'; import RPCQueueManager from '../RPCQueueManager'; import { SDKConnect } from '../SDKConnect'; diff --git a/app/core/SDKConnect/utils/wait.util.ts b/app/core/SDKConnect/utils/wait.util.ts index d95753f41eea..86cd0b177434 100644 --- a/app/core/SDKConnect/utils/wait.util.ts +++ b/app/core/SDKConnect/utils/wait.util.ts @@ -1,5 +1,5 @@ import { KeyringController } from '@metamask/keyring-controller'; -import { DappClient } from '../AndroidSDK/dapp-sdk-types'; +import { DappClient } from '../dapp-sdk-types'; import RPCQueueManager from '../RPCQueueManager'; import { SDKConnect } from '../SDKConnect'; import DevLogger from './DevLogger'; From 28bb881abf4931bfcab3226ee5c8fc4c55b92362 Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Wed, 17 Dec 2025 22:43:56 +0700 Subject: [PATCH 08/13] feat: reveal srp flow ui updates (#24058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** * Reveal SRP flow ui and text updates * Jira tasks: https://consensyssoftware.atlassian.net/browse/SL-413 https://consensyssoftware.atlassian.net/browse/SL-412 https://consensyssoftware.atlassian.net/browse/SL-414 https://consensyssoftware.atlassian.net/browse/SL-415 https://consensyssoftware.atlassian.net/browse/SL-416 ## **Changelog** CHANGELOG entry: reveal SRP flow ui and text updates. ## **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** https://github.com/user-attachments/assets/d1e89548-1169-40c8-b92c-25c49d0045b9 ### **After** https://github.com/user-attachments/assets/93696473-3fe4-4c62-93d8-f5b1872ffadc ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Updates SRP reveal flow UI and strings, centers `InfoModal` header with a separate close button, adjusts quiz image asset/sizing, and aligns tests/locales. > > - **UI**: > - **`InfoModal`**: Center-aligns header/title and introduces `closeButtonContainer` to position the close/action content absolutely at top-right; moves `InfoView` into this container. > - **Quiz**: Reduces intro image size in `Views/Quiz/QuizContent/styles.ts`; switches SRP quiz intro image to `images/reveal_srp.png`. > - **SRP Reveal Flow**: > - **`RevealPrivateCredential.tsx`**: Trims SRP explanation (removes non-custodial link segment); minor copy/link updates; retains warning structure; modal copy unchanged otherwise. > - **Localization**: > - **`locales/languages/en.json`**: Removes newline from SRP explanation sentence; normalizes casing for `reveal_credential.text` → "Text" and `qr_code` → "QR code". > - **Tests**: > - **`RevealPrivateCredential.test.tsx` + snapshots**: Update expectations to match shortened SRP copy and UI changes; minor comment tweak. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 93e72624598df1d0c75e9a6522180197a745abdd. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Quiz/QuizContent/styles.ts | 4 +- .../Views/Quiz/SRPQuiz/SRPQuiz.test.tsx | 18 ++- app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx | 14 ++- .../RevealPrivateCredential.test.tsx | 3 +- .../RevealPrivateCredential.tsx | 18 +-- .../RevealPrivateCredential.test.tsx.snap | 108 ------------------ app/images/reveal_srp.png | Bin 0 -> 30820 bytes locales/languages/en.json | 6 +- 8 files changed, 28 insertions(+), 143 deletions(-) create mode 100644 app/images/reveal_srp.png diff --git a/app/components/Views/Quiz/QuizContent/styles.ts b/app/components/Views/Quiz/QuizContent/styles.ts index c5b647779937..48dacb4025ae 100644 --- a/app/components/Views/Quiz/QuizContent/styles.ts +++ b/app/components/Views/Quiz/QuizContent/styles.ts @@ -42,8 +42,8 @@ const styleSheet = (params: { theme: Theme }) => { width: '100%', }, image: { - width: 300, - height: 250, + width: 190, + height: 220, }, bottomContainer: { width: '100%', diff --git a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx index 1e63130ecb78..c6e2b7830bea 100644 --- a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx +++ b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; +import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context'; import { ThemeContext, mockTheme } from '../../../../util/theme'; import { SrpQuizGetStartedSelectorsIDs, @@ -15,6 +16,11 @@ import { Linking } from 'react-native'; const mockNavigate = jest.fn(); +const initialMetrics: Metrics = { + frame: { x: 0, y: 0, width: 390, height: 844 }, + insets: { top: 47, left: 0, right: 0, bottom: 34 }, +}; + jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate, @@ -43,11 +49,13 @@ const renderSRPQuiz = ( const store = mockStore(initialState); const renderResult = render( - - - - - , + + + + + + + , ); if (completeQuiz) { diff --git a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx index 47e73d337990..bf61db21ba76 100644 --- a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx +++ b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx @@ -2,7 +2,9 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { View, Linking, AppState } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import ReusableModal, { ReusableModalRef } from '../../../UI/ReusableModal'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; import { ButtonVariants } from '../../../../component-library/components/Buttons/Button'; import Icon, { IconSize, @@ -30,7 +32,7 @@ import { import { selectSeedlessOnboardingLoginFlow } from '../../../../selectors/seedlessOnboardingController'; import { useSelector } from 'react-redux'; -const introductionImg = require('../../../../images/reveal-srp.png'); +const introductionImg = require('../../../../images/reveal_srp.png'); export interface SRPQuizProps { route: { @@ -48,7 +50,7 @@ const SRPQuiz = (props: SRPQuizProps) => { params: { keyringId }, }, } = props; - const modalRef = useRef(null); + const modalRef = useRef(null); const [stage, setStage] = useState(QuizStage.introduction); const { styles, theme } = useStyles(stylesheet, {}); const { colors } = theme; @@ -56,7 +58,7 @@ const SRPQuiz = (props: SRPQuizProps) => { const { trackEvent, createEventBuilder } = useMetrics(); const dismissModal = (): void => { - modalRef.current?.dismissModal(); + modalRef.current?.onCloseBottomSheet(); }; useEffect(() => { @@ -428,9 +430,9 @@ const SRPQuiz = (props: SRPQuizProps) => { ]); return ( - + {quizPage()} - + ); }; diff --git a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx index 031f2a5fa61e..c581c9fc9a0b 100644 --- a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx +++ b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx @@ -253,9 +253,8 @@ describe('RevealPrivateCredential', () => { />, ); - // Should render SRP explanation instead of AccountInfo + // Renders SRP explanation instead of AccountInfo expect(getByText('Secret Recovery Phrase')).toBeTruthy(); - expect(getByText('non-custodial wallet.')).toBeTruthy(); }); it('shows warning message on incorrect password', async () => { diff --git a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx index b0c6035369d6..886daf368eff 100644 --- a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx +++ b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx @@ -32,11 +32,7 @@ import { ScreenshotDeterrent } from '../../UI/ScreenshotDeterrent'; import { showAlert } from '../../../actions/alert'; import { recordSRPRevealTimestamp } from '../../../actions/privacy'; import { WRONG_PASSWORD_ERROR } from '../../../constants/error'; -import { - KEEP_SRP_SAFE_URL, - NON_CUSTODIAL_WALLET_URL, - SRP_GUIDE_URL, -} from '../../../constants/urls'; +import { KEEP_SRP_SAFE_URL, SRP_GUIDE_URL } from '../../../constants/urls'; import ClipboardManager from '../../../core/ClipboardManager'; import { useTheme } from '../../../util/theme'; import { MetaMetricsEvents } from '../../../core/Analytics'; @@ -408,9 +404,6 @@ const RevealPrivateCredential = ({ tabLabel={strings(`reveal_credential.text`)} testID={RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_TEXT} > - - {strings(`reveal_credential.${privCredentialName}`)} - {' '} {strings('reveal_credential.seed_phrase_explanation')[2]}{' '} {strings('reveal_credential.seed_phrase_explanation')[3]} - {strings('reveal_credential.seed_phrase_explanation')[4]}{' '} - Linking.openURL(NON_CUSTODIAL_WALLET_URL)} - > - {strings('reveal_credential.seed_phrase_explanation')[5]}{' '} - - {strings('reveal_credential.seed_phrase_explanation')[6]}{' '} - {strings('reveal_credential.seed_phrase_explanation')[7]} ); diff --git a/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap b/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap index a6eb4b03c228..e34d082d5b63 100644 --- a/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap +++ b/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap @@ -143,42 +143,6 @@ exports[`RevealPrivateCredential handles keyring ID parameter correctly 1`] = ` } > full access to your wallet, funds and accounts. - - - - MetaMask is a - - - non-custodial wallet. - - - That means, - - - you are the owner of your Secret Recovery Phrase. full access to your wallet, funds and accounts. - - - - MetaMask is a - - - non-custodial wallet. - - - That means, - - - you are the owner of your Secret Recovery Phrase. full access to your wallet, funds and accounts. - - - - MetaMask is a - - - non-custodial wallet. - - - That means, - - - you are the owner of your Secret Recovery Phrase. ei!I00009a7bBm000XU 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
lOjxtVO@?#PPs1p)<^ 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 literal 0 HcmV?d00001 diff --git a/locales/languages/en.json b/locales/languages/en.json index 9121c08f6aea..c57821f6217e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3154,7 +3154,7 @@ "Your", "Secret Recovery Phrase", "gives", - "full access to your wallet, funds and accounts.\n\n", + "full access to your wallet, funds and accounts.", "MetaMask is a", "non-custodial wallet.", "That means,", @@ -3189,8 +3189,8 @@ "unknown_error": "Couldn't unlock your account. Please try again.", "hardware_error": "This is a hardware wallet account, you cannot export your private key.", "seed_warning": "This is your wallet's 12 word phrase. This phrase can be used to take control of all of your current and future accounts, including the ability to send away any of their funds. Keep this phrase stored safely, DO NOT share it with anyone.", - "text": "TEXT", - "qr_code": "QR CODE", + "text": "Text", + "qr_code": "QR code", "hold_to_reveal_credential": "Hold to reveal {{credentialName}}", "reveal_credential": "Reveal {{credentialName}}", "keep_credential_safe": "Keep your {{credentialName}} safe", From d33faeeea0e4a7205fcfc165fa04c63a8af13d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Wed, 17 Dec 2025 15:59:01 +0000 Subject: [PATCH 09/13] chore: update tron snap (#24107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates Tron snap version to 1.16.1 https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.16.1 ## **Changelog** CHANGELOG entry: null ## **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. ## **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] > Updates dependency @metamask/tron-wallet-snap from ^1.16.0 to ^1.16.1 and refreshes yarn.lock. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d071134cfd787bf1b3ddd3a35d1cf7216054a1d8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 93047dc48b8c..fe3b4a348e95 100644 --- a/package.json +++ b/package.json @@ -291,7 +291,7 @@ "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", "@metamask/transaction-pay-controller": "^10.5.0", - "@metamask/tron-wallet-snap": "^1.16.0", + "@metamask/tron-wallet-snap": "^1.16.1", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", "@nktkas/hyperliquid": "^0.27.1", diff --git a/yarn.lock b/yarn.lock index b6fc99ae0bc0..4653cbaf7632 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9608,10 +9608,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:^1.16.0": - version: 1.16.0 - resolution: "@metamask/tron-wallet-snap@npm:1.16.0" - checksum: 10/86be8ef0b7258b8375b9ab43eb4a8f55018f02a226a3784b727a25c03c249afa75e9adacecbf72f982dbf720db4107882a1b8304634bc5dad25ce2db760beff0 +"@metamask/tron-wallet-snap@npm:^1.16.1": + version: 1.16.1 + resolution: "@metamask/tron-wallet-snap@npm:1.16.1" + checksum: 10/f6e871c911edc22af6955676e190a7479446a1663ebecdbd69ee2cf31611a3721a162ed8d6a9d8857f44809a6c4973dd1b9c33c067a5168297c62a21f7cfa44c languageName: node linkType: hard @@ -34263,7 +34263,7 @@ __metadata: "@metamask/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" "@metamask/transaction-pay-controller": "npm:^10.5.0" - "@metamask/tron-wallet-snap": "npm:^1.16.0" + "@metamask/tron-wallet-snap": "npm:^1.16.1" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" "@nktkas/hyperliquid": "npm:^0.27.1" From 6f0467bcf8a7cc47c267f3a3b9b0cfc5d878e414 Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Wed, 17 Dec 2025 08:26:03 -0800 Subject: [PATCH 10/13] chore: Add learn more CTA nav back to PerpsHome (#24036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds Learn More CTA to Perps onboarding flow. ## **Changelog** CHANGELOG entry: Add Perps Learn More CTA to Perps onboarding flow ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2130 ## **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/fe0e2fa3-62e9-47fc-82db-c4909ac44f42 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a Learn more button to the Perps tutorial’s last screen (opens support in-app) and makes the browser back button return to Perps Home when opened from Perps; hides skip on the last screen and updates tests, types, and strings. > > - **Perps Tutorial**: > - Add secondary `Learn more` button on the last screen opening support URL in the in-app browser (`fromPerps`, `EXTERNAL_LINK_TYPE`). > - Hide `skip` on the last screen; keep primary `Let's go` CTA; adjust footer/button spacing; remove `ready_to_trade.footer_text` usage. > - Update tests to assert `Learn more` presence and `skip` hidden on the last screen. > - **Browser**: > - Plumb `fromPerps` through `Browser` → `BrowserTab`; when true, back button navigates to `Routes.PERPS.PERPS_HOME`. > - **Types/Selectors/Locales**: > - Add `fromPerps?: boolean` to `BrowserTabProps`. > - Add `PerpsTutorialSelectorsIDs.LEARN_MORE_BUTTON`. > - Add i18n string `perps.tutorial.learn_more`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cd54ebfdce3e7ca56b22d4777be2a0088c5c796d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsTutorialCarousel.styles.ts | 2 +- .../PerpsTutorialCarousel.test.tsx | 33 +++++++----- .../PerpsTutorialCarousel.tsx | 52 ++++++++++++++----- app/components/Views/Browser/index.js | 2 + .../Views/BrowserTab/BrowserTab.tsx | 10 +++- app/components/Views/BrowserTab/types.ts | 4 ++ e2e/selectors/Perps/Perps.selectors.ts | 1 + locales/languages/en.json | 1 + 8 files changed, 76 insertions(+), 29 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts index c52e9a587f5a..cc863692224e 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts @@ -89,10 +89,10 @@ const createStyles = (params: { flexDirection: 'column', alignItems: 'stretch', gap: 16, + marginBottom: 16, }, skipButton: { paddingHorizontal: 16, - marginBottom: 16, alignSelf: 'center', opacity: params.vars.shouldShowSkipButton ? 1 : 0, }, diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx index 236fa44a1bcf..4b3ff1f5e295 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx @@ -262,7 +262,7 @@ describe('PerpsTutorialCarousel', () => { expect(mockMarkTutorialCompleted).toHaveBeenCalled(); }); - it('enables skip button on last screen for eligible users', async () => { + it("shows Learn more and Let's go buttons on last screen", async () => { render(); // Navigate to the last screen @@ -273,9 +273,14 @@ describe('PerpsTutorialCarousel', () => { screen.getByText(strings('perps.tutorial.ready_to_trade.title')), ).toBeOnTheScreen(); - // Skip button should be enabled on last screen for eligible users - const skipButton = screen.getByTestId('perps-tutorial-skip-button'); - expect(skipButton.props.disabled).toBe(false); + // Skip button is hidden on last screen + expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull(); + + // Learn more button should be visible on last screen + const learnMoreButton = screen.getByTestId( + 'perps-tutorial-learn-more-button', + ); + expect(learnMoreButton).toBeOnTheScreen(); // Main "Let's go" button should be visible const continueButton = screen.getByTestId( @@ -375,7 +380,7 @@ describe('PerpsTutorialCarousel', () => { } }); - it('shows "Got it" button on last screen for eligible users', async () => { + it('shows "Let\'s go" and "Learn more" buttons on last screen for eligible users', async () => { render(); // Navigate through all screens to get to last screen @@ -392,9 +397,14 @@ describe('PerpsTutorialCarousel', () => { ); expect(continueButton).toBeOnTheScreen(); - // Skip button should be enabled for eligible users on last screen - const skipButton = screen.getByTestId('perps-tutorial-skip-button'); - expect(skipButton.props.disabled).toBe(false); + // Learn more button should be visible on last screen + const learnMoreButton = screen.getByTestId( + 'perps-tutorial-learn-more-button', + ); + expect(learnMoreButton).toBeOnTheScreen(); + + // Skip button is hidden on last screen + expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull(); }); it('navigates to perps home when eligible user completes tutorial', async () => { @@ -509,7 +519,7 @@ describe('PerpsTutorialCarousel', () => { ).not.toBeOnTheScreen(); }); - it('shows "Let\'s go" button and disables skip button on last screen for non-eligible users', async () => { + it('shows "Let\'s go" button and hides skip button on last screen for non-eligible users', async () => { render(); // Navigate through all screens to get to last screen (4 clicks for 5 screens) @@ -526,9 +536,8 @@ describe('PerpsTutorialCarousel', () => { ); expect(continueButton).toBeOnTheScreen(); - // Skip button should be disabled on last screen - const skipButton = screen.getByTestId('perps-tutorial-skip-button'); - expect(skipButton.props.disabled).toBe(true); + // Skip button is hidden on last screen (for both eligible and non-eligible users) + expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull(); }); it('shows skip button for non-eligible users on non-last screens', () => { diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index bc2e86c756e1..b4ba78cfa2b8 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -26,6 +26,7 @@ import Text, { import { useStyles } from '../../../../../component-library/hooks'; import Routes from '../../../../../constants/navigation/Routes'; import NavigationService from '../../../../../core/NavigationService'; +import { EXTERNAL_LINK_TYPE } from '../../../../../constants/browser'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import { PerpsEventProperties, @@ -118,7 +119,6 @@ const getTutorialScreens = (isEligible: boolean): TutorialScreen[] => { id: 'ready_to_trade', title: strings('perps.tutorial.ready_to_trade.title'), description: strings('perps.tutorial.ready_to_trade.description'), - footerText: strings('perps.tutorial.ready_to_trade.footer_text'), riveArtboardName: PERPS_RIVE_ARTBOARD_NAMES.READY, }; @@ -357,6 +357,18 @@ const PerpsTutorialCarousel: React.FC = () => { navigateToMarketsList, ]); + const handleLearnMore = useCallback(() => { + NavigationService.navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: 'https://support.metamask.io/manage-crypto/trade/perps', + linkType: EXTERNAL_LINK_TYPE, + timestamp: Date.now(), + fromPerps: true, + }, + }); + }, []); + const renderTabBar = () => ; const buttonLabel = useMemo(() => { @@ -464,6 +476,16 @@ const PerpsTutorialCarousel: React.FC = () => { {/* Footer */} + {isLastScreen && ( +