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/.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 }} 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/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/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/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/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/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/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) 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/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; } 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/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/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/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/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; 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/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/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/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, + }; + }, +); 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/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/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 = { 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 8a82c928ecd..de0a2d0321a 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", @@ -5663,11 +5663,11 @@ "fee": "Fee" }, "musd_conversion": { - "confirmation_button": "Convert to mUSD", - "earn_rewards_with": "Earn rewards with mUSD", + "convert_to_musd": "Convert to 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": { @@ -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": { 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"