From 3a558e80e75f32290f173648be022258b4c62cda Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 5 Dec 2025 12:40:36 +0100 Subject: [PATCH 01/11] fix(perps): correct Stop Loss banner ROE threshold to -10% and skip debounce for old orders cp-7.61.0 (#23427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes a bug where the Stop Loss banner was not displaying at the correct Return on Equity (ROE) threshold, and adds an enhancement to bypass the debounce delay for established positions. ## **Changelog** CHANGELOG entry: Fixed Stop Loss banner to display at -10% ROE instead of -20% ROE and skip debounce for old orders ## **Related issues** Jira issue: https://consensyssoftware.atlassian.net/browse/TAT-2163 Fixes https://github.com/MetaMask/metamask-mobile/issues/23473 ## **Manual testing steps** ```gherkin Feature: Stop Loss Banner Display Scenario: user has position with -10% ROE Given user has an open Perps position And position ROE is between -10% and -20% And liquidation distance is greater than 3% And position has no existing stop loss set And ROE has been below -10% for at least 60 seconds When user views the position details Then Stop Loss prompt banner should be displayed And banner should suggest setting a stop loss ``` ## **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] > Stop-loss prompt now triggers at -10% ROE and skips debounce for positions >2 minutes old using order fills; integrated into market details view with updated tests. > > - **Perps Stop-Loss Prompt Logic**: > - Update `STOP_LOSS_PROMPT_CONFIG.ROE_THRESHOLD` to `-10` in `constants/perpsConfig.ts`. > - Enhance `useStopLossPrompt`: > - Add `positionOpenedTimestamp` param and bypass 60s debounce when ROE below threshold and position age ≥ 2 minutes. > - Add `finishDebounce` helper and `hasBeenShownRef` to show once per position lifecycle; reset appropriately. > - **View Integration**: > - In `PerpsMarketDetailsView.tsx`, fetch fills via `usePerpsOrderFills`, compute `positionOpenedTimestamp` from latest matching "Open" fill, and pass to `useStopLossPrompt`. > - **Tests**: > - Extend `useStopLossPrompt.test.ts` for new threshold and debounce-bypass behavior, including edge cases and priority rules. > - In `PerpsMarketDetailsView.test.tsx`, mock `usePerpsOrderFills` and add test verifying position opened timestamp computation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cf3a2a9c5465e115d7be9b10f449437600798a59. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsMarketDetailsView.test.tsx | 117 ++++++++ .../PerpsMarketDetailsView.tsx | 23 ++ .../UI/Perps/constants/perpsConfig.ts | 2 +- .../UI/Perps/hooks/useStopLossPrompt.test.ts | 264 +++++++++++++++++- .../UI/Perps/hooks/useStopLossPrompt.ts | 46 ++- 5 files changed, 440 insertions(+), 12 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index f6a41c3df0d..9df1270f6aa 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -242,6 +242,23 @@ jest.mock('../../hooks/usePerpsOpenOrders', () => ({ usePerpsOpenOrders: () => mockUsePerpsOpenOrdersImpl(), })); +const mockUsePerpsOrderFillsImpl = jest.fn< + ReturnType< + typeof import('../../hooks/usePerpsOrderFills').usePerpsOrderFills + >, + [] +>(() => ({ + orderFills: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, +})); + +jest.mock('../../hooks/usePerpsOrderFills', () => ({ + usePerpsOrderFills: () => mockUsePerpsOrderFillsImpl(), +})); + const mockRefreshMarketStats = jest.fn(); jest.mock('../../hooks/usePerpsMarketStats', () => ({ usePerpsMarketStats: () => ({ @@ -560,6 +577,15 @@ describe('PerpsMarketDetailsView', () => { volume: '$1.23B', maxLeverage: '40x', }; + + // Reset order fills mock to default + mockUsePerpsOrderFillsImpl.mockReturnValue({ + orderFills: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); }); // Clean up mocks after each test @@ -1645,6 +1671,97 @@ describe('PerpsMarketDetailsView', () => { }); }); + describe('Position opened timestamp calculation', () => { + it('computes position opened timestamp from order fills data', () => { + // Arrange + const timestamp = Date.now(); + mockUseHasExistingPosition.mockReturnValue({ + hasPosition: true, + isLoading: false, + error: null, + existingPosition: { + coin: 'BTC', + size: '0.5', + entryPrice: '44000', + positionValue: '22000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '40000', + maxLeverage: 20, + returnOnEquity: '1.14', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + refreshPosition: jest.fn(), + }); + + mockUsePerpsOrderFillsImpl.mockReturnValue({ + orderFills: [ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + direction: 'Open Long', + timestamp: timestamp - 2000, + size: '0.3', + price: '43000', + pnl: '0', + fee: '0.001', + feeToken: 'USDC', + }, + { + orderId: 'order-2', + symbol: 'BTC', + side: 'buy', + direction: 'Open Long', + timestamp, + size: '0.5', + price: '44000', + pnl: '0', + fee: '0.001', + feeToken: 'USDC', + }, + { + orderId: 'order-3', + symbol: 'ETH', + side: 'sell', + direction: 'Open Short', + timestamp: timestamp - 1000, + size: '1.0', + price: '3000', + pnl: '0', + fee: '0.001', + feeToken: 'USDC', + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + // Act + const { getByTestId } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + // Assert + expect( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.CONTAINER), + ).toBeTruthy(); + expect(mockUsePerpsOrderFillsImpl).toHaveBeenCalled(); + }); + }); + describe('TP/SL child order filtering', () => { beforeEach(() => { // Reset to default mock implementation diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index fc447a8fc88..d87d001df40 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -63,6 +63,7 @@ import { usePerpsNavigation, usePositionManagement, } from '../../hooks'; +import { usePerpsOrderFills } from '../../hooks/usePerpsOrderFills'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; import { usePerpsDataMonitor, @@ -366,6 +367,27 @@ const PerpsMarketDetailsView: React.FC = () => { loadOnMount: true, }); + // Fetch order fills to get position opened timestamp + const { orderFills } = usePerpsOrderFills({ + skipInitialFetch: false, + }); + + // Get position opened timestamp from fills data + const positionOpenedTimestamp = useMemo(() => { + if (!existingPosition || !orderFills) return undefined; + + // Find the most recent "Open" fill for this asset + const openFill = orderFills + .filter((fill) => { + const isMatchingAsset = fill.symbol === existingPosition.coin; + const isOpenDirection = fill.direction?.startsWith('Open'); + return isMatchingAsset && isOpenDirection; + }) + .sort((a, b) => b.timestamp - a.timestamp)[0]; // Most recent first + + return openFill?.timestamp; + }, [existingPosition, orderFills]); + // Compute TP/SL lines for the chart based on existing position // Always include currentPrice to ensure chart price line matches header (TAT-2112) const tpslLines = useMemo(() => { @@ -396,6 +418,7 @@ const PerpsMarketDetailsView: React.FC = () => { } = useStopLossPrompt({ position: existingPosition, currentPrice, + positionOpenedTimestamp, }); // Reset stop loss banner state when market or position changes diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 88276d483a8..a3d089de1e6 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -496,7 +496,7 @@ export const STOP_LOSS_PROMPT_CONFIG = { // ROE (Return on Equity) threshold (percentage) // Shows "Set stop loss" banner when ROE drops below this value - ROE_THRESHOLD: -20, + ROE_THRESHOLD: -10, // Debounce duration for ROE threshold (milliseconds) // User must have ROE below threshold for this duration before showing banner diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts index 381d1b9b33d..ad7ed8f67ea 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts @@ -20,7 +20,7 @@ describe('useStopLossPrompt', () => { }, liquidationPrice: '45000', maxLeverage: 50, - returnOnEquity: '-0.20', // -20% + returnOnEquity: '-0.05', // -5% (above threshold for most tests) cumulativeFunding: { allTime: '0', sinceOpen: '0', @@ -107,7 +107,7 @@ describe('useStopLossPrompt', () => { // Position with liquidation at 45000, current price 45500 (1.1% away) const position = createMockPosition({ liquidationPrice: '45000', - returnOnEquity: '-0.10', // Not at ROE threshold + returnOnEquity: '-0.05', // -5% (above -10% ROE threshold) }); const { result } = renderHook(() => @@ -144,7 +144,7 @@ describe('useStopLossPrompt', () => { describe('stop_loss variant', () => { it('shows stop_loss variant after ROE debounce period', () => { const position = createMockPosition({ - returnOnEquity: '-0.25', // -25% ROE + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) liquidationPrice: '40000', // Far from liquidation }); @@ -169,7 +169,7 @@ describe('useStopLossPrompt', () => { it('does not show stop_loss variant if ROE recovers before debounce', () => { const position = createMockPosition({ - returnOnEquity: '-0.25', // -25% ROE + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) liquidationPrice: '40000', }); @@ -189,7 +189,7 @@ describe('useStopLossPrompt', () => { // ROE recovers const recoveredPosition = createMockPosition({ - returnOnEquity: '-0.10', // -10% ROE (above threshold) + returnOnEquity: '-0.05', // -5% ROE (above threshold) liquidationPrice: '40000', }); @@ -204,6 +204,258 @@ describe('useStopLossPrompt', () => { }); }); + describe('positionOpenedTimestamp bypass logic', () => { + const POSITION_AGE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + + beforeEach(() => { + jest.setSystemTime(new Date('2024-01-01T12:00:00.000Z')); + }); + + it('bypasses debounce immediately when position is older than 2 minutes and ROE is below threshold', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; // 2 minutes 1 second ago + const position = createMockPosition({ + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + liquidationPrice: '40000', // Far from liquidation + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + positionOpenedTimestamp, + }), + ); + + // Should show immediately without waiting for debounce + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + + // Verify no debounce time was needed + act(() => { + jest.advanceTimersByTime(100); // Small advance, should still show + }); + + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('does not bypass debounce when position is less than 2 minutes old', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS + 1000; // 1 minute 59 seconds ago + const position = createMockPosition({ + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + liquidationPrice: '40000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + positionOpenedTimestamp, + }), + ); + + // Should NOT show immediately (position too new) + expect(result.current.shouldShowBanner).toBe(false); + + // Should still require full debounce period + act(() => { + jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.ROE_DEBOUNCE_MS - 100); + }); + + expect(result.current.shouldShowBanner).toBe(false); + + // After full debounce, should show + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('does not bypass debounce when ROE is above threshold even if position is old', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; // 2 minutes 1 second ago + const position = createMockPosition({ + returnOnEquity: '-0.05', // -5% ROE (above -10% threshold) + liquidationPrice: '40000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + positionOpenedTimestamp, + }), + ); + + // Should NOT show (ROE above threshold) + expect(result.current.shouldShowBanner).toBe(false); + + // Even after time passes, should not show + act(() => { + jest.advanceTimersByTime(10000); + }); + + expect(result.current.shouldShowBanner).toBe(false); + }); + + it('bypasses debounce when position is exactly 2 minutes old', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS; // Exactly 2 minutes ago + const position = createMockPosition({ + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + liquidationPrice: '40000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + positionOpenedTimestamp, + }), + ); + + // Should show immediately (exactly at threshold) + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('bypasses debounce only once per position lifecycle', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; + const position = createMockPosition({ + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + liquidationPrice: '40000', + }); + + const { result, rerender } = renderHook< + { pos: Position | null; timestamp?: number }, + ReturnType + >( + ({ pos, timestamp }) => + useStopLossPrompt({ + position: pos, + currentPrice: 50000, + positionOpenedTimestamp: timestamp, + }), + { + initialProps: { pos: position, timestamp: positionOpenedTimestamp }, + }, + ); + + // Should show immediately + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + + // Simulate position update (ROE changes but still below threshold) + const updatedPosition = createMockPosition({ + returnOnEquity: '-0.12', // Still below threshold + liquidationPrice: '40000', + }); + + rerender({ pos: updatedPosition, timestamp: positionOpenedTimestamp }); + + // Should still show (bypass already happened) + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('does not bypass when positionOpenedTimestamp is undefined', () => { + const position = createMockPosition({ + returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + liquidationPrice: '40000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + positionOpenedTimestamp: undefined, + }), + ); + + // Should NOT show immediately (no timestamp provided) + expect(result.current.shouldShowBanner).toBe(false); + + // Should require full debounce period + act(() => { + jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.ROE_DEBOUNCE_MS + 100); + }); + + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('resets bypass state when position is closed', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; + const position = createMockPosition({ + returnOnEquity: '-0.15', + liquidationPrice: '40000', + }); + + const { result, rerender } = renderHook< + { pos: Position | null; timestamp?: number }, + ReturnType + >( + ({ pos, timestamp }) => + useStopLossPrompt({ + position: pos, + currentPrice: 50000, + positionOpenedTimestamp: timestamp, + }), + { + initialProps: { pos: position, timestamp: positionOpenedTimestamp }, + }, + ); + + // Should show immediately + expect(result.current.shouldShowBanner).toBe(true); + + // Close position + rerender({ pos: null, timestamp: undefined }); + + expect(result.current.shouldShowBanner).toBe(false); + + // Reopen position with same timestamp + rerender({ pos: position, timestamp: positionOpenedTimestamp }); + + // Should show again (state was reset) + expect(result.current.shouldShowBanner).toBe(true); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('does not bypass when hook is disabled', () => { + const now = Date.now(); + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; + const position = createMockPosition({ + returnOnEquity: '-0.15', + liquidationPrice: '40000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + positionOpenedTimestamp, + enabled: false, + }), + ); + + // Should NOT show (hook disabled) + expect(result.current.shouldShowBanner).toBe(false); + + act(() => { + jest.advanceTimersByTime(10000); + }); + + expect(result.current.shouldShowBanner).toBe(false); + }); + }); + describe('suggested stop loss calculations', () => { it('calculates suggested stop loss price for long position', () => { const position = createMockPosition({ @@ -324,7 +576,7 @@ describe('useStopLossPrompt', () => { it('prioritizes add_margin over stop_loss when both conditions met', () => { const position = createMockPosition({ - returnOnEquity: '-0.30', // Below ROE threshold + returnOnEquity: '-0.15', // Below -10% ROE threshold liquidationPrice: '49000', // Very close to liquidation }); diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.ts index 48705c4c494..60728490e47 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef, useEffect, useState } from 'react'; +import { useMemo, useRef, useEffect, useState, useCallback } from 'react'; import type { Position } from '../controllers/types'; import { STOP_LOSS_PROMPT_CONFIG } from '../constants/perpsConfig'; @@ -24,6 +24,8 @@ export interface UseStopLossPromptParams { currentPrice: number; /** Enable/disable the hook (default: true) */ enabled?: boolean; + /** Timestamp when position was opened (from order fills) - bypasses debounce if position is >2min old */ + positionOpenedTimestamp?: number; } export interface UseStopLossPromptResult { @@ -44,7 +46,7 @@ export interface UseStopLossPromptResult { * * Implements the logic from TASK_AUTOSET.md: * - Shows "add_margin" variant when within 3% of liquidation - * - Shows "stop_loss" variant when ROE <= -20% for 60s (debounced) + * - Shows "stop_loss" variant when ROE <= -10% for 60s (debounced) * - Suppresses when position has cross margin or existing stop loss * * @example @@ -57,6 +59,7 @@ export interface UseStopLossPromptResult { * } = useStopLossPrompt({ * position: existingPosition, * currentPrice: 50000, + * positionOpenedTimestamp: 1234567890000, // Optional: from order fills * }); * ``` */ @@ -64,9 +67,11 @@ export const useStopLossPrompt = ({ position, currentPrice, enabled = true, + positionOpenedTimestamp, }: UseStopLossPromptParams): UseStopLossPromptResult => { // Track when ROE first dropped below threshold for debouncing const roeBelowThresholdSinceRef = useRef(null); + const hasBeenShownRef = useRef(false); const [roeDebounceComplete, setRoeDebounceComplete] = useState(false); // Calculate liquidation distance @@ -96,11 +101,42 @@ export const useStopLossPrompt = ({ return roeValue * 100; }, [position?.returnOnEquity]); + const finishDebounce = useCallback(() => { + setRoeDebounceComplete(true); + hasBeenShownRef.current = true; + }, []); + + useEffect(() => { + hasBeenShownRef.current = false; + }, [position?.coin]); + + useEffect(() => { + if (!enabled || roePercent === null || hasBeenShownRef.current) { + return; + } + + // Check if position was opened more than 2 minutes ago (from order fills timestamp) + const POSITION_AGE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + const positionAge = positionOpenedTimestamp + ? Date.now() - positionOpenedTimestamp + : 0; + + const isBelowThreshold = + roePercent <= STOP_LOSS_PROMPT_CONFIG.ROE_THRESHOLD; + + // If position is old enough (from actual order fill data), bypass debounce + if (positionAge >= POSITION_AGE_THRESHOLD_MS && isBelowThreshold) { + finishDebounce(); + return; + } + }, [positionOpenedTimestamp, enabled, roePercent, finishDebounce]); + // Handle ROE debounce logic useEffect(() => { if (!enabled || roePercent === null) { roeBelowThresholdSinceRef.current = null; setRoeDebounceComplete(false); + hasBeenShownRef.current = false; // Reset when position is closed return; } @@ -116,14 +152,14 @@ export const useStopLossPrompt = ({ // Check if debounce period has passed const elapsed = Date.now() - roeBelowThresholdSinceRef.current; if (elapsed >= STOP_LOSS_PROMPT_CONFIG.ROE_DEBOUNCE_MS) { - setRoeDebounceComplete(true); + finishDebounce(); } else { // Set up timer to check again const remainingTime = STOP_LOSS_PROMPT_CONFIG.ROE_DEBOUNCE_MS - elapsed; const timer = setTimeout(() => { // Re-check if still below threshold if (roeBelowThresholdSinceRef.current !== null) { - setRoeDebounceComplete(true); + finishDebounce(); } }, remainingTime); @@ -136,7 +172,7 @@ export const useStopLossPrompt = ({ } return undefined; - }, [enabled, roePercent]); + }, [enabled, roePercent, position, positionOpenedTimestamp, finishDebounce]); // Calculate suggested stop loss price based on entry price and target ROE // Formula: For a position, SL price at -50% ROE = entryPrice * (1 + targetROE/100/leverage) From f77e5349f2280ad627dbbe324500b2a950b5521e Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Fri, 5 Dec 2025 05:47:16 -0600 Subject: [PATCH 02/11] chore: update musd conversion toasts to match design (#23669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates mUSD conversion toasts to match the latest design specifications. ### UI Changes - **In-progress toast**: Now displays a token icon with an animated spinning gradient ring, shows "Converting {token} → mUSD" with an estimated time (~15 seconds) - **Success toast**: Updated to show "Your mUSD has been delivered!" with a green checkmark icon - **Failed toast**: Updated to use `CircleX` icon for error state - Added close button to all toasts for manual dismissal ### Behavioral Changes - Changed in-progress toast trigger from `TransactionStatus.submitted` to `TransactionStatus.approved` to show immediately after user confirms - Added token symbol and icon lookup using `metamaskPay` transaction metadata - After confirmation, navigates user to wallet home screen - Excluded `musdConversion` transactions from standard notification system (toasts handle this) ### Technical Changes - Created `TokenIconWithSpinner` component with custom SVG-based gradient spinner using `react-native-reanimated` - `inProgress` toast option changed from static object to function accepting `{ tokenSymbol, tokenIcon, estimatedTimeSeconds }` - Added `formatEstimatedTime` utility for human-readable ETA display ## Manual Testing Steps 1. Initiate an mUSD conversion transaction 2. Confirm the transaction 3. Verify in-progress toast appears immediately with token icon + spinner 4. Verify success toast appears when transaction confirms 5. Test failure scenarios (rejected/dropped transactions) Note: there are some intermittent issues with conversion at the moment but they are outside the scope of this PR. If needed, test manually to see what the notification toasts would be in cases. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-102 ## **Manual testing steps** ```gherkin Feature: mUSD Conversion Toasts Scenario: In-progress toast appears when user confirms transaction Given I initiated an mUSD conversion When my transaction status changes to "approved" Then I see a toast with token icon, spinner, and "Converting {token} → mUSD" Scenario: Success toast appears when transaction confirms Given my mUSD conversion is in progress When my transaction status changes to "confirmed" Then I see a toast showing "Your mUSD has been delivered!" And I am navigated to wallet home Scenario: Failed toast appears when transaction fails Given my mUSD conversion is in progress When my transaction status changes to "failed" Then I see a toast showing "mUSD conversion failed" Scenario: Non-mUSD transactions do not show conversion toasts Given I have a swap transaction When my transaction status changes Then I do not see any mUSD conversion toasts ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/62e8c78f-dd4f-41f3-984f-6b9fb7247e47 ## **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] > Overhauls mUSD conversion toasts to show a token icon with a rotating gradient spinner and ETA (triggered on approved), adds close buttons, fetches token/icon/ETA from state, navigates to Wallet Home post-confirmation, and excludes these txs from standard notifications. > > - **UI/Components**: > - Add `TokenIconWithSpinner` (SVG + reanimated gradient ring) used as toast `startAccessory`. > - **Hooks/Toasts**: > - Refactor `useEarnToasts`: > - `mUsdConversion.inProgress` now a function accepting `{ tokenSymbol, tokenIcon, estimatedTimeSeconds }`. > - Uses `IconName.Confirmation`/`CircleX`, new colors, close button, and ETA via `descriptionOptions`. > - Update `useMusdConversionStatus`: > - Trigger in-progress toast on `TransactionStatus.approved`. > - Resolve token symbol/icon from `metamaskPay` + token cache/`getAssetImageUrl`; read ETA from `TransactionPayController` (`selectTransactionPayTransactionData`). > - Duplicate prevention and cleanup adjusted for `approved` flow. > - **Navigation/Notifications**: > - `useTransactionConfirm`: navigate to `Routes.WALLET.HOME` after `TransactionType.musdConversion` confirm. > - `NotificationManager`: skip notifications for `TransactionType.musdConversion`. > - **State/Selectors**: > - Add `selectTransactionPayTransactionData` to expose transaction pay data map. > - **i18n**: > - Replace toasts copy with `earn.musd_conversion.toasts.converting`, `eta`, `delivered`, `failed`. > - **Tests**: > - Comprehensive updates for new toast API, spinner accessory, approved-status trigger, selector-driven token/icon/ETA, close button, and navigation behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 57a0cae4002401bfcb3f20e4bce464cbe3cbf450. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TokenIconWithSpinner.constants.ts | 31 + .../TokenIconWithSpinner.styles.ts | 27 + .../TokenIconWithSpinner.utils.test.ts | 137 +++++ .../TokenIconWithSpinner.utils.ts | 15 + .../components/TokenIconWithSpinner/index.tsx | 102 ++++ .../UI/Earn/hooks/useEarnToasts.test.tsx | 285 +++++++-- .../UI/Earn/hooks/useEarnToasts.tsx | 164 ++++-- .../hooks/useMusdConversionStatus.test.ts | 550 +++++++++++++++--- .../UI/Earn/hooks/useMusdConversionStatus.ts | 71 ++- .../useTransactionConfirm.test.ts | 20 + .../transactions/useTransactionConfirm.ts | 7 + app/core/NotificationManager.js | 1 + app/selectors/transactionPayController.ts | 5 + locales/languages/en.json | 5 +- 14 files changed, 1241 insertions(+), 179 deletions(-) create mode 100644 app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.constants.ts create mode 100644 app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.styles.ts create mode 100644 app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.test.ts create mode 100644 app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.ts create mode 100644 app/components/UI/Earn/components/TokenIconWithSpinner/index.tsx diff --git a/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.constants.ts b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.constants.ts new file mode 100644 index 00000000000..23bd3b0faae --- /dev/null +++ b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.constants.ts @@ -0,0 +1,31 @@ +import { createArcPath } from './TokenIconWithSpinner.utils'; + +// Token icon dimensions +export const TOKEN_ICON_SIZE = 32; +export const RING_STROKE_WIDTH = 4; +// Ring size matches token icon - no gap between icon and ring +export const RING_SIZE = TOKEN_ICON_SIZE + RING_STROKE_WIDTH * 2; + +// Spinner configuration +export const SPINNER_NUM_SEGMENTS = 18; +export const SPINNER_ARC_DEGREES = 360; +export const SPINNER_DURATION_MS = 1000; + +// Pre-calculate arc paths for the gradient spinner +export const SPINNER_RADIUS = (RING_SIZE - RING_STROKE_WIDTH) / 2; +export const SPINNER_CENTER = RING_SIZE / 2; +export const SEGMENT_DEGREES = SPINNER_ARC_DEGREES / SPINNER_NUM_SEGMENTS; + +// Pre-calculate all arc paths and opacities at module load time +export const SPINNER_SEGMENTS = Array.from( + { length: SPINNER_NUM_SEGMENTS }, + (_, i) => ({ + path: createArcPath( + i * SEGMENT_DEGREES, + (i + 1) * SEGMENT_DEGREES + 1, + SPINNER_CENTER, + SPINNER_RADIUS, + ), + opacity: (i + 1) / SPINNER_NUM_SEGMENTS, + }), +); diff --git a/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.styles.ts b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.styles.ts new file mode 100644 index 00000000000..49b29d6d03c --- /dev/null +++ b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.styles.ts @@ -0,0 +1,27 @@ +import { StyleSheet } from 'react-native'; +import { RING_SIZE, TOKEN_ICON_SIZE } from './TokenIconWithSpinner.constants'; + +const styles = StyleSheet.create({ + tokenIconWithRingContainer: { + width: RING_SIZE, + height: RING_SIZE, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + spinningRingWrapper: { + position: 'absolute', + width: RING_SIZE, + height: RING_SIZE, + }, + tokenIconWrapper: { + position: 'absolute', + }, + tokenIcon: { + width: TOKEN_ICON_SIZE, + height: TOKEN_ICON_SIZE, + borderRadius: TOKEN_ICON_SIZE / 2, + }, +}); + +export default styles; diff --git a/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.test.ts b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.test.ts new file mode 100644 index 00000000000..3d21489883f --- /dev/null +++ b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.test.ts @@ -0,0 +1,137 @@ +import { createArcPath } from './TokenIconWithSpinner.utils'; + +describe('createArcPath', () => { + const defaultCenter = 20; + const defaultRadius = 18; + + describe('arc path format', () => { + it('returns SVG path string with correct format', () => { + const result = createArcPath(0, 20, defaultCenter, defaultRadius); + + expect(result).toMatch(/^M .+ .+ A .+ .+ 0 [01] 1 .+ .+$/); + }); + + it('includes move command with start coordinates', () => { + const result = createArcPath(0, 20, defaultCenter, defaultRadius); + + expect(result).toContain('M '); + }); + + it('includes arc command with radius values', () => { + const result = createArcPath(0, 20, defaultCenter, defaultRadius); + + expect(result).toContain(`A ${defaultRadius} ${defaultRadius}`); + }); + }); + + describe('start coordinates calculation', () => { + it('calculates start point at 0 degrees (right side of circle)', () => { + const result = createArcPath(0, 10, defaultCenter, defaultRadius); + + // At 0 degrees: x = center + radius, y = center + expect(result).toContain( + `M ${defaultCenter + defaultRadius} ${defaultCenter}`, + ); + }); + + it('calculates start point at 90 degrees (bottom of circle)', () => { + const result = createArcPath(90, 100, defaultCenter, defaultRadius); + + // At 90 degrees: x = center, y = center + radius + const expectedX = + defaultCenter + defaultRadius * Math.cos((90 * Math.PI) / 180); + const expectedY = + defaultCenter + defaultRadius * Math.sin((90 * Math.PI) / 180); + + expect(result).toContain(`M ${expectedX} ${expectedY}`); + }); + + it('calculates start point at 180 degrees (left side of circle)', () => { + const result = createArcPath(180, 190, defaultCenter, defaultRadius); + + const expectedX = + defaultCenter + defaultRadius * Math.cos((180 * Math.PI) / 180); + const expectedY = + defaultCenter + defaultRadius * Math.sin((180 * Math.PI) / 180); + + expect(result).toContain(`M ${expectedX} ${expectedY}`); + }); + }); + + describe('large arc flag', () => { + it('sets large arc flag to 0 when arc spans less than 180 degrees', () => { + const result = createArcPath(0, 90, defaultCenter, defaultRadius); + + // Format: A rx ry x-axis-rotation large-arc-flag sweep-flag x y + expect(result).toMatch(/A \d+ \d+ 0 0 1/); + }); + + it('sets large arc flag to 0 when arc spans exactly 180 degrees', () => { + const result = createArcPath(0, 180, defaultCenter, defaultRadius); + + expect(result).toMatch(/A \d+ \d+ 0 0 1/); + }); + + it('sets large arc flag to 1 when arc spans more than 180 degrees', () => { + const result = createArcPath(0, 270, defaultCenter, defaultRadius); + + expect(result).toMatch(/A \d+ \d+ 0 1 1/); + }); + + it('sets large arc flag to 1 for full circle arc', () => { + const result = createArcPath(0, 359, defaultCenter, defaultRadius); + + expect(result).toMatch(/A \d+ \d+ 0 1 1/); + }); + }); + + describe('different center and radius values', () => { + it('generates path with custom center value', () => { + const customCenter = 50; + const result = createArcPath(0, 20, customCenter, defaultRadius); + + expect(result).toContain(`M ${customCenter + defaultRadius}`); + }); + + it('generates path with custom radius value', () => { + const customRadius = 30; + const result = createArcPath(0, 20, defaultCenter, customRadius); + + expect(result).toContain(`A ${customRadius} ${customRadius}`); + }); + + it('generates path with both custom center and radius', () => { + const customCenter = 40; + const customRadius = 35; + const result = createArcPath(0, 20, customCenter, customRadius); + + expect(result).toContain( + `M ${customCenter + customRadius} ${customCenter}`, + ); + expect(result).toContain(`A ${customRadius} ${customRadius}`); + }); + }); + + describe('edge cases', () => { + it('handles zero degree arc', () => { + const result = createArcPath(0, 0, defaultCenter, defaultRadius); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + }); + + it('handles negative start angle', () => { + const result = createArcPath(-45, 45, defaultCenter, defaultRadius); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + }); + + it('handles angles greater than 360 degrees', () => { + const result = createArcPath(0, 450, defaultCenter, defaultRadius); + + expect(result).toBeDefined(); + expect(result).toMatch(/A \d+ \d+ 0 1 1/); + }); + }); +}); diff --git a/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.ts b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.ts new file mode 100644 index 00000000000..d4a3dfa49db --- /dev/null +++ b/app/components/UI/Earn/components/TokenIconWithSpinner/TokenIconWithSpinner.utils.ts @@ -0,0 +1,15 @@ +export const createArcPath = ( + startAngle: number, + endAngle: number, + center: number, + radius: number, +): string => { + const startRad = (startAngle * Math.PI) / 180; + const endRad = (endAngle * Math.PI) / 180; + const x1 = center + radius * Math.cos(startRad); + const y1 = center + radius * Math.sin(startRad); + const x2 = center + radius * Math.cos(endRad); + const y2 = center + radius * Math.sin(endRad); + const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0; + return `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`; +}; diff --git a/app/components/UI/Earn/components/TokenIconWithSpinner/index.tsx b/app/components/UI/Earn/components/TokenIconWithSpinner/index.tsx new file mode 100644 index 00000000000..e28e038ee8d --- /dev/null +++ b/app/components/UI/Earn/components/TokenIconWithSpinner/index.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useMemo } from 'react'; +import { View } from 'react-native'; +import Svg, { Path } from 'react-native-svg'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, + cancelAnimation, +} from 'react-native-reanimated'; +import { useAppThemeFromContext } from '../../../../../util/theme'; +import TokenIcon from '../../../../Base/TokenIcon'; +import { + RING_SIZE, + RING_STROKE_WIDTH, + SPINNER_DURATION_MS, + SPINNER_SEGMENTS, +} from './TokenIconWithSpinner.constants'; +import styles from './TokenIconWithSpinner.styles'; + +interface GradientSpinnerProps { + color: string; +} + +/** + * Reusable gradient spinner component + * Renders a circular arc with gradient opacity that rotates continuously + */ +export const GradientSpinner: React.FC = ({ color }) => { + const rotation = useSharedValue(0); + + useEffect(() => { + rotation.value = withRepeat( + withTiming(360, { + duration: SPINNER_DURATION_MS, + easing: Easing.linear, + }), + -1, + ); + + return () => { + cancelAnimation(rotation); + }; + }, [rotation]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + const segments = useMemo( + () => + SPINNER_SEGMENTS.map(({ path, opacity }, i) => ( + + )), + [color], + ); + + return ( + + + {segments} + + + ); +}; + +export interface TokenIconWithSpinnerProps { + tokenSymbol: string; + tokenIcon?: string; +} + +/** + * Token icon with a spinning gradient ring around it + */ +export const TokenIconWithSpinner: React.FC = ({ + tokenSymbol, + tokenIcon, +}) => { + const { colors } = useAppThemeFromContext(); + + return ( + + + + + + + ); +}; diff --git a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx index 621d27a05cd..7d25bca5986 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx @@ -5,36 +5,26 @@ import useEarnToasts from './useEarnToasts'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; import { IconName } from '../../../../component-library/components/Icons/Icon'; +import { ButtonIconProps } from '../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; jest.mock('expo-haptics'); -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { - if (key === 'earn.musd_conversion.toasts.in_progress') { - return `Converting to mUSD`; - } - if (key === 'earn.musd_conversion.toasts.success') { - return `Converted to mUSD`; - } - if (key === 'earn.musd_conversion.toasts.failed') { - return `Failed to convert to mUSD`; - } - return key; - }), -})); const mockTheme = { colors: { - accent01: { - dark: '#accent01-dark', - light: '#accent01-light', + success: { + default: '#success-default', + }, + error: { + default: '#error-default', + }, + icon: { + default: '#icon-default', }, - accent03: { - dark: '#accent03-dark', - normal: '#accent03-normal', + background: { + default: '#background-default', }, - accent04: { - dark: '#accent04-dark', - normal: '#accent04-normal', + primary: { + default: '#primary-default', }, }, }; @@ -83,7 +73,7 @@ describe('useEarnToasts', () => { expect(mockShowToast).toHaveBeenCalledWith( expect.objectContaining({ variant: ToastVariants.Icon, - iconName: IconName.CheckBold, + iconName: IconName.Confirmation, }), ); }); @@ -106,9 +96,12 @@ describe('useEarnToasts', () => { it('excludes hapticsType from toast options passed to toastRef', () => { const { result } = renderHook(() => useEarnToasts(), { wrapper }); - const testConfig = { - ...result.current.EarnToastOptions.mUsdConversion.inProgress, - }; + const testConfig = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + tokenIcon: 'https://example.com/eth.png', + estimatedTimeSeconds: 15, + }); result.current.showToast(testConfig); @@ -141,17 +134,20 @@ describe('useEarnToasts', () => { result.current.EarnToastOptions.mUsdConversion.success; expect(successToast.variant).toBe(ToastVariants.Icon); - expect(successToast.iconName).toBe(IconName.CheckBold); + expect(successToast.iconName).toBe(IconName.Confirmation); expect(successToast.iconColor).toBeDefined(); - expect(successToast.backgroundColor).toBeDefined(); expect(successToast.hapticsType).toBe(NotificationFeedbackType.Success); }); - it('configures inProgress toast with correct properties', () => { + it('configures inProgress toast with correct properties when called with params', () => { const { result } = renderHook(() => useEarnToasts(), { wrapper }); const inProgressToast = - result.current.EarnToastOptions.mUsdConversion.inProgress; + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + tokenIcon: 'https://example.com/eth.png', + estimatedTimeSeconds: 15, + }); expect(inProgressToast.variant).toBe(ToastVariants.Icon); expect(inProgressToast.iconName).toBe(IconName.Loading); @@ -160,6 +156,7 @@ describe('useEarnToasts', () => { expect(inProgressToast.hapticsType).toBe( NotificationFeedbackType.Warning, ); + expect(inProgressToast.hasNoTimeout).toBe(true); }); it('configures failed toast with correct properties', () => { @@ -168,37 +165,44 @@ describe('useEarnToasts', () => { const failedToast = result.current.EarnToastOptions.mUsdConversion.failed; expect(failedToast.variant).toBe(ToastVariants.Icon); - expect(failedToast.iconName).toBe(IconName.Warning); + expect(failedToast.iconName).toBe(IconName.CircleX); expect(failedToast.iconColor).toBeDefined(); - expect(failedToast.backgroundColor).toBeDefined(); expect(failedToast.hapticsType).toBe(NotificationFeedbackType.Error); }); }); describe('spinner for inProgress toast', () => { - it('includes startAccessory with Spinner for inProgress toast', () => { + it('includes startAccessory with TokenIconWithSpinner for inProgress toast', () => { const { result } = renderHook(() => useEarnToasts(), { wrapper }); const inProgressToast = - result.current.EarnToastOptions.mUsdConversion.inProgress; + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + tokenIcon: 'https://example.com/eth.png', + estimatedTimeSeconds: 15, + }); expect(inProgressToast.startAccessory).toBeDefined(); }); }); describe('toast labels', () => { - it('includes tokenSymbol in inProgress label', () => { + it('includes labelOptions in inProgress toast', () => { const { result } = renderHook(() => useEarnToasts(), { wrapper }); const inProgressToast = - result.current.EarnToastOptions.mUsdConversion.inProgress; + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + tokenIcon: 'https://example.com/eth.png', + estimatedTimeSeconds: 15, + }); expect(inProgressToast.labelOptions).toBeDefined(); expect(Array.isArray(inProgressToast.labelOptions)).toBe(true); expect(inProgressToast.labelOptions).toHaveLength(1); }); - it('includes tokenSymbol in success label', () => { + it('includes labelOptions in success toast', () => { const { result } = renderHook(() => useEarnToasts(), { wrapper }); const successToast = @@ -209,7 +213,7 @@ describe('useEarnToasts', () => { expect(successToast.labelOptions).toHaveLength(1); }); - it('includes tokenSymbol in failed label', () => { + it('includes labelOptions in failed toast', () => { const { result } = renderHook(() => useEarnToasts(), { wrapper }); const failedToast = result.current.EarnToastOptions.mUsdConversion.failed; @@ -220,6 +224,192 @@ describe('useEarnToasts', () => { }); }); + describe('closeButtonOptions', () => { + it('includes closeButtonOptions on inProgress toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + tokenIcon: 'https://example.com/eth.png', + estimatedTimeSeconds: 15, + }); + + expect(inProgressToast.closeButtonOptions).toBeDefined(); + expect( + (inProgressToast.closeButtonOptions as ButtonIconProps)?.iconName, + ).toBe(IconName.Close); + expect(inProgressToast.closeButtonOptions?.onPress).toBeDefined(); + }); + + it('includes closeButtonOptions on success toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const successToast = + result.current.EarnToastOptions.mUsdConversion.success; + + expect(successToast.closeButtonOptions).toBeDefined(); + expect( + (successToast.closeButtonOptions as ButtonIconProps)?.iconName, + ).toBe(IconName.Close); + }); + + it('includes closeButtonOptions on failed toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const failedToast = result.current.EarnToastOptions.mUsdConversion.failed; + + expect(failedToast.closeButtonOptions).toBeDefined(); + expect( + (failedToast.closeButtonOptions as ButtonIconProps)?.iconName, + ).toBe(IconName.Close); + }); + + it('calls closeToast when closeButtonOptions.onPress is invoked', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const successToast = + result.current.EarnToastOptions.mUsdConversion.success; + + successToast.closeButtonOptions?.onPress?.(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + }); + + describe('startAccessory icons', () => { + it('includes startAccessory with Icon for success toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const successToast = + result.current.EarnToastOptions.mUsdConversion.success; + + expect(successToast.startAccessory).toBeDefined(); + }); + + it('includes startAccessory with Icon for failed toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const failedToast = result.current.EarnToastOptions.mUsdConversion.failed; + + expect(failedToast.startAccessory).toBeDefined(); + }); + }); + + describe('inProgress toast parameters', () => { + it('creates toast without tokenIcon parameter', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'USDC', + estimatedTimeSeconds: 30, + }); + + expect(inProgressToast.variant).toBe(ToastVariants.Icon); + expect(inProgressToast.startAccessory).toBeDefined(); + }); + + it('creates toast without estimatedTimeSeconds parameter', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'DAI', + tokenIcon: 'https://example.com/dai.png', + }); + + expect(inProgressToast.variant).toBe(ToastVariants.Icon); + expect(inProgressToast.hasNoTimeout).toBe(true); + }); + + it('creates toast with only required tokenSymbol parameter', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'WETH', + }); + + expect(inProgressToast.variant).toBe(ToastVariants.Icon); + expect(inProgressToast.iconName).toBe(IconName.Loading); + }); + }); + + describe('theme colors', () => { + it('sets iconColor on success toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const successToast = + result.current.EarnToastOptions.mUsdConversion.success; + + expect(successToast.iconColor).toBeDefined(); + expect(typeof successToast.iconColor).toBe('string'); + }); + + it('sets iconColor on failed toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const failedToast = result.current.EarnToastOptions.mUsdConversion.failed; + + expect(failedToast.iconColor).toBeDefined(); + expect(typeof failedToast.iconColor).toBe('string'); + }); + + it('sets iconColor on inProgress toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + }); + + expect(inProgressToast.iconColor).toBeDefined(); + expect(typeof inProgressToast.iconColor).toBe('string'); + }); + + it('sets backgroundColor on inProgress toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + }); + + expect(inProgressToast.backgroundColor).toBeDefined(); + expect(typeof inProgressToast.backgroundColor).toBe('string'); + }); + }); + + describe('haptics types', () => { + it('triggers warning haptics for inProgress toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const inProgressToast = + result.current.EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: 'ETH', + }); + + result.current.showToast(inProgressToast); + + expect(mockNotificationAsync).toHaveBeenCalledWith( + NotificationFeedbackType.Warning, + ); + }); + + it('triggers error haptics for failed toast', () => { + const { result } = renderHook(() => useEarnToasts(), { wrapper }); + + const failedToast = result.current.EarnToastOptions.mUsdConversion.failed; + + result.current.showToast(failedToast); + + expect(mockNotificationAsync).toHaveBeenCalledWith( + NotificationFeedbackType.Error, + ); + }); + }); + describe('edge cases', () => { it('handles missing toastRef gracefully', () => { const emptyWrapper = ({ children }: { children: React.ReactNode }) => ( @@ -240,5 +430,22 @@ describe('useEarnToasts', () => { expect(mockNotificationAsync).toHaveBeenCalled(); }); + + it('handles closeToast with null toastRef gracefully', () => { + const emptyWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useEarnToasts(), { + wrapper: emptyWrapper, + }); + + const successToast = + result.current.EarnToastOptions.mUsdConversion.success; + + expect(() => successToast.closeButtonOptions?.onPress?.()).not.toThrow(); + }); }); }); diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx index 749a46f45e2..a7c74190c70 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx @@ -1,19 +1,19 @@ -import { - IconColor as ReactNativeDsIconColor, - IconSize as ReactNativeDsIconSize, -} from '@metamask/design-system-react-native'; -import { Spinner } from '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs'; import { notificationAsync, NotificationFeedbackType } from 'expo-haptics'; import React, { useCallback, useContext, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { strings } from '../../../../../locales/i18n'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; +import Icon, { + IconName, + IconSize, +} from '../../../../component-library/components/Icons/Icon'; import { ToastContext } from '../../../../component-library/components/Toast'; import { + ButtonIconVariant, ToastOptions, ToastVariants, } from '../../../../component-library/components/Toast/Toast.types'; import { useAppThemeFromContext } from '../../../../util/theme'; +import { TokenIconWithSpinner } from '../components/TokenIconWithSpinner'; export type EarnToastOptions = Omit< Extract, @@ -27,22 +27,35 @@ export type EarnToastOptions = Omit< }[]; }; +export interface MusdConversionInProgressParams { + tokenSymbol: string; + tokenIcon?: string; + estimatedTimeSeconds?: number; +} + export interface EarnToastOptionsConfig { mUsdConversion: { - inProgress: EarnToastOptions; + inProgress: (params: MusdConversionInProgressParams) => EarnToastOptions; success: EarnToastOptions; failed: EarnToastOptions; }; } -const getEarnToastLabels = ( - primary: string | React.ReactNode, - secondary?: string | React.ReactNode, -) => { +interface EarnToastLabelOptions { + primary: string | React.ReactNode; + secondary?: string | React.ReactNode; + primaryIsBold?: boolean; +} + +const getEarnToastLabels = ({ + primary, + secondary, + primaryIsBold = true, +}: EarnToastLabelOptions) => { const labels = [ { label: primary, - isBold: true, + isBold: primaryIsBold, }, ]; @@ -62,16 +75,33 @@ const getEarnToastLabels = ( return labels; }; +const formatEstimatedTime = (seconds?: number): string => { + if (!seconds || seconds <= 0) { + return strings('earn.musd_conversion.toasts.eta', { time: '< 1 minute' }); + } + + if (seconds < 60) { + const secondText = seconds === 1 ? 'second' : 'seconds'; + return strings('earn.musd_conversion.toasts.eta', { + time: `${seconds} ${secondText}`, + }); + } + + const minutes = Math.ceil(seconds / 60); + const minuteText = minutes === 1 ? 'minute' : 'minutes'; + return strings('earn.musd_conversion.toasts.eta', { + time: `${minutes} ${minuteText}`, + }); +}; + const EARN_TOASTS_DEFAULT_OPTIONS: Partial = { hasNoTimeout: false, + customBottomOffset: 32, }; const toastStyles = StyleSheet.create({ - spinnerContainer: { - paddingRight: 12, - alignContent: 'center', - alignItems: 'center', - justifyContent: 'center', + iconWrapper: { + marginRight: 16, }, }); @@ -82,29 +112,33 @@ const useEarnToasts = (): { const { toastRef } = useContext(ToastContext); const theme = useAppThemeFromContext(); + const closeToast = useCallback(() => { + toastRef?.current?.closeToast(); + }, [toastRef]); + + const closeButtonOptions = useMemo( + () => ({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: closeToast, + }), + [closeToast], + ); + const earnBaseToastOptions: Record = useMemo( () => ({ success: { ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions), variant: ToastVariants.Icon, - iconName: IconName.CheckBold, - iconColor: theme.colors.accent03.dark, - backgroundColor: theme.colors.accent03.normal, + iconName: IconName.Confirmation, + iconColor: theme.colors.success.default, hapticsType: NotificationFeedbackType.Success, - }, - // Intentional duplication for now to avoid coupling with success options. - inProgress: { - ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions), - variant: ToastVariants.Icon, - iconName: IconName.Loading, - iconColor: theme.colors.accent04.dark, - backgroundColor: theme.colors.accent04.normal, - hapticsType: NotificationFeedbackType.Warning, startAccessory: ( - - + ), @@ -112,10 +146,18 @@ const useEarnToasts = (): { error: { ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions), variant: ToastVariants.Icon, - iconName: IconName.Warning, - iconColor: theme.colors.accent01.dark, - backgroundColor: theme.colors.accent01.light, + iconName: IconName.CircleX, + iconColor: theme.colors.error.default, hapticsType: NotificationFeedbackType.Error, + startAccessory: ( + + + + ), }, }), [theme], @@ -134,30 +176,56 @@ const useEarnToasts = (): { const EarnToastOptions: EarnToastOptionsConfig = useMemo( () => ({ mUsdConversion: { - inProgress: { - ...earnBaseToastOptions.inProgress, - labelOptions: getEarnToastLabels( - strings('earn.musd_conversion.toasts.in_progress'), + inProgress: ({ + tokenSymbol, + tokenIcon, + estimatedTimeSeconds, + }: MusdConversionInProgressParams) => ({ + ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions), + variant: ToastVariants.Icon, + iconName: IconName.Loading, + iconColor: theme.colors.icon.default, + backgroundColor: theme.colors.background.default, + hapticsType: NotificationFeedbackType.Warning, + hasNoTimeout: true, + startAccessory: ( + ), - }, + labelOptions: getEarnToastLabels({ + primary: strings('earn.musd_conversion.toasts.converting', { + token: tokenSymbol, + }), + }), + descriptionOptions: { + description: formatEstimatedTime(estimatedTimeSeconds), + }, + closeButtonOptions, + }), success: { ...earnBaseToastOptions.success, - labelOptions: getEarnToastLabels( - strings('earn.musd_conversion.toasts.success'), - ), + labelOptions: getEarnToastLabels({ + primary: strings('earn.musd_conversion.toasts.delivered'), + }), + closeButtonOptions, }, failed: { ...earnBaseToastOptions.error, - labelOptions: getEarnToastLabels( - strings('earn.musd_conversion.toasts.failed'), - ), + labelOptions: getEarnToastLabels({ + primary: strings('earn.musd_conversion.toasts.failed'), + }), + closeButtonOptions, }, }, }), [ + closeButtonOptions, earnBaseToastOptions.error, - earnBaseToastOptions.inProgress, earnBaseToastOptions.success, + theme.colors.background.default, + theme.colors.icon.default, ], ); diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts index 39b32092c19..ec1a5879919 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts @@ -14,6 +14,30 @@ import { NotificationFeedbackType } from 'expo-haptics'; // Mock all external dependencies jest.mock('../../../../core/Engine'); jest.mock('./useEarnToasts'); +jest.mock('../../Bridge/hooks/useAssetMetadata/utils', () => ({ + getAssetImageUrl: jest.fn(), +})); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); +jest.mock('../../../../selectors/tokenListController', () => ({ + selectERC20TokensByChain: jest.fn(), +})); +jest.mock('../../../../selectors/transactionPayController', () => ({ + selectTransactionPayTransactionData: jest.fn(), +})); + +import { useSelector } from 'react-redux'; +import { getAssetImageUrl } from '../../Bridge/hooks/useAssetMetadata/utils'; +import { selectERC20TokensByChain } from '../../../../selectors/tokenListController'; +import { selectTransactionPayTransactionData } from '../../../../selectors/transactionPayController'; + +const mockUseSelector = jest.mocked(useSelector); +const mockGetAssetImageUrl = jest.mocked(getAssetImageUrl); +const mockSelectERC20TokensByChain = jest.mocked(selectERC20TokensByChain); +const mockSelectTransactionPayTransactionData = jest.mocked( + selectTransactionPayTransactionData, +); type TransactionStatusUpdatedHandler = (event: { transactionMeta: TransactionMeta; @@ -40,19 +64,21 @@ Object.defineProperty(Engine, 'controllerMessenger', { describe('useMusdConversionStatus', () => { const mockShowToast = jest.fn(); + const mockInProgressToast = { + variant: ToastVariants.Icon as const, + iconName: IconName.Loading, + hasNoTimeout: true, + iconColor: '#000000', + backgroundColor: '#FFFFFF', + hapticsType: NotificationFeedbackType.Warning, + labelOptions: [{ label: 'In Progress', isBold: true }], + }; + const mockInProgressFn = jest.fn(() => mockInProgressToast); const mockEarnToastOptions: EarnToastOptionsConfig = { mUsdConversion: { - inProgress: { - variant: ToastVariants.Icon, - iconName: IconName.Loading, - hasNoTimeout: false, - iconColor: '#000000', - backgroundColor: '#FFFFFF', - hapticsType: NotificationFeedbackType.Success, - labelOptions: [{ label: 'In Progress', isBold: true }], - }, + inProgress: mockInProgressFn, success: { - variant: ToastVariants.Icon, + variant: ToastVariants.Icon as const, iconName: IconName.CheckBold, hasNoTimeout: false, iconColor: '#000000', @@ -61,7 +87,7 @@ describe('useMusdConversionStatus', () => { labelOptions: [{ label: 'Success', isBold: true }], }, failed: { - variant: ToastVariants.Icon, + variant: ToastVariants.Icon as const, iconName: IconName.Danger, hasNoTimeout: false, iconColor: '#000000', @@ -72,16 +98,63 @@ describe('useMusdConversionStatus', () => { }, }; + // Default mock data + const defaultTokensChainsCache = {}; + const defaultTransactionPayData = {}; + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + mockInProgressFn.mockClear(); mockUseEarnToasts.mockReturnValue({ showToast: mockShowToast, EarnToastOptions: mockEarnToastOptions, }); + + // Setup useSelector to return different values based on selector + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectERC20TokensByChain) { + return defaultTokensChainsCache; + } + if (selector === mockSelectTransactionPayTransactionData) { + return defaultTransactionPayData; + } + return {}; + }); + + mockGetAssetImageUrl.mockReturnValue('https://example.com/token-icon.png'); }); + // Helper to setup token cache mock + const setupTokensCacheMock = (tokenData: Record) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectERC20TokensByChain) { + return tokenData; + } + if (selector === mockSelectTransactionPayTransactionData) { + return defaultTransactionPayData; + } + return {}; + }); + }; + + // Helper to setup transaction pay data mock + const setupTransactionPayDataMock = ( + transactionPayData: Record, + tokenData: Record = {}, + ) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectERC20TokensByChain) { + return tokenData; + } + if (selector === mockSelectTransactionPayTransactionData) { + return transactionPayData; + } + return {}; + }); + }; + afterEach(() => { jest.clearAllMocks(); jest.useRealTimers(); @@ -91,18 +164,21 @@ describe('useMusdConversionStatus', () => { status: TransactionStatus, transactionId = 'test-transaction-1', type = TransactionType.musdConversion, - ): TransactionMeta => ({ - id: transactionId, - status, - type, - chainId: '0x1', - networkClientId: 'mainnet', - time: Date.now(), - txParams: { - from: '0x123', - to: '0x456', - }, - }); + metamaskPay?: { chainId?: string; tokenAddress?: string }, + ): TransactionMeta => + ({ + id: transactionId, + status, + type, + chainId: '0x1', + networkClientId: 'mainnet', + time: Date.now(), + txParams: { + from: '0x123', + to: '0x456', + }, + ...(metamaskPay && { metamaskPay }), + }) as TransactionMeta; const getSubscribedHandler = (): TransactionStatusUpdatedHandler => { const subscribeCalls = mockSubscribe.mock.calls; @@ -139,36 +215,363 @@ describe('useMusdConversionStatus', () => { }); }); - describe('submitted transaction status', () => { - it('shows in-progress toast when transaction status is submitted', () => { + describe('approved transaction status', () => { + it('shows in-progress toast when transaction status is approved', () => { + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta(TransactionStatus.approved); + + handler({ transactionMeta }); + + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith(mockInProgressToast); + }); + + it('prevents duplicate in-progress toast for same transaction', () => { + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta(TransactionStatus.approved); + + handler({ transactionMeta }); + handler({ transactionMeta }); + handler({ transactionMeta }); + + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('passes token symbol and icon from metamaskPay data to in-progress toast', () => { + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0x89'; + const mockTokenData = { + [chainId]: { + data: { + [tokenAddress]: { symbol: 'USDC' }, + }, + }, + }; + setupTokensCacheMock(mockTokenData); + renderHook(() => useMusdConversionStatus()); const handler = getSubscribedHandler(); const transactionMeta = createTransactionMeta( - TransactionStatus.submitted, + TransactionStatus.approved, + 'test-tx-with-token', + TransactionType.musdConversion, + { chainId, tokenAddress }, ); handler({ transactionMeta }); - expect(mockShowToast).toHaveBeenCalledTimes(1); - expect(mockShowToast).toHaveBeenCalledWith( - mockEarnToastOptions.mUsdConversion.inProgress, + expect(mockGetAssetImageUrl).toHaveBeenCalledWith( + tokenAddress.toLowerCase(), + chainId, ); + expect(mockInProgressFn).toHaveBeenCalledWith({ + tokenSymbol: 'USDC', + tokenIcon: 'https://example.com/token-icon.png', + estimatedTimeSeconds: 15, + }); }); - it('prevents duplicate in-progress toast for same transaction', () => { + it('uses lowercase token address as fallback for symbol lookup', () => { + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const chainId = '0x1'; + const mockTokenData = { + [chainId]: { + data: { + [tokenAddress.toLowerCase()]: { symbol: 'DAI' }, + }, + }, + }; + setupTokensCacheMock(mockTokenData); + renderHook(() => useMusdConversionStatus()); const handler = getSubscribedHandler(); const transactionMeta = createTransactionMeta( - TransactionStatus.submitted, + TransactionStatus.approved, + 'test-tx-lowercase', + TransactionType.musdConversion, + { chainId, tokenAddress }, ); handler({ transactionMeta }); + + expect(mockInProgressFn).toHaveBeenCalledWith( + expect.objectContaining({ tokenSymbol: 'DAI' }), + ); + }); + + it('uses "Token" as fallback when token symbol is not found', () => { + const tokenAddress = '0x1111111111111111111111111111111111111111'; + const chainId = '0x1'; + setupTokensCacheMock({ + [chainId]: { data: {} }, + }); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + 'test-tx-unknown', + TransactionType.musdConversion, + { chainId, tokenAddress }, + ); + handler({ transactionMeta }); + + expect(mockInProgressFn).toHaveBeenCalledWith( + expect.objectContaining({ tokenSymbol: 'Token' }), + ); + }); + + it('passes empty tokenSymbol and undefined tokenIcon when payTokenAddress is missing', () => { + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + 'test-tx-no-token', + TransactionType.musdConversion, + { chainId: '0x1' }, + ); + handler({ transactionMeta }); - expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockGetAssetImageUrl).not.toHaveBeenCalled(); + expect(mockInProgressFn).toHaveBeenCalledWith({ + tokenSymbol: 'Token', + tokenIcon: undefined, + estimatedTimeSeconds: 15, + }); + }); + + it('passes empty tokenSymbol and undefined tokenIcon when metamaskPay is missing', () => { + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta(TransactionStatus.approved); + + handler({ transactionMeta }); + + expect(mockGetAssetImageUrl).not.toHaveBeenCalled(); + expect(mockInProgressFn).toHaveBeenCalledWith({ + tokenSymbol: 'Token', + tokenIcon: undefined, + estimatedTimeSeconds: 15, + }); + }); + + it('uses estimatedDuration from transaction pay data when available', () => { + const transactionId = 'test-tx-with-duration'; + setupTransactionPayDataMock({ + [transactionId]: { + totals: { + estimatedDuration: 45, + }, + }, + }); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + transactionId, + ); + + handler({ transactionMeta }); + + expect(mockInProgressFn).toHaveBeenCalledWith( + expect.objectContaining({ estimatedTimeSeconds: 45 }), + ); + }); + + it('falls back to default estimated time when transaction pay data is missing', () => { + setupTransactionPayDataMock({}); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + 'test-tx-no-pay-data', + ); + + handler({ transactionMeta }); + + expect(mockInProgressFn).toHaveBeenCalledWith( + expect.objectContaining({ estimatedTimeSeconds: 15 }), + ); + }); + + it('falls back to default estimated time when totals is missing', () => { + const transactionId = 'test-tx-no-totals'; + setupTransactionPayDataMock({ + [transactionId]: {}, + }); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + transactionId, + ); + + handler({ transactionMeta }); + + expect(mockInProgressFn).toHaveBeenCalledWith( + expect.objectContaining({ estimatedTimeSeconds: 15 }), + ); + }); + + it('falls back to default estimated time when estimatedDuration is missing', () => { + const transactionId = 'test-tx-no-duration'; + setupTransactionPayDataMock({ + [transactionId]: { + totals: {}, + }, + }); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + transactionId, + ); + + handler({ transactionMeta }); + + expect(mockInProgressFn).toHaveBeenCalledWith( + expect.objectContaining({ estimatedTimeSeconds: 15 }), + ); + }); + + it('uses iconUrl from token cache when available', () => { + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0x89'; + const cachedIconUrl = 'https://cached.example.com/usdc-icon.png'; + const mockTokenData = { + [chainId]: { + data: { + [tokenAddress]: { + symbol: 'USDC', + iconUrl: cachedIconUrl, + }, + }, + }, + }; + setupTokensCacheMock(mockTokenData); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + 'test-tx-cached-icon', + TransactionType.musdConversion, + { chainId, tokenAddress }, + ); + + handler({ transactionMeta }); + + expect(mockGetAssetImageUrl).not.toHaveBeenCalled(); + expect(mockInProgressFn).toHaveBeenCalledWith({ + tokenSymbol: 'USDC', + tokenIcon: cachedIconUrl, + estimatedTimeSeconds: 15, + }); + }); + + it('falls back to getAssetImageUrl when iconUrl is not in token cache', () => { + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0x89'; + const mockTokenData = { + [chainId]: { + data: { + [tokenAddress]: { + symbol: 'USDC', + // No iconUrl + }, + }, + }, + }; + setupTokensCacheMock(mockTokenData); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + 'test-tx-fallback-icon', + TransactionType.musdConversion, + { chainId, tokenAddress }, + ); + + handler({ transactionMeta }); + + expect(mockGetAssetImageUrl).toHaveBeenCalledWith( + tokenAddress.toLowerCase(), + chainId, + ); + expect(mockInProgressFn).toHaveBeenCalledWith({ + tokenSymbol: 'USDC', + tokenIcon: 'https://example.com/token-icon.png', + estimatedTimeSeconds: 15, + }); + }); + + it('uses both cached iconUrl and estimatedDuration together', () => { + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0x89'; + const transactionId = 'test-tx-full-data'; + const cachedIconUrl = 'https://cached.example.com/usdc-icon.png'; + + const mockTokenData = { + [chainId]: { + data: { + [tokenAddress]: { + symbol: 'USDC', + iconUrl: cachedIconUrl, + }, + }, + }, + }; + + const mockPayData = { + [transactionId]: { + totals: { + estimatedDuration: 120, + }, + }, + }; + + setupTransactionPayDataMock(mockPayData, mockTokenData); + + renderHook(() => useMusdConversionStatus()); + + const handler = getSubscribedHandler(); + const transactionMeta = createTransactionMeta( + TransactionStatus.approved, + transactionId, + TransactionType.musdConversion, + { chainId, tokenAddress }, + ); + + handler({ transactionMeta }); + + expect(mockGetAssetImageUrl).not.toHaveBeenCalled(); + expect(mockInProgressFn).toHaveBeenCalledWith({ + tokenSymbol: 'USDC', + tokenIcon: cachedIconUrl, + estimatedTimeSeconds: 120, + }); }); }); @@ -208,8 +611,8 @@ describe('useMusdConversionStatus', () => { const handler = getSubscribedHandler(); const transactionId = 'test-transaction-1'; - const submittedMeta = createTransactionMeta( - TransactionStatus.submitted, + const approvedMeta = createTransactionMeta( + TransactionStatus.approved, transactionId, ); const confirmedMeta = createTransactionMeta( @@ -217,7 +620,7 @@ describe('useMusdConversionStatus', () => { transactionId, ); - handler({ transactionMeta: submittedMeta }); + handler({ transactionMeta: approvedMeta }); handler({ transactionMeta: confirmedMeta }); expect(mockShowToast).toHaveBeenCalledTimes(2); @@ -225,7 +628,7 @@ describe('useMusdConversionStatus', () => { jest.advanceTimersByTime(5000); // After cleanup, should be able to show toasts again for same transaction - handler({ transactionMeta: submittedMeta }); + handler({ transactionMeta: approvedMeta }); handler({ transactionMeta: confirmedMeta }); expect(mockShowToast).toHaveBeenCalledTimes(4); @@ -264,8 +667,8 @@ describe('useMusdConversionStatus', () => { const handler = getSubscribedHandler(); const transactionId = 'test-transaction-2'; - const submittedMeta = createTransactionMeta( - TransactionStatus.submitted, + const approvedMeta = createTransactionMeta( + TransactionStatus.approved, transactionId, ); const failedMeta = createTransactionMeta( @@ -273,7 +676,7 @@ describe('useMusdConversionStatus', () => { transactionId, ); - handler({ transactionMeta: submittedMeta }); + handler({ transactionMeta: approvedMeta }); handler({ transactionMeta: failedMeta }); expect(mockShowToast).toHaveBeenCalledTimes(2); @@ -281,21 +684,21 @@ describe('useMusdConversionStatus', () => { jest.advanceTimersByTime(5000); // After cleanup, should be able to show toasts again for same transaction - handler({ transactionMeta: submittedMeta }); + handler({ transactionMeta: approvedMeta }); handler({ transactionMeta: failedMeta }); expect(mockShowToast).toHaveBeenCalledTimes(4); }); }); - describe('transaction flow from submitted to final status', () => { + describe('transaction flow from approved to final status', () => { it('shows both in-progress and success toasts for transaction flow', () => { renderHook(() => useMusdConversionStatus()); const handler = getSubscribedHandler(); const transactionId = 'test-transaction-3'; - const submittedMeta = createTransactionMeta( - TransactionStatus.submitted, + const approvedMeta = createTransactionMeta( + TransactionStatus.approved, transactionId, ); const confirmedMeta = createTransactionMeta( @@ -303,12 +706,10 @@ describe('useMusdConversionStatus', () => { transactionId, ); - handler({ transactionMeta: submittedMeta }); + handler({ transactionMeta: approvedMeta }); expect(mockShowToast).toHaveBeenCalledTimes(1); - expect(mockShowToast).toHaveBeenCalledWith( - mockEarnToastOptions.mUsdConversion.inProgress, - ); + expect(mockShowToast).toHaveBeenCalledWith(mockInProgressToast); handler({ transactionMeta: confirmedMeta }); @@ -323,8 +724,8 @@ describe('useMusdConversionStatus', () => { const handler = getSubscribedHandler(); const transactionId = 'test-transaction-4'; - const submittedMeta = createTransactionMeta( - TransactionStatus.submitted, + const approvedMeta = createTransactionMeta( + TransactionStatus.approved, transactionId, ); const failedMeta = createTransactionMeta( @@ -332,12 +733,10 @@ describe('useMusdConversionStatus', () => { transactionId, ); - handler({ transactionMeta: submittedMeta }); + handler({ transactionMeta: approvedMeta }); expect(mockShowToast).toHaveBeenCalledTimes(1); - expect(mockShowToast).toHaveBeenCalledWith( - mockEarnToastOptions.mUsdConversion.inProgress, - ); + expect(mockShowToast).toHaveBeenCalledWith(mockInProgressToast); handler({ transactionMeta: failedMeta }); @@ -354,7 +753,7 @@ describe('useMusdConversionStatus', () => { const handler = getSubscribedHandler(); const transactionMeta = createTransactionMeta( - TransactionStatus.submitted, + TransactionStatus.approved, 'test-transaction-5', 'contractInteraction' as typeof TransactionType.musdConversion, ); @@ -409,11 +808,13 @@ describe('useMusdConversionStatus', () => { expect(mockShowToast).not.toHaveBeenCalled(); }); - it('ignores transaction when status is approved', () => { + it('ignores transaction when status is submitted', () => { renderHook(() => useMusdConversionStatus()); const handler = getSubscribedHandler(); - const transactionMeta = createTransactionMeta(TransactionStatus.approved); + const transactionMeta = createTransactionMeta( + TransactionStatus.submitted, + ); handler({ transactionMeta }); @@ -430,17 +831,6 @@ describe('useMusdConversionStatus', () => { expect(mockShowToast).not.toHaveBeenCalled(); }); - - it('ignores transaction when status is rejected', () => { - renderHook(() => useMusdConversionStatus()); - - const handler = getSubscribedHandler(); - const transactionMeta = createTransactionMeta(TransactionStatus.rejected); - - handler({ transactionMeta }); - - expect(mockShowToast).not.toHaveBeenCalled(); - }); }); describe('multiple concurrent transactions', () => { @@ -448,12 +838,12 @@ describe('useMusdConversionStatus', () => { renderHook(() => useMusdConversionStatus()); const handler = getSubscribedHandler(); - const transaction1Submitted = createTransactionMeta( - TransactionStatus.submitted, + const transaction1Approved = createTransactionMeta( + TransactionStatus.approved, 'transaction-1', ); - const transaction2Submitted = createTransactionMeta( - TransactionStatus.submitted, + const transaction2Approved = createTransactionMeta( + TransactionStatus.approved, 'transaction-2', ); const transaction1Confirmed = createTransactionMeta( @@ -465,20 +855,14 @@ describe('useMusdConversionStatus', () => { 'transaction-2', ); - handler({ transactionMeta: transaction1Submitted }); - handler({ transactionMeta: transaction2Submitted }); + handler({ transactionMeta: transaction1Approved }); + handler({ transactionMeta: transaction2Approved }); handler({ transactionMeta: transaction1Confirmed }); handler({ transactionMeta: transaction2Failed }); expect(mockShowToast).toHaveBeenCalledTimes(4); - expect(mockShowToast).toHaveBeenNthCalledWith( - 1, - mockEarnToastOptions.mUsdConversion.inProgress, - ); - expect(mockShowToast).toHaveBeenNthCalledWith( - 2, - mockEarnToastOptions.mUsdConversion.inProgress, - ); + expect(mockShowToast).toHaveBeenNthCalledWith(1, mockInProgressToast); + expect(mockShowToast).toHaveBeenNthCalledWith(2, mockInProgressToast); expect(mockShowToast).toHaveBeenNthCalledWith( 3, mockEarnToastOptions.mUsdConversion.success, @@ -524,9 +908,7 @@ describe('useMusdConversionStatus', () => { expect(mockUseEarnToasts).toHaveBeenCalledTimes(1); const handler = getSubscribedHandler(); - const transactionMeta = createTransactionMeta( - TransactionStatus.submitted, - ); + const transactionMeta = createTransactionMeta(TransactionStatus.approved); handler({ transactionMeta }); diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts index e313c3b4bee..f1fa1a3a4de 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts @@ -3,9 +3,18 @@ import { TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; import { useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectERC20TokensByChain } from '../../../../selectors/tokenListController'; +import { selectTransactionPayTransactionData } from '../../../../selectors/transactionPayController'; +import { safeToChecksumAddress } from '../../../../util/address'; +import { getAssetImageUrl } from '../../Bridge/hooks/useAssetMetadata/utils'; import useEarnToasts from './useEarnToasts'; + +const DEFAULT_ESTIMATED_TIME_SECONDS = 15; + /** * Hook to monitor mUSD conversion transaction status and show appropriate toasts * @@ -13,7 +22,7 @@ import useEarnToasts from './useEarnToasts'; * 1. Subscribes to TransactionController:transactionStatusUpdated events * 2. Filters for mUSD conversion transactions (type === 'musdConversion') * 3. Shows toasts based on transaction status: - * - submitted → in-progress toast + * - approved → in-progress toast with token icon and ETA (fires immediately after confirm) * - confirmed → success toast * - failed → failed toast * 4. Tracks shown toasts to prevent duplicates @@ -23,10 +32,34 @@ import useEarnToasts from './useEarnToasts'; */ export const useMusdConversionStatus = () => { const { showToast, EarnToastOptions } = useEarnToasts(); + const tokensChainsCache = useSelector(selectERC20TokensByChain); + const transactionPayData = useSelector(selectTransactionPayTransactionData); const shownToastsRef = useRef>(new Set()); + const tokensCacheRef = useRef(tokensChainsCache); + const transactionPayDataRef = useRef(transactionPayData); + tokensCacheRef.current = tokensChainsCache; + transactionPayDataRef.current = transactionPayData; useEffect(() => { + const getTokenData = ( + chainId: Hex, + tokenAddress: string, + ): { symbol: string; iconUrl?: string } => { + const chainTokens = tokensCacheRef.current?.[chainId]?.data; + if (!chainTokens) return { symbol: '' }; + + const checksumAddress = safeToChecksumAddress(tokenAddress); + const tokenData = + chainTokens[checksumAddress as string] || + chainTokens[tokenAddress.toLowerCase()]; + + return { + symbol: tokenData?.symbol || '', + iconUrl: tokenData?.iconUrl, + }; + }; + const handleTransactionStatusUpdated = ({ transactionMeta, }: { @@ -36,7 +69,9 @@ export const useMusdConversionStatus = () => { return; } - const { id: transactionId, status } = transactionMeta; + const { id: transactionId, status, metamaskPay } = transactionMeta; + const { chainId: payChainId, tokenAddress: payTokenAddress } = + metamaskPay || {}; const toastKey = `${transactionId}-${status}`; @@ -45,17 +80,41 @@ export const useMusdConversionStatus = () => { } switch (status) { - case TransactionStatus.submitted: - showToast(EarnToastOptions.mUsdConversion.inProgress); + case TransactionStatus.approved: { + // Get token info for the in-progress toast + // Using 'approved' status to show toast immediately after user confirms + const tokenData = payTokenAddress + ? getTokenData(payChainId as Hex, payTokenAddress) + : { symbol: '' }; + const tokenSymbol = tokenData.symbol; + // Use cached icon if available, fallback to static URL + const tokenIcon = payTokenAddress + ? tokenData.iconUrl || + getAssetImageUrl(payTokenAddress.toLowerCase(), payChainId as Hex) + : undefined; + + // Get estimated duration from transaction pay data + const estimatedTimeSeconds = + transactionPayDataRef.current?.[transactionId]?.totals + ?.estimatedDuration ?? DEFAULT_ESTIMATED_TIME_SECONDS; + + showToast( + EarnToastOptions.mUsdConversion.inProgress({ + tokenSymbol: tokenSymbol || 'Token', + tokenIcon, + estimatedTimeSeconds, + }), + ); shownToastsRef.current.add(toastKey); break; + } case TransactionStatus.confirmed: showToast(EarnToastOptions.mUsdConversion.success); shownToastsRef.current.add(toastKey); // Clean up entries for this transaction after final status setTimeout(() => { shownToastsRef.current.delete( - `${transactionId}-${TransactionStatus.submitted}`, + `${transactionId}-${TransactionStatus.approved}`, ); shownToastsRef.current.delete( `${transactionId}-${TransactionStatus.confirmed}`, @@ -68,7 +127,7 @@ export const useMusdConversionStatus = () => { // Clean up entries for this transaction after final status setTimeout(() => { shownToastsRef.current.delete( - `${transactionId}-${TransactionStatus.submitted}`, + `${transactionId}-${TransactionStatus.approved}`, ); shownToastsRef.current.delete( `${transactionId}-${TransactionStatus.failed}`, diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 8c8b5a7e95e..6914b3b6966 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -282,6 +282,26 @@ describe('useTransactionConfirm', () => { }); }); + it('wallet home if musdConversion', async () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + type: TransactionType.musdConversion, + } as TransactionMeta); + + const { result } = renderHook(); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }); + it('transactions if full screen', async () => { const { result } = renderHook(); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index e34b7291f9b..2e9ad8aaae3 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -132,6 +132,13 @@ export function useTransactionConfirm() { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.PERPS_HOME, }); + } else if (type === TransactionType.musdConversion) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); } else if ( isFullScreenConfirmation && !hasTransactionType(transactionMetadata, GO_BACK_TYPES) diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 15a38ab31df..211a4522691 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -26,6 +26,7 @@ export const SKIP_NOTIFICATION_TRANSACTION_TYPES = [ TransactionType.predictDeposit, TransactionType.predictClaim, TransactionType.predictWithdraw, + TransactionType.musdConversion, ]; export const IN_PROGRESS_SKIP_STATUS = [ diff --git a/app/selectors/transactionPayController.ts b/app/selectors/transactionPayController.ts index 1f17e05a7db..d2996035494 100644 --- a/app/selectors/transactionPayController.ts +++ b/app/selectors/transactionPayController.ts @@ -42,3 +42,8 @@ export const selectTransactionPaySourceAmountsByTransactionId = createSelector( selectTransactionDataByTransactionId, (transactionData) => transactionData?.sourceAmounts, ); + +export const selectTransactionPayTransactionData = createSelector( + selectTransactionPayControllerState, + (state) => state.transactionData, +); diff --git a/locales/languages/en.json b/locales/languages/en.json index 8a82c928ecd..71637c63f87 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5666,8 +5666,9 @@ "confirmation_button": "Convert to mUSD", "earn_rewards_with": "Earn rewards with mUSD", "toasts": { - "in_progress": "mUSD conversion in progress", - "success": "mUSD conversion succeeded", + "converting": "Converting {{token}} → mUSD", + "eta": "~{{time}}", + "delivered": "Your mUSD has been delivered!", "failed": "mUSD conversion failed" }, "education": { From 177447b5b734ad15eba5d4f6f51b35b9c5f7a7ab Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:31:58 -0500 Subject: [PATCH 03/11] feat: MUSD-134: update mUSD conversion header (#23691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates the mUSD confirmation screen's header to display the mUSD token and target network icon. ## **Changelog** CHANGELOG entry: updated the mUSD confirmation screen's header to display the mUSD token and target network icon. ## **Related issues** Fixes: [MUSD-134: Update mUSD Conversion Screen Header](https://consensyssoftware.atlassian.net/browse/MUSD-134) ## **Manual testing steps** ```gherkin Feature: Updated mUSD confirmation screen header Scenario: user wants to convert supported stablecoins to mUSD Given user has stablecoins to convert When user visits the mUSD conversion confirmation screen Then the updated header displays the mUSD icon with the target network. Then the updated header displays "Convert to mUSD" ``` ## **Screenshots/Recordings** ### **Before** The mUSD conversion header displayed "Earn rewards with mUSD" ### **After** https://www.loom.com/share/0fb8bc1a18a84cf9a0adcfad3ac5ef06 ## **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 dedicated mUSD conversion header with token/network badge, enables per-chain mUSD conversion, and updates hooks, routes, UI, tests, and strings. > > - **UI/Navigation**: > - Add `getMusdConversionNavbarOptions` with mUSD token icon + network badge and back button; integrate into `EarnScreenStack` `REDESIGNED_CONFIRMATIONS` options to avoid flicker. > - **Hooks/Flow**: > - Extend `useMusdConversionTokens` with `isMusdSupportedOnChain`; export and use in `StakeButton` to validate chain and set `outputChainId` to asset chain; improved error handling. > - **Constants**: > - Expand `MUSD_TOKEN_ADDRESS_BY_CHAIN` and `MUSD_TOKEN_ASSET_ID_BY_CHAIN` to include Linea and BSC. > - **Confirmations**: > - Update button label to `earn.musd_conversion.convert_to_musd` in `custom-amount-info`. > - Refactor `MusdConversionInfo` to use `outputChainId` param, remove navbar hook, and auto-add mUSD token per chain. > - **CTA/Components**: > - Minor cleanup in `MusdConversionAssetListCta`; maintain default `outputChainId` usage. > - **Tests**: > - Add unit tests for navbar options; update tests across Earn/Stake to mock `isMusdSupportedOnChain` and new behaviors. > - **i18n**: > - Replace `confirmation_button`/`earn_rewards_with` with `earn.musd_conversion.convert_to_musd`; keep related copy consistent. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 99947c2de637c452815eee446e7f16db3585b468. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Earn/Navbars/musdNavbarOptions.test.tsx | 108 ++++++++++++++++++ .../UI/Earn/Navbars/musdNavbarOptions.tsx | 104 +++++++++++++++++ .../EarnLendingBalance.test.tsx | 3 + .../MusdConversionAssetListCta.test.tsx | 11 ++ .../Musd/MusdConversionAssetListCta/index.tsx | 15 +-- app/components/UI/Earn/constants/musd.ts | 3 + .../hooks/useMusdConversionTokens.test.ts | 56 ++++++++- .../UI/Earn/hooks/useMusdConversionTokens.ts | 5 + app/components/UI/Earn/routes/index.tsx | 60 ++++++---- .../StakeButton/StakeButton.test.tsx | 6 + .../UI/Stake/components/StakeButton/index.tsx | 17 ++- .../custom-amount-info/custom-amount-info.tsx | 2 +- .../musd-conversion-info.test.tsx | 31 +---- .../musd-conversion-info.tsx | 11 +- locales/languages/en.json | 3 +- 15 files changed, 358 insertions(+), 77 deletions(-) create mode 100644 app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx create mode 100644 app/components/UI/Earn/Navbars/musdNavbarOptions.tsx diff --git a/app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx b/app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx new file mode 100644 index 00000000000..ab3ee537a60 --- /dev/null +++ b/app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { getMusdConversionNavbarOptions } from './musdNavbarOptions'; +import { mockTheme } from '../../../../util/theme'; +import { strings } from '../../../../../locales/i18n'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +const mockStrings = strings as jest.MockedFunction; + +describe('getMusdConversionNavbarOptions', () => { + const mockGoBack = jest.fn(); + const mockCanGoBack = jest.fn(); + + const createMockNavigation = () => ({ + goBack: mockGoBack, + canGoBack: mockCanGoBack, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns navbar options with expected structure', () => { + const navigation = createMockNavigation(); + const chainId = CHAIN_IDS.MAINNET; + + const options = getMusdConversionNavbarOptions( + navigation, + mockTheme, + chainId, + ); + + expect(options.headerTitleAlign).toBe('center'); + expect(typeof options.headerTitle).toBe('function'); + expect(typeof options.headerLeft).toBe('function'); + expect(options.headerStyle.backgroundColor).toBe( + mockTheme.colors.background.alternative, + ); + }); + + it('renders headerTitle with mUSD icon, network badge, and localized text', () => { + const navigation = createMockNavigation(); + const chainId = CHAIN_IDS.MAINNET; + + const options = getMusdConversionNavbarOptions( + navigation, + mockTheme, + chainId, + ); + + const HeaderTitle = options.headerTitle as React.FC; + const { getByTestId, getByText } = render(); + + expect(getByTestId('musd-token-icon')).toBeOnTheScreen(); + expect(getByTestId('badge-wrapper-badge')).toBeOnTheScreen(); + expect(getByTestId('badgenetwork')).toBeOnTheScreen(); + expect(mockStrings).toHaveBeenCalledWith( + 'earn.musd_conversion.convert_to_musd', + ); + expect(getByText('earn.musd_conversion.convert_to_musd')).toBeOnTheScreen(); + }); + + it('calls goBack when back button pressed and canGoBack returns true', () => { + const navigation = createMockNavigation(); + mockCanGoBack.mockReturnValue(true); + const chainId = CHAIN_IDS.MAINNET; + + const options = getMusdConversionNavbarOptions( + navigation, + mockTheme, + chainId, + ); + + const HeaderLeft = options.headerLeft as React.FC; + const { getByTestId } = render(); + + const backButton = getByTestId('button-icon'); + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not call goBack when canGoBack returns false', () => { + const navigation = createMockNavigation(); + mockCanGoBack.mockReturnValue(false); + const chainId = CHAIN_IDS.MAINNET; + + const options = getMusdConversionNavbarOptions( + navigation, + mockTheme, + chainId, + ); + + const HeaderLeft = options.headerLeft as React.FC; + const { getByTestId } = render(); + + const backButton = getByTestId('button-icon'); + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Earn/Navbars/musdNavbarOptions.tsx b/app/components/UI/Earn/Navbars/musdNavbarOptions.tsx new file mode 100644 index 00000000000..a121705aee5 --- /dev/null +++ b/app/components/UI/Earn/Navbars/musdNavbarOptions.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Theme } from '../../../../util/theme/models'; +import { View, StyleSheet, Image } from 'react-native'; +import Text, { + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../../component-library/components/Badges/Badge'; +import { getNetworkImageSource } from '../../../../util/networks'; +import { MUSD_TOKEN } from '../constants/musd'; +import { strings } from '../../../../../locales/i18n'; +import { + ButtonIcon, + ButtonIconSize, + IconColor, + IconName, +} from '@metamask/design-system-react-native'; + +/** + * Function that returns the navigation options for the mUSD conversion screen + * + * @param {Object} navigation - Navigation object required to push new views + * @param {Theme} theme - Theme object required to style the navbar + * @param {string} chainId - Chain ID for the network badge + * @returns {Object} - Corresponding navbar options + */ + +export const getMusdConversionNavbarOptions = ( + navigation: { goBack: () => void; canGoBack: () => boolean }, + theme: Theme, + chainId: string, +) => { + const innerStyles = StyleSheet.create({ + tokenIcon: { + width: 16, + height: 16, + }, + badgeWrapper: { + alignSelf: 'center', + }, + headerLeft: { + marginHorizontal: 8, + }, + headerTitle: { + flexDirection: 'row', + gap: 8, + }, + headerStyle: { + backgroundColor: theme.colors.background.alternative, + }, + }); + + const networkImageSource = getNetworkImageSource({ + chainId, + }); + + const handleBackPress = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }; + + return { + headerTitleAlign: 'center', + headerTitle: () => ( + + + } + > + + + + {strings('earn.musd_conversion.convert_to_musd')} + + + ), + headerLeft: () => ( + + + + ), + headerStyle: innerStyles.headerStyle, + } as const; +}; diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index 54d3ad26757..98a4c70911b 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -473,6 +473,7 @@ describe('EarnLendingBalance', () => { ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), tokenFilter: jest.fn().mockReturnValue([]), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); @@ -500,6 +501,7 @@ describe('EarnLendingBalance', () => { ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), tokenFilter: jest.fn().mockReturnValue([]), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); @@ -534,6 +536,7 @@ describe('EarnLendingBalance', () => { ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), tokenFilter: jest.fn().mockReturnValue([]), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index ab72753b951..ecd761aa0ea 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -96,6 +96,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByTestId } = renderWithProvider( @@ -119,6 +120,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -137,6 +139,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -157,6 +160,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -175,6 +179,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [mockToken], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -195,6 +200,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); }); @@ -231,6 +237,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [mockToken], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -266,6 +273,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [firstToken, secondToken], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -296,6 +304,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [mockToken], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); const { getByText } = renderWithProvider(, { @@ -321,6 +330,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [mockToken], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); ( @@ -379,6 +389,7 @@ describe('MusdConversionAssetListCta', () => { tokens: [mockToken], tokenFilter: jest.fn(), isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), }); }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index f1589465300..14e4111a340 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -1,5 +1,5 @@ -import React, { View } from 'react-native'; -import { useStyles } from '../../../../../hooks/useStyles'; +import React, { useMemo } from 'react'; +import { View } from 'react-native'; import styleSheet from './MusdConversionAssetListCta.styles'; import Text, { TextVariant, @@ -15,11 +15,6 @@ import { MUSD_TOKEN, MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../../../constants/musd'; -import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; -import { useMemo } from 'react'; -import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../../hooks/useMusdConversion'; import { toHex } from '@metamask/controller-utils'; import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation'; import { RampIntent } from '../../../../Ramp/types'; @@ -28,6 +23,11 @@ import { EARN_TEST_IDS } from '../../../constants/testIds'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../../../constants/navigation/Routes'; import Logger from '../../../../../../util/Logger'; +import { useStyles } from '../../../../../hooks/useStyles'; +import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens'; +import { useMusdConversion } from '../../../hooks/useMusdConversion'; +import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; +import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; const MusdConversionAssetListCta = () => { const { styles } = useStyles(styleSheet, {}); @@ -35,6 +35,7 @@ const MusdConversionAssetListCta = () => { const { goToBuy } = useRampNavigation(); const { tokens } = useMusdConversionTokens(); + const { initiateConversion, hasSeenConversionEducationScreen } = useMusdConversion(); diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index bd32b2badda..2a6d342c8b9 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -18,6 +18,8 @@ export const MUSD_CONVERSION_DEFAULT_CHAIN_ID = CHAIN_IDS.MAINNET; export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { [CHAIN_IDS.MAINNET]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + [CHAIN_IDS.LINEA_MAINNET]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + [CHAIN_IDS.BSC]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', }; export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { @@ -25,6 +27,7 @@ export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', [CHAIN_IDS.LINEA_MAINNET]: 'eip155:59144/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.BSC]: 'eip155:56/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', }; export const MUSD_CURRENCY = 'MUSD'; diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts index 79cf48e50a0..b3bb4aa8b72 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useMusdConversionTokens } from './useMusdConversionTokens'; import { selectMusdConversionPaymentTokensAllowlist } from '../selectors/featureFlags'; import { isMusdConversionPaymentToken } from '../utils/musd'; @@ -101,11 +102,12 @@ describe('useMusdConversionTokens', () => { }); describe('hook structure', () => { - it('returns object with tokenFilter, isConversionToken, and tokens properties', () => { + it('returns object with tokenFilter, isConversionToken, isMusdSupportedOnChain, and tokens properties', () => { const { result } = renderHook(() => useMusdConversionTokens()); expect(result.current).toHaveProperty('tokenFilter'); expect(result.current).toHaveProperty('isConversionToken'); + expect(result.current).toHaveProperty('isMusdSupportedOnChain'); expect(result.current).toHaveProperty('tokens'); }); @@ -121,6 +123,12 @@ describe('useMusdConversionTokens', () => { expect(typeof result.current.isConversionToken).toBe('function'); }); + it('returns isMusdSupportedOnChain as a function', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(typeof result.current.isMusdSupportedOnChain).toBe('function'); + }); + it('returns tokens as an array', () => { const { result } = renderHook(() => useMusdConversionTokens()); @@ -272,6 +280,52 @@ describe('useMusdConversionTokens', () => { }); }); + describe('isMusdSupportedOnChain', () => { + it('returns true for Ethereum mainnet', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + const isSupported = result.current.isMusdSupportedOnChain( + CHAIN_IDS.MAINNET, + ); + + expect(isSupported).toBe(true); + }); + + it('returns true for Linea mainnet', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + const isSupported = result.current.isMusdSupportedOnChain( + CHAIN_IDS.LINEA_MAINNET, + ); + + expect(isSupported).toBe(true); + }); + + it('returns true for BSC', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + const isSupported = result.current.isMusdSupportedOnChain(CHAIN_IDS.BSC); + + expect(isSupported).toBe(true); + }); + + it('returns false for unsupported chain', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + const isSupported = result.current.isMusdSupportedOnChain('0x89'); + + expect(isSupported).toBe(false); + }); + + it('returns false for empty string', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + const isSupported = result.current.isMusdSupportedOnChain(''); + + expect(isSupported).toBe(false); + }); + }); + describe('tokenFilter callback', () => { it('filters array of tokens correctly', () => { mockUseAccountTokens.mockReturnValue([]); diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts index f22922fa123..ff6d4229daa 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -5,6 +5,7 @@ import { AssetType } from '../../../Views/confirmations/types/token'; import { useAccountTokens } from '../../../Views/confirmations/hooks/send/useAccountTokens'; import { useCallback, useMemo } from 'react'; import { TokenI } from '../../Tokens/types'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; export const useMusdConversionTokens = () => { const musdConversionPaymentTokensAllowlist = useSelector( @@ -40,9 +41,13 @@ export const useMusdConversionTokens = () => { ); }; + const isMusdSupportedOnChain = (chainId: string) => + Object.keys(MUSD_TOKEN_ADDRESS_BY_CHAIN).includes(chainId); + return { tokenFilter, isConversionToken, + isMusdSupportedOnChain, tokens: conversionTokens, }; }; diff --git a/app/components/UI/Earn/routes/index.tsx b/app/components/UI/Earn/routes/index.tsx index 29dc681f214..d4d62b3dfef 100644 --- a/app/components/UI/Earn/routes/index.tsx +++ b/app/components/UI/Earn/routes/index.tsx @@ -7,6 +7,9 @@ import EarnMusdConversionEducationView from '../Views/EarnMusdConversionEducatio import EarnLendingMaxWithdrawalModal from '../modals/LendingMaxWithdrawalModal'; import LendingLearnMoreModal from '../LendingLearnMoreModal'; import { Confirm } from '../../../Views/confirmations/components/confirm'; +import { getMusdConversionNavbarOptions } from '../Navbars/musdNavbarOptions'; +import { useTheme } from '../../../../util/theme'; +import { MusdConversionConfig } from '../hooks/useMusdConversion'; const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); @@ -19,29 +22,40 @@ const clearStackNavigatorOptions = { animationEnabled: false, }; -const EarnScreenStack = () => ( - - - - - - -); +const EarnScreenStack = () => { + const theme = useTheme(); + + return ( + + + + { + const params = route.params as Partial; + + return getMusdConversionNavbarOptions( + navigation, + theme, + params.outputChainId ?? '', + ); + }} + /> + + + ); +}; const EarnModalStack = () => ( diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 75925a95bd8..80345b3a592 100644 --- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx @@ -140,6 +140,7 @@ const mockUseMusdConversionTokens = mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); @@ -498,6 +499,7 @@ describe('StakeButton', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); }); @@ -516,6 +518,7 @@ describe('StakeButton', () => { asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); @@ -547,6 +550,7 @@ describe('StakeButton', () => { asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); @@ -589,6 +593,7 @@ describe('StakeButton', () => { asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); @@ -626,6 +631,7 @@ describe('StakeButton', () => { asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index b69a6a22de9..62cd1810393 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -47,7 +47,6 @@ import { isTronChainId } from '../../../../../core/Multichain/utils'; import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import Logger from '../../../../../util/Logger'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; -import { MUSD_CONVERSION_DEFAULT_CHAIN_ID } from '../../../Earn/constants/musd'; interface StakeButtonProps { asset: TokenI; @@ -93,7 +92,8 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { const { initiateConversion, hasSeenConversionEducationScreen } = useMusdConversion(); - const { isConversionToken } = useMusdConversionTokens(); + const { isConversionToken, isMusdSupportedOnChain } = + useMusdConversionTokens(); const isConvertibleStablecoin = isMusdConversionFlowEnabled && isConversionToken(asset); @@ -225,11 +225,19 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { throw new Error('Asset address or chain ID is not set'); } + const assetChainId = toHex(asset.chainId); + + const isSupportedChain = isMusdSupportedOnChain(assetChainId); + + if (!isSupportedChain) { + throw new Error('Chain is not supported for mUSD conversion'); + } + const config = { - outputChainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID, + outputChainId: assetChainId, preferredPaymentToken: { address: toHex(asset.address), - chainId: toHex(asset.chainId), + chainId: assetChainId, }, navigationStack: Routes.EARN.ROOT, }; @@ -265,6 +273,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { asset.chainId, hasSeenConversionEducationScreen, initiateConversion, + isMusdSupportedOnChain, navigation, ]); diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index e892a51e900..68b897b62bb 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -277,7 +277,7 @@ function useButtonLabel() { } if (hasTransactionType(transaction, [TransactionType.musdConversion])) { - return strings('earn.musd_conversion.confirmation_button'); + return strings('earn.musd_conversion.convert_to_musd'); } return strings('confirm.deposit_edit_amount_done'); diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx index de88cfa8846..983519bd4a3 100644 --- a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx +++ b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx @@ -2,13 +2,10 @@ import React from 'react'; import { Hex } from '@metamask/utils'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { MusdConversionInfo } from './musd-conversion-info'; -import useNavbar from '../../../hooks/ui/useNavbar'; import { useAddToken } from '../../../hooks/tokens/useAddToken'; import { useRoute } from '@react-navigation/native'; -import { strings } from '../../../../../../../locales/i18n'; import { CustomAmountInfo } from '../custom-amount-info'; -jest.mock('../../../hooks/ui/useNavbar'); jest.mock('../../../hooks/tokens/useAddToken'); jest.mock('../custom-amount-info', () => ({ @@ -30,7 +27,6 @@ jest.mock('@react-navigation/native', () => { }); describe('MusdConversionInfo', () => { - const mockUseNavbar = jest.mocked(useNavbar); const mockUseAddToken = jest.mocked(useAddToken); const mockUseRoute = jest.mocked(useRoute); @@ -58,29 +54,10 @@ describe('MusdConversionInfo', () => { state: {}, }); - expect(mockUseNavbar).toHaveBeenCalled(); expect(mockUseAddToken).toHaveBeenCalled(); }); }); - describe('navbar title', () => { - it('calls useNavbar with earn_rewards_with title for mUSD token', () => { - mockRoute.params = { - outputChainId: '0x1' as Hex, - }; - - mockUseRoute.mockReturnValue(mockRoute); - - renderWithProvider(, { - state: {}, - }); - - expect(mockUseNavbar).toHaveBeenCalledWith( - strings('earn.musd_conversion.earn_rewards_with'), - ); - }); - }); - describe('useAddToken', () => { it('calls useAddToken with mUSD token info', () => { mockRoute.params = { @@ -112,13 +89,7 @@ describe('MusdConversionInfo', () => { mockRoute.params = { preferredPaymentToken, - outputToken: { - address: '0x123' as Hex, - chainId: '0x1' as Hex, - symbol: 'TEST', - name: 'Test Token', - decimals: 6, - }, + outputChainId: '0x1' as Hex, }; mockUseRoute.mockReturnValue(mockRoute); diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx index 6bd88090fb4..986046af297 100644 --- a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx +++ b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx @@ -1,9 +1,6 @@ import React from 'react'; -import { strings } from '../../../../../../../locales/i18n'; -import useNavbar from '../../../hooks/ui/useNavbar'; import { CustomAmountInfo } from '../custom-amount-info'; import { - MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN, MUSD_TOKEN_ADDRESS_BY_CHAIN, } from '../../../../../UI/Earn/constants/musd'; @@ -13,15 +10,11 @@ import { useParams } from '../../../../../../util/navigation/navUtils'; export const MusdConversionInfo = () => { const { outputChainId, preferredPaymentToken } = - useParams({ - outputChainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - }); - - useNavbar(strings('earn.musd_conversion.earn_rewards_with')); + useParams(); const { decimals, name, symbol } = MUSD_TOKEN; - const tokenToAddAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[outputChainId]; + const tokenToAddAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN?.[outputChainId]; if (!tokenToAddAddress) { throw new Error( diff --git a/locales/languages/en.json b/locales/languages/en.json index 71637c63f87..954e04b9509 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5663,8 +5663,7 @@ "fee": "Fee" }, "musd_conversion": { - "confirmation_button": "Convert to mUSD", - "earn_rewards_with": "Earn rewards with mUSD", + "convert_to_musd": "Convert to mUSD", "toasts": { "converting": "Converting {{token}} → mUSD", "eta": "~{{time}}", From 2f8ab0806d483999ebf76ee24b984a393b8b521f Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Fri, 5 Dec 2025 11:33:42 -0300 Subject: [PATCH 04/11] fix(ramp): experience switcher text cp-7.61.0 (#23712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates the copy text in the "escape hatch" modals that allow users to switch between the Aggregator (classic) and Deposit (new) buy experiences. **Reason for change:** The current copy text ("Use new buy experience" / "Try new native on ramp" and "More ways to buy" / "Use a different payment provider") was confusing users about the action being performed. Users didn't realize they were switching to a completely different feature/experience. **Solution:** Updated the modal copy to be clearer about switching between versions: - **Aggregator → Native (Deposit):** "More ways to buy" / "Switch to the new version" - **Native (Deposit) → Aggregator:** "More ways to buy" / "Switch to the classic version" ## **Changelog** CHANGELOG entry: Improved clarity of the "More ways to buy" modal copy when switching between buy experiences ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2858 Fixes https://github.com/MetaMask/metamask-mobile/issues/23716 ## **Manual testing steps** ```gherkin Feature: More ways to buy escape hatch copy Scenario: user views escape hatch modal from Aggregator (classic) experience Given user is in the Aggregator buy flow When user opens the settings modal Then user sees "More ways to buy" with description "Switch to the new version" Scenario: user views escape hatch modal from Deposit (new) experience Given user is in the Deposit buy flow When user opens the configuration modal Then user sees "More ways to buy" with description "Switch to the classic version" ``` ## **Screenshots/Recordings** ### **Before** | Aggregator | Deposit | |:--:|:--:| | before_agg | before_deposit | ### **After** | Aggregator | Deposit | |:--:|:--:| | after_agg | after_deposit | ## **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. --- You can drop your 4 screenshots in the Before/After sections where I've left the placeholders! --- > [!NOTE] > Updates copy and tests for the buy experience switcher: “More ways to buy” with “Switch to the new version” (Aggregator) and “Switch to the classic version” (Deposit). > > - **UI copy updates** > - `fiat_on_ramp_aggregator.settings_modal`: > - `use_new_buy_experience` → `More ways to buy` > - Description → `Switch to the new version` > - `deposit.configuration_modal`: > - `more_ways_to_buy_description` → `Switch to the classic version` > - **Tests & snapshots** > - Update `SettingsModal.test.tsx` and related snapshots to assert new labels and descriptions. > - Update `ConfigurationModal` snapshot to reflect new description. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit faecee2f253379f7312cf6a105c685f0b8249909. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Modals/Settings/SettingsModal.test.tsx | 24 +++++++++---------- .../__snapshots__/SettingsModal.test.tsx.snap | 4 ++-- .../ConfigurationModal.test.tsx.snap | 2 +- locales/languages/en.json | 6 ++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx index 01e646a61a5..4a3e0c25ebb 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx @@ -100,11 +100,11 @@ describe('SettingsModal', () => { expect(getByText('View order history')).toBeTruthy(); }); - it('displays use new buy experience menu item', () => { + it('displays more ways to buy menu item', () => { const { getByText } = render(); - expect(getByText('Use new buy experience')).toBeTruthy(); - expect(getByText('Try new native on ramp')).toBeTruthy(); + expect(getByText('More ways to buy')).toBeTruthy(); + expect(getByText('Switch to the new version')).toBeTruthy(); }); it('navigates to transactions view when view order history is pressed', () => { @@ -121,11 +121,11 @@ describe('SettingsModal', () => { }); }); - it('navigates to deposit when use new buy experience is pressed', () => { + it('navigates to deposit when more ways to buy is pressed', () => { const { getByText } = render(); - const newBuyExperienceButton = getByText('Use new buy experience'); + const moreWaysToBuyButton = getByText('More ways to buy'); - fireEvent.press(newBuyExperienceButton); + fireEvent.press(moreWaysToBuyButton); expect(mockDangerouslyGetParent).toHaveBeenCalled(); expect(mockGoToDeposit).toHaveBeenCalled(); @@ -140,9 +140,9 @@ describe('SettingsModal', () => { }); const { getByText } = render(); - const newBuyExperienceButton = getByText('Use new buy experience'); + const moreWaysToBuyButton = getByText('More ways to buy'); - fireEvent.press(newBuyExperienceButton); + fireEvent.press(moreWaysToBuyButton); expect(mockParentGoBack).toHaveBeenCalled(); }); @@ -162,10 +162,10 @@ describe('SettingsModal', () => { expect(getByText('View order history')).toBeTruthy(); }); - it('renders add icon for new buy experience', () => { + it('renders add icon for more ways to buy', () => { const { getByText } = render(); - expect(getByText('Use new buy experience')).toBeTruthy(); + expect(getByText('More ways to buy')).toBeTruthy(); }); }); @@ -179,9 +179,9 @@ describe('SettingsModal', () => { it('tracks event when deposit is pressed', () => { const { getByText } = render(); - const newBuyExperienceButton = getByText('Use new buy experience'); + const moreWaysToBuyButton = getByText('More ways to buy'); - fireEvent.press(newBuyExperienceButton); + fireEvent.press(moreWaysToBuyButton); expect(mockTrackEvent).toHaveBeenCalledWith('RAMPS_BUTTON_CLICKED', { location: 'Buy Settings Modal', diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap index 362ef235ddd..95cd46f5e05 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap @@ -691,7 +691,7 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` } } > - Use new buy experience + More ways to buy - Try new native on ramp + Switch to the new version diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap index 6c35990355a..faeda3d83f6 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap @@ -792,7 +792,7 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` } } > - Use a different payment provider + Switch to the classic version diff --git a/locales/languages/en.json b/locales/languages/en.json index 954e04b9509..904c7b77f90 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -701,7 +701,7 @@ "error_sdk_not_initialized": "SDK not initialized", "logged_out_error": "Error logging out", "more_ways_to_buy": "More ways to buy", - "more_ways_to_buy_description": "Use a different payment provider" + "more_ways_to_buy_description": "Switch to the classic version" }, "region_modal": { "select_a_region": "Select a region", @@ -4684,8 +4684,8 @@ "webview_error_no_address_provided": "No wallet address was provided to continue", "settings_modal": { "title": "Settings", - "use_new_buy_experience": "Use new buy experience", - "use_new_buy_experience_description": "Try new native on ramp" + "use_new_buy_experience": "More ways to buy", + "use_new_buy_experience_description": "Switch to the new version" }, "onboarding": { "what_to_expect": "What to Expect", From 6c083340d2664af2bc7cd95491fcb1e8bb14bf78 Mon Sep 17 00:00:00 2001 From: Jorge Carrasco Date: Fri, 5 Dec 2025 15:41:50 +0100 Subject: [PATCH 05/11] chore: update github-tools to v1.1.3 (#23710) ## **Description** Updates `github-tools` to v1.1.3 in release workflows. ### Changes - Updated `update-release-changelog.yml` to use `github-tools@v1.1.3` ### What's included in v1.1.3 - **Fixed**: Prevent changelog PR creation when branches are in sync ([#177](https://github.com/MetaMask/github-tools/pull/177)) See [github-tools v1.1.3 release notes](https://github.com/MetaMask/github-tools/releases/tag/v1.1.3). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/INFRA-3167 ## **Manual testing steps** ```gherkin Feature: Release changelog workflow Scenario: Workflow uses updated github-tools version Given the workflow configuration is updated When a release branch receives a push Then the workflow uses github-tools v1.1.3 ``` ## **Screenshots/Recordings** N/A - workflow configuration change only ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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. --- .github/workflows/update-release-changelog.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-release-changelog.yml b/.github/workflows/update-release-changelog.yml index 8e323839ac3..bad290531c9 100644 --- a/.github/workflows/update-release-changelog.yml +++ b/.github/workflows/update-release-changelog.yml @@ -48,11 +48,11 @@ jobs: pull-requests: write steps: - name: Update Release Changelog - uses: MetaMask/github-tools/.github/actions/update-release-changelog@v1.1.2 + uses: MetaMask/github-tools/.github/actions/update-release-changelog@v1.1.3 with: release-branch: ${{ github.ref_name }} repository-url: ${{ github.server_url }}/${{ github.repository }} platform: mobile previous-version-ref: 'null' - github-tools-version: v1.1.2 + github-tools-version: v1.1.3 github-token: ${{ secrets.PR_TOKEN }} From b631c6d7022f615673abca99487bb298ed25ddfc Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Dec 2025 15:06:01 +0000 Subject: [PATCH 06/11] feat: disable tokens in metamask pay (#23680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Disable tokens in asset picker for MetaMask Pay if there is no native balance and gas station is not supported. ## **Changelog** CHANGELOG entry: Disable token selection in Perps and Predict deposits if no native balance and gas station not supported ## **Related issues** Fixes: [#6361](https://github.com/MetaMask/MetaMask-planning/issues/6361) ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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 MetaMask Pay asset-picker tokens when no native balance unless allowed by gas station feature flags, adding selector support and tests. > > - **Confirmations utils**: > - Enhance `getAvailableTokens` to mark tokens as `disabled` with `disabledMessage` when no native balance and token isn’t supported by gas station; keep selection/required-token behavior. Uses `store`, new `selectGasFeeTokenFlags`, and `strings('pay_with_modal.no_gas')`. > - Add helper to compute supported gas-fee tokens per `chainId`. > - **Feature flags**: > - Add `GasFeeTokenFlags` type and `selectGasFeeTokenFlags` selector in `selectors/featureFlagController/confirmations`. > - Strengthen `selectMetaMaskPayFlags` typing. > - **Tests**: > - Update `transaction-pay.test.ts` to cover disabled/enabled cases (native balance present/absent, gas station support) and mock store/selector. > - Update `pay-with-modal.test.tsx` to mock `getAvailableTokens` instead of `useAccountTokens` and remove mUSD allowlist token filtering tests. > - Update `useTransactionPayAvailableTokens.test.ts` to mock `getAvailableTokens` and expect returned tokens. > - Add tests for `selectGasFeeTokenFlags` selector behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 522201267d46d8e1860c6fd8d0e79e7962c95647. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../pay-with-modal/pay-with-modal.test.tsx | 61 ++---------- .../useTransactionPayAvailableTokens.test.ts | 5 +- .../utils/transaction-pay.test.ts | 95 +++++++++++++++++++ .../confirmations/utils/transaction-pay.ts | 43 +++++++++ .../confirmations/index.test.ts | 81 ++++++++++++++++ .../confirmations/index.ts | 54 +++++++++-- 6 files changed, 278 insertions(+), 61 deletions(-) diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx index f3ca72558c0..ed3268daf43 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx @@ -17,7 +17,6 @@ import { TransactionType, TransactionStatus, } from '@metamask/transaction-controller'; -import { useAccountTokens } from '../../../hooks/send/useAccountTokens'; import { AssetType, TokenStandard } from '../../../types/token'; import { TransactionPayRequiredToken } from '@metamask/transaction-pay-controller'; import { useTransactionPayRequiredTokens } from '../../../hooks/pay/useTransactionPayData'; @@ -26,11 +25,17 @@ import { Hex } from '@metamask/utils'; import { useRoute } from '@react-navigation/native'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { EMPTY_ADDRESS } from '../../../../../../constants/transaction'; +import { getAvailableTokens } from '../../../utils/transaction-pay'; jest.mock('../../../hooks/pay/useTransactionPayToken'); -jest.mock('../../../hooks/send/useAccountTokens'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); +jest.mock('../../../utils/transaction-pay'); + +jest.mock('../../../hooks/send/useAccountTokens', () => ({ + useAccountTokens: () => [], +})); + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useRoute: jest.fn(), @@ -160,7 +165,7 @@ function render({ minimumFiatBalance }: { minimumFiatBalance?: number } = {}) { describe('PayWithModal', () => { const setPayTokenMock = jest.fn(); const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); - const useAccountTokensMock = jest.mocked(useAccountTokens); + const getAvailableTokensMock = jest.mocked(getAvailableTokens); const useTransactionPayRequiredTokensMock = jest.mocked( useTransactionPayRequiredTokens, ); @@ -172,7 +177,7 @@ describe('PayWithModal', () => { beforeEach(() => { jest.resetAllMocks(); - useAccountTokensMock.mockReturnValue(TOKENS_MOCK); + getAvailableTokensMock.mockReturnValue(TOKENS_MOCK); useTransactionPayRequiredTokensMock.mockReturnValue(REQUIRED_TOKENS_MOCK); useTransactionPayTokenMock.mockReturnValue({ @@ -223,52 +228,4 @@ describe('PayWithModal', () => { }); }); }); - - describe('tokenFilter', () => { - describe('when transaction type is musdConversion', () => { - it('filters tokens using musd conversion payment allowlist', async () => { - useTransactionMetadataRequestMock.mockReturnValue({ - id: transactionIdMock, - chainId: CHAIN_ID_1_MOCK, - networkClientId: '', - status: TransactionStatus.unapproved, - time: 0, - txParams: { - from: EMPTY_ADDRESS, - }, - type: TransactionType.musdConversion, - } as unknown as ReturnType); - - const { getByText, queryByText } = render(); - - expect(getByText('USD Coin')).toBeDefined(); - expect(getByText('USDC')).toBeDefined(); - - expect(queryByText('Test Token 1')).toBeNull(); - expect(queryByText('Test Token 2')).toBeNull(); - }); - }); - - describe('when transaction type is NOT musdConversion', () => { - it('shows all available tokens without mUSD allowlist filtering', async () => { - useTransactionMetadataRequestMock.mockReturnValue({ - id: transactionIdMock, - chainId: CHAIN_ID_1_MOCK, - networkClientId: '', - status: TransactionStatus.unapproved, - time: 0, - txParams: { - from: EMPTY_ADDRESS, - }, - type: TransactionType.simpleSend, - } as unknown as ReturnType); - - const { getByText } = render(); - - expect(getByText('Native Token 1')).toBeDefined(); - expect(getByText('Test Token 1')).toBeDefined(); - expect(getByText('USD Coin')).toBeDefined(); - }); - }); - }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts index 4c15e981311..8356c1ed20f 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts @@ -4,8 +4,10 @@ import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTo import { NATIVE_TOKEN_ADDRESS } from '../../constants/tokens'; import { AssetType, TokenStandard } from '../../types/token'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { getAvailableTokens } from '../../utils/transaction-pay'; jest.mock('../send/useAccountTokens'); +jest.mock('../../utils/transaction-pay'); const TOKEN_MOCK = { accountType: EthAccountType.Eoa, @@ -25,7 +27,8 @@ describe('useTransactionPayAvailableTokens', () => { beforeEach(() => { jest.resetAllMocks(); - useAccountTokensMock.mockReturnValue([TOKEN_MOCK]); + useAccountTokensMock.mockReturnValue([]); + jest.mocked(getAvailableTokens).mockReturnValue([TOKEN_MOCK]); }); it('returns available tokens', () => { diff --git a/app/components/Views/confirmations/utils/transaction-pay.test.ts b/app/components/Views/confirmations/utils/transaction-pay.test.ts index 0585b81bc0a..9fbd22dae0f 100644 --- a/app/components/Views/confirmations/utils/transaction-pay.test.ts +++ b/app/components/Views/confirmations/utils/transaction-pay.test.ts @@ -19,6 +19,19 @@ import { TransactionPaymentToken, } from '@metamask/transaction-pay-controller'; import { Hex } from '@metamask/utils'; +import { store } from '../../../../store'; +import { selectGasFeeTokenFlags } from '../../../../selectors/featureFlagController/confirmations'; +import { strings } from '../../../../../locales/i18n'; + +jest.mock('../../../../store', () => ({ + store: { + getState: jest.fn(), + }, +})); + +jest.mock('../../../../selectors/featureFlagController/confirmations', () => ({ + selectGasFeeTokenFlags: jest.fn(), +})); const CHAIN_ID_MOCK = '0x1'; const TO_MOCK = '0x0987654321098765432109876543210987654321'; @@ -38,7 +51,23 @@ const TOKEN_MOCK = { symbol: 'NTV1', } as AssetType; +const ERC20_TOKEN_MOCK = { + ...TOKEN_MOCK, + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'Test Token', + symbol: 'TST', + balance: '2.34', +} as AssetType; + describe('Transaction Pay Utils', () => { + const selectGasFeeTokenFlagsMock = jest.mocked(selectGasFeeTokenFlags); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(store.getState).mockReturnValue({} as never); + selectGasFeeTokenFlagsMock.mockReturnValue({ gasFeeTokens: {} }); + }); + describe('getRequiredBalance', () => { it('returns value if transaction type is perps deposit', () => { const transactionMeta = { @@ -251,5 +280,71 @@ describe('Transaction Pay Utils', () => { expect(result).toStrictEqual([]); }); + + describe('disabled', () => { + it('marks token as disabled when no native balance and no gas station support', () => { + const result = getAvailableTokens({ + tokens: [ + ERC20_TOKEN_MOCK, + { ...TOKEN_MOCK, balance: '0' } as AssetType, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].disabled).toBe(true); + expect(result[0].disabledMessage).toBe( + strings('pay_with_modal.no_gas'), + ); + }); + + it('marks token as enabled when native balance exists', () => { + const result = getAvailableTokens({ + tokens: [ERC20_TOKEN_MOCK, TOKEN_MOCK], + }); + + expect(result).toHaveLength(2); + expect(result[0].disabled).toBe(false); + expect(result[0].disabledMessage).toBeUndefined(); + }); + + it('marks token as enabled when no native balance but gas station supports token', () => { + selectGasFeeTokenFlagsMock.mockReturnValue({ + gasFeeTokens: { + [CHAIN_ID_MOCK]: { + name: 'Ethereum', + tokens: [ + { + name: 'Test Token', + address: ERC20_TOKEN_MOCK.address as Hex, + }, + ], + }, + }, + }); + + const result = getAvailableTokens({ + tokens: [ + ERC20_TOKEN_MOCK, + { ...TOKEN_MOCK, balance: '0' } as AssetType, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].disabled).toBe(false); + expect(result[0].disabledMessage).toBeUndefined(); + }); + + it('marks token as disabled when native token is not found in tokens list', () => { + const result = getAvailableTokens({ + tokens: [ERC20_TOKEN_MOCK], + }); + + expect(result).toHaveLength(1); + expect(result[0].disabled).toBe(true); + expect(result[0].disabledMessage).toBe( + strings('pay_with_modal.no_gas'), + ); + }); + }); }); }); diff --git a/app/components/Views/confirmations/utils/transaction-pay.ts b/app/components/Views/confirmations/utils/transaction-pay.ts index 1287d9726bc..0c4dd34f8c0 100644 --- a/app/components/Views/confirmations/utils/transaction-pay.ts +++ b/app/components/Views/confirmations/utils/transaction-pay.ts @@ -13,6 +13,10 @@ import { } from '@metamask/transaction-pay-controller'; import { BigNumber } from 'bignumber.js'; import { isTestNet } from '../../../../util/networks'; +import { store } from '../../../../store'; +import { selectGasFeeTokenFlags } from '../../../../selectors/featureFlagController/confirmations'; +import { getNativeTokenAddress } from './asset'; +import { strings } from '../../../../../locales/i18n'; const FOUR_BYTE_TOKEN_TRANSFER = '0xa9059cbb'; @@ -88,6 +92,8 @@ export function getAvailableTokens({ requiredTokens?: TransactionPayRequiredToken[]; tokens: AssetType[]; }): AssetType[] { + const supportedGasFeeTokens = getSupportedGasFeeTokens(); + return tokens .filter((token) => { if ( @@ -120,13 +126,50 @@ export function getAvailableTokens({ return new BigNumber(token.balance).gt(0); }) .map((token) => { + const chainId = (token.chainId as Hex) ?? '0x0'; + + const nativeToken = tokens.find( + (t) => + t.chainId === chainId && t.address === getNativeTokenAddress(chainId), + ); + + const noNativeBalance = + !nativeToken || new BigNumber(nativeToken.balance).isZero(); + + const isGasStationSupported = supportedGasFeeTokens[chainId]?.includes( + token.address?.toLowerCase() as Hex, + ); + + const disabled = noNativeBalance && !isGasStationSupported; + + const disabledMessage = disabled + ? strings('pay_with_modal.no_gas') + : undefined; + const isSelected = payToken?.address.toLowerCase() === token.address.toLowerCase() && payToken?.chainId === token.chainId; return { ...token, + disabled, + disabledMessage, isSelected, }; }); } + +function getSupportedGasFeeTokens(): Record { + const state = store.getState(); + const { gasFeeTokens } = selectGasFeeTokenFlags(state); + + return Object.keys(gasFeeTokens).reduce( + (acc, chainId) => ({ + ...acc, + [chainId]: gasFeeTokens[chainId as Hex].tokens.map( + (token) => token.address.toLowerCase() as Hex, + ), + }), + {}, + ); +} diff --git a/app/selectors/featureFlagController/confirmations/index.test.ts b/app/selectors/featureFlagController/confirmations/index.test.ts index 1ecad23679a..c0a630ce163 100644 --- a/app/selectors/featureFlagController/confirmations/index.test.ts +++ b/app/selectors/featureFlagController/confirmations/index.test.ts @@ -11,9 +11,12 @@ import { SLIPPAGE_DEFAULT, BUFFER_SUBSEQUENT_DEFAULT, selectNonZeroUnusedApprovalsAllowList, + selectGasFeeTokenFlags, + GasFeeTokenFlags, } from '.'; import mockedEngine from '../../../core/__mocks__/MockedEngine'; import { mockedEmptyFlagsState, mockedUndefinedFlagsState } from '../mocks'; +import { Hex } from '@metamask/utils'; jest.mock('../../../core/Engine', () => ({ init: () => mockedEngine.init(), @@ -437,3 +440,81 @@ describe('Non-Zero Unused Approvals Allow List', () => { expect(result).toEqual([]); }); }); + +describe('Gas Fee Token Flags', () => { + const chainIdMock = '0x1' as Hex; + + const mockedGasFeeTokenFlags: GasFeeTokenFlags = { + gasFeeTokens: { + [chainIdMock]: { + name: 'Ethereum', + tokens: [ + { + name: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, + }, + { + name: 'DAI', + address: '0x6b175474e89094c44da98b954eedeac495271d0f' as Hex, + }, + ], + }, + '0x89': { + name: 'Polygon', + tokens: [{ name: 'USDC.e', address: '0xusdce' as Hex }], + }, + }, + }; + + const mockedStateWithGasFeeTokenFlags = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + confirmations_gas_fee_tokens: mockedGasFeeTokenFlags, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + it('returns empty gasFeeTokens when empty feature flag state', () => { + const result = selectGasFeeTokenFlags(mockedEmptyFlagsState); + + expect(result).toEqual({ gasFeeTokens: {} }); + }); + + it('returns empty gasFeeTokens when undefined RemoteFeatureFlagController state', () => { + const result = selectGasFeeTokenFlags(mockedUndefinedFlagsState); + + expect(result).toEqual({ gasFeeTokens: {} }); + }); + + it('returns gas fee tokens from feature flag', () => { + const result = selectGasFeeTokenFlags( + mockedStateWithGasFeeTokenFlags as never, + ); + + expect(result).toEqual(mockedGasFeeTokenFlags); + }); + + it('returns empty gasFeeTokens when confirmations_gas_fee_tokens exists but gasFeeTokens is undefined', () => { + const stateWithUndefinedGasFeeTokens = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + confirmations_gas_fee_tokens: {}, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectGasFeeTokenFlags(stateWithUndefinedGasFeeTokens); + + expect(result).toEqual({ gasFeeTokens: {} }); + }); +}); diff --git a/app/selectors/featureFlagController/confirmations/index.ts b/app/selectors/featureFlagController/confirmations/index.ts index e0bbb799849..b469a55dde5 100644 --- a/app/selectors/featureFlagController/confirmations/index.ts +++ b/app/selectors/featureFlagController/confirmations/index.ts @@ -1,7 +1,7 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '..'; import { getFeatureFlagValue } from '../env'; -import { Json } from '@metamask/utils'; +import { Hex, Json } from '@metamask/utils'; export const ATTEMPTS_MAX_DEFAULT = 2; export const BUFFER_INITIAL_DEFAULT = 0.025; @@ -33,6 +33,18 @@ export interface MetaMaskPayFlags { slippage: number; } +export interface GasFeeTokenFlags { + gasFeeTokens: { + [chainId: Hex]: { + name: string; + tokens: { + name: string; + address: Hex; + }[]; + }; + }; +} + /** * Determines the enabled state of confirmation redesign features by combining * local environment variables with remote feature flags. @@ -143,18 +155,25 @@ export const selectSendRedesignFlags = createSelector( export const selectMetaMaskPayFlags = createSelector( selectRemoteFeatureFlags, - (featureFlags) => { + (featureFlags): MetaMaskPayFlags => { const metaMaskPayFlags = featureFlags?.confirmation_pay as | Record | undefined; - const attemptsMax = metaMaskPayFlags?.attemptsMax ?? ATTEMPTS_MAX_DEFAULT; + const attemptsMax = + (metaMaskPayFlags?.attemptsMax as number) ?? ATTEMPTS_MAX_DEFAULT; + const bufferInitial = - metaMaskPayFlags?.bufferInitial ?? BUFFER_INITIAL_DEFAULT; - const bufferStep = metaMaskPayFlags?.bufferStep ?? BUFFER_STEP_DEFAULT; + (metaMaskPayFlags?.bufferInitial as number) ?? BUFFER_INITIAL_DEFAULT; + + const bufferStep = + (metaMaskPayFlags?.bufferStep as number) ?? BUFFER_STEP_DEFAULT; + const bufferSubsequent = - metaMaskPayFlags?.bufferSubsequent ?? BUFFER_SUBSEQUENT_DEFAULT; - const slippage = metaMaskPayFlags?.slippage ?? SLIPPAGE_DEFAULT; + (metaMaskPayFlags?.bufferSubsequent as number) ?? + BUFFER_SUBSEQUENT_DEFAULT; + + const slippage = (metaMaskPayFlags?.slippage as number) ?? SLIPPAGE_DEFAULT; return { attemptsMax, @@ -162,7 +181,7 @@ export const selectMetaMaskPayFlags = createSelector( bufferStep, bufferSubsequent, slippage, - } as MetaMaskPayFlags; + }; }, ); @@ -177,3 +196,22 @@ export const selectNonZeroUnusedApprovalsAllowList = createSelector( (remoteFeatureFlags: ReturnType) => remoteFeatureFlags?.nonZeroUnusedApprovals ?? [], ); + +export const selectGasFeeTokenFlags = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): GasFeeTokenFlags => { + const gasFeeTokenFlags = + remoteFeatureFlags?.confirmations_gas_fee_tokens as + | Record + | undefined; + + const gasFeeTokens = + (gasFeeTokenFlags?.gasFeeTokens as + | GasFeeTokenFlags['gasFeeTokens'] + | undefined) ?? {}; + + return { + gasFeeTokens, + }; + }, +); From 43bd6bc4edf4fd298c3a161175524c6d5ffc1828 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Dec 2025 15:09:35 +0000 Subject: [PATCH 07/11] fix: cp-7.61.0 improve relay provider and network fees (#23717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bump controller versions to improve accuracy of provider and network fees in Perps and Predict deposits. ## **Changelog** CHANGELOG entry: Improved accuracy of provider and network fees in Perps and Predict deposits ## **Related issues** Fixes: #23698 [#6394](https://github.com/MetaMask/MetaMask-planning/issues/6394) ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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 MetaMask controller dependencies, notably `@metamask/transaction-controller` to 62.5.0 and `@metamask/transaction-pay-controller` to 10.4.0, plus aligned assets/network/bridge packages and remote feature flag controller. > > - **Dependencies**: > - **Transactions**: > - Update `@metamask/transaction-controller` to `62.5.0` (patch resolution and lockfile). > - Update `@metamask/transaction-pay-controller` to `^10.4.0`. > - **Assets/Network/Bridge**: > - Bump `@metamask/assets-controllers` to `^93.1.0`. > - Bump `@metamask/network-controller` to `^27.0.0`. > - Bump `@metamask/bridge-controller` to `^64.0.0` and `@metamask/bridge-status-controller` to `^64.0.1`. > - **Feature Flags**: > - Align `@metamask/remote-feature-flag-controller` to `^3.0.0` where referenced. > - Update `yarn.lock` to reflect the above version changes and dependency realignments. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c7552f6ef1327a033b00ef9b8ef80b541a66feb2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 6 +- yarn.lock | 176 ++++++++++++++++----------------------------------- 2 files changed, 57 insertions(+), 125 deletions(-) diff --git a/package.json b/package.json index 8bba1446335..03dc8172ad5 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ "@scure/bip32": "1.7.0", "@metamask/snaps-sdk": "^10.0.0", "react-native@0.76.9": "patch:react-native@npm%3A0.76.9#./.yarn/patches/react-native-npm-0.76.9-1c25352097.patch", - "@metamask/transaction-controller@npm:^62.4.0": "patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-controller@npm:^62.5.0": "patch:@metamask/transaction-controller@npm%3A62.5.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -281,8 +281,8 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", "@metamask/token-search-discovery-controller": "^4.0.0", - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", - "@metamask/transaction-pay-controller": "^10.3.0", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.5.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", + "@metamask/transaction-pay-controller": "^10.4.0", "@metamask/tron-wallet-snap": "^1.13.0", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index cc4b55f64d0..bf527acd591 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7176,61 +7176,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^91.0.0": - version: 91.0.0 - resolution: "@metamask/assets-controllers@npm:91.0.0" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.16.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/polling-controller": "npm:^16.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-sdk": "npm:^9.0.0" - "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/utils": "npm:^11.8.1" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/account-tree-controller": ^4.0.0 - "@metamask/accounts-controller": ^35.0.0 - "@metamask/approval-controller": ^8.0.0 - "@metamask/core-backend": ^5.0.0 - "@metamask/keyring-controller": ^25.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/permission-controller": ^12.0.0 - "@metamask/phishing-controller": ^16.0.0 - "@metamask/preferences-controller": ^22.0.0 - "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 - "@metamask/transaction-controller": ^62.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/8e43d631a5ae86fc4801912e79d944ad087a605bb7a5e2813de64b6e068dc26482d25d37c3e2272e435a71ee8a7dafe875edb46cbfbcd150fb474588b45e6ff4 - languageName: node - linkType: hard - -"@metamask/assets-controllers@npm:^92.0.0": - version: 92.0.0 - resolution: "@metamask/assets-controllers@npm:92.0.0" +"@metamask/assets-controllers@npm:^93.0.0, @metamask/assets-controllers@npm:^93.1.0": + version: 93.1.0 + resolution: "@metamask/assets-controllers@npm:93.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7252,7 +7200,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^4.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/permission-controller": "npm:^12.1.1" "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.0" @@ -7262,7 +7210,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/transaction-controller": "npm:^62.3.0" + "@metamask/transaction-controller": "npm:^62.4.0" "@metamask/utils": "npm:^11.8.1" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7278,7 +7226,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/fa6d43e9397654ed504f76d19f74a343bf937171dcf639cf203a6135d7e7e31617ff98d302283d3cabcb2d39767632cbad5717580bb40fcd63e2aa0f948d6bb4 + checksum: 10/9511e927310959e84a6a046ffde6a7f553c9c64e122d7951010ed0cb7da4066d6f27b4ef874706ebce3c664de0662ccd792339bb66ac9359b5686ec53858e9ae languageName: node linkType: hard @@ -7448,9 +7396,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^63.2.0": - version: 63.2.0 - resolution: "@metamask/bridge-controller@npm:63.2.0" +"@metamask/bridge-controller@npm:^64.0.0": + version: 64.0.0 + resolution: "@metamask/bridge-controller@npm:64.0.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7458,7 +7406,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^35.0.0" - "@metamask/assets-controllers": "npm:^91.0.0" + "@metamask/assets-controllers": "npm:^93.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" @@ -7466,16 +7414,16 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-network-controller": "npm:^3.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/remote-feature-flag-controller": "npm:^2.0.1" "@metamask/snaps-controllers": "npm:^14.0.1" - "@metamask/transaction-controller": "npm:^62.3.0" + "@metamask/transaction-controller": "npm:^62.4.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/b55e31f5bf393007ef2c1adb42fe8d87cba28e34609033f37cc373539971e6f9de98f0e61812627890b17b1da901b19e528288766cc09756a6adff8e80a4af6c + checksum: 10/d9a73530421d74606ebcabccd6348a38a21ef786eb42d529bd73b05aee567e44e952482b26c2a7d5f93863afc955ced8dbd4979f76b3f0c7a0d9805e2abcceab languageName: node linkType: hard @@ -7501,24 +7449,24 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^63.1.0": - version: 63.1.0 - resolution: "@metamask/bridge-status-controller@npm:63.1.0" +"@metamask/bridge-status-controller@npm:^64.0.1": + version: 64.0.1 + resolution: "@metamask/bridge-status-controller@npm:64.0.1" dependencies: "@metamask/accounts-controller": "npm:^35.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^63.2.0" + "@metamask/bridge-controller": "npm:^64.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.3.0" + "@metamask/transaction-controller": "npm:^62.4.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" uuid: "npm:^8.3.2" - checksum: 10/f3c9b78d7a256f0b72f1b1ec4d4e33cc925b77ecf8b5e2db5f47194b80967a261c7226fdd89ccea9f79d087c4a813276e69a37f9787d8d7a4eb41c608c86b83e + checksum: 10/9051920b3cfdf0eb0c74193dd610835cb619b304d2774996d213217ad299de04e1a535105ee6d0e1f94b9c3acc9b5bfb5d0df31f1acda5a66893e5c0fa18cf56 languageName: node linkType: hard @@ -8660,35 +8608,6 @@ __metadata: languageName: node linkType: hard -"@metamask/network-controller@npm:^26.0.0": - version: 26.0.0 - resolution: "@metamask/network-controller@npm:26.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.16.0" - "@metamask/eth-block-tracker": "npm:^15.0.0" - "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^22.0.0" - "@metamask/eth-json-rpc-provider": "npm:^6.0.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.2.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.8.1" - async-mutex: "npm:^0.5.0" - fast-deep-equal: "npm:^3.1.3" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - reselect: "npm:^5.1.1" - uri-js: "npm:^4.4.1" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/error-reporting-service": ^3.0.0 - checksum: 10/f66c9bda2b88efbbd23144ed3d6503ceb26025df54de86195485185827272d0f7364e59b633946933c3045d24ccd1a46ce9a852d534d5b3ea58392524dd9f3e3 - languageName: node - linkType: hard - "@metamask/network-controller@npm:^27.0.0": version: 27.0.0 resolution: "@metamask/network-controller@npm:27.0.0" @@ -9183,6 +9102,19 @@ __metadata: languageName: node linkType: hard +"@metamask/remote-feature-flag-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/remote-feature-flag-controller@npm:3.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" + uuid: "npm:^8.3.2" + checksum: 10/50cb1f01ba96de56a79313477f84791fdf40ec1551ab2a7d609ae5097967df4798d3c12a9bfc9b580a3555cae69bf516e4f77aaddc0412a1ee00630b5af50b45 + languageName: node + linkType: hard + "@metamask/rpc-errors@npm:7.0.2": version: 7.0.2 resolution: "@metamask/rpc-errors@npm:7.0.2" @@ -9672,9 +9604,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:62.4.0, @metamask/transaction-controller@npm:^62.3.0": - version: 62.4.0 - resolution: "@metamask/transaction-controller@npm:62.4.0" +"@metamask/transaction-controller@npm:62.5.0, @metamask/transaction-controller@npm:^62.4.0": + version: 62.5.0 + resolution: "@metamask/transaction-controller@npm:62.5.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9693,7 +9625,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^27.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^2.0.1" + "@metamask/remote-feature-flag-controller": "npm:^3.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" async-mutex: "npm:^0.5.0" @@ -9706,7 +9638,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/36a816c881babf7b71542857be50045cb25b1a5cf7fa5444c0ad0c101da3c6718cfd83942ad5f868b53088aa2601c234dcf47e324173014ee5037c084f783438 + checksum: 10/fe07b5013381b3410dafcc03f93bfae3c378cb1742f01788486adff1b4a4a3a5c31be8b91bf9220f247786b7bec6078271c8a0a03b156f51c9c9b5e51fba5829 languageName: node linkType: hard @@ -9748,9 +9680,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": - version: 62.4.0 - resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.4.0&hash=1a3342" +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.5.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": + version: 62.5.0 + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.5.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.5.0&hash=1a3342" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9769,7 +9701,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^27.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^2.0.1" + "@metamask/remote-feature-flag-controller": "npm:^3.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" async-mutex: "npm:^0.5.0" @@ -9782,33 +9714,33 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/de9c227ae3d846e60b7f4860c65d8ea75fe6c399cf51750a2baf96fe361b3453e22ab614b8f937a71ffa7dc60d86f17b408a2d23f38baf59919f307ea60ac7d2 + checksum: 10/5b2e053d8f0c4a099c8f3e43d4f07a1750948126259f9148942985715f4fd3a83f4b710dcad612e2ea523dd8bba7113bb8e071647c6a831b37ac6c2b37365eb8 languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^10.3.0": - version: 10.3.0 - resolution: "@metamask/transaction-pay-controller@npm:10.3.0" +"@metamask/transaction-pay-controller@npm:^10.4.0": + version: 10.4.0 + resolution: "@metamask/transaction-pay-controller@npm:10.4.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^92.0.0" + "@metamask/assets-controllers": "npm:^93.1.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^63.2.0" - "@metamask/bridge-status-controller": "npm:^63.1.0" + "@metamask/bridge-controller": "npm:^64.0.0" + "@metamask/bridge-status-controller": "npm:^64.0.1" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^27.0.0" - "@metamask/remote-feature-flag-controller": "npm:^2.0.1" - "@metamask/transaction-controller": "npm:^62.4.0" + "@metamask/remote-feature-flag-controller": "npm:^3.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/511f7f58791b31a752e80229e35749fc86a5b1333aa3dc956b6b294f0680d0881464548eaf9b7e659a85977a2dae00928e80a5bcf84638cefb9b408ed3336701 + checksum: 10/e2cd3699fbeaa06ca405a1f82c08ba096ce1bc45db873e509eb0e5deec245939347ef1c90ba47f83080650a6aaf3a4cb0929fcde9d47707c69b4b21d615296a1 languageName: node linkType: hard @@ -34152,8 +34084,8 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/token-search-discovery-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" - "@metamask/transaction-pay-controller": "npm:^10.3.0" + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.5.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-pay-controller": "npm:^10.4.0" "@metamask/tron-wallet-snap": "npm:^1.13.0" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" From f73593c117eaa2e7d59d1629bc724b786173ae70 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Fri, 5 Dec 2025 12:39:42 -0300 Subject: [PATCH 08/11] feat(card): add MM Travel and ToS links on Card Home (#23550) ## **Description** This PR implements the `navigateToInternalBrowserPage` method in the `useNavigateToCardPage` hook to support navigation to Card, Travel, and Terms of Service (TOS) pages with proper URL verification and analytics tracking. **Key changes:** 1. Created `CardInternalBrowserPage` enum to define the three page types: `CARD`, `TRAVEL`, and `TOS` 2. Implemented `PAGE_CONFIG` mapping that associates each page type with its URL check function, URL getter, and analytics action 3. For Card and Travel pages: navigates to the internal browser, reusing existing tabs when available 4. For TOS page: opens the URL externally using `Linking.openURL` (PDF document) 5. Added convenience wrapper functions (`navigateToCardPage`, `navigateToTravelPage`, `navigateToCardTosPage`) via `useNavigateToCardPage` hook 6. Connected Travel and TOS navigation to their respective UI elements in `CardHome` 7. Added comprehensive unit test coverage for all navigation scenarios ## **Changelog** CHANGELOG entry: Implement MM Travel and ToS links on Card Home ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card internal page navigation Scenario: user navigates to Card page Given user is on the Card Home screen When user taps on the Advanced Card Management item Then the Card page opens in the internal browser Scenario: user navigates to Travel page Given user is on the Card Home screen When user taps on the Travel item Then the Travel page opens in the internal browser Scenario: user navigates to Card TOS page Given user is on the Card Home screen and authenticated When user taps on the Card Terms of Service item Then the TOS PDF opens in the device's default PDF viewer/browser ``` ## **Screenshots/Recordings** ### **Before** ### **After** #### Non-authenticated (only MM Travel) Simulator Screenshot - iPhone 16 -
2025-12-02 at 13 35 47 #### Authenticated (MM Travel + ToS) https://github.com/user-attachments/assets/542a6847-8972-4eba-8b7a-8e6854cead31 ## **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 internal browser navigation for Card and Travel plus external ToS linking, wires new actions into Card Home, and updates URLs, metrics, selectors, locales, and tests. > > - **Card Home UI (`CardHome.tsx`)**: > - Add "MetaMask Travel" and "Card Terms and Conditions" list items with `navigateToTravelPage` and `navigateToCardTosPage` handlers; keep existing advanced management item. > - Gate ToS item behind authentication; update tests and snapshots. > - **Navigation Hook (`useNavigateToCardPage.tsx`)**: > - Introduce `useNavigateToInternalBrowserPage` with `CardInternalBrowserPage` enum and `PAGE_CONFIG` to open Card/Travel in internal browser (reuse existing tab) and ToS via `Linking.openURL`. > - Expose `navigateToCardPage`, `navigateToTravelPage`, `navigateToCardTosPage`; track `CARD_BUTTON_CLICKED` with new `CardActions`. > - Add comprehensive unit tests for tab reuse, analytics, and edge cases. > - **URL Utilities (`app/util/url`)**: > - Add `isCardTravelUrl` and `isCardTosUrl` (with logging) alongside `isCardUrl`; extend tests. > - **Constants/Selectors/Locales**: > - Add `CARD.TRAVEL_URL` and `CARD.CARD_TOS_URL` in `AppConstants`. > - Add e2e selectors for `travel-item` and `card-tos-item`. > - Add i18n strings for Travel and Card ToS labels/descriptions. > - **Metrics**: > - Add `NAVIGATE_TO_TRAVEL_PAGE`, `NAVIGATE_TO_CARD_TOS_PAGE`, `NAVIGATE_TO_CARD_PAGE` actions. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e924cd6cdb81fef315d7c5bb4d0b415daf65459d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 32 + .../UI/Card/Views/CardHome/CardHome.tsx | 35 +- .../__snapshots__/CardHome.test.tsx.snap | 194 ++++++ .../Card/hooks/useNavigateToCardPage.test.ts | 580 ++++++++++-------- .../UI/Card/hooks/useNavigateToCardPage.tsx | 146 ++++- app/components/UI/Card/util/metrics.ts | 3 + app/core/AppConstants.ts | 2 + app/util/url/index.ts | 23 + app/util/url/url.test.ts | 38 ++ e2e/selectors/Card/CardHome.selectors.ts | 2 + locales/languages/en.json | 6 +- 11 files changed, 780 insertions(+), 281 deletions(-) diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index aa4777256fe..2d3c45014c6 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -163,8 +163,13 @@ const mockUseAssetBalances = jest.fn(() => }), ); +const mockNavigateToTravelPage = jest.fn(); +const mockNavigateToCardTosPage = jest.fn(); + const mockUseNavigateToCardPage = jest.fn(() => ({ navigateToCardPage: mockNavigateToCardPage, + navigateToTravelPage: mockNavigateToTravelPage, + navigateToCardTosPage: mockNavigateToCardTosPage, })); const mockUseSwapBridgeNavigation = jest.fn(() => ({ @@ -629,6 +634,8 @@ describe('CardHome Component', () => { mockUseNavigateToCardPage.mockReturnValue({ navigateToCardPage: mockNavigateToCardPage, + navigateToTravelPage: mockNavigateToTravelPage, + navigateToCardTosPage: mockNavigateToCardTosPage, }); mockUseSwapBridgeNavigation.mockReturnValue({ @@ -792,6 +799,31 @@ describe('CardHome Component', () => { }); }); + it('calls navigateToTravelPage when travel item is pressed', async () => { + render(); + + const travelItem = screen.getByTestId(CardHomeSelectors.TRAVEL_ITEM); + fireEvent.press(travelItem); + + await waitFor(() => { + expect(mockNavigateToTravelPage).toHaveBeenCalled(); + }); + }); + + it('calls navigateToCardTosPage when TOS item is pressed', async () => { + setupMockSelectors({ isAuthenticated: true }); + setupLoadCardDataMock({ isAuthenticated: true }); + + render(); + + const tosItem = screen.getByTestId(CardHomeSelectors.CARD_TOS_ITEM); + fireEvent.press(tosItem); + + await waitFor(() => { + expect(mockNavigateToCardTosPage).toHaveBeenCalled(); + }); + }); + it('displays correct priority token information', async () => { // Given: USDC is the priority token // When: component renders with privacy mode off diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 7386c3f7d01..7649709f20c 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -160,7 +160,8 @@ const CardHome = () => { const { provisionCard, isLoading: isLoadingProvisionCard } = useCardProvision(); - const { navigateToCardPage } = useNavigateToCardPage(navigation); + const { navigateToCardPage, navigateToTravelPage, navigateToCardTosPage } = + useNavigateToCardPage(navigation); const { openSwaps } = useOpenSwaps({ priorityToken, }); @@ -992,15 +993,35 @@ const CardHome = () => { onPress={navigateToCardPage} testID={CardHomeSelectors.ADVANCED_CARD_MANAGEMENT_ITEM} /> + {isAuthenticated && ( - + <> + + + )} ); diff --git a/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap b/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap index 26b99d922c5..b7753eee3ba 100644 --- a/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap +++ b/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap @@ -1167,6 +1167,103 @@ exports[`CardHome Component renders correctly and matches snapshot 1`] = ` + + + + + + card.card_home.manage_card_options.travel_title + + + card.card_home.manage_card_options.travel_description + + + + + + + + + @@ -2349,6 +2446,103 @@ exports[`CardHome Component renders correctly with privacy mode enabled 1`] = ` + + + + + + card.card_home.manage_card_options.travel_title + + + card.card_home.manage_card_options.travel_description + + + + + + + + + diff --git a/app/components/UI/Card/hooks/useNavigateToCardPage.test.ts b/app/components/UI/Card/hooks/useNavigateToCardPage.test.ts index 8bc69c614c3..1e975d98173 100644 --- a/app/components/UI/Card/hooks/useNavigateToCardPage.test.ts +++ b/app/components/UI/Card/hooks/useNavigateToCardPage.test.ts @@ -1,11 +1,17 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector } from 'react-redux'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; -import { useNavigateToCardPage } from './useNavigateToCardPage'; -import { isCardUrl } from '../../../../util/url'; +import { + useNavigateToCardPage, + useNavigateToInternalBrowserPage, + CardInternalBrowserPage, +} from './useNavigateToCardPage'; +import { isCardUrl, isCardTravelUrl } from '../../../../util/url'; import Routes from '../../../../constants/navigation/Routes'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { BrowserTab } from '../../Tokens/types'; +import { CardActions } from '../util/metrics'; +import { Linking } from 'react-native'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -14,23 +20,50 @@ jest.mock('react-redux', () => ({ jest.mock('../../../hooks/useMetrics', () => ({ useMetrics: jest.fn(), MetaMetricsEvents: { - CARD_ADVANCED_CARD_MANAGEMENT_CLICKED: - 'card_advanced_card_management_clicked', + CARD_BUTTON_CLICKED: 'card_button_clicked', }, })); jest.mock('../../../../util/url', () => ({ isCardUrl: jest.fn(), + isCardTravelUrl: jest.fn(), + isCardTosUrl: jest.fn(), })); jest.mock('../../../../core/AppConstants', () => ({ CARD: { URL: 'https://card.metamask.io', + TRAVEL_URL: 'https://travel.metamask.io/access', + CARD_TOS_URL: 'https://secure.baanx.co.uk/MM-Card-RoW-Terms-2025-Sept.pdf', }, })); -describe('useNavigateToCardPage', () => { - const mockNavigation = { +jest.mock('react-native', () => ({ + Linking: { + openURL: jest.fn(), + }, +})); + +// Browser navigation test config (excludes TOS which uses Linking) +const BROWSER_PAGE_CONFIG = [ + { + page: CardInternalBrowserPage.CARD, + url: 'https://card.metamask.io', + urlCheckFn: isCardUrl, + action: CardActions.NAVIGATE_TO_CARD_PAGE, + tabId: 'card-tab-id', + }, + { + page: CardInternalBrowserPage.TRAVEL, + url: 'https://travel.metamask.io/access', + urlCheckFn: isCardTravelUrl, + action: CardActions.NAVIGATE_TO_TRAVEL_PAGE, + tabId: 'travel-tab-id', + }, +] as const; + +const createMockNavigation = (): NavigationProp => + ({ navigate: jest.fn(), dispatch: jest.fn(), reset: jest.fn(), @@ -47,323 +80,390 @@ describe('useNavigateToCardPage', () => { type: 'stack', stale: false, })), - } as unknown as NavigationProp; - - const mockTrackEvent = jest.fn(); - const mockCreateEventBuilder = jest.fn(); - const mockEventBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn(), - }; - - const mockExistingTab: BrowserTab = { - id: 'existing-tab-id', - url: 'https://card.metamask.io/dashboard', - }; - - const mockBrowserTabs: BrowserTab[] = [ - { - id: 'tab-1', - url: 'https://example.com', - }, - mockExistingTab, - { - id: 'tab-2', - url: 'https://another-site.com', - }, - ]; + }) as unknown as NavigationProp; + +const createMockBrowserTab = ( + overrides: Partial = {}, +): BrowserTab => ({ + id: 'tab-id', + url: 'https://example.com', + ...overrides, +}); - beforeEach(() => { - jest.clearAllMocks(); +const createMockEventBuilder = () => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + event: MetaMetricsEvents.CARD_BUTTON_CLICKED, + }), +}); - (useSelector as jest.Mock).mockReturnValue(mockBrowserTabs); - (useMetrics as jest.Mock).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }); - (isCardUrl as jest.Mock).mockImplementation( - (url: string) => url?.includes('card.metamask.io') || false, - ); - mockCreateEventBuilder.mockReturnValue(mockEventBuilder); - mockEventBuilder.build.mockReturnValue({ - event: MetaMetricsEvents.CARD_ADVANCED_CARD_MANAGEMENT_CLICKED, - }); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); + +const setupMocks = ( + mockEventBuilder: ReturnType, +) => { + (useSelector as jest.Mock).mockReturnValue([]); + (useMetrics as jest.Mock).mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }); + (isCardUrl as jest.Mock).mockReturnValue(false); + (isCardTravelUrl as jest.Mock).mockReturnValue(false); + mockCreateEventBuilder.mockReturnValue(mockEventBuilder); +}; - it('should initialize correctly and return navigateToCardPage function', () => { - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); +describe('useNavigateToInternalBrowserPage', () => { + let mockNavigation: NavigationProp; + let mockEventBuilder: ReturnType; - expect(typeof result.current.navigateToCardPage).toBe('function'); + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); }); - it('should navigate to existing card tab when one exists', () => { - (isCardUrl as jest.Mock).mockImplementation( - (url: string) => url === 'https://card.metamask.io/dashboard', + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns navigateToInternalBrowserPage function', () => { + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), ); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + expect(typeof result.current.navigateToInternalBrowserPage).toBe( + 'function', + ); + }); - act(() => { - result.current.navigateToCardPage(); - }); + describe.each(BROWSER_PAGE_CONFIG)( + 'CardInternalBrowserPage.$page', + ({ page, url, urlCheckFn, action, tabId }) => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); + }); - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: { - existingTabId: 'existing-tab-id', - newTabUrl: undefined, - timestamp: expect.any(Number), - }, - }); - }); + it('creates new tab when no existing tab found', () => { + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); + + act(() => { + result.current.navigateToInternalBrowserPage(page); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: url, + }), + }), + ); + }); - it('should create new tab when no existing card tab is found', () => { - (isCardUrl as jest.Mock).mockReturnValue(false); + it('navigates to existing tab when one exists', () => { + const tab = createMockBrowserTab({ id: tabId, url }); + (useSelector as jest.Mock).mockReturnValue([tab]); + (urlCheckFn as jest.Mock).mockReturnValue(true); + + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); + + act(() => { + result.current.navigateToInternalBrowserPage(page); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + existingTabId: tabId, + newTabUrl: undefined, + }), + }), + ); + }); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + it(`tracks CARD_BUTTON_CLICKED with ${action} action`, () => { + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); - act(() => { - result.current.navigateToCardPage(); - }); + act(() => { + result.current.navigateToInternalBrowserPage(page); + }); - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: { - newTabUrl: 'https://card.metamask.io/', - timestamp: expect.any(Number), - }, + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ action }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + }, + ); + + describe('CardInternalBrowserPage.TOS', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); }); - }); - it('should track analytics event when navigateToCardPage is called', () => { - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + it('opens TOS URL with Linking.openURL', () => { + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); - act(() => { - result.current.navigateToCardPage(); - }); + act(() => { + result.current.navigateToInternalBrowserPage( + CardInternalBrowserPage.TOS, + ); + }); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.CARD_ADVANCED_CARD_MANAGEMENT_CLICKED, - ); - expect(mockEventBuilder.build).toHaveBeenCalled(); - expect(mockTrackEvent).toHaveBeenCalledWith({ - event: MetaMetricsEvents.CARD_ADVANCED_CARD_MANAGEMENT_CLICKED, + expect(Linking.openURL).toHaveBeenCalledWith( + 'https://secure.baanx.co.uk/MM-Card-RoW-Terms-2025-Sept.pdf', + ); }); - }); - it('should handle empty browser tabs array', () => { - (useSelector as jest.Mock).mockReturnValue([]); + it('does not navigate to browser when opening TOS', () => { + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); - - act(() => { - result.current.navigateToCardPage(); - }); + act(() => { + result.current.navigateToInternalBrowserPage( + CardInternalBrowserPage.TOS, + ); + }); - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: { - newTabUrl: 'https://card.metamask.io/', - timestamp: expect.any(Number), - }, + expect(mockNavigation.navigate).not.toHaveBeenCalled(); }); }); - it('should handle multiple existing card tabs and use the first one found', () => { - const multipleBrowserTabs: BrowserTab[] = [ - { - id: 'card-tab-1', - url: 'https://card.metamask.io/dashboard', + describe('edge cases', () => { + it.each([undefined, null, []])( + 'handles browser tabs as %p without throwing', + (tabsValue) => { + (useSelector as jest.Mock).mockReturnValue(tabsValue); + + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); + + expect(() => { + act(() => { + result.current.navigateToInternalBrowserPage( + CardInternalBrowserPage.CARD, + ); + }); + }).not.toThrow(); }, - { - id: 'card-tab-2', - url: 'https://card.metamask.io/settings', - }, - ]; + ); - (useSelector as jest.Mock).mockReturnValue(multipleBrowserTabs); - (isCardUrl as jest.Mock).mockReturnValue(true); + it('uses first matching tab when multiple exist', () => { + const tabs = [ + createMockBrowserTab({ + id: 'first-tab', + url: 'https://card.metamask.io/page1', + }), + createMockBrowserTab({ + id: 'second-tab', + url: 'https://card.metamask.io/page2', + }), + ]; + (useSelector as jest.Mock).mockReturnValue(tabs); + (isCardUrl as jest.Mock).mockReturnValue(true); + + const { result } = renderHook(() => + useNavigateToInternalBrowserPage(mockNavigation), + ); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + act(() => { + result.current.navigateToInternalBrowserPage( + CardInternalBrowserPage.CARD, + ); + }); - act(() => { - result.current.navigateToCardPage(); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + params: expect.objectContaining({ existingTabId: 'first-tab' }), + }), + ); }); + }); +}); - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: { - existingTabId: 'card-tab-1', - newTabUrl: undefined, - timestamp: expect.any(Number), - }, - }); +describe('useNavigateToCardPage', () => { + let mockNavigation: NavigationProp; + let mockEventBuilder: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); }); - it('should handle browser tabs selector returning undefined', () => { - (useSelector as jest.Mock).mockReturnValue(undefined); + afterEach(() => { + jest.resetAllMocks(); + }); + it('returns all three navigation functions', () => { const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); - expect(() => { - act(() => { - result.current.navigateToCardPage(); - }); - }).not.toThrow(); + expect(typeof result.current.navigateToCardPage).toBe('function'); + expect(typeof result.current.navigateToTravelPage).toBe('function'); + expect(typeof result.current.navigateToCardTosPage).toBe('function'); }); - it('should handle null browser tabs', () => { - (useSelector as jest.Mock).mockReturnValue(null); + describe('navigateToCardPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); + }); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + it('navigates to card URL in browser', () => { + const { result } = renderHook(() => + useNavigateToCardPage(mockNavigation), + ); - expect(() => { act(() => { result.current.navigateToCardPage(); }); - }).not.toThrow(); - }); - - it('should generate unique timestamps for each call', () => { - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); - - const firstCallTime = Date.now(); - act(() => { - result.current.navigateToCardPage(); - }); - - jest.spyOn(Date, 'now').mockReturnValue(firstCallTime + 100); - act(() => { - result.current.navigateToCardPage(); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://card.metamask.io', + }), + }), + ); }); - expect(mockNavigation.navigate).toHaveBeenCalledTimes(2); + it('tracks CARD_BUTTON_CLICKED with NAVIGATE_TO_CARD_PAGE action', () => { + const { result } = renderHook(() => + useNavigateToCardPage(mockNavigation), + ); - const firstCall = (mockNavigation.navigate as jest.Mock).mock.calls[0][1]; - const secondCall = (mockNavigation.navigate as jest.Mock).mock.calls[1][1]; + act(() => { + result.current.navigateToCardPage(); + }); - expect(firstCall.params.timestamp).toBeGreaterThanOrEqual(firstCallTime); - expect(secondCall.params.timestamp).toBeGreaterThan( - firstCall.params.timestamp, - ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + action: CardActions.NAVIGATE_TO_CARD_PAGE, + }); + }); }); - it('should handle isCardUrl function throwing an error', () => { - (isCardUrl as jest.Mock).mockImplementation(() => { - throw new Error('URL parsing error'); + describe('navigateToTravelPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); }); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + it('navigates to travel URL in browser', () => { + const { result } = renderHook(() => + useNavigateToCardPage(mockNavigation), + ); - expect(() => { act(() => { - result.current.navigateToCardPage(); + result.current.navigateToTravelPage(); }); - }).toThrow('URL parsing error'); - }); - it('should use correct URL from AppConstants', () => { - (useSelector as jest.Mock).mockReturnValue([]); - (isCardUrl as jest.Mock).mockReturnValue(false); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://travel.metamask.io/access', + }), + }), + ); + }); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + it('tracks CARD_BUTTON_CLICKED with NAVIGATE_TO_TRAVEL_PAGE action', () => { + const { result } = renderHook(() => + useNavigateToCardPage(mockNavigation), + ); - act(() => { - result.current.navigateToCardPage(); - }); + act(() => { + result.current.navigateToTravelPage(); + }); - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: expect.objectContaining({ - newTabUrl: 'https://card.metamask.io/', - }), + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + action: CardActions.NAVIGATE_TO_TRAVEL_PAGE, + }); }); }); - it('should handle navigation prop methods being called', () => { - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); - - act(() => { - result.current.navigateToCardPage(); + describe('navigateToCardTosPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation = createMockNavigation(); + mockEventBuilder = createMockEventBuilder(); + setupMocks(mockEventBuilder); }); - expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - }); + it('opens TOS URL with Linking.openURL', () => { + const { result } = renderHook(() => + useNavigateToCardPage(mockNavigation), + ); - it('should handle tabs with missing properties gracefully', () => { - const incompleteTab = { - id: 'incomplete-tab', - } as BrowserTab; + act(() => { + result.current.navigateToCardTosPage(); + }); - (useSelector as jest.Mock).mockReturnValue([incompleteTab]); - (isCardUrl as jest.Mock).mockImplementation((url: string) => { - if (!url) return false; - return url.includes('card.metamask.io'); + expect(Linking.openURL).toHaveBeenCalledWith( + 'https://secure.baanx.co.uk/MM-Card-RoW-Terms-2025-Sept.pdf', + ); }); - const { result } = renderHook(() => useNavigateToCardPage(mockNavigation)); + it('does not navigate to browser', () => { + const { result } = renderHook(() => + useNavigateToCardPage(mockNavigation), + ); - expect(() => { act(() => { - result.current.navigateToCardPage(); + result.current.navigateToCardTosPage(); }); - }).not.toThrow(); - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: { - newTabUrl: 'https://card.metamask.io/', - timestamp: expect.any(Number), - }, + expect(mockNavigation.navigate).not.toHaveBeenCalled(); }); }); - it('should recalculate existing tab when browser tabs change', () => { + it('returns stable function references across rerenders', () => { const { result, rerender } = renderHook(() => useNavigateToCardPage(mockNavigation), ); - (useSelector as jest.Mock).mockReturnValue([]); - (isCardUrl as jest.Mock).mockReturnValue(false); - - act(() => { - result.current.navigateToCardPage(); - }); + const initialFunctions = { ...result.current }; + rerender(); - expect(mockNavigation.navigate).toHaveBeenLastCalledWith( - Routes.BROWSER.HOME, - { - screen: Routes.BROWSER.VIEW, - params: { - newTabUrl: 'https://card.metamask.io/', - timestamp: expect.any(Number), - }, - }, + expect(result.current.navigateToCardPage).toBe( + initialFunctions.navigateToCardPage, ); - - (useSelector as jest.Mock).mockReturnValue(mockBrowserTabs); - (isCardUrl as jest.Mock).mockImplementation( - (url: string) => url === 'https://card.metamask.io/dashboard', + expect(result.current.navigateToTravelPage).toBe( + initialFunctions.navigateToTravelPage, ); - - rerender(); - - act(() => { - result.current.navigateToCardPage(); - }); - - expect(mockNavigation.navigate).toHaveBeenLastCalledWith( - Routes.BROWSER.HOME, - { - screen: Routes.BROWSER.VIEW, - params: { - existingTabId: 'existing-tab-id', - newTabUrl: undefined, - timestamp: expect.any(Number), - }, - }, + expect(result.current.navigateToCardTosPage).toBe( + initialFunctions.navigateToCardTosPage, ); }); }); diff --git a/app/components/UI/Card/hooks/useNavigateToCardPage.tsx b/app/components/UI/Card/hooks/useNavigateToCardPage.tsx index 8d1a13547bc..e036336bfb0 100644 --- a/app/components/UI/Card/hooks/useNavigateToCardPage.tsx +++ b/app/components/UI/Card/hooks/useNavigateToCardPage.tsx @@ -1,52 +1,132 @@ +import { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '../../../../reducers'; import { BrowserTab } from '../../Tokens/types'; -import { isCardUrl } from '../../../../util/url'; +import { isCardUrl, isCardTravelUrl, isCardTosUrl } from '../../../../util/url'; import AppConstants from '../../../../core/AppConstants'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { CardActions } from '../util/metrics'; +import { Linking } from 'react-native'; -export const useNavigateToCardPage = ( +export enum CardInternalBrowserPage { + TRAVEL = 'travel', + TOS = 'tos', + CARD = 'card', +} + +const PAGE_CONFIG: Record< + CardInternalBrowserPage, + { + urlCheck: (url: string) => boolean; + getUrl: () => string; + action: CardActions; + } +> = { + [CardInternalBrowserPage.CARD]: { + urlCheck: isCardUrl, + getUrl: () => AppConstants.CARD.URL, + action: CardActions.NAVIGATE_TO_CARD_PAGE, + }, + [CardInternalBrowserPage.TRAVEL]: { + urlCheck: isCardTravelUrl, + getUrl: () => AppConstants.CARD.TRAVEL_URL, + action: CardActions.NAVIGATE_TO_TRAVEL_PAGE, + }, + [CardInternalBrowserPage.TOS]: { + urlCheck: isCardTosUrl, + getUrl: () => AppConstants.CARD.CARD_TOS_URL, + action: CardActions.NAVIGATE_TO_CARD_TOS_PAGE, + }, +}; + +export const useNavigateToInternalBrowserPage = ( navigation: NavigationProp, ) => { const browserTabs = useSelector((state: RootState) => state.browser.tabs); const { trackEvent, createEventBuilder } = useMetrics(); - const navigateToCardPage = () => { - const existingCardTab = browserTabs?.find(({ url }: BrowserTab) => - isCardUrl(url), - ); - - let existingTabId; - let newTabUrl; - - if (existingCardTab) { - existingTabId = existingCardTab.id; - } else { - const cardUrl = new URL(AppConstants.CARD.URL); - newTabUrl = cardUrl.href; - } - - const params = { - ...(newTabUrl && { newTabUrl }), - ...(existingTabId && { existingTabId, newTabUrl: undefined }), - timestamp: Date.now(), - }; - - navigation.navigate(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params, - }); - - trackEvent( - createEventBuilder( - MetaMetricsEvents.CARD_ADVANCED_CARD_MANAGEMENT_CLICKED, - ).build(), - ); + const navigateToInternalBrowserPage = useCallback( + (page: CardInternalBrowserPage) => { + const { urlCheck, getUrl, action } = PAGE_CONFIG[page]; + + if (page === CardInternalBrowserPage.TOS) { + Linking.openURL(getUrl()); + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action, + }) + .build(), + ); + return; + } + + const existingTab = browserTabs?.find(({ url }: BrowserTab) => + urlCheck(url), + ); + + let existingTabId; + let newTabUrl; + + if (existingTab) { + existingTabId = existingTab.id; + } else { + newTabUrl = getUrl(); + } + + const params = { + ...(newTabUrl && { newTabUrl }), + ...(existingTabId && { existingTabId, newTabUrl: undefined }), + timestamp: Date.now(), + }; + + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params, + }); + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action, + }) + .build(), + ); + }, + [browserTabs, navigation, trackEvent, createEventBuilder], + ); + + return { + navigateToInternalBrowserPage, }; +}; + +/** + * Hook that provides navigation functions for Card-related internal browser pages. + * Returns convenience methods for navigating to Card, Travel, and TOS pages. + */ +export const useNavigateToCardPage = ( + navigation: NavigationProp, +) => { + const { navigateToInternalBrowserPage } = + useNavigateToInternalBrowserPage(navigation); + + const navigateToCardPage = useCallback(() => { + navigateToInternalBrowserPage(CardInternalBrowserPage.CARD); + }, [navigateToInternalBrowserPage]); + + const navigateToTravelPage = useCallback(() => { + navigateToInternalBrowserPage(CardInternalBrowserPage.TRAVEL); + }, [navigateToInternalBrowserPage]); + + const navigateToCardTosPage = useCallback(() => { + navigateToInternalBrowserPage(CardInternalBrowserPage.TOS); + }, [navigateToInternalBrowserPage]); return { navigateToCardPage, + navigateToTravelPage, + navigateToCardTosPage, }; }; diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts index 40f1e342d2c..acf7127708a 100644 --- a/app/components/UI/Card/util/metrics.ts +++ b/app/components/UI/Card/util/metrics.ts @@ -50,6 +50,9 @@ enum CardActions { CLOSE_SPENDING_LIMIT_WARNING_DISMISS_BUTTON = 'CLOSE_SPENDING_LIMIT_WARNING_DISMISS_BUTTON', CLOSE_SPENDING_LIMIT_WARNING_SET_NEW_LIMIT_BUTTON = 'CLOSE_SPENDING_LIMIT_WARNING_SET_NEW_LIMIT_BUTTON', ASSET_ITEM_SELECT_TOKEN_BOTTOMSHEET = 'ASSET_ITEM_SELECT_TOKEN_BOTTOMSHEET', + NAVIGATE_TO_TRAVEL_PAGE = 'NAVIGATE_TO_TRAVEL_PAGE', + NAVIGATE_TO_CARD_TOS_PAGE = 'NAVIGATE_TO_CARD_TOS_PAGE', + NAVIGATE_TO_CARD_PAGE = 'NAVIGATE_TO_CARD_PAGE', } export { CardScreens, CardActions }; diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index de310f81458..d3f2354069e 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -39,6 +39,8 @@ export default { }, CARD: { URL: 'https://card.metamask.io', + TRAVEL_URL: 'https://travel.metamask.io/access', + CARD_TOS_URL: 'https://secure.baanx.co.uk/MM-Card-RoW-Terms-2025-Sept.pdf', }, CONNEXT: { HUB_EXCHANGE_CEILING_TOKEN: 69, diff --git a/app/util/url/index.ts b/app/util/url/index.ts index 8e45da0bd4a..18fda7d74c4 100644 --- a/app/util/url/index.ts +++ b/app/util/url/index.ts @@ -5,6 +5,7 @@ import AppConstants from '../../core/AppConstants'; * {@see {@link https://github.com/mathiasbynens/punycode.js?tab=readme-ov-file#installation} */ import { toASCII } from 'punycode/'; +import Logger from '../Logger'; const hostnameRegex = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/)?(?:www\.)?([^/?:]+)(?::\d+)?/; @@ -38,10 +39,32 @@ export const isCardUrl = (url: string) => { const currentUrl = new URL(url); return currentUrl.origin === AppConstants.CARD.URL; } catch (error) { + Logger.log('Error in isCardUrl', error); return false; } }; +export const isCardTravelUrl = (url: string) => { + try { + const currentUrl = new URL(url); + const travelUrl = new URL(AppConstants.CARD.TRAVEL_URL); + return currentUrl.origin === travelUrl.origin; + } catch (error) { + Logger.log('Error in isCardTravelUrl', error); + return false; + } +}; + +export const isCardTosUrl = (url: string) => { + try { + const currentUrl = new URL(url); + const tosUrl = new URL(AppConstants.CARD.CARD_TOS_URL); + return currentUrl.origin === tosUrl.origin; + } catch (error) { + Logger.log('Error in isCardTosUrl', error); + return false; + } +}; /** * This method does not use the URL library because it does not support punycode encoding in react native. * It compares the original hostname to a punycode version of the hostname. diff --git a/app/util/url/url.test.ts b/app/util/url/url.test.ts index 6f92465e783..83b996967ca 100644 --- a/app/util/url/url.test.ts +++ b/app/util/url/url.test.ts @@ -1,6 +1,9 @@ import { isPortfolioUrl, isBridgeUrl, + isCardUrl, + isCardTravelUrl, + isCardTosUrl, isValidASCIIURL, toPunycodeURL, isSameOrigin, @@ -67,6 +70,41 @@ describe('URL Check Functions', () => { }); }); + describe('isCardUrl', () => { + it.each([ + [AppConstants.CARD.URL, true], + [`${AppConstants.CARD.URL}/path`, true], + ['https://example.com', false], + ['invalid url', false], + ])('returns expected result for %s', (url, expected) => { + expect(isCardUrl(url)).toBe(expected); + }); + }); + + describe('isCardTravelUrl', () => { + it.each([ + [AppConstants.CARD.TRAVEL_URL, true], + [`${AppConstants.CARD.TRAVEL_URL}/booking`, true], + ['https://example.com', false], + ['invalid url', false], + ])('returns expected result for %s', (url, expected) => { + expect(isCardTravelUrl(url)).toBe(expected); + }); + }); + + describe('isCardTosUrl', () => { + const tosOrigin = new URL(AppConstants.CARD.CARD_TOS_URL).origin; + + it.each([ + [AppConstants.CARD.CARD_TOS_URL, true], + [`${tosOrigin}/other-doc.pdf`, true], + ['https://example.com', false], + ['invalid url', false], + ])('returns expected result for %s', (url, expected) => { + expect(isCardTosUrl(url)).toBe(expected); + }); + }); + describe('isValidASCIIURL', () => { it('returns true for URL containing only ASCII characters in its hostname', () => { expect(isValidASCIIURL('https://www.google.com')).toEqual(true); diff --git a/e2e/selectors/Card/CardHome.selectors.ts b/e2e/selectors/Card/CardHome.selectors.ts index b7672f94594..f23ebe19998 100644 --- a/e2e/selectors/Card/CardHome.selectors.ts +++ b/e2e/selectors/Card/CardHome.selectors.ts @@ -7,6 +7,8 @@ export const CardHomeSelectors = { ADD_FUNDS_BUTTON: 'add-funds-button', CHANGE_ASSET_BUTTON: 'change-asset-button', ADVANCED_CARD_MANAGEMENT_ITEM: 'advanced-card-management-item', + TRAVEL_ITEM: 'travel-item', + CARD_TOS_ITEM: 'card-tos-item', ENABLE_CARD_BUTTON: 'enable-card-button', ENABLE_ASSETS_BUTTON: 'enable-assets-button', MANAGE_SPENDING_LIMIT_ITEM: 'manage-spending-limit-item', diff --git a/locales/languages/en.json b/locales/languages/en.json index 904c7b77f90..de0a2d0321a 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6705,7 +6705,11 @@ "manage_spending_limit_description_restricted": "Limited spending is on", "manage_spending_limit_description_full": "Full access is on", "manage_card": "Manage card", - "advanced_card_management_description": "See card details, transactions and more" + "advanced_card_management_description": "See card details, transactions and more", + "travel_title": "MetaMask Travel", + "travel_description": "Book hotels with up to 60% discounts vs. Expedia", + "card_tos_title": "Card Terms and Conditions", + "card_tos_description": "Read the card provider's terms" } }, "card_spending_limit": { From 8be0a09a42b030f7ce0fe10a87a9e4181c0f9753 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Fri, 5 Dec 2025 15:53:03 +0000 Subject: [PATCH 09/11] test: disable predict feature flag globally and only enable in predict tests (#23709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Disable predict feature in main feature flag mock - Suppress predict modal as this can cause other tests to fail Screenshot 2025-12-05 at 10 27 20 ## **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** - [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 - [ ] I’ve included tests if applicable - [ ] 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 disabled Predict GTM onboarding modal flag to mocks and sets CI env to suppress the modal, keeping Predict trading disabled by default. > > - **E2E API Mocks**: > - Add `predictGtmOnboardingModalEnabled` (disabled; `minimumVersion: '7.60.0'`) to `DEFAULT_FEATURE_FLAGS_ARRAY` in `e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts` and to `remoteFeatureFlagPredictEnabled` in `e2e/api-mocking/mock-responses/feature-flags-mocks.ts`. > - Ensure `predictTradingEnabled` remains disabled by default in mocks. > - **CI Workflows**: > - Set `MM_PREDICT_GTM_MODAL_ENABLED: 'false'` in `/.github/workflows/build-android-e2e.yml` and `/.github/workflows/build-ios-e2e.yml` to suppress the modal during E2E builds. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a737aac971ba7d903f7b5fc622b75d6bb568665a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/build-android-e2e.yml | 1 + .github/workflows/build-ios-e2e.yml | 1 + e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts | 6 ++++++ e2e/api-mocking/mock-responses/feature-flags-mocks.ts | 4 ++++ 4 files changed, 12 insertions(+) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 4effd0be4de..2b407efa6f8 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -183,6 +183,7 @@ jobs: GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + MM_PREDICT_GTM_MODAL_ENABLED: 'false' - name: Repack APK with JS updates using @expo/repack-app if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' }} diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 8fe0940cd6d..ec839409ea9 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -48,6 +48,7 @@ jobs: GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + MM_PREDICT_GTM_MODAL_ENABLED: 'false' steps: # Get the source code from the repository diff --git a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts index 8e62b9b8552..0ccf7c21157 100644 --- a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts +++ b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts @@ -270,6 +270,12 @@ const DEFAULT_FEATURE_FLAGS_ARRAY: Record[] = [ minimumVersion: '7.60.0', }, }, + { + predictGtmOnboardingModalEnabled: { + enabled: false, + minimumVersion: '7.60.0', + }, + }, { additionalNetworksBlacklist: [], // Empty by default, can be overridden in tests }, diff --git a/e2e/api-mocking/mock-responses/feature-flags-mocks.ts b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts index a8b9a05962d..7ab5b5e6827 100644 --- a/e2e/api-mocking/mock-responses/feature-flags-mocks.ts +++ b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts @@ -150,6 +150,10 @@ export const remoteFeatureFlagPredictEnabled = (enabled = true) => ({ enabled, minimumVersion: '7.60.0', }, + predictGtmOnboardingModalEnabled: { + enabled: false, + minimumVersion: '7.60.0', + }, }); export const remoteFeatureFlagSendRedesignDisabled = { From 488a864685df697ba46d2db0b0414e154586f3c0 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:00:48 +0100 Subject: [PATCH 10/11] fix: NFT: Cannot read property toLowerCase of undefined cp-7.61.0 (#23679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes this: Cannot read property toLowerCase of undefined. ## **Changelog** CHANGELOG entry: Fixes this: Cannot read property toLowerCase of undefined ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/23533 ## **Manual testing steps** ## **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 support for extracting the recipient from ERC-721 `safeTransferFrom` transactions and adds a unit test to verify it. > > - **Hooks**: > - Update `useTransferRecipient` to handle `TransactionType.tokenMethodSafeTransferFrom`, parsing the recipient from transaction `data`. > - **Tests**: > - Add `NFT safeTransferFrom` case in `useTransferRecipient.test.ts` to assert correct recipient extraction. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c850faba4b6c198f66177befa303ffea5d96d5ba. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../transactions/useTransferRecipient.test.ts | 27 +++++++++++++++++++ .../transactions/useTransferRecipient.ts | 1 + 2 files changed, 28 insertions(+) diff --git a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts index 0f8e4f31c3b..8f4a82af652 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.test.ts @@ -44,6 +44,25 @@ const erc20TransferState = merge({}, transferConfirmationState, { }, }); +const nftSafeTransferState = merge({}, transferConfirmationState, { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { + // safeTransferFrom(address from, address to, uint256 tokenId) + data: '0x42842e0e000000000000000000000000dc47789de4ceff0e8fe9d15d728af7f17550c16400000000000000000000000097cb1fdd071da9960d38306c07f146bc98b2d3170000000000000000000000000000000000000000000000000000000000000001', + from: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }, + }, + ], + }, + }, + }, +}); + const noNestedTransactionsState = merge({}, transferConfirmationState, { engine: { backgroundState: { @@ -192,6 +211,14 @@ describe('useTransferRecipient', () => { expect(result.current).toBe('0x97cb1fdD071da9960d38306C07F146bc98b2D317'); }); + + it('returns the correct recipient for NFT safeTransferFrom', async () => { + const { result } = renderHookWithProvider(() => useTransferRecipient(), { + state: nftSafeTransferState, + }); + + expect(result.current).toBe('0x97cb1fdD071da9960d38306C07F146bc98b2D317'); + }); }); describe('useNestedTransactionTransferRecipients', () => { diff --git a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts index bf95906dce8..f5c3cf12b40 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransferRecipient.ts @@ -56,6 +56,7 @@ function getRecipientByType( return transactionTo; case TransactionType.tokenMethodTransfer: case TransactionType.tokenMethodTransferFrom: + case TransactionType.tokenMethodSafeTransferFrom: return getTransactionDataRecipient(data); default: return undefined; From cb0a747fdb6acb272f834bdf794a18c9f2f8d0d1 Mon Sep 17 00:00:00 2001 From: AxelGes <34173844+AxelGes@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:38:41 -0300 Subject: [PATCH 11/11] fix(ramps): use asset chainId to fetch correct token balance in useBalance cp-7.61.0 (#23719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `useBalance` hook in the Ramp Aggregator was using `selectContractBalances`, which returns token balances only for the **currently selected network**. This caused incorrect behavior. **Solution:** Changed the hook to use `selectContractBalancesPerChainId` and access the correct chain's balances using the `asset.chainId` parameter that is already passed to the hook. ## **Changelog** CHANGELOG entry: Fixed incorrect token balance check in Ramp when selecting tokens from non-active networks ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2875?atlOrigin=eyJpIjoiNDE5YjQ4YjQ3NGRkNGUzMGFkODUxNjI0M2QxMzlkNjUiLCJwIjoiaiJ9 Fixes https://github.com/MetaMask/metamask-mobile/issues/23722 ## **Manual testing steps** ```gherkin Feature: Ramp token balance check Scenario: user sells USDC on Arbitrum while connected to different network Given user has 40 USDC on Arbitrum And user's wallet is connected to Mainnet When user navigates to Sell flow in Ramp And user selects USDC on Arbitrum as the asset to sell And user enters an amount less than their balance (e.g. 22) Then the "this amount is higher than your balance" error should NOT appear And the balance should correctly reflect the Arbitrum USDC balance ``` ## **Screenshots/Recordings** ### **Before** `balanceBN` was `null` for tokens on non-active networks, causing false insufficient balance errors. (quick amount selector isn't shown because amount is not being detected) image ### **After** `balanceBN` correctly reflects the token balance on the asset's chain regardless of the currently active network. 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] > Compute token balances from the asset’s chain using per-chain balances, fixing incorrect balances when the active network differs. > > - **Ramp Aggregator `useBalance`** (`app/components/UI/Ramp/Aggregator/hooks/useBalance.ts`): > - Switch to `selectContractBalancesPerChainId` and index by the asset’s `chainId` (via `toHex`) to retrieve correct per-chain ERC-20 balances. > - Update balance computations (`balance`, `balanceBN`, `balanceFiat`) to use chain-scoped balances and existing exchange rates. > - Ensure native balance lookup uses `accountsByChainId[hexChainId]` guard for the asset’s chain. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2fa36f196b255d7e4a88785f658992dcdc3a9f5c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Co-authored-by: Pedro Pablo Aste Kompen --- .../UI/Ramp/Aggregator/hooks/useBalance.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/hooks/useBalance.ts b/app/components/UI/Ramp/Aggregator/hooks/useBalance.ts index 2d8aad4d109..283029cbcef 100644 --- a/app/components/UI/Ramp/Aggregator/hooks/useBalance.ts +++ b/app/components/UI/Ramp/Aggregator/hooks/useBalance.ts @@ -6,7 +6,7 @@ import { selectCurrentCurrency, } from '../../../../../selectors/currencyRateController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; -import { selectContractBalances } from '../../../../../selectors/tokenBalancesController'; +import { selectContractBalancesPerChainId } from '../../../../../selectors/tokenBalancesController'; import { selectContractExchangeRates } from '../../../../../selectors/tokenRatesController'; import { safeToChecksumAddress } from '../../../../../util/address'; import { @@ -52,7 +52,7 @@ export default function useBalance(asset?: Asset) { const conversionRate = useSelector(selectConversionRate); const currentCurrency = useSelector(selectCurrentCurrency); const tokenExchangeRates = useSelector(selectContractExchangeRates); - const balances = useSelector(selectContractBalances); + const balancesPerChainId = useSelector(selectContractBalancesPerChainId); if (!asset || (!asset.address && !asset.assetId) || !selectedAddress) { return defaultReturn; @@ -99,10 +99,13 @@ export default function useBalance(asset?: Asset) { } else if (asset.address) { const assetAddress = safeToChecksumAddress(asset.address); const exchangeRate = tokenExchangeRates?.[assetAddress as Hex]?.price; + // Use the asset's chainId to get balances for the correct chain + const hexChainId = asset.chainId ? toHex(asset.chainId) : undefined; + const chainBalances = hexChainId ? balancesPerChainId[hexChainId] : {}; balance = - assetAddress && assetAddress in balances + assetAddress && chainBalances && assetAddress in chainBalances ? renderFromTokenMinimalUnit( - balances[assetAddress], + chainBalances[assetAddress], asset.decimals ?? 18, ) : 0; @@ -113,8 +116,8 @@ export default function useBalance(asset?: Asset) { currentCurrency, ); balanceBN = - assetAddress && assetAddress in balances - ? hexToBN(balances[assetAddress]) + assetAddress && chainBalances && assetAddress in chainBalances + ? hexToBN(chainBalances[assetAddress]) : null; }