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 (
+
+
+
+ );
+};
+
+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"