From 7b4433ec12ec02376eedd6394b8b7241e4abfbbf Mon Sep 17 00:00:00 2001
From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com>
Date: Fri, 6 Mar 2026 06:51:48 +0000
Subject: [PATCH 1/9] fix: reject pending confirmations when app locks (#26905)
## **Description**
If the user is on a send confirmation and the app goes idle until the
device and MetaMask lock, after unlocking they no longer see that
confirmation. If they then start a **new** send, the UI can show the
**previous** confirmation instead of the new one, because the old
approval was never rejected and remains first in the pending list.
**Solution**
When the app locks, reject all pending approvals by calling
`ApprovalController.clear(providerErrors.userRejectedRequest())` in the
lock saga, before navigating to the lock screen. That way there are no
stale confirmations after unlock, and any new send shows the correct
confirmation.
**Changes**
- **`app/store/sagas/index.ts`**: In `appLockStateMachine`, after
handling `LOCKED_APP`, clear pending approvals via
`Engine.context.ApprovalController.clear(...)` inside try/catch, then
navigate to `LOCK_SCREEN`. Log and ignore errors so navigation still
runs.
- **`app/store/sagas/sagas.test.ts`**: Add `ApprovalController` with
`clear` to the Engine mock; add tests that clear is called with
`userRejectedRequest()` when the app locks and that navigation to
`LOCK_SCREEN` still happens when `clear` throws.
-
## **Changelog**
CHANGELOG entry: Fixed issue of confirmation not rejecting when app
locks
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/26320
## **Manual testing steps**
```gherkin
Feature: Transaction Confirmation Persistence After Lock
Scenario: Stale confirmation displayed after device lock timeout and new transaction
Given the user has MetaMask open and unlocked on the home screen
# First transaction
When user initiates a send transaction
And user reaches the confirmation screen
# Lock timeout
And user allows the phone to idle until device and MetaMask lock
And user unlocks the phone
And user unlocks MetaMask
Then the confirmation screen should no longer be open
# Second transaction - bug occurs
When user initiates a different send transaction
And user reaches the confirmation screen
Then the confirmation shown should be for the previous transaction instead of the current one
```
## **Screenshots/Recordings**
[reject-approval-app-locks.webm](https://github.com/user-attachments/assets/ed331559-bf7a-452b-8688-7014dd4bff34)
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Changes approval/confirmation lifecycle by clearing all pending
approvals on app lock, which could inadvertently reject legitimate
in-flight requests if triggered unexpectedly. Guarded with try/catch and
covered by new saga tests, but behavior impacts transaction
confirmations.
>
> **Overview**
> Prevents stale transaction/permission confirmations after unlocking by
clearing any pending approvals when `UserActionType.LOCKED_APP` fires,
rejecting them with `providerErrors.userRejectedRequest()` before
navigating to `Routes.LOCK_SCREEN`.
>
> Updates saga tests to mock `ApprovalController.clear` and assert it is
invoked on lock, and that navigation to the lock screen still occurs
even if clearing approvals throws.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2f1c2d302b71c517322f59d53c495bfd35da5f30. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/store/sagas/index.ts | 14 ++++++++++++++
app/store/sagas/sagas.test.ts | 31 +++++++++++++++++++++++++++++++
2 files changed, 45 insertions(+)
diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts
index 75e31bf2019..6ce9589bedd 100644
--- a/app/store/sagas/index.ts
+++ b/app/store/sagas/index.ts
@@ -34,6 +34,7 @@ import { rewardsBulkLinkSaga } from './rewardsBulkLinkAccountGroups';
import Authentication from '../../core/Authentication';
import { AppState, AppStateStatus } from 'react-native';
import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics';
+import { providerErrors } from '@metamask/rpc-errors';
/**
* Creates a channel to listen to app state changes.
@@ -109,6 +110,19 @@ export function* appLockStateMachine() {
while (true) {
yield take(UserActionType.LOCKED_APP);
+ // Reject any pending confirmations so the user doesn't see a stale confirmation after unlock.
+ try {
+ const { ApprovalController } = Engine.context;
+ if (ApprovalController) {
+ ApprovalController.clear(providerErrors.userRejectedRequest());
+ }
+ } catch (error) {
+ Logger.error(
+ error as Error,
+ 'Failed to reject pending approvals on app lock',
+ );
+ }
+
// Navigate to lock screen.
NavigationService.navigation?.navigate(Routes.LOCK_SCREEN);
diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts
index 9c05603cc4d..76033332988 100644
--- a/app/store/sagas/sagas.test.ts
+++ b/app/store/sagas/sagas.test.ts
@@ -25,6 +25,7 @@ import WC2Manager from '../../core/WalletConnect/WalletConnectV2';
import Authentication from '../../core/Authentication';
import AppConstants from '../../core/AppConstants';
import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics';
+import { providerErrors } from '@metamask/rpc-errors';
const mockNavigate = jest.fn();
const mockReset = jest.fn();
@@ -75,6 +76,9 @@ jest.mock('../../core/Engine', () => ({
AccountsController: {
updateAccounts: jest.fn(),
},
+ ApprovalController: {
+ clear: jest.fn(),
+ },
RemoteFeatureFlagController: {
state: {
remoteFeatureFlags: {
@@ -344,9 +348,13 @@ describe('appStateListenerTask', () => {
});
describe('appLockStateMachine', () => {
+ const mockApprovalControllerClear = Engine.context.ApprovalController
+ .clear as jest.Mock;
+
beforeEach(() => {
mockNavigate.mockClear();
mockReset.mockClear();
+ mockApprovalControllerClear.mockClear();
});
it('forks appStateListenerTask and navigates to LockScreen when app is locked', async () => {
@@ -359,6 +367,29 @@ describe('appLockStateMachine', () => {
// Verify navigation to LockScreen
expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN);
});
+
+ it('clears pending approvals via ApprovalController.clear when app is locked', async () => {
+ await expectSaga(appLockStateMachine)
+ .dispatch({ type: UserActionType.LOCKED_APP })
+ .run();
+
+ expect(mockApprovalControllerClear).toHaveBeenCalledWith(
+ providerErrors.userRejectedRequest(),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN);
+ });
+
+ it('navigates to LockScreen even when ApprovalController.clear throws', async () => {
+ mockApprovalControllerClear.mockImplementationOnce(() => {
+ throw new Error('clear failed');
+ });
+
+ await expectSaga(appLockStateMachine)
+ .dispatch({ type: UserActionType.LOCKED_APP })
+ .run();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN);
+ });
});
// TODO: Update all saga tests to use expectSaga (more intuitive and easier to read)
From 724390406b359a574b8c5138128d14517286749e Mon Sep 17 00:00:00 2001
From: Vince Howard
Date: Fri, 6 Mar 2026 15:17:30 +0700
Subject: [PATCH 2/9] chore(homepage): homepage session summary segment event
(#26634)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Adds a `session_summary` analytics event that fires when a user
navigates away from the homepage. This completes the homepage sections
analytics suite alongside the existing `section_viewed` event.
The event reuses the `HOMEPAGE_SECTION_VIEWED` Segment event with
`interaction_type: 'session_summary'` and captures:
- `total_sections_viewed`— how many sections reached ≥50% visibility
this visit
- `total_sections_loaded` — how many sections were enabled via feature
flags
- `entry_point` — how the user arrived (app_opened, home_tab,
navigated_back)
- `session_time` — seconds spent on the homepage
location: 'home'
Implementation details:
- New `useHomepageSessionSummary` hook owns all session tracking. All
state lives in refs — zero re-renders on scroll or blur path.
- Reacts to visitId increments from `useHomepageEntryPoint` to detect
focus and reset per-visit state.
- Fires on navigation blur via a stable ref-wrapped callback to avoid
stale closures.
- `notifySectionViewed` added to `HomepageScrollContext` so sections
self-report views when their `section_viewed` event fires.
Segment Event PR: https://github.com/Consensys/segment-schema/pull/477
This PR needs to be merged first before review:
https://github.com/MetaMask/metamask-mobile/pull/26529
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Homepage session summary analytics
Scenario: user navigates away from the homepage
Given the homepage sections feature flag is enabled
And the user is on the homepage
When user navigates to another screen (e.g. sends a transaction)
Then a session_summary event fires with interaction_type "session_summary"
And session_time reflects time spent on the homepage
And total_sections_viewed reflects sections that entered the viewport
Scenario: feature flag is disabled
Given the homepage sections feature flag is disabled
When user navigates away from the homepage
Then no session_summary event is fired
```
## **Screenshots/Recordings**
https://github.com/user-attachments/assets/573caf7a-3afe-4cee-a1e1-5f447f877bac
### **Before**
`~`
### **After**
`~`
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Adds new analytics firing on homepage blur and expands
`HomepageScrollContext` contract, which could affect event
volume/accuracy if focus/visit tracking is wrong. Runtime risk is
limited since it uses refs/sets and doesn’t change wallet transaction or
account logic.
>
> **Overview**
> Adds a new homepage *session summary* analytics emission:
`useHomeSessionSummary` fires `MetaMetricsEvents.HOME_VIEWED` with
`interaction_type: 'session_summary'` when the homepage blurs, including
`session_time`, `entry_point`, `total_sections_loaded`, and
`total_sections_viewed`.
>
> Extends `HomepageScrollContext` with
`notifySectionViewed`/`getViewedSectionCount`; `Wallet` now tracks
distinct viewed sections per `visitId` in a ref-backed `Set`, and
`useHomeViewedEvent` reports section views into this aggregator.
Includes new unit tests for the session-summary hook and updates
existing section-view tests for the new context fields.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
d91715f6f540153af765b33f7a9d979a94843bff. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/components/Views/Homepage/Homepage.tsx | 3 +
.../Homepage/context/HomepageScrollContext.ts | 13 +
.../hooks/useHomeSessionSummary.test.ts | 340 ++++++++++++++++++
.../Homepage/hooks/useHomeSessionSummary.ts | 77 ++++
.../Homepage/hooks/useHomeViewedEvent.test.ts | 6 +
.../Homepage/hooks/useHomeViewedEvent.ts | 3 +
app/components/Views/Wallet/index.tsx | 28 +-
7 files changed, 469 insertions(+), 1 deletion(-)
create mode 100644 app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts
create mode 100644 app/components/Views/Homepage/hooks/useHomeSessionSummary.ts
diff --git a/app/components/Views/Homepage/Homepage.tsx b/app/components/Views/Homepage/Homepage.tsx
index b729a8ee1f4..46136441bc4 100644
--- a/app/components/Views/Homepage/Homepage.tsx
+++ b/app/components/Views/Homepage/Homepage.tsx
@@ -17,6 +17,7 @@ import { selectPerpsEnabledFlag } from '../../UI/Perps';
import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags';
import { selectAssetsDefiPositionsEnabled } from '../../../selectors/featureFlagController/assetsDefiPositions';
import { HomeSectionNames, HomeSectionName } from './hooks/useHomeViewedEvent';
+import useHomeSessionSummary from './hooks/useHomeSessionSummary';
/**
* Homepage component - Main view for the redesigned wallet homepage.
@@ -53,6 +54,8 @@ const Homepage = forwardRef((_, ref) => {
const totalSectionsLoaded = enabledSections.length;
+ useHomeSessionSummary({ totalSectionsLoaded });
+
const getSectionIndex = useCallback(
(name: HomeSectionName) =>
enabledSections.findIndex((s) => s.name === name),
diff --git a/app/components/Views/Homepage/context/HomepageScrollContext.ts b/app/components/Views/Homepage/context/HomepageScrollContext.ts
index 17135e40d5a..294683f9450 100644
--- a/app/components/Views/Homepage/context/HomepageScrollContext.ts
+++ b/app/components/Views/Homepage/context/HomepageScrollContext.ts
@@ -1,4 +1,5 @@
import { createContext, useContext } from 'react';
+import type { HomeSectionName } from '../hooks/useHomeViewedEvent';
export const HomepageEntryPoints = {
APP_OPENED: 'app_opened',
@@ -36,6 +37,16 @@ interface HomepageScrollContextValue {
* this to reset their "has fired" state and re-fire on every visit.
*/
visitId: number;
+ /**
+ * Called by each section immediately after its section_viewed event fires.
+ * Used to aggregate the total number of distinct sections viewed this visit.
+ */
+ notifySectionViewed: (sectionName: HomeSectionName) => void;
+ /**
+ * Returns the number of distinct sections viewed during the current visit.
+ * Intended for use in the session_summary event fired on blur.
+ */
+ getViewedSectionCount: () => number;
}
const noop = () => () => {
@@ -48,6 +59,8 @@ const defaultValue: HomepageScrollContextValue = {
containerScreenY: 0,
entryPoint: HomepageEntryPoints.APP_OPENED,
visitId: 0,
+ notifySectionViewed: () => undefined,
+ getViewedSectionCount: () => 0,
};
export const HomepageScrollContext =
diff --git a/app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts b/app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts
new file mode 100644
index 00000000000..76375cd3171
--- /dev/null
+++ b/app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts
@@ -0,0 +1,340 @@
+import { useFocusEffect } from '@react-navigation/native';
+import { renderHook, act } from '@testing-library/react-hooks';
+import useHomeSessionSummary from './useHomeSessionSummary';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+
+// --- @react-navigation/native mock ---
+jest.mock('@react-navigation/native', () => ({
+ useFocusEffect: jest.fn(),
+}));
+
+const mockUseFocusEffect = useFocusEffect as jest.MockedFunction<
+ typeof useFocusEffect
+>;
+
+// --- Analytics mock ---
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ builtEvent: true }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn(() => ({
+ addProperties: mockAddProperties,
+}));
+
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
+// --- Scroll context mock ---
+const HomepageEntryPoints = {
+ APP_OPENED: 'app_opened',
+ HOME_TAB: 'home_tab',
+ NAVIGATED_BACK: 'navigated_back',
+} as const;
+
+let mockGetViewedSectionCount = jest.fn(() => 3);
+let mockNotifySectionViewed = jest.fn();
+
+let mockContextValue = {
+ subscribeToScroll: jest.fn(() => jest.fn()),
+ viewportHeight: 800,
+ containerScreenY: 0,
+ entryPoint: HomepageEntryPoints.APP_OPENED as string,
+ visitId: 1,
+ notifySectionViewed: mockNotifySectionViewed,
+ getViewedSectionCount: mockGetViewedSectionCount,
+};
+
+jest.mock('../context/HomepageScrollContext', () => ({
+ useHomepageScrollContext: () => mockContextValue,
+ HomepageEntryPoints: {
+ APP_OPENED: 'app_opened',
+ HOME_TAB: 'home_tab',
+ NAVIGATED_BACK: 'navigated_back',
+ },
+}));
+
+// --- Helpers ---
+
+/** Type for addProperties(firstArg) so mock.calls is safely indexable after toHaveBeenCalled(). */
+type AddPropertiesCall = [Record];
+
+/**
+ * Simulate useFocusEffect: call the callback (focus) and return a function
+ * that invokes the cleanup (blur).
+ */
+const setupFocusBlur = () => {
+ let blurCleanup: (() => void) | undefined;
+ mockUseFocusEffect.mockImplementation((callback) => {
+ blurCleanup = (callback as () => (() => void) | undefined)();
+ });
+ return {
+ simulateBlur: () => blurCleanup?.(),
+ };
+};
+
+describe('useHomeSessionSummary', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetViewedSectionCount = jest.fn(() => 3);
+ mockNotifySectionViewed = jest.fn();
+ mockContextValue = {
+ subscribeToScroll: jest.fn(() => jest.fn()),
+ viewportHeight: 800,
+ containerScreenY: 0,
+ entryPoint: HomepageEntryPoints.APP_OPENED,
+ visitId: 1,
+ notifySectionViewed: mockNotifySectionViewed,
+ getViewedSectionCount: mockGetViewedSectionCount,
+ };
+ });
+
+ describe('blur guard — visitId === 0', () => {
+ it('does not fire when visitId is 0 (pre-focus state)', () => {
+ mockContextValue = { ...mockContextValue, visitId: 0 };
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('fires on blur only', () => {
+ it('does not fire on focus — only fires when the blur cleanup runs', () => {
+ // Track whether trackEvent is called during focus (render) phase
+ let calledDuringFocus = false;
+ mockUseFocusEffect.mockImplementation((callback) => {
+ // Call the focus callback and check if track was called before blur
+ (callback as () => void)();
+ calledDuringFocus = mockTrackEvent.mock.calls.length > 0;
+ });
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ expect(calledDuringFocus).toBe(false);
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+
+ it('fires exactly once on blur', () => {
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('event properties', () => {
+ it('uses the HOME_VIEWED MetaMetrics event', () => {
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.HOME_VIEWED,
+ );
+ });
+
+ it('fires with interaction_type: session_summary', () => {
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ interaction_type: 'session_summary' }),
+ );
+ });
+
+ it('fires with location: home', () => {
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ location: 'home' }),
+ );
+ });
+
+ it('uses getViewedSectionCount() for total_sections_viewed', () => {
+ mockGetViewedSectionCount = jest.fn(() => 4);
+ mockContextValue = {
+ ...mockContextValue,
+ getViewedSectionCount: mockGetViewedSectionCount,
+ };
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ total_sections_viewed: 4 }),
+ );
+ });
+
+ it('uses the totalSectionsLoaded prop for total_sections_loaded', () => {
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 3 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ total_sections_loaded: 3 }),
+ );
+ });
+
+ it('uses the entry_point from context', () => {
+ mockContextValue = {
+ ...mockContextValue,
+ entryPoint: HomepageEntryPoints.HOME_TAB,
+ };
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ entry_point: HomepageEntryPoints.HOME_TAB }),
+ );
+ });
+
+ it('includes a non-negative session_time (in seconds)', () => {
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalled();
+ const calls = mockAddProperties.mock
+ .calls as unknown as AddPropertiesCall[];
+ const props = calls[0][0];
+ expect(typeof props.session_time).toBe('number');
+ expect(props.session_time).toBeGreaterThanOrEqual(0);
+ });
+
+ it('passes the built event to trackEvent', () => {
+ const builtEvent = { builtEvent: true };
+ mockBuild.mockReturnValue(builtEvent);
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(builtEvent);
+ });
+
+ it('fires with all required properties', () => {
+ mockGetViewedSectionCount = jest.fn(() => 2);
+ mockContextValue = {
+ ...mockContextValue,
+ entryPoint: HomepageEntryPoints.NAVIGATED_BACK,
+ getViewedSectionCount: mockGetViewedSectionCount,
+ };
+ const { simulateBlur } = setupFocusBlur();
+
+ renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 4 }));
+
+ act(() => {
+ simulateBlur();
+ });
+
+ expect(mockAddProperties).toHaveBeenCalled();
+ const calls = mockAddProperties.mock
+ .calls as unknown as AddPropertiesCall[];
+ const props = calls[0][0];
+ expect(props).toMatchObject({
+ interaction_type: 'session_summary',
+ location: 'home',
+ total_sections_viewed: 2,
+ total_sections_loaded: 4,
+ entry_point: HomepageEntryPoints.NAVIGATED_BACK,
+ });
+ expect(typeof props.session_time).toBe('number');
+ });
+ });
+
+ describe('session timer resets on new visit', () => {
+ it('session_time reflects time since most recent visitId change', () => {
+ jest.useFakeTimers();
+
+ const { simulateBlur } = setupFocusBlur();
+ let currentVisitId = 1;
+ mockContextValue = { ...mockContextValue, visitId: currentVisitId };
+
+ const { rerender } = renderHook(() =>
+ useHomeSessionSummary({ totalSectionsLoaded: 5 }),
+ );
+
+ // Advance 10 seconds and blur — first visit
+ jest.advanceTimersByTime(10_000);
+
+ act(() => {
+ simulateBlur();
+ });
+
+ const addPropertiesCalls = mockAddProperties.mock
+ .calls as unknown as AddPropertiesCall[];
+ const firstSessionTime = addPropertiesCalls[0][0].session_time as number;
+ expect(firstSessionTime).toBeGreaterThanOrEqual(10);
+
+ // Simulate new visit: visitId increments
+ currentVisitId = 2;
+ mockContextValue = { ...mockContextValue, visitId: currentVisitId };
+
+ act(() => {
+ rerender();
+ });
+
+ // Only 2 seconds into the new visit before blur
+ jest.advanceTimersByTime(2_000);
+
+ act(() => {
+ simulateBlur();
+ });
+
+ const secondSessionTime = addPropertiesCalls[1][0].session_time as number;
+ // Second visit was only ~2 s, not ~12 s
+ expect(secondSessionTime).toBeLessThan(firstSessionTime);
+ expect(secondSessionTime).toBeGreaterThanOrEqual(2);
+
+ jest.useRealTimers();
+ });
+ });
+});
diff --git a/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts b/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts
new file mode 100644
index 00000000000..24008d7649e
--- /dev/null
+++ b/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts
@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useFocusEffect } from '@react-navigation/native';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import { useHomepageScrollContext } from '../context/HomepageScrollContext';
+
+interface UseHomeSessionSummaryParams {
+ totalSectionsLoaded: number;
+}
+
+/**
+ * Fires a `Home Viewed` Segment event with `interaction_type: 'session_summary'`
+ * when the user navigates away from the homepage. Captures:
+ *
+ * - `total_sections_viewed` — distinct sections that reached ≥50% visibility
+ * - `total_sections_loaded` — sections enabled via feature flags
+ * - `entry_point` — how the user arrived (app_opened, home_tab, navigated_back)
+ * - `session_time` — seconds spent on the homepage this visit
+ *
+ * All session state is held in refs — no re-renders occur on scroll or blur.
+ */
+const useHomeSessionSummary = ({
+ totalSectionsLoaded,
+}: UseHomeSessionSummaryParams) => {
+ const { visitId, entryPoint, getViewedSectionCount } =
+ useHomepageScrollContext();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
+ const sessionStartRef = useRef(Date.now());
+
+ // Reset session start time on each new visit (visitId increments on focus).
+ useEffect(() => {
+ sessionStartRef.current = Date.now();
+ }, [visitId]);
+
+ // Stable refs for the blur callback to avoid stale closure issues.
+ const visitIdRef = useRef(visitId);
+ const entryPointRef = useRef(entryPoint);
+ const totalSectionsLoadedRef = useRef(totalSectionsLoaded);
+
+ useEffect(() => {
+ visitIdRef.current = visitId;
+ }, [visitId]);
+ useEffect(() => {
+ entryPointRef.current = entryPoint;
+ }, [entryPoint]);
+ useEffect(() => {
+ totalSectionsLoadedRef.current = totalSectionsLoaded;
+ }, [totalSectionsLoaded]);
+
+ useFocusEffect(
+ useCallback(
+ () => () => {
+ // Blur — user is leaving the homepage. Skip if never actually focused.
+ if (visitIdRef.current === 0) return;
+ const sessionTime = Math.round(
+ (Date.now() - sessionStartRef.current) / 1000,
+ );
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.HOME_VIEWED)
+ .addProperties({
+ interaction_type: 'session_summary',
+ location: 'home',
+ total_sections_viewed: getViewedSectionCount(),
+ total_sections_loaded: totalSectionsLoadedRef.current,
+ entry_point: entryPointRef.current,
+ session_time: sessionTime,
+ })
+ .build(),
+ );
+ },
+ [trackEvent, createEventBuilder, getViewedSectionCount],
+ ),
+ );
+};
+
+export default useHomeSessionSummary;
diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts
index 697cc101dce..caba07e080a 100644
--- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts
+++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts
@@ -34,12 +34,16 @@ const HomeEntryPointsValues = {
NAVIGATED_BACK: 'navigated_back',
} as const;
+const mockNotifySectionViewed = jest.fn();
+
let mockContextValue = {
subscribeToScroll: mockSubscribeToScroll,
viewportHeight: 800,
containerScreenY: 0,
entryPoint: HomeEntryPointsValues.APP_OPENED as string,
visitId: 0,
+ notifySectionViewed: mockNotifySectionViewed,
+ getViewedSectionCount: jest.fn(() => 0),
};
jest.mock('../context/HomepageScrollContext', () => ({
@@ -86,6 +90,8 @@ describe('useHomeViewedEvent', () => {
containerScreenY: 0,
entryPoint: HomeEntryPointsValues.APP_OPENED,
visitId: 1, // Use 1 as default so "event fires" tests pass; 0 = pre-focus, no fire
+ notifySectionViewed: mockNotifySectionViewed,
+ getViewedSectionCount: jest.fn(() => 0),
};
});
diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts
index f1eb877d714..25ecaa3ce85 100644
--- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts
+++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts
@@ -63,6 +63,7 @@ const useHomeViewedEvent = ({
containerScreenY,
entryPoint,
visitId,
+ notifySectionViewed,
} = useHomepageScrollContext();
const { trackEvent, createEventBuilder } = useAnalytics();
@@ -96,6 +97,7 @@ const useHomeViewedEvent = ({
})
.build(),
);
+ notifySectionViewed(sectionName);
}, [
visitId,
sectionName,
@@ -106,6 +108,7 @@ const useHomeViewedEvent = ({
entryPoint,
trackEvent,
createEventBuilder,
+ notifySectionViewed,
]);
// Reset on each homepage visit so the event re-fires.
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index 62dff744def..b26d90149b6 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -639,6 +639,8 @@ const Wallet = ({
// Callbacks registered by sections to be notified of scroll events.
// Using a ref+Set avoids any React state updates (and re-renders) on scroll.
const scrollSubscribersRef = useRef void>>(new Set());
+ // Tracks which sections have been viewed this visit (reset on each focus).
+ const viewedSectionsRef = useRef>(new Set());
// ─────────────────────────────────────────────────────────────────────────
const isPerpsFlagEnabled = useSelector(selectPerpsEnabledFlag);
@@ -1392,6 +1394,20 @@ const Wallet = ({
return () => scrollSubscribersRef.current.delete(cb);
}, []);
+ // Reset viewed sections on each new visit so session summary starts fresh.
+ useEffect(() => {
+ viewedSectionsRef.current.clear();
+ }, [visitId]);
+
+ const notifySectionViewed = useCallback((sectionName: string) => {
+ viewedSectionsRef.current.add(sectionName);
+ }, []);
+
+ const getViewedSectionCount = useCallback(
+ () => viewedSectionsRef.current.size,
+ [],
+ );
+
const homepageScrollContextValue = useMemo(
() => ({
subscribeToScroll,
@@ -1399,8 +1415,18 @@ const Wallet = ({
containerScreenY,
entryPoint,
visitId,
+ notifySectionViewed,
+ getViewedSectionCount,
}),
- [subscribeToScroll, viewportHeight, containerScreenY, entryPoint, visitId],
+ [
+ subscribeToScroll,
+ viewportHeight,
+ containerScreenY,
+ entryPoint,
+ visitId,
+ notifySectionViewed,
+ getViewedSectionCount,
+ ],
);
const content = (
From ec4fbf279cc188b6b714b2dc803f95001532c8d0 Mon Sep 17 00:00:00 2001
From: Bryan Fullam
Date: Fri, 6 Mar 2026 09:36:32 +0100
Subject: [PATCH 3/9] fix: account for POL non-zero native address cp-7.69.0
(#27052)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fixes a bug in the bridge token selector where Polygon's native token
(POL) appeared twice in the list — once with balance at the top and once
without balance at the bottom. Selecting the balance entry as a
destination token caused the quote to show 0 and the rate to display
"--".
**Root cause**: Polygon's native token uses address
`0x0000000000000000000000000000000000001010` in wallet state (from
`getNativeTokenAddress`), but the bridge API expects `AddressZero`
(`0x0000...0000`) for all native assets. The bridge-controller's
`isNativeAddress()` does not recognize `0x...1010` as native, so:
1. `tokenToIncludeAsset` sent the wrong asset ID (`erc20:0x...1010`
instead of `slip44:966`) to the API, which couldn't deduplicate it with
its own native POL entry.
2. When the user selected POL with `0x...1010`, quote matching in
`useBridgeQuoteData` failed because the returned quote used
`AddressZero` for `destAsset.address`.
**Fix**: Extracted the existing normalization logic from
`useTokenAddress` into a reusable pure function `normalizeTokenAddress`,
and applied it in `useTokensWithBalance` when building `BridgeToken`
objects from wallet state. This ensures POL enters the bridge flow with
`AddressZero` from the start, fixing both the duplicate listing and the
quote/rate mismatch.
## **Changelog**
CHANGELOG entry: Fixed a bug where Polygon's native token (POL) appeared
twice in the bridge token selector and selecting it showed incorrect
quote data.
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Bridge token selector - Polygon native token
Scenario: user selects POL as destination token on Polygon
Given user has a POL balance on Polygon
And user opens the bridge token selector for destination
When user filters by Polygon network
Then POL appears only once in the token list with balance displayed
Scenario: user gets a valid quote after selecting POL destination
Given user has a source token with balance
And user has selected POL on Polygon as the destination token
When the quote loads
Then the destination input shows a non-zero amount
And the rate displays a valid exchange rate (not "--")
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Medium risk because it changes how token addresses are represented in
the bridge token list and downstream API/quote matching, which could
affect token identification on Polygon. Scope is small and isolated to
bridge token normalization.
>
> **Overview**
> Fixes Polygon native token (POL) handling in the bridge UI by
normalizing Polygon’s non-zero native token address to the zero address
the bridge API expects.
>
> Extracts the Polygon-specific normalization from `useTokenAddress`
into a reusable `normalizeTokenAddress` utility and applies it when
constructing tokens in `useTokensWithBalance`, preventing duplicate POL
entries and quote mismatches caused by inconsistent native-address
representations.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
b0deef0a33717584695e32d4fdaa0455ad80c788. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/Bridge/hooks/useTokenAddress/index.ts | 19 +++++--------------
.../hooks/useTokensWithBalance/index.ts | 3 ++-
app/components/UI/Bridge/utils/tokenUtils.ts | 19 +++++++++++++++++++
3 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/app/components/UI/Bridge/hooks/useTokenAddress/index.ts b/app/components/UI/Bridge/hooks/useTokenAddress/index.ts
index c3009e884c6..0388e066eb7 100644
--- a/app/components/UI/Bridge/hooks/useTokenAddress/index.ts
+++ b/app/components/UI/Bridge/hooks/useTokenAddress/index.ts
@@ -1,16 +1,7 @@
import { BridgeToken } from '../../types';
-import { zeroAddress } from 'ethereumjs-util';
-import { CHAIN_IDS } from '@metamask/transaction-controller';
-import { POLYGON_NATIVE_TOKEN } from '../../constants/assets';
+import { normalizeTokenAddress } from '../../utils/tokenUtils';
-export const useTokenAddress = (token: BridgeToken | undefined) => {
- // Polygon native token address can be 0x0000000000000000000000000000000000001010
- // so we need to use the zero address for the token address
- const tokenAddress =
- token?.chainId === CHAIN_IDS.POLYGON &&
- token?.address === POLYGON_NATIVE_TOKEN
- ? zeroAddress()
- : token?.address;
-
- return tokenAddress;
-};
+export const useTokenAddress = (
+ token: BridgeToken | undefined,
+): string | undefined =>
+ token ? normalizeTokenAddress(token.address, token.chainId) : undefined;
diff --git a/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts b/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts
index 4ac20afdd88..e0684eb577c 100644
--- a/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts
+++ b/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts
@@ -39,6 +39,7 @@ import {
isNonEvmChainId,
} from '@metamask/bridge-controller';
import { isTradableToken } from '../../utils/isTradableToken';
+import { normalizeTokenAddress } from '../../utils/tokenUtils';
interface CalculateFiatBalancesParams {
assets: TokenI[];
@@ -261,7 +262,7 @@ export const useTokensWithBalance: ({
}
return {
- address: token.address,
+ address: normalizeTokenAddress(token.address, chainId),
name: token.name,
decimals: token.decimals,
symbol: token.isETH ? 'ETH' : token.symbol, // TODO: not sure why symbol is ETHEREUM, will also break the token icon for ETH
diff --git a/app/components/UI/Bridge/utils/tokenUtils.ts b/app/components/UI/Bridge/utils/tokenUtils.ts
index 5f7efded794..b4f0269e78c 100644
--- a/app/components/UI/Bridge/utils/tokenUtils.ts
+++ b/app/components/UI/Bridge/utils/tokenUtils.ts
@@ -5,9 +5,28 @@ import {
getNativeAssetForChainId,
isNonEvmChainId,
} from '@metamask/bridge-controller';
+import { zeroAddress } from 'ethereumjs-util';
+import { CHAIN_IDS } from '@metamask/transaction-controller';
import { BridgeToken } from '../types';
import { DefaultSwapDestTokens } from '../constants/default-swap-dest-tokens';
import { IncludeAsset } from '../hooks/usePopularTokens';
+import { POLYGON_NATIVE_TOKEN } from '../constants/assets';
+
+/**
+ * Normalizes chain-specific native token addresses to the zero address for the bridge flow.
+ *
+ * Some chains use a non-zero contract address for their native token
+ * (e.g. Polygon uses 0x0000000000000000000000000000000000001010), but the bridge API
+ * expects the zero address for all native assets.
+ */
+export const normalizeTokenAddress = (
+ address: string,
+ chainId: Hex | CaipChainId,
+): string => {
+ const isPolygonNativeToken =
+ chainId === CHAIN_IDS.POLYGON && address === POLYGON_NATIVE_TOKEN;
+ return isPolygonNativeToken ? zeroAddress() : address;
+};
/**
* Creates a formatted native token object for the given chain ID
From 3fba94ee89e33a6540dbdf3a2d64419e7d6b07a7 Mon Sep 17 00:00:00 2001
From: Bryan Fullam
Date: Fri, 6 Mar 2026 09:49:55 +0100
Subject: [PATCH 4/9] feat: add swap page trending tokens section (#26620)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR implements the mobile Swap zero-state Trending Tokens experience
for Bridge and hardens related Bridge rendering behavior.
Key updates:
- Added `BridgeTrendingTokensSection` to render Trending tokens only in
Swap zero state.
- Added filter controls (Sort by / Network / Time) and list chunking
with a centered "Load more" action while preserving single-screen scroll
behavior.
- Refined `BridgeView` content-mode precedence so
loading/error/quote/zero states render deterministically.
- Preserved quote + confirm visibility during quote refresh (`isLoading
&& activeQuote`) and only show skeleton when loading without an active
quote.
- Updated/expanded Bridge tests and removed brittle snapshot dependency
in `BridgeView` tests.
## **Changelog**
CHANGELOG entry: Added Trending tokens to the mobile Swap zero state
with filter controls and improved Bridge quote/loading state handling.
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4038
## **Manual testing steps**
```gherkin
Feature: Swap zero-state trending list on mobile
Scenario: Trending list visibility follows zero state
Given user is on the Swap screen
When no source amount is entered
Then Trending tokens are visible below the swap form
When user enters a non-zero source amount
Then Trending tokens are hidden
Scenario: Numpad hidden on initial load
Given user opens Swap for the first time
When the screen is rendered
Then numpad is hidden and swap form is visible
Scenario: Quote loading and refresh behavior
Given user has entered a non-zero amount
When quote is loading with no active quote
Then quote skeleton is shown and trending list is hidden
When quote is refreshing with an active quote
Then quote content and confirm button remain visible
Scenario: Single scroll behavior
Given user is in zero state with Trending tokens visible
When user scrolls
Then swap form and trending list scroll together in one vertical scroll area
Scenario: Filters update results
Given user is in zero state with Trending tokens visible
When user changes Sort by, Network, or Time filters
Then list content updates to match selected filters
And default sort is Price change high to low
```
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
https://github.com/user-attachments/assets/e55f04c5-6190-4c26-a15a-2e0c00a8b879
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Changes Bridge/Swap screen rendering precedence
(loading/error/quote/zero) and scroll behavior, which could affect quote
visibility and confirm UX during refreshes. Mostly UI/state-driven with
good test coverage but touches a core transaction entry flow.
>
> **Overview**
> Adds a **Swap zero-state Trending Tokens** section to `BridgeView`,
gated behind the temporary `swapsTrendingTokens` remote feature flag,
with filter bottom sheets and incremental “show more” loading triggered
by button or near-bottom scroll.
>
> Refactors `BridgeView` to render deterministically via a `contentMode`
state machine: shows a `QuoteDetailsCardSkeleton` only when *loading
without an active quote*, preserves quote + confirm UI while refreshing
(`isLoading && activeQuote`), and keeps error banners/zero-state
separate from quote content.
>
> Updates styles to support a single unified scroll area (inputs +
dynamic content), introduces new `testID`s, and rewrites/expands tests
to avoid brittle snapshots and to assert the new
loading/error/quote/zero behaviors (including mocking the trending
section).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ab8ffe2bd2408e1f5e23bc72c45aa0f9bc764667. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../Views/BridgeView/BridgeView.styles.ts | 6 +-
.../Views/BridgeView/BridgeView.test.tsx | 245 +-
.../Views/BridgeView/BridgeView.testIds.ts | 6 +
.../Views/BridgeView/BridgeView.view.test.tsx | 138 +-
.../__snapshots__/BridgeView.test.tsx.snap | 3639 -----------------
.../UI/Bridge/Views/BridgeView/index.tsx | 174 +-
.../BridgeTrendingTokensSection.test.tsx | 160 +
.../BridgeTrendingTokensSection.tsx | 254 ++
.../QuoteDetailsCardSkeleton.tsx | 37 +
.../components/TokenInputArea/index.tsx | 2 +-
.../hooks/useBridgeViewOnFocus/index.ts | 21 +-
.../useBridgeViewOnFocus.test.ts | 6 +-
.../components/FilterBar/FilterBar.tsx | 8 +-
.../TrendingTokenNetworkBottomSheet.test.tsx | 8 +-
.../TrendingTokenNetworkBottomSheet.tsx | 7 -
...endingTokenPriceChangeBottomSheet.test.tsx | 8 +-
.../TrendingTokenPriceChangeBottomSheet.tsx | 7 -
.../TrendingTokenTimeBottomSheet.test.tsx | 8 +-
.../TrendingTokenTimeBottomSheet.tsx | 9 +-
.../TrendingTokensBottomSheet/index.ts | 1 +
20 files changed, 841 insertions(+), 3903 deletions(-)
delete mode 100644 app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap
create mode 100644 app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx
create mode 100644 app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx
create mode 100644 app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts
index 93d161ce1df..c67b0d52ec4 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts
@@ -23,14 +23,12 @@ export const createStyles = (params: { theme: Theme }) => {
backgroundColor: theme.colors.background.default,
},
quoteContainer: {
- flex: 1,
justifyContent: 'flex-start',
},
destinationAccountSelectorContainer: {
paddingBottom: 12,
},
dynamicContent: {
- flex: 1,
justifyContent: 'flex-start',
},
keypadContainerWithDestinationPicker: {
@@ -42,6 +40,10 @@ export const createStyles = (params: { theme: Theme }) => {
},
scrollViewContent: {
flexGrow: 1,
+ paddingBottom: 16,
+ },
+ loadingContainer: {
+ paddingTop: 8,
},
disclaimerText: {
textAlign: 'center',
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
index 9eee5f81d23..05e9736bff4 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
@@ -21,6 +21,7 @@ import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
import { useRWAToken } from '../../hooks/useRWAToken';
import { strings } from '../../../../../../locales/i18n';
import { isHardwareAccount } from '../../../../../util/address';
+import { BridgeViewSelectorsIDs } from './BridgeView.testIds';
import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/test/keyringControllerTestUtils';
import { RootState } from '../../../../../reducers';
import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata';
@@ -281,6 +282,25 @@ jest.mock('react-native-fade-in-image', () => {
};
});
+jest.mock(
+ '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection',
+ () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ const { BridgeViewSelectorsIDs: BridgeViewTestIds } = jest.requireActual(
+ './BridgeView.testIds',
+ );
+
+ return {
+ __esModule: true,
+ default: () =>
+ React.createElement(View, {
+ testID: BridgeViewTestIds.TRENDING_TOKENS_SECTION,
+ }),
+ };
+ },
+);
+
// Mock BottomSheetDialog so that onCloseDialog synchronously calls onClose,
// allowing keypad close() to work in tests (the real component uses reanimated
// withTiming which never completes in JSDOM).
@@ -331,8 +351,8 @@ describe('BridgeView', () => {
jest.clearAllMocks();
});
- it('renders', async () => {
- const { toJSON } = renderScreen(
+ it('renders source and destination token areas', async () => {
+ const { getByTestId } = renderScreen(
BridgeView,
{
name: Routes.BRIDGE.ROOT,
@@ -340,7 +360,10 @@ describe('BridgeView', () => {
{ state: mockState },
);
- expect(toJSON()).toMatchSnapshot();
+ expect(getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_AREA)).toBeTruthy();
+ expect(
+ getByTestId(BridgeViewSelectorsIDs.DESTINATION_TOKEN_AREA),
+ ).toBeTruthy();
});
it('should open BridgeTokenSelector when clicking source token', async () => {
@@ -393,7 +416,12 @@ describe('BridgeView', () => {
{ state: mockState },
);
- // Verify keypad is open (opened by useBridgeViewOnFocus on mount)
+ const sourceInput = getByTestId('source-token-area-input');
+ await act(async () => {
+ sourceInput.props.onPressIn();
+ });
+
+ // Verify keypad is open
await waitFor(() => {
expect(getByText('1')).toBeTruthy();
expect(queryByTestId('keypad-delete-button')).toBeTruthy();
@@ -434,6 +462,11 @@ describe('BridgeView', () => {
{ state: mockState },
);
+ const sourceInput = getByTestId('source-token-area-input');
+ await act(async () => {
+ sourceInput.props.onPressIn();
+ });
+
// Press number buttons to input
fireEvent.press(getByText('9'));
fireEvent.press(getByText('.'));
@@ -755,8 +788,8 @@ describe('BridgeView', () => {
.mockImplementation(() => mockUseBridgeQuoteData);
});
- it('displays keypad when no amount is entered', () => {
- const { getByText } = renderScreen(
+ it('does not display keypad on initial render when no amount is entered', () => {
+ const { queryByTestId } = renderScreen(
BridgeView,
{
name: Routes.BRIDGE.ROOT,
@@ -764,12 +797,10 @@ describe('BridgeView', () => {
{ state: mockState },
);
- // Keypad is visible instead of "Select amount" text
- expect(getByText('1')).toBeTruthy();
- expect(getByText('5')).toBeTruthy();
+ expect(queryByTestId('keypad-delete-button')).toBeNull();
});
- it('displays keypad when amount is zero', () => {
+ it('does not display keypad on initial render when amount is zero', () => {
const stateWithZeroAmount = {
...mockState,
bridge: {
@@ -778,7 +809,7 @@ describe('BridgeView', () => {
},
};
- const { getByText } = renderScreen(
+ const { queryByTestId } = renderScreen(
BridgeView,
{
name: Routes.BRIDGE.ROOT,
@@ -786,12 +817,10 @@ describe('BridgeView', () => {
{ state: stateWithZeroAmount },
);
- // Keypad is visible instead of "Select amount" text
- expect(getByText('1')).toBeTruthy();
- expect(getByText('5')).toBeTruthy();
+ expect(queryByTestId('keypad-delete-button')).toBeNull();
});
- it('displays "Fetching quote" when quotes are loading and there is no active quote', () => {
+ it('shows loading mode with quote skeleton only', () => {
const testState = createBridgeTestState({
bridgeControllerOverrides: {
quotesLastFetched: null,
@@ -806,7 +835,7 @@ describe('BridgeView', () => {
activeQuote: null,
}));
- const { getByText } = renderScreen(
+ const { getByTestId, queryByTestId, queryByText } = renderScreen(
BridgeView,
{
name: Routes.BRIDGE.ROOT,
@@ -814,7 +843,182 @@ describe('BridgeView', () => {
{ state: testState },
);
- expect(getByText('Fetching quote')).toBeTruthy();
+ expect(
+ getByTestId(BridgeViewSelectorsIDs.QUOTE_DETAILS_SKELETON),
+ ).toBeTruthy();
+ expect(queryByTestId('banneralert')).toBeNull();
+ expect(queryByTestId('edit-slippage-button')).toBeNull();
+ expect(
+ queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
+ ).toBeNull();
+ expect(queryByText('Fetching quote')).toBeNull();
+ });
+
+ it('keeps quote mode content visible while refreshing an existing quote', async () => {
+ const now = Date.now();
+ const testState = createBridgeTestState({
+ bridgeControllerOverrides: {
+ quotesLoadingStatus: RequestStatus.LOADING,
+ quotes: [mockQuoteWithMetadata as unknown as QuoteResponse],
+ quotesLastFetched: now,
+ },
+ bridgeReducerOverrides: {
+ sourceAmount: '1.0',
+ },
+ });
+
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ isLoading: true,
+ activeQuote: mockQuoteWithMetadata as unknown as QuoteResponse,
+ }));
+
+ const { getByTestId, queryByTestId } = renderScreen(
+ BridgeView,
+ {
+ name: Routes.BRIDGE.ROOT,
+ },
+ { state: testState },
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('edit-slippage-button')).toBeTruthy();
+ });
+
+ expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy();
+ expect(
+ queryByTestId(BridgeViewSelectorsIDs.QUOTE_DETAILS_SKELETON),
+ ).toBeNull();
+ });
+
+ it('shows error mode with banner and without quote or zero state', async () => {
+ const testState = createBridgeTestState({
+ bridgeControllerOverrides: {
+ quotesLoadingStatus: RequestStatus.FETCHED,
+ quotes: [],
+ quotesLastFetched: 12,
+ },
+ });
+
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: null,
+ isLoading: false,
+ quoteFetchError: 'Error fetching quote',
+ isNoQuotesAvailable: true,
+ }));
+
+ const { queryByTestId } = renderScreen(
+ BridgeView,
+ {
+ name: Routes.BRIDGE.ROOT,
+ },
+ { state: testState },
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('banneralert')).toBeTruthy();
+ });
+ expect(queryByTestId('edit-slippage-button')).toBeNull();
+ expect(
+ queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
+ ).toBeNull();
+ });
+
+ it('shows quote mode with quote content and confirm button', async () => {
+ const now = Date.now();
+ const testState = createBridgeTestState({
+ bridgeControllerOverrides: {
+ quotesLoadingStatus: RequestStatus.FETCHED,
+ quotes: [mockQuoteWithMetadata as unknown as QuoteResponse],
+ quotesLastFetched: now,
+ },
+ bridgeReducerOverrides: {
+ sourceAmount: '1.0',
+ },
+ });
+
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ isLoading: false,
+ activeQuote: mockQuoteWithMetadata as unknown as QuoteResponse,
+ }));
+
+ const { getByTestId, queryByTestId } = renderScreen(
+ BridgeView,
+ {
+ name: Routes.BRIDGE.ROOT,
+ },
+ { state: testState },
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('edit-slippage-button')).toBeTruthy();
+ });
+ expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy();
+ expect(
+ queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
+ ).toBeNull();
+ });
+
+ it('shows zero mode with trending section and without quote content', () => {
+ const testState = createBridgeTestState(
+ {
+ bridgeControllerOverrides: {
+ quotesLoadingStatus: RequestStatus.FETCHED,
+ quotes: [],
+ quotesLastFetched: 12,
+ },
+ bridgeReducerOverrides: {
+ sourceAmount: undefined,
+ },
+ },
+ {
+ ...mockState,
+ engine: {
+ ...mockState.engine,
+ backgroundState: {
+ ...mockState.engine?.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ swapsTrendingTokens: true,
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ } as DeepPartial,
+ );
+
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: null,
+ isLoading: false,
+ quoteFetchError: null,
+ isNoQuotesAvailable: false,
+ destTokenAmount: undefined,
+ }));
+
+ const { getByTestId, queryByTestId } = renderScreen(
+ BridgeView,
+ {
+ name: Routes.BRIDGE.ROOT,
+ },
+ { state: testState },
+ );
+
+ expect(
+ getByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
+ ).toBeTruthy();
+ expect(queryByTestId('edit-slippage-button')).toBeNull();
});
it('navigates to QuoteExpiredModal when quote expires without refresh', async () => {
@@ -932,7 +1136,7 @@ describe('BridgeView', () => {
});
});
- it('blurs input when opening QuoteExpiredModal', async () => {
+ it('navigates to QuoteExpiredModal when quote expires and leaves quote content hidden', async () => {
jest
.mocked(useBridgeQuoteData as unknown as jest.Mock)
.mockImplementation(() => ({
@@ -943,7 +1147,7 @@ describe('BridgeView', () => {
activeQuote: undefined, // activeQuote is undefined when quote expires without refresh
}));
- const { toJSON } = renderScreen(
+ const { queryByTestId } = renderScreen(
BridgeView,
{
name: Routes.BRIDGE.ROOT,
@@ -956,8 +1160,7 @@ describe('BridgeView', () => {
screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL,
});
});
-
- expect(toJSON()).toMatchSnapshot();
+ expect(queryByTestId('edit-slippage-button')).toBeNull();
});
it('displays hardware wallet not supported banner when using hardware wallet with Solana source', async () => {
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts
index ae2e2762ee1..f343b17df0d 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts
@@ -6,6 +6,12 @@ export const BridgeViewSelectorsIDs = {
CONFIRM_BUTTON: 'bridge-confirm-button',
CONFIRM_BUTTON_KEYPAD: 'bridge-confirm-button-keypad',
BRIDGE_VIEW_SCROLL: 'bridge-view-scroll',
+ TRENDING_TOKENS_SECTION: 'bridge-trending-tokens-section',
+ TRENDING_PRICE_FILTER: 'bridge-trending-price-filter',
+ TRENDING_NETWORK_FILTER: 'bridge-trending-network-filter',
+ TRENDING_TIME_FILTER: 'bridge-trending-time-filter',
+ TRENDING_SHOW_MORE: 'bridge-trending-show-more',
+ QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton',
} as const;
export type BridgeViewSelectorsIDsType = typeof BridgeViewSelectorsIDs;
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx
index 71e0b41c30f..aeaff64ce20 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx
@@ -13,11 +13,12 @@ import { describeForPlatforms } from '../../../../../util/test/platform';
import { BridgeViewSelectorsIDs } from './BridgeView.testIds';
import { BuildQuoteSelectors } from '../../../Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds';
import { CommonSelectorsIDs } from '../../../../../util/Common.testIds';
-import Engine from '../../../../../core/Engine';
import { setSlippage } from '../../../../../core/redux/slices/bridge';
import { BridgeTokenSelector } from '../../components/BridgeTokenSelector/BridgeTokenSelector';
+import Engine from '../../../../../core/Engine';
import type { DeepPartial } from '../../../../../util/test/renderWithProvider';
import type { RootState } from '../../../../../reducers';
+import { RequestStatus } from '@metamask/bridge-controller';
import {
DEFAULT_BRIDGE,
ETH_SOURCE,
@@ -90,7 +91,10 @@ describeForPlatforms('BridgeView', () => {
fireEvent.press(closeBanner);
}
- // Keypad opens on focus (useBridgeViewOnFocus); wait for it to render
+ const sourceInput = getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT);
+ fireEvent(sourceInput, 'pressIn');
+
+ // Keypad opens on source input interaction
await waitFor(() => {
expect(
getByTestId(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON),
@@ -120,7 +124,7 @@ describeForPlatforms('BridgeView', () => {
unknown
>,
quotesLastFetched: now,
- quotesLoadingStatus: 'SUCCEEDED',
+ quotesLoadingStatus: RequestStatus.FETCHED,
quoteFetchError: null,
},
},
@@ -138,12 +142,7 @@ describeForPlatforms('BridgeView', () => {
).not.toBe(true);
});
- it('calls quote API with custom slippage when user has set 5% and quote is requested', async () => {
- const updateQuoteSpy = jest.spyOn(
- Engine.context.BridgeController,
- 'updateBridgeQuoteRequestParams',
- );
-
+ it('stores custom slippage when user sets 5%', async () => {
const { store } = defaultBridgeWithTokens({
bridge: { selectedDestChainId: '0x1' },
engine: {
@@ -151,121 +150,23 @@ describeForPlatforms('BridgeView', () => {
BridgeController: {
quotesLastFetched: 0,
quotes: [],
- quotesLoadingStatus: 'IDLE',
+ quotesLoadingStatus: null,
quoteFetchError: null,
},
},
},
} as unknown as Record);
- updateQuoteSpy.mockClear();
-
act(() => {
store.dispatch(setSlippage('5'));
});
await waitFor(
() => {
- expect(updateQuoteSpy).toHaveBeenCalledWith(
- expect.objectContaining({ slippage: 5 }),
- expect.anything(),
- );
+ expect(store.getState().bridge.slippage).toBe('5');
},
{ timeout: 1000 },
);
-
- updateQuoteSpy.mockRestore();
- });
-
- it('displays no MM fee disclaimer for mUSD destination with zero MM fee', async () => {
- const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da';
- const now = Date.now();
- const active = {
- ...(mockQuoteWithMetadata as unknown as Record),
- };
- const currentQuote = (active.quote as Record) ?? {};
- active.quote = {
- ...currentQuote,
- feeData: {
- metabridge: { quoteBpsFee: 0 },
- },
- gasIncluded: true,
- srcChainId: 1,
- destChainId: 1,
- };
-
- const { findByText } = defaultBridgeWithTokens({
- bridge: {
- sourceAmount: '1.0',
- sourceToken: ETH_SOURCE,
- destToken: {
- address: musdAddress,
- chainId: '0x1',
- decimals: 18,
- symbol: 'mUSD',
- name: 'mStable USD',
- },
- },
- engine: {
- backgroundState: {
- BridgeController: {
- quotes: [active as unknown as Record],
- recommendedQuote: active as unknown as Record,
- quotesLastFetched: now,
- quotesLoadingStatus: 'SUCCEEDED',
- quoteFetchError: null,
- },
- RemoteFeatureFlagController: {
- remoteFeatureFlags: {
- bridgeConfigV2: {
- minimumVersion: '0.0.0',
- maxRefreshCount: 5,
- refreshRate: 30000,
- support: true,
- chains: {
- 'eip155:1': {
- isActiveSrc: true,
- isActiveDest: true,
- noFeeAssets: [musdAddress],
- },
- },
- },
- },
- },
- },
- },
- } as unknown as Record);
-
- const expected = strings('bridge.no_mm_fee_disclaimer', {
- destTokenSymbol: 'mUSD',
- });
- expect(await findByText(expected)).toBeOnTheScreen();
- });
-
- it('shows confirm button when refreshing quote with previous active quote', () => {
- const now = Date.now();
- const previousQuote = { ...mockQuoteWithMetadata };
-
- const { getByTestId } = defaultBridgeWithTokens({
- engine: {
- backgroundState: {
- BridgeController: {
- quotes: [previousQuote as unknown as Record],
- recommendedQuote: previousQuote as unknown as Record<
- string,
- unknown
- >,
- quotesLastFetched: now - 1000,
- quotesLoadingStatus: 'LOADING',
- quoteFetchError: null,
- },
- },
- },
- } as unknown as Record);
-
- // With a previous quote and loading, confirm button is shown (may be in keypad or main content)
- const confirmButton = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON);
- expect(confirmButton).toBeOnTheScreen();
});
it('navigates to dest token selector on press', async () => {
@@ -319,7 +220,7 @@ describeForPlatforms('BridgeView', () => {
quotes: [quoteWithGasIncluded],
recommendedQuote: quoteWithGasIncluded,
quotesLastFetched: now,
- quotesLoadingStatus: 'SUCCEEDED',
+ quotesLoadingStatus: RequestStatus.FETCHED,
quoteFetchError: null,
},
},
@@ -488,18 +389,9 @@ describeForPlatforms('BridgeView', () => {
);
fireEvent.changeText(searchInput, 'USDT');
- // useSearchTokens debounce is 300ms; wait so the search request is sent
- await new Promise((resolve) => {
- setTimeout(resolve, 350);
- });
-
- // Ensure the search API was called (proves debounce + chainIds are correct)
- const urlStr = (url: unknown) =>
- typeof url === 'string' ? url : (url as URL).toString();
- const searchCalls = fetchSpy.mock.calls.filter(([url]) =>
- urlStr(url).includes('/getTokens/search'),
- );
- expect(searchCalls.length).toBeGreaterThanOrEqual(1);
+ // Force immediate re-search by changing network with an active query.
+ // BridgeTokenSelector calls `searchTokens(searchString)` on chain switch.
+ fireEvent.press(getByText('Linea'));
// Wait for list to show results (second token has unique name)
await waitFor(
@@ -558,7 +450,7 @@ describeForPlatforms('BridgeView', () => {
quotes: [],
recommendedQuote: null,
quotesLastFetched: 0,
- quotesLoadingStatus: 'IDLE',
+ quotesLoadingStatus: null,
quoteFetchError: null,
},
},
diff --git a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap
deleted file mode 100644
index f6843cfa141..00000000000
--- a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap
+++ /dev/null
@@ -1,3639 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
- Bridge
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ETH
-
-
-
-
-
-
-
-
- 2 ETH
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Swap to
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 25%
-
-
-
-
- 50%
-
-
-
-
- 75%
-
-
-
-
- 90%
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- 3
-
-
-
-
-
-
-
-
- 4
-
-
-
-
-
-
- 5
-
-
-
-
-
-
- 6
-
-
-
-
-
-
-
-
- 7
-
-
-
-
-
-
- 8
-
-
-
-
-
-
- 9
-
-
-
-
-
-
-
-
- .
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`BridgeView renders 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
- Bridge
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ETH
-
-
-
-
-
-
-
-
- 2 ETH
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Swap to
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 25%
-
-
-
-
- 50%
-
-
-
-
- 75%
-
-
-
-
- 90%
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- 3
-
-
-
-
-
-
-
-
- 4
-
-
-
-
-
-
- 5
-
-
-
-
-
-
- 6
-
-
-
-
-
-
-
-
- 7
-
-
-
-
-
-
- 8
-
-
-
-
-
-
- 9
-
-
-
-
-
-
-
-
- .
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx
index 64a11629c8f..927a0cc952d 100644
--- a/app/components/UI/Bridge/Views/BridgeView/index.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx
@@ -1,4 +1,10 @@
-import React, { useEffect, useState, useRef, useMemo } from 'react';
+import React, {
+ useEffect,
+ useState,
+ useRef,
+ useMemo,
+ useCallback,
+} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ScreenView from '../../../../Base/ScreenView';
import {
@@ -44,6 +50,7 @@ import { strings } from '../../../../../../locales/i18n';
import Engine from '../../../../../core/Engine';
import Routes from '../../../../../constants/navigation/Routes';
import QuoteDetailsCard from '../../components/QuoteDetailsCard';
+import QuoteDetailsCardSkeleton from '../../components/QuoteDetailsCard/QuoteDetailsCardSkeleton';
import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest';
import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
import BannerAlert from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert';
@@ -56,7 +63,11 @@ import { selectSelectedNetworkClientId } from '../../../../../selectors/networkC
import { useIsNetworkEnabled } from '../../hooks/useIsNetworkEnabled';
import { BridgeToken } from '../../types';
import { useSwitchTokens } from '../../hooks/useSwitchTokens';
-import { ScrollView } from 'react-native';
+import {
+ ScrollView,
+ type NativeSyntheticEvent,
+ type NativeScrollEvent,
+} from 'react-native';
import useIsInsufficientBalance from '../../hooks/useInsufficientBalance';
import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController';
import { isHardwareAccount } from '../../../../../util/address';
@@ -84,12 +95,24 @@ import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.ts
import { useBridgeViewOnFocus } from '../../hooks/useBridgeViewOnFocus/index.ts';
import { useRenderQuoteExpireModal } from '../../hooks/useRenderQuoteExpireModal/index.ts';
import { type BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation/index.ts';
+import BridgeTrendingTokensSection from '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection';
+import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController';
+import type { RootState } from '../../../../../reducers';
+const SCROLL_NEAR_BOTTOM_PX = 160;
import { useTrackSwapPageViewed } from '../../hooks/useTrackSwapPageViewed/index.ts';
const BridgeView = () => {
const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(true);
+ const [isNearBottom, setIsNearBottom] = useState(false);
const isSubmittingTx = useSelector(selectIsSubmittingTx);
+ // Inline selector because this is a temporary feature flag
+ // TODO: Remove this once trending tokens feature is prod hardened
+ const isSwapsTrendingTokensEnabled = useSelector(
+ (state: RootState) =>
+ selectRemoteFeatureFlags(state).swapsTrendingTokens === true,
+ );
+
const { styles } = useStyles(createStyles);
const dispatch = useDispatch();
const navigation = useNavigation();
@@ -246,6 +269,7 @@ const BridgeView = () => {
// Always show quote details when there's an active quote
const shouldDisplayQuoteDetails = !!activeQuote;
+ const isZeroState = !sourceAmount || !(Number(sourceAmount) > 0);
// Update quote parameters when relevant state changes
useEffect(() => {
@@ -326,15 +350,30 @@ const BridgeView = () => {
? strings('bridge.stock_token_error_banner_description')
: strings('bridge.error_banner_description');
+ const getContentMode = () => {
+ if (isLoading && !activeQuote) return 'loading';
+ if (isError && isErrorBannerVisible) return 'error';
+ if (shouldDisplayQuoteDetails) return 'quote';
+ if (isZeroState) return 'zero';
+ return 'none';
+ };
+ const contentMode = getContentMode();
+
+ const handleScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ const { contentOffset, contentSize, layoutMeasurement } =
+ event.nativeEvent;
+ setIsNearBottom(
+ contentOffset.y + layoutMeasurement.height >=
+ contentSize.height - SCROLL_NEAR_BOTTOM_PX,
+ );
+ },
+ [],
+ );
+
const renderBottomContent = () => {
if (isLoading && !activeQuote) {
- return (
-
-
- {strings('bridge.fetching_quote')}
-
-
- );
+ return null;
}
// Prevent bottom section from rendering when no active
@@ -418,64 +457,70 @@ const BridgeView = () => {
keypadRef.current?.close();
}}
>
-
- keypadRef.current?.open()}
- onTokenPress={handleSourceTokenPress}
- onMaxPress={handleSourceMaxPress}
- latestAtomicBalance={latestSourceBalance?.atomicBalance}
- isSourceToken
- isQuoteSponsored={isQuoteSponsored}
- />
-
- keypadRef.current?.close()}
- onTokenPress={handleDestTokenPress}
- isLoading={!destTokenAmount && isLoading}
- style={styles.destTokenArea}
- isQuoteSponsored={isQuoteSponsored}
- />
-
-
- {/* Scrollable Dynamic Content */}
+
+ keypadRef.current?.open()}
+ onTokenPress={handleSourceTokenPress}
+ onMaxPress={handleSourceMaxPress}
+ latestAtomicBalance={latestSourceBalance?.atomicBalance}
+ isSourceToken
+ isQuoteSponsored={isQuoteSponsored}
+ />
+
+ keypadRef.current?.close()}
+ onTokenPress={handleDestTokenPress}
+ isLoading={!destTokenAmount && isLoading}
+ style={styles.destTokenArea}
+ isQuoteSponsored={isQuoteSponsored}
+ />
+
+
- {isError && isErrorBannerVisible && (
+ {contentMode === 'loading' ? (
+
+
+
+ ) : null}
+ {contentMode === 'error' ? (
{
}}
/>
- )}
- {shouldDisplayQuoteDetails && (
+ ) : null}
+ {contentMode === 'quote' ? (
- )}
+ ) : null}
+ {contentMode === 'zero' && isSwapsTrendingTokensEnabled ? (
+
+ ) : null}
diff --git a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx
new file mode 100644
index 00000000000..c1d64a86c58
--- /dev/null
+++ b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx
@@ -0,0 +1,160 @@
+import { TrendingAsset } from '@metamask/assets-controllers';
+import { fireEvent, render } from '@testing-library/react-native';
+import React from 'react';
+import BridgeTrendingTokensSection from './BridgeTrendingTokensSection';
+import { useTokenListFilters } from '../../../Trending/hooks/useTokenListFilters/useTokenListFilters';
+import { useTrendingRequest } from '../../../Trending/hooks/useTrendingRequest/useTrendingRequest';
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(() => ({})),
+}));
+
+jest.mock(
+ '../../../Trending/hooks/useTokenListFilters/useTokenListFilters',
+ () => ({
+ useTokenListFilters: jest.fn(),
+ }),
+);
+
+jest.mock(
+ '../../../Trending/hooks/useTrendingRequest/useTrendingRequest',
+ () => ({
+ useTrendingRequest: jest.fn(),
+ }),
+);
+
+jest.mock('../../../Trending/utils/sortTrendingTokens', () => ({
+ sortTrendingTokens: jest.fn((tokens: TrendingAsset[]) => tokens),
+}));
+
+jest.mock(
+ '../../../Trending/components/TrendingTokenRowItem/TrendingTokenRowItem',
+ () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({ token }: { token: { assetId: string } }) =>
+ React.createElement(View, { testID: `row-${token.assetId}` }),
+ };
+ },
+);
+
+jest.mock(
+ '../../../Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton',
+ () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () => React.createElement(View, { testID: 'skeleton-row' }),
+ };
+ },
+);
+
+jest.mock('../../../Trending/components/TrendingTokensBottomSheet', () => ({
+ TrendingTokenTimeBottomSheet: () => null,
+ TrendingTokenNetworkBottomSheet: () => null,
+ TrendingTokenPriceChangeBottomSheet: () => null,
+ mapTimeOptionToSortBy: jest.fn(() => 'h24_trending'),
+}));
+
+const mockUseTokenListFilters = useTokenListFilters as jest.Mock;
+const mockUseTrendingRequest = useTrendingRequest as jest.Mock;
+
+const createTrendingTokens = (count: number): TrendingAsset[] =>
+ Array.from({ length: count }, (_, index) => ({
+ assetId: `eip155:1/erc20:0x${(index + 1).toString(16).padStart(40, '0')}`,
+ symbol: `T${index + 1}`,
+ name: `Token ${index + 1}`,
+ decimals: 18,
+ price: `${index + 1}`,
+ aggregatedUsdVolume: index + 1,
+ marketCap: index + 1,
+ priceChangePct: {
+ h24: `${index + 1}`,
+ h6: `${index + 1}`,
+ h1: `${index + 1}`,
+ m5: `${index + 1}`,
+ },
+ }));
+
+const setupMocks = (tokens: TrendingAsset[], isLoading = false) => {
+ mockUseTokenListFilters.mockReturnValue({
+ selectedTimeOption: '24h',
+ setSelectedTimeOption: jest.fn(),
+ selectedNetwork: null,
+ selectedPriceChangeOption: 'price_change',
+ priceChangeSortDirection: 'descending',
+ selectedNetworkName: 'All networks',
+ priceChangeButtonText: 'Price change',
+ filterContext: {
+ timeFilter: '24h',
+ sortOption: 'price_change',
+ networkFilter: 'all',
+ isSearchResult: false,
+ },
+ handlePriceChangeSelect: jest.fn(),
+ handleNetworkSelect: jest.fn(),
+ });
+ mockUseTrendingRequest.mockReturnValue({
+ results: tokens,
+ isLoading,
+ error: null,
+ fetch: jest.fn(),
+ });
+};
+
+describe('BridgeTrendingTokensSection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setupMocks(createTrendingTokens(30));
+ });
+
+ it('renders 12 tokens initially and shows the show-more button', () => {
+ const { getAllByTestId, getByTestId } = render(
+ ,
+ );
+
+ const rows = getAllByTestId(/^row-/);
+ expect(rows).toHaveLength(12);
+ expect(getByTestId('bridge-trending-show-more')).toBeTruthy();
+ });
+
+ it('appends one chunk when isNearBottom becomes true', () => {
+ const { getAllByTestId, rerender } = render(
+ ,
+ );
+
+ rerender();
+
+ expect(getAllByTestId(/^row-/)).toHaveLength(24);
+ });
+
+ it('resets visible token count when dataset changes', () => {
+ const { getAllByTestId, queryByTestId, rerender } = render(
+ ,
+ );
+
+ rerender();
+ expect(getAllByTestId(/^row-/)).toHaveLength(24);
+
+ setupMocks(createTrendingTokens(8));
+ rerender();
+
+ expect(getAllByTestId(/^row-/)).toHaveLength(8);
+ expect(queryByTestId('bridge-trending-show-more')).toBeNull();
+ });
+
+ it('does not append chunk while a bottom sheet is open', () => {
+ const { getAllByTestId, getByTestId, rerender } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('bridge-trending-price-filter'));
+
+ rerender();
+
+ expect(getAllByTestId(/^row-/)).toHaveLength(12);
+ });
+});
diff --git a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx
new file mode 100644
index 00000000000..bed9ba7c97b
--- /dev/null
+++ b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx
@@ -0,0 +1,254 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Modal, Pressable } from 'react-native';
+import { useSelector } from 'react-redux';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ Box,
+ BoxFlexDirection,
+ Text,
+ TextVariant,
+ TextColor,
+ FontWeight,
+} from '@metamask/design-system-react-native';
+import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController';
+import {
+ TrendingTokenNetworkBottomSheet,
+ TrendingTokenPriceChangeBottomSheet,
+ TrendingTokenTimeBottomSheet,
+ mapTimeOptionToSortBy,
+} from '../../../Trending/components/TrendingTokensBottomSheet';
+import {
+ ALLOWED_BRIDGE_CHAIN_IDS,
+ formatChainIdToCaip,
+} from '@metamask/bridge-controller';
+import TrendingTokensSkeleton from '../../../Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton';
+import TrendingTokenRowItem from '../../../Trending/components/TrendingTokenRowItem/TrendingTokenRowItem';
+import { useTokenListFilters } from '../../../Trending/hooks/useTokenListFilters/useTokenListFilters';
+import { useTrendingRequest } from '../../../Trending/hooks/useTrendingRequest/useTrendingRequest';
+import { sortTrendingTokens } from '../../../Trending/utils/sortTrendingTokens';
+import { strings } from '../../../../../../locales/i18n';
+import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds';
+import { getNetworkImageSource } from '../../../../../util/networks';
+import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge';
+import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
+import type { CaipChainId } from '@metamask/utils';
+import { FilterButton } from '../../../Trending/components/FilterBar/FilterBar';
+
+const TOKEN_CHUNK_SIZE = 12;
+
+type ActiveBottomSheet = 'none' | 'time' | 'network' | 'price_change';
+
+interface BridgeTrendingTokensSectionProps {
+ isNearBottom?: boolean;
+}
+
+const BridgeTrendingTokensSection = ({
+ isNearBottom,
+}: BridgeTrendingTokensSectionProps) => {
+ const tw = useTailwind();
+ const [activeBottomSheet, setActiveBottomSheet] =
+ useState('none');
+ const closeBottomSheet = () => setActiveBottomSheet('none');
+ const [visibleTokenCount, setVisibleTokenCount] = useState(TOKEN_CHUNK_SIZE);
+
+ const networkConfigurations = useSelector(
+ selectNetworkConfigurationsByCaipChainId,
+ );
+
+ const {
+ selectedTimeOption,
+ setSelectedTimeOption,
+ selectedNetwork,
+ selectedPriceChangeOption,
+ priceChangeSortDirection,
+ selectedNetworkName,
+ priceChangeButtonText,
+ filterContext,
+ handlePriceChangeSelect,
+ handleNetworkSelect,
+ } = useTokenListFilters();
+
+ const sortBy = useMemo(
+ () => mapTimeOptionToSortBy(selectedTimeOption),
+ [selectedTimeOption],
+ );
+
+ const { results, isLoading } = useTrendingRequest({
+ sortBy,
+ chainIds: selectedNetwork ?? undefined,
+ });
+
+ const trendingTokens = useMemo(() => {
+ if (results.length === 0 || !selectedPriceChangeOption) {
+ return results;
+ }
+ return sortTrendingTokens(
+ results,
+ selectedPriceChangeOption,
+ priceChangeSortDirection,
+ selectedTimeOption,
+ );
+ }, [
+ results,
+ selectedPriceChangeOption,
+ priceChangeSortDirection,
+ selectedTimeOption,
+ ]);
+
+ useEffect(() => {
+ if (isLoading) {
+ setVisibleTokenCount(TOKEN_CHUNK_SIZE);
+ return;
+ }
+ setVisibleTokenCount(Math.min(TOKEN_CHUNK_SIZE, trendingTokens.length));
+ }, [isLoading, trendingTokens]);
+
+ const hasMore = visibleTokenCount < trendingTokens.length;
+ const bridgeTrendingNetworks = useMemo(
+ () =>
+ ALLOWED_BRIDGE_CHAIN_IDS.map((allowedChainId) => {
+ const caipChainId = formatChainIdToCaip(allowedChainId) as CaipChainId;
+ // Map to network configurations first because network filter dropdown does the same
+ // Fallback to NETWORK_TO_SHORT_NETWORK_NAME_MAP because some bridge chains are not in network configurations
+ const networkName =
+ networkConfigurations[caipChainId]?.name ??
+ NETWORK_TO_SHORT_NETWORK_NAME_MAP[allowedChainId] ??
+ caipChainId;
+
+ return {
+ id: caipChainId,
+ name: networkName,
+ caipChainId,
+ isSelected: false,
+ imageSource: getNetworkImageSource({ chainId: allowedChainId }),
+ } as ProcessedNetwork;
+ }),
+ [networkConfigurations],
+ );
+
+ const loadNextChunk = useCallback(() => {
+ setVisibleTokenCount((currentCount) =>
+ Math.min(currentCount + TOKEN_CHUNK_SIZE, trendingTokens.length),
+ );
+ }, [trendingTokens.length]);
+
+ useEffect(() => {
+ if (isNearBottom && activeBottomSheet === 'none' && !isLoading && hasMore) {
+ loadNextChunk();
+ }
+ }, [isNearBottom, activeBottomSheet, isLoading, hasMore, loadNextChunk]);
+
+ return (
+ <>
+
+
+ {strings('trending.trending_tokens')}
+
+
+ setActiveBottomSheet('price_change')}
+ label={priceChangeButtonText}
+ twClassName="flex-1"
+ />
+ setActiveBottomSheet('network')}
+ label={selectedNetworkName}
+ twClassName="flex-1"
+ />
+ setActiveBottomSheet('time')}
+ label={selectedTimeOption}
+ twClassName="w-[72px] shrink-0"
+ />
+
+
+ {isLoading
+ ? Array.from({ length: 6 }).map((_, index) => (
+
+ ))
+ : trendingTokens
+ .slice(0, visibleTokenCount)
+ .map((token, index) => (
+
+ ))}
+ {!isLoading && hasMore ? (
+
+ tw.style('mt-3 py-2 self-center', pressed && 'opacity-70')
+ }
+ >
+
+ {strings('rewards.settings.show_more')}
+
+
+ ) : null}
+
+
+
+ {activeBottomSheet === 'time' && (
+
+ setSelectedTimeOption(timeOption)
+ }
+ selectedTime={selectedTimeOption}
+ />
+ )}
+ {activeBottomSheet === 'network' && (
+
+ )}
+ {activeBottomSheet === 'price_change' && (
+
+ )}
+
+ >
+ );
+};
+
+export default BridgeTrendingTokensSection;
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx
new file mode 100644
index 00000000000..e524eccb0fa
--- /dev/null
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import {
+ Box,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ BoxAlignItems,
+} from '@metamask/design-system-react-native';
+import { Skeleton } from '../../../../../component-library/components/Skeleton';
+import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds';
+
+const ROWS: readonly (readonly [string, string])[] = [
+ ['35%', '42%'],
+ ['28%', '24%'],
+ ['30%', '18%'],
+ ['32%', '22%'],
+];
+
+const QuoteDetailsCardSkeleton = () => (
+
+ {ROWS.map(([left, right], i) => (
+
+
+
+
+ ))}
+
+);
+
+export default QuoteDetailsCardSkeleton;
diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx
index 35b0aef090a..3b7b4402c6c 100644
--- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx
+++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx
@@ -286,7 +286,7 @@ export const TokenInputArea = forwardRef<
isReadonly={tokenType === TokenInputAreaType.Destination}
showSoftInputOnFocus={false}
caretHidden={false}
- autoFocus
+ autoFocus={false}
placeholder="0"
testID={`${testID}-input`}
onPressIn={() => {
diff --git a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts
index 9228de0e99a..a37c6f83b52 100644
--- a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts
@@ -1,5 +1,5 @@
import { useFocusEffect } from '@react-navigation/native';
-import { RefObject, useCallback, useRef } from 'react';
+import { RefObject, useCallback } from 'react';
import { TokenInputAreaRef } from '../../components/TokenInputArea';
import { SwapsKeypadRef } from '../../components/SwapsKeypad/types';
@@ -9,22 +9,13 @@ interface Params {
}
export const useBridgeViewOnFocus = ({ inputRef, keypadRef }: Params) => {
- // Track whether this is the very first time the screen gains focus
- const isFirstFocus = useRef(true);
-
useFocusEffect(
- useCallback(() => {
- if (isFirstFocus.current) {
- // Always auto-focus and open keypad on initial mount
- isFirstFocus.current = false;
- inputRef.current?.focus();
- keypadRef.current?.open();
- }
-
- return () => {
+ useCallback(
+ () => () => {
inputRef.current?.blur();
keypadRef.current?.close();
- };
- }, [inputRef, keypadRef]),
+ },
+ [inputRef, keypadRef],
+ ),
);
};
diff --git a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts
index 58b9e326f84..16f2bfe9a18 100644
--- a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts
@@ -33,7 +33,7 @@ describe('useBridgeViewOnFocus', () => {
focusCallback = undefined;
});
- it('focuses input and opens keypad on initial focus', () => {
+ it('does not focus input or open keypad on initial focus', () => {
// Arrange
const inputRef = createMockInputRef();
const keypadRef = createMockKeypadRef();
@@ -44,8 +44,8 @@ describe('useBridgeViewOnFocus', () => {
const cleanup = focusCallback?.();
// Assert
- expect(inputRef.current.focus).toHaveBeenCalledTimes(1);
- expect(keypadRef.current.open).toHaveBeenCalledTimes(1);
+ expect(inputRef.current.focus).not.toHaveBeenCalled();
+ expect(keypadRef.current.open).not.toHaveBeenCalled();
// Cleanup should exist
expect(typeof cleanup).toBe('function');
diff --git a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx
index 3ecb916d3a5..7ba1fdcaddb 100644
--- a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx
+++ b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx
@@ -8,7 +8,7 @@ import Icon, {
} from '../../../../../component-library/components/Icons/Icon';
import Text from '../../../../../component-library/components/Texts/Text';
-interface FilterButtonProps {
+export interface FilterButtonProps {
testID: string;
label: string;
onPress: () => void;
@@ -17,9 +17,11 @@ interface FilterButtonProps {
ellipsizeMode?: 'tail' | 'head' | 'middle' | 'clip';
/** Extra horizontal padding (px-3) vs default (p-2) */
wide?: boolean;
+ /** Optional Tailwind class overrides for layout in custom contexts */
+ twClassName?: string;
}
-const FilterButton: React.FC = ({
+export const FilterButton: React.FC = ({
testID,
label,
onPress,
@@ -27,6 +29,7 @@ const FilterButton: React.FC = ({
numberOfLines,
ellipsizeMode,
wide = false,
+ twClassName,
}) => {
const tw = useTailwind();
@@ -38,6 +41,7 @@ const FilterButton: React.FC = ({
'min-w-0 shrink items-center rounded-lg bg-muted',
wide ? 'py-2 px-3' : 'p-2',
disabled && 'opacity-50',
+ twClassName,
)}
activeOpacity={0.2}
disabled={disabled}
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx
index 12564d914ef..731fd11b17c 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx
@@ -487,8 +487,8 @@ describe('TrendingTokenNetworkBottomSheet', () => {
expect(queryByTestId('bottom-sheet')).toBeNull();
});
- it('calls onOpenBottomSheet when isVisible becomes true', () => {
- const { rerender } = renderWithProvider(
+ it('renders when isVisible becomes true', () => {
+ const { rerender, queryByTestId } = renderWithProvider(
{
false,
);
- expect(mockOnOpenBottomSheet).not.toHaveBeenCalled();
+ expect(queryByTestId('bottom-sheet')).toBeNull();
rerender(
{
/>,
);
- expect(mockOnOpenBottomSheet).toHaveBeenCalled();
+ expect(queryByTestId('bottom-sheet')).toBeOnTheScreen();
});
});
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx
index 06eca9eaa0e..7f341f1ea91 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx
@@ -55,13 +55,6 @@ const TrendingTokenNetworkBottomSheet: React.FC<
}
}, [initialSelectedNetwork]);
- // Open bottom sheet when isVisible becomes true
- useEffect(() => {
- if (isVisible) {
- sheetRef.current?.onOpenBottomSheet();
- }
- }, [isVisible]);
-
const optionStyles = StyleSheet.create({
optionsList: {
paddingBottom: 16,
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx
index d2e5499c653..8563bc918dc 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx
@@ -285,21 +285,21 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
expect(getByText('Low to high')).toBeOnTheScreen();
});
- it('calls onOpenBottomSheet when isVisible becomes true', () => {
- const { rerender } = render(
+ it('renders when isVisible becomes true', () => {
+ const { rerender, queryByTestId } = render(
,
);
- expect(mockOnOpenBottomSheet).not.toHaveBeenCalled();
+ expect(queryByTestId('bottom-sheet')).toBeNull();
rerender(
,
);
- expect(mockOnOpenBottomSheet).toHaveBeenCalled();
+ expect(queryByTestId('bottom-sheet')).toBeOnTheScreen();
});
it('selects MarketCap option when pressed', () => {
const { getByText } = render(
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx
index aaa4a798e76..f0e2b3d7192 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx
@@ -75,13 +75,6 @@ const TrendingTokenPriceChangeBottomSheet: React.FC<
}
}, [initialSelectedOption, initialSortDirection, isVisible]);
- // Open bottom sheet when isVisible becomes true
- useEffect(() => {
- if (isVisible) {
- sheetRef.current?.onOpenBottomSheet();
- }
- }, [isVisible]);
-
const optionStyles = StyleSheet.create({
optionsList: {
paddingBottom: 24,
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx
index 2316f3c830c..8056c509982 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx
@@ -299,15 +299,15 @@ describe('TrendingTokenTimeBottomSheet', () => {
expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
- it('calls onOpenBottomSheet when isVisible becomes true', () => {
- const { rerender } = render(
+ it('renders when isVisible becomes true', () => {
+ const { rerender, queryByTestId } = render(
,
);
- expect(mockOnOpenBottomSheet).not.toHaveBeenCalled();
+ expect(queryByTestId('bottom-sheet')).toBeNull();
rerender();
- expect(mockOnOpenBottomSheet).toHaveBeenCalled();
+ expect(queryByTestId('bottom-sheet')).toBeOnTheScreen();
});
});
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx
index e71fde2e326..3c8fd0c43df 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx
@@ -32,7 +32,7 @@ export interface TrendingTokenTimeBottomSheetProps {
/**
* Maps TimeOption to SortTrendingBy
*/
-const mapTimeOptionToSortBy = (option: TimeOption): SortTrendingBy => {
+export const mapTimeOptionToSortBy = (option: TimeOption): SortTrendingBy => {
switch (option) {
case TimeOption.TwentyFourHours:
return 'h24_trending' as SortTrendingBy;
@@ -89,13 +89,6 @@ const TrendingTokenTimeBottomSheet: React.FC<
}
}, [initialSelectedTime]);
- // Open bottom sheet when isVisible becomes true
- useEffect(() => {
- if (isVisible) {
- sheetRef.current?.onOpenBottomSheet();
- }
- }, [isVisible]);
-
const optionStyles = StyleSheet.create({
optionsList: {
paddingBottom: 16,
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts b/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts
index 59892b12476..f80c9b072e9 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts
@@ -2,6 +2,7 @@ export {
TrendingTokenTimeBottomSheet,
TimeOption,
mapSortByToTimeOption,
+ mapTimeOptionToSortBy,
type TrendingTokenTimeBottomSheetProps,
} from './TrendingTokenTimeBottomSheet';
From f9325e323a68e6ed45c0ff236e1a2c3d5172a9a0 Mon Sep 17 00:00:00 2001
From: Matthew Grainger <46547583+Matt561@users.noreply.github.com>
Date: Fri, 6 Mar 2026 04:03:35 -0500
Subject: [PATCH 5/9] feat: Updating mUSD conversion copy to reflect annualized
bonus and claim timeline (#27097)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR updates mUSD conversion copy to reflect annualized bonus and
claim timeline.
## **Changelog**
CHANGELOG entry: updated mUSD conversion copy to reflect annualized
bonus and claim timeline
## **Related issues**
Fixes:
- [MUSD-392: Annual bonus
copy](https://consensyssoftware.atlassian.net/browse/MUSD-392)
- [MUSD-393: Communicate the timeframe of the
bonus](https://consensyssoftware.atlassian.net/browse/MUSD-393)
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
### Education screen
### Custom convert navbar tooltip
### Claimable bonus tooltip
Custom convert
Quick conver
### Asset details CTA
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Low risk: primarily copy/translation key updates plus minor UI
tooltip/toast rendering changes (adds a terms link and an extra
success-toast description) with no changes to conversion logic or data
handling.
>
> **Overview**
> Updates mUSD conversion user-facing messaging to consistently describe
the incentive as an *annualized bonus* and to communicate that the bonus
becomes claimable within about a day.
>
> This refreshes strings across the education screen, quick convert
header, asset overview CTA, claimable bonus tooltip, and conversion
success toast (now includes a secondary description line), and adjusts
the confirmation `PercentageRow` tooltip to include a tappable “Terms
apply” link to the bonus terms URL. Tests are updated to match the new
copy and label formatting.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
388fcc0f47f2a53338b180ca20da458bc770d7eb. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
...nMusdConversionEducationView.view.test.tsx | 6 ++-
.../MusdQuickConvertView.test.tsx | 2 +-
.../Earn/Views/MusdQuickConvertView/index.tsx | 4 +-
.../MusdConversionAssetOverviewCta.test.tsx | 2 +-
...sdConversionAssetOverviewCta.view.test.tsx | 4 +-
.../UI/Earn/hooks/useEarnToasts.test.tsx | 2 +-
.../UI/Earn/hooks/useEarnToasts.tsx | 15 +++++-
.../percentage-row/percentage-row.test.tsx | 36 +++++---------
.../rows/percentage-row/percentage-row.tsx | 47 ++++++++++---------
locales/languages/en.json | 14 +++---
10 files changed, 69 insertions(+), 63 deletions(-)
diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx
index 44015b4697b..34281237673 100644
--- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx
+++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx
@@ -57,7 +57,9 @@ describeForPlatforms('EarnMusdConversionEducationView', () => {
),
).toBeOnTheScreen();
expect(
- getByText(/Convert your stablecoins to mUSD.*receive up to a \d+% bonus/),
+ getByText(
+ /Convert your stablecoins to mUSD.*earn up to a \d+% annualized bonus/,
+ ),
).toBeOnTheScreen();
expect(
getByText(strings('earn.musd_conversion.education.primary_button')),
@@ -389,7 +391,7 @@ describeForPlatforms('EarnMusdConversionEducationView', () => {
// Assert
const description = getByText(
- /Convert your stablecoins to mUSD.*receive up to a \d+% bonus/,
+ /Convert your stablecoins to mUSD.*earn up to a \d+% annualized bonus/,
);
expect(description).toBeOnTheScreen();
expect(description.props.children[0]).toContain(`${MUSD_CONVERSION_APY}%`);
diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx
index 2ec33887b61..b06f4efd5b9 100644
--- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx
+++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx
@@ -318,7 +318,7 @@ describe('MusdQuickConvertView', () => {
expect(
getByText(
strings('earn.musd_conversion.quick_convert.title', {
- apy: MUSD_CONVERSION_APY,
+ percentage: MUSD_CONVERSION_APY,
}),
),
).toBeOnTheScreen();
diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx
index 52af5bb6083..d13e718d22d 100644
--- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx
+++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx
@@ -265,12 +265,12 @@ const MusdQuickConvertView = () => {
{strings('earn.musd_conversion.quick_convert.title', {
- apy: MUSD_CONVERSION_APY,
+ percentage: MUSD_CONVERSION_APY,
})}
{strings('earn.musd_conversion.quick_convert.subtitle', {
- apy: MUSD_CONVERSION_APY,
+ percentage: MUSD_CONVERSION_APY,
})}{' '}
{
).toBeOnTheScreen();
expect(
getByText(
- `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`,
+ `Convert your stablecoins to mUSD and get a ${MUSD_CONVERSION_APY}% annualized bonus.`,
),
).toBeOnTheScreen();
});
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx
index 8329744547a..3fa6a6945e6 100644
--- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx
+++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx
@@ -103,7 +103,7 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => {
).toBeOnTheScreen();
expect(
getByText(
- `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`,
+ `Convert your stablecoins to mUSD and get a ${MUSD_CONVERSION_APY}% annualized bonus.`,
),
).toBeOnTheScreen();
});
@@ -537,7 +537,7 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => {
// Assert
expect(
getByText(
- `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`,
+ `Convert your stablecoins to mUSD and get a ${MUSD_CONVERSION_APY}% annualized bonus.`,
),
).toBeOnTheScreen();
});
diff --git a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx
index 4a8bfc28bf1..417195c10cd 100644
--- a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx
+++ b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx
@@ -200,7 +200,7 @@ describe('useEarnToasts', () => {
expect(successToast.labelOptions).toBeDefined();
expect(Array.isArray(successToast.labelOptions)).toBe(true);
- expect(successToast.labelOptions).toHaveLength(1);
+ expect(successToast.labelOptions).toHaveLength(3);
});
it('includes labelOptions in failed toast', () => {
diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx
index 3047debb549..978311ff51f 100644
--- a/app/components/UI/Earn/hooks/useEarnToasts.tsx
+++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx
@@ -14,7 +14,12 @@ import {
} from '../../../../component-library/components/Toast/Toast.types';
import { useAppThemeFromContext } from '../../../../util/theme';
import { Spinner } from '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs';
-import { IconSize as ReactNativeDsIconSize } from '@metamask/design-system-react-native';
+import {
+ IconSize as ReactNativeDsIconSize,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
export type EarnToastOptions = Omit<
Extract,
@@ -185,6 +190,14 @@ const useEarnToasts = (): {
...earnBaseToastOptions.success,
labelOptions: getEarnToastLabels({
primary: strings('earn.musd_conversion.toasts.delivered'),
+ secondary: (
+
+ {strings('earn.musd_conversion.toasts.delivered_description')}
+
+ ),
}),
closeButtonOptions,
},
diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx
index e7fccf6b817..f425bc42b99 100644
--- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx
+++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx
@@ -2,9 +2,9 @@ import React from 'react';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
import { PercentageRow } from './percentage-row';
import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData';
+import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
import { strings } from '../../../../../../../locales/i18n';
import { MUSD_CONVERSION_APY } from '../../../../../UI/Earn/constants/musd';
-import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
import { TransactionType } from '@metamask/transaction-controller';
jest.mock('../../../hooks/pay/useTransactionPayData');
@@ -28,7 +28,7 @@ describe('PercentageRow', () => {
useIsTransactionPayLoadingMock.mockReturnValue(false);
useTransactionMetadataRequestMock.mockReturnValue({
type: TransactionType.musdConversion,
- } as never);
+ } as ReturnType);
});
it('renders label, tooltip and APY when not loading', () => {
@@ -39,28 +39,6 @@ describe('PercentageRow', () => {
expect(getByText(`${MUSD_CONVERSION_APY}%`)).toBeOnTheScreen();
});
- it('renders nothing when tx type is not supported', () => {
- useTransactionMetadataRequestMock.mockReturnValue({
- type: TransactionType.contractInteraction,
- } as never);
-
- const { queryByText, queryByTestId } = render();
-
- expect(queryByTestId('percentage-row-skeleton')).toBeNull();
- expect(queryByText(strings('earn.claimable_bonus'))).toBeNull();
- expect(queryByText(`${MUSD_CONVERSION_APY}%`)).toBeNull();
- });
-
- it('renders nothing when transaction metadata is undefined', () => {
- useTransactionMetadataRequestMock.mockReturnValue(undefined as never);
-
- const { queryByText, queryByTestId } = render();
-
- expect(queryByTestId('percentage-row-skeleton')).toBeNull();
- expect(queryByText(strings('earn.claimable_bonus'))).toBeNull();
- expect(queryByText(`${MUSD_CONVERSION_APY}%`)).toBeNull();
- });
-
it('renders skeleton when transaction pay is loading', () => {
useIsTransactionPayLoadingMock.mockReturnValue(true);
@@ -68,4 +46,14 @@ describe('PercentageRow', () => {
expect(getByTestId('percentage-row-skeleton')).toBeOnTheScreen();
});
+
+ it('renders nothing for non-musdConversion transactions', () => {
+ useTransactionMetadataRequestMock.mockReturnValue({
+ type: TransactionType.simpleSend,
+ } as ReturnType);
+
+ const { toJSON } = render();
+
+ expect(toJSON()).toBeNull();
+ });
});
diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx
index a0232f9d9c5..30c74e0824a 100644
--- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx
+++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { StyleSheet, Linking } from 'react-native';
import InfoRow from '../../UI/info-row';
import { MUSD_CONVERSION_APY } from '../../../../../UI/Earn/constants/musd';
import Text, {
@@ -8,45 +9,47 @@ import Text, {
import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData';
import { InfoRowSkeleton } from '../../UI/info-row/info-row';
import { strings } from '../../../../../../../locales/i18n';
+import { IconColor } from '../../../../../../component-library/components/Icons/Icon';
+import AppConstants from '../../../../../../core/AppConstants';
import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
import { TransactionType } from '@metamask/transaction-controller';
-import { IconColor } from '../../../../../../component-library/components/Icons/Icon';
+import { hasTransactionType } from '../../../utils/transaction';
-function getTxTypeRowConfig(
- transactionType?: TransactionType,
-): { label: string; tooltip: string } | undefined {
- if (transactionType === TransactionType.musdConversion) {
- return {
- label: strings('earn.claimable_bonus'),
- tooltip: strings('earn.claimable_bonus_tooltip'),
- };
- }
-
- return undefined;
-}
+const styles = StyleSheet.create({
+ termsText: {
+ textDecorationLine: 'underline',
+ },
+});
export function PercentageRow() {
- const transactionMetadata = useTransactionMetadataRequest();
-
const isLoading = useIsTransactionPayLoading();
- const transactionType = transactionMetadata?.type;
- const rowConfig = getTxTypeRowConfig(transactionType);
+ const transactionMetadata = useTransactionMetadataRequest();
- if (!rowConfig) {
+ if (
+ !hasTransactionType(transactionMetadata, [TransactionType.musdConversion])
+ ) {
return null;
}
+ const redirectToBonusFaq = () =>
+ Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE);
+
if (isLoading) {
return ;
}
- const { label, tooltip } = rowConfig;
-
return (
+ {strings('earn.claimable_bonus_tooltip')}{' '}
+
+ {strings('earn.musd_conversion.education.terms_apply')}
+
+
+ }
tooltipColor={IconColor.Alternative}
>
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 0663ab03edd..500950ec214 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -5915,7 +5915,7 @@
"error_description": "Installation of {{snap}} failed."
},
"earn": {
- "claimable_bonus_tooltip": "An annual bonus claimable daily from your wallet.",
+ "claimable_bonus_tooltip": "The annualized bonus you’ve earned for holding mUSD. Your bonus is claimable daily on Linea.",
"earn_a_percentage_bonus": "Earn a {{percentage}}% bonus",
"claimable_bonus": "Claimable bonus",
"claim_bonus": "Claim bonus",
@@ -6023,12 +6023,13 @@
"toasts": {
"converting": "Converting {{token}} → mUSD",
"eta": "~{{time}}",
- "delivered": "Your mUSD is here!",
+ "delivered": "mUSD conversion successful",
+ "delivered_description": "Bonus will be claimable within a day.",
"failed": "mUSD conversion failed"
},
"education": {
"heading": "GET {{percentage}}% ON\nSTABLECOINS",
- "description": "Convert your stablecoins to mUSD, MetaMask’s US dollar-backed stablecoin, and receive up to a {{percentage}}% bonus.",
+ "description": "Convert your stablecoins to mUSD and earn up to a {{percentage}}% annualized bonus that you can claim daily.",
"terms_apply": "Terms apply.",
"primary_button": "Get Started",
"secondary_button": "Not now"
@@ -6036,17 +6037,16 @@
"buy_musd": "Buy mUSD",
"get_musd": "Get mUSD",
"bonus_title": "Get {{percentage}}% on your stablecoins",
- "bonus_description": "Convert your stablecoins to mUSD and receive up to a {{percentage}}% bonus.",
+ "bonus_description": "Convert your stablecoins to mUSD and get a {{percentage}}% annualized bonus.",
"powered_by_relay": "Powered by Relay",
"max": "Max",
"quick_convert_button": "Convert",
- "cta_body_earn_apy": "Earn {{apy}} yield automatically for holding mUSD.",
"learn_more": "Learn more",
"tooltip_title": "Earn yield with mUSD",
"tooltip_content": "Convert your USDC, USDT, or DAI for mUSD, MetaMask's dollar-backed stablecoin. Earn {{apy}} yield on every dollar you hold.",
"quick_convert": {
- "title": "Convert and get {{apy}}%",
- "subtitle": "Convert your stablecoins to mUSD and receive up to a {{apy}}% bonus.",
+ "title": "Convert and get {{percentage}}%",
+ "subtitle": "Convert your stablecoins to mUSD and receive up to a {{percentage}}% annualized bonus that you can claim daily.",
"inline_failed_message": "Conversion failed. Try again.",
"confirmation": {
"title": "Convert max"
From 65e5c816e9ff6be949c416b3277a2a730e6d8fdb Mon Sep 17 00:00:00 2001
From: George Marshall
Date: Fri, 6 Mar 2026 01:52:32 -0800
Subject: [PATCH 6/9] test: color-no-hex rewards (#27031)
## **Description**
This PR is the Rewards-only split of the `color-no-hex` batch work,
extracted from the original umbrella PR #26651.
Scope:
- Rewards files only (`app/components/UI/Rewards/**`)
- temporary eslint rollout override for
`app/components/UI/Rewards/**/*.{js,jsx,ts,tsx}`
- includes replacing straightforward mock color suppressions with
`mockTheme` in a subset of Rewards tests
Reference PR: https://github.com/MetaMask/metamask-mobile/pull/26651
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: color-no-hex rewards batch
Scenario: validate rewards lint and tests
Given this branch is checked out
When running eslint for Rewards scope
Then there are no lint errors
When running jest for Rewards scope with snapshot updates
Then tests pass
```
## **Screenshots/Recordings**
### **Before**
N/A (test/lint/config updates only)
### **After**
N/A (test/lint/config updates only)
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Low runtime risk since changes are limited to ESLint configuration
plus test/story updates; main risk is CI/dev friction if any remaining
Rewards hex literals trigger the newly-enforced lint rule.
>
> **Overview**
> **Enforces `@metamask/design-tokens/color-no-hex` for Rewards UI
code.** Updates `.eslintrc.js` to include
`app/components/UI/Rewards/**/*` in the folders where hex colors are
treated as lint errors.
>
> **Aligns Rewards tests/stories with the rule.** Rewards tests now mock
`useTheme` by reusing the shared `mockTheme` (and remove a local
onboarding `mockTheme` helper), and a Rewards Storybook story
(`RewardPointsAnimation`) is refactored to use Tailwind/design-system
`Button`s instead of inline styles/hex colors, with a couple of tests
explicitly scoping hex-only mock API colors behind lint disables.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
3a5d5c1ef3c117c66fdd07872df82fc489985492. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.eslintrc.js | 19 +--
.../UI/Rewards/RewardsNavigator.test.tsx | 14 +--
.../Rewards/Views/RewardsDashboard.test.tsx | 14 +--
.../Views/RewardsReferralView.test.tsx | 14 +--
.../EndOfSeasonClaimBottomSheet.test.tsx | 15 +--
.../OnboardingNoActiveSeasonStep.test.tsx | 15 +--
.../__tests__/OnboardingStep.test.tsx | 23 +---
.../__tests__/OnboardingStep1.test.tsx | 15 +--
.../__tests__/OnboardingStep2.test.tsx | 15 +--
.../__tests__/OnboardingStep3.test.tsx | 15 +--
.../__tests__/OnboardingStep4.test.tsx | 15 +--
.../components/Onboarding/testUtils.ts | 17 ---
.../PreviousSeasonUnlockedRewards.test.tsx | 19 +--
.../RewardPointsAnimation.stories.tsx | 108 ++++++------------
.../RewardPointsAnimation/index.test.tsx | 16 +--
.../RewardsReferralCodeTag.test.tsx | 6 +-
.../SeasonStatus/SeasonStatus.test.tsx | 21 +---
.../Settings/ReferredByCodeSection.test.tsx | 15 +--
.../RewardSettingsAccountGroup.test.tsx | 15 +--
.../RewardSettingsAccountGroupList.test.tsx | 18 +--
.../Tabs/LevelsTab/UnlockedRewards.test.tsx | 16 +--
.../Tabs/LevelsTab/UpcomingRewards.test.tsx | 15 ++-
.../Tabs/OverviewTab/ActiveBoosts.test.tsx | 14 ++-
.../WaysToEarn/BonusCodeBottomSheet.test.tsx | 9 +-
.../RewardsThemeImageComponent.test.tsx | 14 +--
.../hooks/useActivePointsBoosts.test.ts | 11 +-
26 files changed, 183 insertions(+), 305 deletions(-)
diff --git a/.eslintrc.js b/.eslintrc.js
index 6f50169814a..db10cce9e90 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -117,19 +117,12 @@ module.exports = {
},
},
{
- files: ['app/components/UI/Card/**/*.{js,jsx,ts,tsx}'],
- rules: {
- '@metamask/design-tokens/color-no-hex': 'error',
- },
- },
- {
- files: ['app/components/Snaps/**/*.{js,jsx,ts,tsx}'],
- rules: {
- '@metamask/design-tokens/color-no-hex': 'error',
- },
- },
- {
- files: ['app/components/UI/Predict/**/*.{js,jsx,ts,tsx}'],
+ files: [
+ 'app/components/UI/Card/**/*.{js,jsx,ts,tsx}',
+ 'app/components/Snaps/**/*.{js,jsx,ts,tsx}',
+ 'app/components/UI/Predict/**/*.{js,jsx,ts,tsx}',
+ 'app/components/UI/Rewards/**/*.{js,jsx,ts,tsx}',
+ ],
rules: {
'@metamask/design-tokens/color-no-hex': 'error',
},
diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx
index 2dbb8397fb6..af3781616ba 100644
--- a/app/components/UI/Rewards/RewardsNavigator.test.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx
@@ -100,14 +100,12 @@ jest.mock('../../Views/ErrorBoundary', () => ({
}));
// Mock theme
-jest.mock('../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- primary: '#000',
- background: '#fff',
- },
- }),
-}));
+jest.mock('../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock getNavigationOptionsTitle
jest.mock('../Navbar', () => ({
diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx
index 3a0f95ef029..b01f9977e0d 100644
--- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx
@@ -106,14 +106,12 @@ const mockSelectSnapshotsRewardsEnabledFlag =
>;
// Mock theme
-jest.mock('../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- primary: '#000',
- background: '#fff',
- },
- }),
-}));
+jest.mock('../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock react-native-safe-area-context
jest.mock('react-native-safe-area-context', () => {
diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx
index bd9b873881b..b82bd22f49a 100644
--- a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx
@@ -29,14 +29,12 @@ jest.mock('@react-navigation/native', () => ({
}));
// Mock theme
-jest.mock('../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- primary: '#000',
- background: '#fff',
- },
- }),
-}));
+jest.mock('../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock i18n
jest.mock('../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx
index ded9c90a707..8b0dcf956c2 100644
--- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx
+++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx
@@ -102,15 +102,12 @@ jest.mock('../../hooks/useLineaSeasonOneTokenReward', () => ({
}));
// Mock useTheme
-jest.mock('../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- text: {
- alternative: '#666666',
- },
- },
- }),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock i18n
jest.mock('../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx
index 4430b2deece..6b7869d650c 100644
--- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx
+++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx
@@ -48,15 +48,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- background: {
- muted: '#f5f5f5',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock strings
jest.mock('../../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx
index 1b35ecd1d7c..45b943cd59e 100644
--- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx
+++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx
@@ -42,23 +42,12 @@ jest.mock('react-redux', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- background: {
- muted: '#f5f5f5',
- default: '#ffffff',
- },
- text: {
- primary: '#000000',
- alternative: '#666666',
- },
- border: {
- muted: '#e0e0e0',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock rewards auth hook
const mockOptin = jest.fn();
diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx
index 4d2088db4ff..6c39fcdd552 100644
--- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx
+++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx
@@ -29,15 +29,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- background: {
- muted: '#f5f5f5',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock strings
jest.mock('../../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx
index 47dab7aa9c4..308af179595 100644
--- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx
+++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx
@@ -29,15 +29,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- background: {
- muted: '#f5f5f5',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock strings
jest.mock('../../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx
index 63fe5b219f6..32397d57824 100644
--- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx
+++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx
@@ -29,15 +29,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- background: {
- muted: '#f5f5f5',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock strings
jest.mock('../../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx
index c39ab1a0619..2f58d403ec0 100644
--- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx
+++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx
@@ -43,15 +43,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- background: {
- muted: '#f5f5f5',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock strings
jest.mock('../../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Onboarding/testUtils.ts b/app/components/UI/Rewards/components/Onboarding/testUtils.ts
index f1dd8b32b49..6a290fcb0ac 100644
--- a/app/components/UI/Rewards/components/Onboarding/testUtils.ts
+++ b/app/components/UI/Rewards/components/Onboarding/testUtils.ts
@@ -182,20 +182,3 @@ export const mockAccount = {
},
},
};
-
-// Mock theme
-export const mockTheme = {
- colors: {
- background: {
- muted: '#f5f5f5',
- default: '#ffffff',
- },
- text: {
- primary: '#000000',
- alternative: '#666666',
- },
- border: {
- muted: '#e0e0e0',
- },
- },
-};
diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx
index 06c06753940..dd0027cdd40 100644
--- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx
+++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx
@@ -52,19 +52,12 @@ const mockUseUnlockedRewards = useUnlockedRewards as jest.MockedFunction<
>;
// Mock useTheme
-jest.mock('../../../../../util/theme', () => ({
- useTheme: () => ({
- themeAppearance: 'light',
- colors: {
- background: {
- default: '#FFFFFF',
- },
- text: {
- muted: '#999999',
- },
- },
- }),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock i18n
jest.mock('../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx b/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx
index be716304229..301b58fe67f 100644
--- a/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx
+++ b/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx
@@ -1,10 +1,13 @@
/* eslint-disable react/display-name */
-/* eslint-disable react-native/no-inline-styles */
-/* eslint-disable @typescript-eslint/no-explicit-any */
-/* eslint-disable react-native/no-color-literals */
import React, { useState, useCallback, useEffect } from 'react';
-import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
+import { View } from 'react-native';
import RewardPointsAnimationComponent, { RewardAnimationState } from './index';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
/**
* Storybook configuration for RewardPointsAnimation component
@@ -22,61 +25,13 @@ const RewardPointsAnimationMeta = {
control: { type: 'number' },
description: 'Animation duration in milliseconds',
},
- variant: {
- control: { type: 'select' },
- options: ['BodyMD', 'BodyLG', 'HeadingMd'],
- description: 'Text variant for styling',
- },
},
};
export default RewardPointsAnimationMeta;
-const styles = StyleSheet.create({
- container: {
- padding: 24,
- },
- buttonContainer: {
- marginTop: 20,
- gap: 10,
- },
- buttonRow: {
- flexDirection: 'row',
- gap: 10,
- },
- primaryButton: {
- backgroundColor: '#007AFF',
- padding: 10,
- borderRadius: 5,
- minWidth: 100,
- alignItems: 'center',
- },
- secondaryButton: {
- backgroundColor: '#6C757D',
- padding: 10,
- borderRadius: 5,
- minWidth: 100,
- alignItems: 'center',
- },
- buttonText: {
- color: 'white',
- fontWeight: 'bold',
- },
- animationContainer: {
- padding: 20,
- paddingHorizontal: 50,
- width: '100%',
- alignItems: 'center',
- justifyContent: 'center',
- alignSelf: 'center',
- },
-});
-
-const InteractiveStory = (args: {
- value: number;
- duration: number;
- variant?: any;
-}) => {
+const InteractiveStory = (args: { value: number; duration: number }) => {
+ const tw = useTailwind();
const [currentValue, setCurrentValue] = useState(0);
const [animationState, setAnimationState] = useState(
RewardAnimationState.Idle,
@@ -126,9 +81,11 @@ const InteractiveStory = (args: {
}, [handleIdle]);
return (
-
+
{/* Animation display */}
-
+
{/* Control buttons for state demonstration */}
-
-
-
- Loading
-
-
+
+
+
-
+
+ Error
+
-
-
- Set random value
-
+
+
diff --git a/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx b/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx
index 663670b612c..94bcf3e5f9c 100644
--- a/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx
+++ b/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx
@@ -26,16 +26,12 @@ jest.mock('../../../../../component-library/hooks', () => ({
})),
}));
-jest.mock('../../../../../util/theme', () => ({
- useTheme: jest.fn(() => ({
- colors: {
- text: {
- default: '#000000',
- alternative: '#666666',
- },
- },
- })),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: jest.fn(() => mockTheme),
+ };
+});
jest.mock('rive-react-native', () => ({
__esModule: true,
diff --git a/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx b/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx
index 80f51d71278..8dd2bdde77e 100644
--- a/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx
+++ b/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx
@@ -4,6 +4,8 @@ import RewardsReferralCodeTag from './RewardsReferralCodeTag';
import ClipboardManager from '../../../../../core/ClipboardManager';
import { useStyles } from '../../../../../component-library/hooks';
+const { mockTheme } = jest.requireActual('../../../../../util/theme');
+
jest.mock('../../../../../component-library/hooks', () => ({
useStyles: jest.fn((_styleSheet, params) => ({
styles: {
@@ -62,7 +64,7 @@ describe('RewardsReferralCodeTag', () => {
});
it('applies custom backgroundColor when provided', () => {
- const customBackgroundColor = '#FF0000';
+ const customBackgroundColor = mockTheme.colors.error.default;
render(
{
});
it('applies custom fontColor when provided', () => {
- const customFontColor = '#00FF00';
+ const customFontColor = mockTheme.colors.success.default;
render(
({
}));
// Mock useTheme
-jest.mock('../../../../../util/theme', () => ({
- useTheme: jest.fn(() => ({
- colors: {
- background: {
- alternative: '#f5f5f5',
- default: '#ffffff',
- section: '#f9f9f9',
- },
- text: {
- primary: '#000000',
- alternative: '#666666',
- },
- },
- })),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: jest.fn(() => mockTheme),
+ };
+});
// Mock Tailwind
jest.mock('@metamask/design-system-twrnc-preset', () => ({
diff --git a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx
index 6287d9926a0..f51bd4df51a 100644
--- a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx
+++ b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx
@@ -219,15 +219,12 @@ jest.mock('../../hooks/useApplyReferralCode', () => ({
useApplyReferralCode: jest.fn(),
}));
-jest.mock('../../../../../util/theme', () => ({
- useTheme: jest.fn(() => ({
- colors: {
- background: { muted: '#f5f5f5' },
- border: { muted: '#e0e0e0' },
- error: { default: '#ff0000' },
- },
- })),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: jest.fn(() => mockTheme),
+ };
+});
jest.mock('@react-navigation/native', () => ({
useFocusEffect: jest.fn((callback) => callback()),
diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx
index e678426377c..5a70da8ce74 100644
--- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx
+++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx
@@ -35,15 +35,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
},
}));
-jest.mock('../../../../../util/theme', () => ({
- useTheme: jest.fn(() => ({
- colors: {
- icon: {
- default: '#000000',
- },
- },
- })),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: jest.fn(() => mockTheme),
+ };
+});
jest.mock('lodash', () => ({
isEmpty: jest.fn((value: unknown) => {
diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx
index a9bfb9de36d..9cd932415b8 100644
--- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx
+++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx
@@ -52,18 +52,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => key),
}));
-jest.mock('../../../../../util/theme', () => ({
- useTheme: jest.fn(() => ({
- colors: {
- primary: {
- default: '#037DD6',
- },
- background: {
- alternative: '#F7F9FA',
- },
- },
- })),
-}));
+jest.mock('../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../util/theme');
+ return {
+ useTheme: jest.fn(() => mockTheme),
+ };
+});
// Mock FlashList
jest.mock('@shopify/flash-list', () => {
diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx
index 7b4c1151998..a2867b3f9c7 100644
--- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx
@@ -41,16 +41,12 @@ jest.mock('../../../../../../reducers/rewards/selectors', () => ({
}));
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- themeAppearance: 'light',
- colors: {
- grey: {
- 700: '#374151',
- },
- },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
// Mock useTailwind
jest.mock('@metamask/design-system-twrnc-preset', () => ({
diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx
index acc54d03f5c..0ac81bd066b 100644
--- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx
@@ -125,12 +125,15 @@ const mockSelectSeasonStartDate = selectSeasonStartDate as jest.MockedFunction<
>;
// Mock theme
-jest.mock('../../../../../../util/theme', () => ({
- useTheme: () => ({
- themeAppearance: 'light',
- brandColors: { grey700: '#374151' },
- }),
-}));
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => ({
+ themeAppearance: 'light',
+ brandColors: mockTheme.brandColors,
+ }),
+ };
+});
// Mock i18n
jest.mock('../../../../../../../locales/i18n', () => ({
diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx
index b6739038729..dd9aedcbd29 100644
--- a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx
@@ -161,6 +161,14 @@ const mockFormatTimeRemaining = jest.requireMock(
'../../../utils/formatUtils',
).formatTimeRemaining;
+/* eslint-disable @metamask/design-tokens/color-no-hex -- domain-specific mock API colors */
+const MOCK_BOOST_COLORS = {
+ swap: '#FF6B35',
+ seasonLong: '#4A90E2',
+ noEndDate: '#50C878',
+} as const;
+/* eslint-enable @metamask/design-tokens/color-no-hex */
+
// Mock React Native components
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
@@ -202,7 +210,7 @@ const mockBoost: PointsBoostDto = {
seasonLong: false,
startDate: '2024-01-01',
endDate: '2024-12-31',
- backgroundColor: '#FF6B35',
+ backgroundColor: MOCK_BOOST_COLORS.swap,
};
const mockSeasonLongBoost: PointsBoostDto = {
@@ -214,7 +222,7 @@ const mockSeasonLongBoost: PointsBoostDto = {
},
boostBips: 1000,
seasonLong: true,
- backgroundColor: '#4A90E2',
+ backgroundColor: MOCK_BOOST_COLORS.seasonLong,
};
const mockBoostWithoutEndDate: PointsBoostDto = {
@@ -226,7 +234,7 @@ const mockBoostWithoutEndDate: PointsBoostDto = {
},
boostBips: 250,
seasonLong: false,
- backgroundColor: '#50C878',
+ backgroundColor: MOCK_BOOST_COLORS.noEndDate,
};
describe('ActiveBoosts', () => {
diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx
index 7652d36b09f..0d59b9412af 100644
--- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx
@@ -120,9 +120,12 @@ jest.mock('../../../../hooks/useRewardsToast', () => ({
}),
}));
-jest.mock('../../../../../../../util/theme', () => ({
- useTheme: () => ({ colors: { icon: { default: '#000000' } } }),
-}));
+jest.mock('../../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
const mockUseValidateBonusCode = useValidateBonusCode as jest.MockedFunction<
typeof useValidateBonusCode
diff --git a/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx b/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx
index b18572fe4e6..7332aebbe0b 100644
--- a/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx
+++ b/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx
@@ -22,6 +22,7 @@ jest.mock('../../../../../util/theme', () => ({
}));
import RewardsThemeImageComponent from './RewardsThemeImageComponent';
+const { mockTheme } = jest.requireActual('../../../../../util/theme');
// Helper function to render with Redux Provider
const renderWithProvider = (component: React.ReactElement) =>
@@ -56,21 +57,14 @@ describe('RewardsThemeImageComponent', () => {
darkModeUrl: 'https://example.com/dark.png',
};
- const mockTheme = {
- colors: {
- primary: {
- default: '#037DD6',
- },
- },
+ const mockRewardsTheme = {
+ ...mockTheme,
themeAppearance: 'light',
- brandColors: {},
- typography: {},
- shadows: {},
} as any;
beforeEach(() => {
jest.clearAllMocks();
- mockUseTheme.mockReturnValue(mockTheme);
+ mockUseTheme.mockReturnValue(mockRewardsTheme);
});
it('renders Image and ActivityIndicator on initial render', () => {
diff --git a/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts b/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts
index dc69ae51889..f180e62a9d2 100644
--- a/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts
+++ b/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts
@@ -51,6 +51,13 @@ jest.mock('@react-navigation/native', () => ({
useFocusEffect: jest.fn(),
}));
+/* eslint-disable @metamask/design-tokens/color-no-hex -- domain-specific mock API colors */
+const MOCK_ACTIVE_BOOST_COLORS = {
+ primary: '#FF0000',
+ secondary: '#00FF00',
+} as const;
+/* eslint-enable @metamask/design-tokens/color-no-hex */
+
describe('useActivePointsBoosts', () => {
const mockDispatch = jest.fn();
const mockUseFocusEffect = useFocusEffect as jest.MockedFunction<
@@ -77,7 +84,7 @@ describe('useActivePointsBoosts', () => {
},
boostBips: 1000,
seasonLong: true,
- backgroundColor: '#FF0000',
+ backgroundColor: MOCK_ACTIVE_BOOST_COLORS.primary,
},
{
id: 'boost-2',
@@ -90,7 +97,7 @@ describe('useActivePointsBoosts', () => {
seasonLong: false,
startDate: '2024-01-01',
endDate: '2024-01-31',
- backgroundColor: '#00FF00',
+ backgroundColor: MOCK_ACTIVE_BOOST_COLORS.secondary,
},
];
From c3d9fe362de2ce63142c5f9b14aadff0a3e07d16 Mon Sep 17 00:00:00 2001
From: Ulisses Ferreira
Date: Fri, 6 Mar 2026 10:14:57 +0000
Subject: [PATCH 7/9] chore: filter out Tron staking special assets (#26360)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
As part of Tron's staking experience improvements we will be sending
more special assets from the Snap to the Extension. These special assets
are not tradeable tokens and should be filtered out from selectors like
we already do for Staked TRX for example.
This PR:
- Adds the new special assets that should be ignored by the selectors
- Renames the variables that deal with this logic to be more inclusive
of assets that are not resources (only Energy and Bandwidth are
resources)
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Closes:
[NEB-582](https://consensyssoftware.atlassian.net/browse/NEB-582),
[NEB-584](https://consensyssoftware.atlassian.net/browse/NEB-584),
[NEB-586](https://consensyssoftware.atlassian.net/browse/NEB-586)
## **Manual testing steps**
All existing Tron functionality should remain unchanged
## **Screenshots/Recordings**
As you can see, the new assets being loaded from the preview build of
https://github.com/MetaMask/snap-tron-wallet/pull/226 are not being
shown here.
### **Before**
n/a
### **After**
n/a
## **Pre-merge author checklist**
- [x] I've followed MetaMask Contributor Docs and MetaMask Mobile Coding
Standards.
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using JSDoc format if applicable
- [x] I've applied the right labels on the PR
[NEB-582]:
https://consensyssoftware.atlassian.net/browse/NEB-582?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[NEB-584]:
https://consensyssoftware.atlassian.net/browse/NEB-584?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[NEB-586]:
https://consensyssoftware.atlassian.net/browse/NEB-586?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
---
> [!NOTE]
> **Medium Risk**
> Changes token/asset filtering for Tron by excluding additional
Snap-provided “special assets” from sorted asset lists and unified
multichain token lists, which could inadvertently hide tokens if symbols
collide or filtering is misapplied. Scope is contained to Tron
selectors/utilities and related UI consumers, with broad test updates.
>
> **Overview**
> Introduces a broader Tron *“special assets”* concept (resources +
staking lifecycle assets) and filters these virtual tokens out of
user-facing asset/token lists.
>
> Renames and expands the Tron selector from
`selectTronResourcesBySelectedAccountGroup` to
`selectTronSpecialAssetsBySelectedAccountGroup` (and `TronResourcesMap`
to `TronSpecialAssetsMap`), adding mappings for `trxReadyForWithdrawal`,
`trxStakingRewards`, and `trxInLockPeriod` while preserving
`totalStakedTrx` computation.
>
> Centralizes special-asset detection in `core/Multichain/utils` via
`isTronSpecialAsset` and reuses it in
`selectSortedAssetsBySelectedAccountGroup`,
`selectAccountTokensAcrossChainsUnified`, and Bridge `isTradableToken`;
updates related Earn/TokenDetails/AssetOverview hooks and tests
accordingly.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
893e98a8928ebcfff7a24e656cac48ba3ac77d6c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../TronEnergyBandwidthDetail.test.tsx | 21 ++-
.../useTronResources.test.ts | 58 +++---
.../useTronResources.ts | 4 +-
.../utils/isTradableToken/index.test.ts | 33 ++++
.../UI/Bridge/utils/isTradableToken/index.ts | 16 +-
.../EarnBalance/EarnBalance.test.tsx | 19 +-
.../UI/Earn/components/EarnBalance/index.tsx | 10 +-
.../StakePreview/TronStakePreview.test.tsx | 23 ++-
.../Tron/StakePreview/TronStakePreview.tsx | 4 +-
.../UI/Earn/hooks/useTronUnstake.test.ts | 13 +-
.../UI/Earn/hooks/useTronUnstake.ts | 14 +-
app/components/UI/Earn/utils/tron.test.ts | 46 ++---
app/components/UI/Earn/utils/tron.ts | 16 +-
.../hooks/useTokenBalance.test.ts | 24 ++-
.../UI/TokenDetails/hooks/useTokenBalance.ts | 4 +-
app/core/Multichain/constants.ts | 24 ++-
app/core/Multichain/utils.ts | 26 ++-
app/selectors/assets/assets-list.test.ts | 167 ++++++++++++++++--
app/selectors/assets/assets-list.ts | 102 +++++++----
app/selectors/multichain/multichain.ts | 14 +-
20 files changed, 444 insertions(+), 194 deletions(-)
diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx
index 2cf100bc170..851939d3ef2 100644
--- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx
+++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx
@@ -6,8 +6,8 @@ import ResourceRing from './ResourceRing';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import { backgroundState } from '../../../../util/test/initial-root-state';
import {
- selectTronResourcesBySelectedAccountGroup,
- TronResourcesMap,
+ selectTronSpecialAssetsBySelectedAccountGroup,
+ TronSpecialAssetsMap,
} from '../../../../selectors/assets/assets-list';
jest.mock('./ResourceRing', () => ({
@@ -33,14 +33,14 @@ jest.mock('../../../../../locales/i18n', () => ({
}));
jest.mock('../../../../selectors/assets/assets-list', () => ({
- selectTronResourcesBySelectedAccountGroup: jest.fn(),
+ selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(),
}));
type SelectorReturn = ReturnType<
- typeof selectTronResourcesBySelectedAccountGroup
+ typeof selectTronSpecialAssetsBySelectedAccountGroup
>;
-const createEmptyResourcesMap = (): TronResourcesMap => ({
+const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -48,6 +48,9 @@ const createEmptyResourcesMap = (): TronResourcesMap => ({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
interface Resource {
@@ -78,7 +81,7 @@ describe('TronEnergyBandwidthDetail', () => {
});
it('renders values, coverage counts, and passes correct progress to ResourceRing', () => {
- jest.mocked(selectTronResourcesBySelectedAccountGroup).mockReturnValue({
+ jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup).mockReturnValue({
energy: res('energy', 130000),
bandwidth: res('bandwidth', 560),
maxEnergy: res('max-energy', 200000),
@@ -110,7 +113,7 @@ describe('TronEnergyBandwidthDetail', () => {
});
it('parses balances and caps progress', () => {
- jest.mocked(selectTronResourcesBySelectedAccountGroup).mockReturnValue({
+ jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup).mockReturnValue({
energy: res('energy', '1000'),
bandwidth: res('bandwidth', '2000'),
maxEnergy: res('max-energy', '400'),
@@ -136,8 +139,8 @@ describe('TronEnergyBandwidthDetail', () => {
it('handles missing resources by showing zeros and 0 progress', () => {
jest
- .mocked(selectTronResourcesBySelectedAccountGroup)
- .mockReturnValue(createEmptyResourcesMap());
+ .mocked(selectTronSpecialAssetsBySelectedAccountGroup)
+ .mockReturnValue(createEmptySpecialAssetsMap());
const { getAllByText, getByText } = renderWithProvider(
,
diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts
index 58788b13edb..5512de427cf 100644
--- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts
+++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts
@@ -4,8 +4,8 @@ import { useSelector } from 'react-redux';
import { useTronResources } from './useTronResources';
import {
- selectTronResourcesBySelectedAccountGroup,
- TronResourcesMap,
+ selectTronSpecialAssetsBySelectedAccountGroup,
+ TronSpecialAssetsMap,
} from '../../../../selectors/assets/assets-list';
jest.mock('react-redux', () => ({
@@ -15,13 +15,13 @@ jest.mock('react-redux', () => ({
jest.mock('../../../../selectors/assets/assets-list', () => ({
__esModule: true,
...jest.requireActual('../../../../selectors/assets/assets-list'),
- selectTronResourcesBySelectedAccountGroup: jest.fn(),
+ selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(),
}));
const mockUseSelector = useSelector as jest.MockedFunction;
-const mockSelectTronResourcesBySelectedAccountGroup =
- selectTronResourcesBySelectedAccountGroup as jest.MockedFunction<
- typeof selectTronResourcesBySelectedAccountGroup
+const mockSelectTronSpecialAssetsBySelectedAccountGroup =
+ selectTronSpecialAssetsBySelectedAccountGroup as jest.MockedFunction<
+ typeof selectTronSpecialAssetsBySelectedAccountGroup
>;
interface MockTronAsset {
@@ -29,7 +29,7 @@ interface MockTronAsset {
balance?: string | number;
}
-const createEmptyResourcesMap = (): TronResourcesMap => ({
+const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -37,6 +37,9 @@ const createEmptyResourcesMap = (): TronResourcesMap => ({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
const createTronAsset = (
@@ -52,13 +55,13 @@ describe('useTronResources', () => {
jest.clearAllMocks();
mockUseSelector.mockImplementation((selector: any) => selector());
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue(
- createEmptyResourcesMap(),
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue(
+ createEmptySpecialAssetsMap(),
);
});
it('builds energy and bandwidth resources from base max capacity', () => {
- const tronResourcesMap: TronResourcesMap = {
+ const tronSpecialAssetsMap: TronSpecialAssetsMap = {
energy: createTronAsset('energy', '500') as any,
bandwidth: createTronAsset('bandwidth', '300') as any,
maxEnergy: createTronAsset('max-energy', '1000') as any,
@@ -66,10 +69,13 @@ describe('useTronResources', () => {
stakedTrxForEnergy: createTronAsset('strx-energy', '500') as any,
stakedTrxForBandwidth: createTronAsset('strx-bandwidth', 0) as any,
totalStakedTrx: 500,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
};
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue(
- tronResourcesMap,
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue(
+ tronSpecialAssetsMap,
);
const { result } = renderHook(() => useTronResources());
@@ -84,8 +90,8 @@ describe('useTronResources', () => {
});
it('returns zeroed resources when no Tron resources exist', () => {
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue(
- createEmptyResourcesMap(),
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue(
+ createEmptySpecialAssetsMap(),
);
const { result } = renderHook(() => useTronResources());
@@ -106,14 +112,14 @@ describe('useTronResources', () => {
});
it('parses balances with comma separators', () => {
- const tronResourcesMap: TronResourcesMap = {
- ...createEmptyResourcesMap(),
+ const tronSpecialAssetsMap: TronSpecialAssetsMap = {
+ ...createEmptySpecialAssetsMap(),
energy: createTronAsset('energy', '1,000') as any,
maxEnergy: createTronAsset('max-energy', '2,000') as any,
};
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue(
- tronResourcesMap,
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue(
+ tronSpecialAssetsMap,
);
const { result } = renderHook(() => useTronResources());
@@ -124,14 +130,14 @@ describe('useTronResources', () => {
});
it('caps percentage at one hundred when current exceeds max', () => {
- const tronResourcesMap: TronResourcesMap = {
- ...createEmptyResourcesMap(),
+ const tronSpecialAssetsMap: TronSpecialAssetsMap = {
+ ...createEmptySpecialAssetsMap(),
energy: createTronAsset('energy', 200) as any,
maxEnergy: createTronAsset('max-energy', 100) as any,
};
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue(
- tronResourcesMap,
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue(
+ tronSpecialAssetsMap,
);
const { result } = renderHook(() => useTronResources());
@@ -141,14 +147,14 @@ describe('useTronResources', () => {
});
it('sets percentage to zero when balances cannot be parsed', () => {
- const tronResourcesMap: TronResourcesMap = {
- ...createEmptyResourcesMap(),
+ const tronSpecialAssetsMap: TronSpecialAssetsMap = {
+ ...createEmptySpecialAssetsMap(),
energy: createTronAsset('energy', 'invalid') as any,
maxEnergy: createTronAsset('max-energy', '1000') as any,
};
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue(
- tronResourcesMap,
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue(
+ tronSpecialAssetsMap,
);
const { result } = renderHook(() => useTronResources());
diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts
index 51719b8d6dd..c27c3bb9087 100644
--- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts
+++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import BigNumber from 'bignumber.js';
-import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list';
+import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list';
import { safeParseBigNumber } from '../../../../util/number/bignumber';
export interface TronResource {
@@ -49,7 +49,7 @@ export const useTronResources = (): {
bandwidth: TronResource;
} => {
const { energy, bandwidth, maxEnergy, maxBandwidth } = useSelector(
- selectTronResourcesBySelectedAccountGroup,
+ selectTronSpecialAssetsBySelectedAccountGroup,
);
return useMemo(() => {
diff --git a/app/components/UI/Bridge/utils/isTradableToken/index.test.ts b/app/components/UI/Bridge/utils/isTradableToken/index.test.ts
index 0efd75c3f7b..1ba0cd83fa6 100644
--- a/app/components/UI/Bridge/utils/isTradableToken/index.test.ts
+++ b/app/components/UI/Bridge/utils/isTradableToken/index.test.ts
@@ -238,5 +238,38 @@ describe('isTradableToken', () => {
expect(result).toBe(false);
});
+
+ it('returns false for Tron Ready for Withdrawal token', () => {
+ const token = createTestToken({
+ chainId: TrxScope.Mainnet,
+ symbol: 'TRX-READY-FOR-WITHDRAWAL',
+ });
+
+ const result = isTradableToken(token);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for Tron Staking Rewards token', () => {
+ const token = createTestToken({
+ chainId: TrxScope.Mainnet,
+ symbol: 'TRX-STAKING-REWARDS',
+ });
+
+ const result = isTradableToken(token);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for Tron In Lock Period token', () => {
+ const token = createTestToken({
+ chainId: TrxScope.Mainnet,
+ symbol: 'TRX-IN-LOCK-PERIOD',
+ });
+
+ const result = isTradableToken(token);
+
+ expect(result).toBe(false);
+ });
});
});
diff --git a/app/components/UI/Bridge/utils/isTradableToken/index.ts b/app/components/UI/Bridge/utils/isTradableToken/index.ts
index 37c5af07686..cad399bebc5 100644
--- a/app/components/UI/Bridge/utils/isTradableToken/index.ts
+++ b/app/components/UI/Bridge/utils/isTradableToken/index.ts
@@ -1,16 +1,8 @@
import { TrxScope } from '@metamask/keyring-api';
import { BridgeToken } from '../../types';
-import {
- TRON_RESOURCE_SYMBOLS,
- TronResourceSymbol,
-} from '../../../../../core/Multichain/constants';
+import { isTronSpecialAsset } from '../../../../../core/Multichain/utils';
import { TokenI } from '../../../Tokens/types';
-export const isTradableToken = (token: BridgeToken | TokenI) => {
- if (token.chainId === TrxScope.Mainnet) {
- return !TRON_RESOURCE_SYMBOLS.includes(
- token.symbol?.toLowerCase() as TronResourceSymbol,
- );
- }
- return true;
-};
+export const isTradableToken = (token: BridgeToken | TokenI) =>
+ token.chainId !== TrxScope.Mainnet ||
+ !isTronSpecialAsset(token.chainId, token.symbol);
diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx
index a20cea02d13..0eb0eae8c95 100644
--- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx
+++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx
@@ -5,7 +5,7 @@ import StakingBalance from '../../../Stake/components/StakingBalance/StakingBala
import { TokenI } from '../../../Tokens/types';
import EarnLendingBalance from '../EarnLendingBalance';
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
-import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
+import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
import TronStakingButtons from '../Tron/TronStakingButtons';
import { selectIsMusdConversionFlowEnabledFlag } from '../../selectors/featureFlags';
@@ -27,7 +27,7 @@ jest.mock(
jest.mock('../../../../../selectors/assets/assets-list', () => ({
...jest.requireActual('../../../../../selectors/assets/assets-list'),
- selectTronResourcesBySelectedAccountGroup: jest.fn(),
+ selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(),
}));
jest.mock('../Tron/TronStakingButtons', () => ({
@@ -137,7 +137,7 @@ jest.mock('../../hooks/useTronStakeApy', () => ({
}),
}));
-const createEmptyResourcesMap = () => ({
+const createEmptySpecialAssetsMap = () => ({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -145,6 +145,9 @@ const createEmptyResourcesMap = () => ({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
describe('EarnBalance', () => {
@@ -152,8 +155,8 @@ describe('EarnBalance', () => {
jest.clearAllMocks();
(jest.mocked(selectTrxStakingEnabled) as jest.Mock).mockReturnValue(false);
(
- jest.mocked(selectTronResourcesBySelectedAccountGroup) as jest.Mock
- ).mockReturnValue(createEmptyResourcesMap());
+ jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup) as jest.Mock
+ ).mockReturnValue(createEmptySpecialAssetsMap());
});
describe('Ethereum Mainnet', () => {
@@ -251,7 +254,7 @@ describe('EarnBalance', () => {
describe('TRON', () => {
const mockFlag = selectTrxStakingEnabled as unknown as jest.Mock;
const mockTronResources =
- selectTronResourcesBySelectedAccountGroup as unknown as jest.Mock;
+ selectTronSpecialAssetsBySelectedAccountGroup as unknown as jest.Mock;
it('renders TRON stake button with aprText for TRX without staked positions', () => {
const trx: Partial = {
@@ -261,7 +264,7 @@ describe('EarnBalance', () => {
};
mockFlag.mockReturnValue(true);
- mockTronResources.mockReturnValue(createEmptyResourcesMap());
+ mockTronResources.mockReturnValue(createEmptySpecialAssetsMap());
renderWithProvider();
@@ -283,7 +286,7 @@ describe('EarnBalance', () => {
mockFlag.mockReturnValue(true);
mockTronResources.mockReturnValue({
- ...createEmptyResourcesMap(),
+ ...createEmptySpecialAssetsMap(),
stakedTrxForEnergy: { symbol: 'strx-energy', balance: '1' },
stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '2' },
totalStakedTrx: 3,
diff --git a/app/components/UI/Earn/components/EarnBalance/index.tsx b/app/components/UI/Earn/components/EarnBalance/index.tsx
index 575f2632d9a..894f55b7309 100644
--- a/app/components/UI/Earn/components/EarnBalance/index.tsx
+++ b/app/components/UI/Earn/components/EarnBalance/index.tsx
@@ -8,7 +8,7 @@ import EarnLendingBalance from '../EarnLendingBalance';
import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens';
///: BEGIN:ONLY_INCLUDE_IF(tron)
import TronStakingButtons from '../Tron/TronStakingButtons';
-import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
+import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
import { hasStakedTrxPositions as hasStakedTrxPositionsUtil } from '../../utils/tron';
import useTronStakeApy from '../../hooks/useTronStakeApy';
@@ -47,10 +47,12 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => {
const isStakedTrxAsset =
isTron && (asset?.ticker === 'sTRX' || asset?.symbol === 'sTRX');
- const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup);
+ const tronSpecialAssets = useSelector(
+ selectTronSpecialAssetsBySelectedAccountGroup,
+ );
const hasStakedTrxPositions = React.useMemo(
- () => hasStakedTrxPositionsUtil(tronResources),
- [tronResources],
+ () => hasStakedTrxPositionsUtil(tronSpecialAssets),
+ [tronSpecialAssets],
);
const { apyPercent: tronApyPercent } = useTronStakeApy();
diff --git a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx
index 2fe50e52747..c43ef4cc19c 100644
--- a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx
+++ b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx
@@ -4,8 +4,8 @@ import { useSelector } from 'react-redux';
import TronStakePreview from './TronStakePreview';
import {
- selectTronResourcesBySelectedAccountGroup,
- TronResourcesMap,
+ selectTronSpecialAssetsBySelectedAccountGroup,
+ TronSpecialAssetsMap,
} from '../../../../../../selectors/assets/assets-list';
import type { ComputeFeeResult } from '../../../utils/tron-staking-snap';
@@ -46,7 +46,9 @@ jest.mock('../../../hooks/useTronStakeApy', () => ({
const mockUseSelector = useSelector as jest.Mock;
-const createMockResourcesMap = (totalStakedTrx: number): TronResourcesMap => ({
+const createMockSpecialAssetsMap = (
+ totalStakedTrx: number,
+): TronSpecialAssetsMap => ({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -54,6 +56,9 @@ const createMockResourcesMap = (totalStakedTrx: number): TronResourcesMap => ({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
describe('TronStakePreview', () => {
@@ -68,10 +73,10 @@ describe('TronStakePreview', () => {
});
// Default: 10 + 5 = 15 TRX staked
- const mockResourcesMap = createMockResourcesMap(15);
+ const mockResourcesMap = createMockSpecialAssetsMap(15);
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectTronResourcesBySelectedAccountGroup) {
+ if (selector === selectTronSpecialAssetsBySelectedAccountGroup) {
return mockResourcesMap;
}
return undefined;
@@ -91,10 +96,10 @@ describe('TronStakePreview', () => {
});
it('calculates annual reward from floating-point balances without precision errors', () => {
- const mockResourcesMap = createMockResourcesMap(130.96926);
+ const mockResourcesMap = createMockSpecialAssetsMap(130.96926);
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectTronResourcesBySelectedAccountGroup) {
+ if (selector === selectTronSpecialAssetsBySelectedAccountGroup) {
return mockResourcesMap;
}
return undefined;
@@ -174,8 +179,8 @@ describe('TronStakePreview', () => {
it('returns empty reward when total staked balance is zero in stake mode', () => {
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectTronResourcesBySelectedAccountGroup) {
- return createMockResourcesMap(0);
+ if (selector === selectTronSpecialAssetsBySelectedAccountGroup) {
+ return createMockSpecialAssetsMap(0);
}
return undefined;
});
diff --git a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx
index 153fb46bf85..ef23240bbad 100644
--- a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx
+++ b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx
@@ -12,7 +12,7 @@ import {
BoxJustifyContent,
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../../locales/i18n';
-import { selectTronResourcesBySelectedAccountGroup } from '../../../../../../selectors/assets/assets-list';
+import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../../selectors/assets/assets-list';
import type { ComputeFeeResult } from '../../../types/tron-staking.types';
import useTronStakeApy from '../../../hooks/useTronStakeApy';
@@ -46,7 +46,7 @@ const TronStakePreview = ({
const tw = useTailwind();
const { totalStakedTrx } = useSelector(
- selectTronResourcesBySelectedAccountGroup,
+ selectTronSpecialAssetsBySelectedAccountGroup,
);
const { apyDecimal } = useTronStakeApy();
diff --git a/app/components/UI/Earn/hooks/useTronUnstake.test.ts b/app/components/UI/Earn/hooks/useTronUnstake.test.ts
index 190555733b8..d909204e571 100644
--- a/app/components/UI/Earn/hooks/useTronUnstake.test.ts
+++ b/app/components/UI/Earn/hooks/useTronUnstake.test.ts
@@ -12,7 +12,7 @@ import { TokenI } from '../../Tokens/types';
const mockSelectSelectedInternalAccountByScope = jest.fn();
const mockSelectTrxStakingEnabled = jest.fn();
-const mockSelectTronResourcesBySelectedAccountGroup = jest.fn();
+const mockSelectTronSpecialAssetsBySelectedAccountGroup = jest.fn();
jest.mock('react-redux', () => ({
useSelector: jest.fn((selector) => selector()),
@@ -31,8 +31,8 @@ jest.mock(
);
jest.mock('../../../../selectors/assets/assets-list', () => ({
- selectTronResourcesBySelectedAccountGroup: () =>
- mockSelectTronResourcesBySelectedAccountGroup(),
+ selectTronSpecialAssetsBySelectedAccountGroup: () =>
+ mockSelectTronSpecialAssetsBySelectedAccountGroup(),
}));
jest.mock('../utils/tron-staking-snap', () => ({
@@ -46,7 +46,7 @@ jest.mock('../../../../core/Multichain/utils', () => ({
}));
jest.mock('../utils/tron', () => ({
- getStakedTrxTotalFromResources: jest.fn(() => 100),
+ getStakedTrxTotalFromSpecialAssets: jest.fn(() => 100),
buildTronEarnTokenIfEligible: jest.fn(() => ({
symbol: 'TRX',
balance: '100',
@@ -102,7 +102,7 @@ describe('useTronUnstake', () => {
// Setup default mock values
mockSelectSelectedInternalAccountByScope.mockReturnValue(mockAccount);
mockSelectTrxStakingEnabled.mockReturnValue(true);
- mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue({
+ mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -110,6 +110,9 @@ describe('useTronUnstake', () => {
stakedTrxForEnergy: { symbol: 'strx-energy', balance: '50' },
stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '50' },
totalStakedTrx: 100,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
});
diff --git a/app/components/UI/Earn/hooks/useTronUnstake.ts b/app/components/UI/Earn/hooks/useTronUnstake.ts
index 70cc40cc145..d2517562475 100644
--- a/app/components/UI/Earn/hooks/useTronUnstake.ts
+++ b/app/components/UI/Earn/hooks/useTronUnstake.ts
@@ -6,14 +6,14 @@ import { useSelector } from 'react-redux';
import { TronResourceType } from '../../../../core/Multichain/constants';
import Logger from '../../../../util/Logger';
import { isTronChainId } from '../../../../core/Multichain/utils';
-import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list';
+import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list';
import { selectTrxStakingEnabled } from '../../../../selectors/featureFlagController/trxStakingEnabled';
import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts';
import { TokenI } from '../../Tokens/types';
import { EarnTokenDetails } from '../types/lending.types';
import {
buildTronEarnTokenIfEligible,
- getStakedTrxTotalFromResources,
+ getStakedTrxTotalFromSpecialAssets,
} from '../utils/tron';
import {
computeStakeFee,
@@ -70,7 +70,9 @@ const useTronUnstake = ({
TrxScope.Mainnet,
);
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);
- const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup);
+ const tronSpecialAssets = useSelector(
+ selectTronSpecialAssetsBySelectedAccountGroup,
+ );
// Derive whether token is on Tron chain
const isTronAsset = useMemo(
@@ -81,10 +83,10 @@ const useTronUnstake = ({
// Tron unstaking is enabled when both flag is on and token is on Tron chain
const isTronEnabled = Boolean(isTrxStakingEnabled && isTronAsset);
- // Compute staked TRX total from resources
const stakedTrxTotal = useMemo(
- () => (isTronEnabled ? getStakedTrxTotalFromResources(tronResources) : 0),
- [isTronEnabled, tronResources],
+ () =>
+ isTronEnabled ? getStakedTrxTotalFromSpecialAssets(tronSpecialAssets) : 0,
+ [isTronEnabled, tronSpecialAssets],
);
// Determine the staked balance to use for withdrawal
diff --git a/app/components/UI/Earn/utils/tron.test.ts b/app/components/UI/Earn/utils/tron.test.ts
index eb31350452f..652c76bbaa6 100644
--- a/app/components/UI/Earn/utils/tron.test.ts
+++ b/app/components/UI/Earn/utils/tron.test.ts
@@ -3,11 +3,11 @@ import Routes from '../../../../constants/navigation/Routes';
import { EARN_EXPERIENCES } from '../constants/experiences';
import type { EarnTokenDetails } from '../types/lending.types';
import type { TokenI } from '../../Tokens/types';
-import type { TronResourcesMap } from '../../../../selectors/assets/assets-list';
+import type { TronSpecialAssetsMap } from '../../../../selectors/assets/assets-list';
import {
buildTronEarnTokenIfEligible,
getLocalizedErrorMessage,
- getStakedTrxTotalFromResources,
+ getStakedTrxTotalFromSpecialAssets,
handleTronStakingNavigationResult,
hasStakedTrxPositions,
} from './tron';
@@ -51,22 +51,21 @@ describe('tron utils', () => {
};
});
- describe('getStakedTrxTotalFromResources', () => {
- it('returns zero when resources are missing', () => {
- const total = getStakedTrxTotalFromResources(undefined);
+ describe('getStakedTrxTotalFromSpecialAssets', () => {
+ it('returns zero when special assets are missing', () => {
+ const total = getStakedTrxTotalFromSpecialAssets(undefined);
expect(total).toBe(0);
});
- it('returns zero when resources are null', () => {
- const total = getStakedTrxTotalFromResources(null);
+ it('returns zero when special assets are null', () => {
+ const total = getStakedTrxTotalFromSpecialAssets(null);
expect(total).toBe(0);
});
- it('returns totalStakedTrx from resources', () => {
- // totalStakedTrx is now pre-computed in the selector
- const resources: TronResourcesMap = {
+ it('returns totalStakedTrx from special assets', () => {
+ const specialAssets: TronSpecialAssetsMap = {
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -74,26 +73,27 @@ describe('tron utils', () => {
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 15,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
};
- const total = getStakedTrxTotalFromResources(resources);
+ const total = getStakedTrxTotalFromSpecialAssets(specialAssets);
expect(total).toBe(15);
});
it('defaults to zero when totalStakedTrx is undefined at runtime', () => {
- // Simulates a malformed object where totalStakedTrx is missing,
- // exercising the ?? 0 fallback in getStakedTrxTotalFromResources
- const resources = {
+ const specialAssets = {
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
maxBandwidth: undefined,
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
- } as unknown as TronResourcesMap;
+ } as unknown as TronSpecialAssetsMap;
- const total = getStakedTrxTotalFromResources(resources);
+ const total = getStakedTrxTotalFromSpecialAssets(specialAssets);
expect(total).toBe(0);
});
@@ -101,7 +101,7 @@ describe('tron utils', () => {
describe('hasStakedTrxPositions', () => {
it('returns false when totalStakedTrx is zero', () => {
- const resources: TronResourcesMap = {
+ const specialAssets: TronSpecialAssetsMap = {
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -109,15 +109,18 @@ describe('tron utils', () => {
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
};
- const result = hasStakedTrxPositions(resources);
+ const result = hasStakedTrxPositions(specialAssets);
expect(result).toBe(false);
});
it('returns true when totalStakedTrx is greater than zero', () => {
- const resources: TronResourcesMap = {
+ const specialAssets: TronSpecialAssetsMap = {
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -125,9 +128,12 @@ describe('tron utils', () => {
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 1,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
};
- const result = hasStakedTrxPositions(resources);
+ const result = hasStakedTrxPositions(specialAssets);
expect(result).toBe(true);
});
diff --git a/app/components/UI/Earn/utils/tron.ts b/app/components/UI/Earn/utils/tron.ts
index f73a90b550d..cbf4205304c 100644
--- a/app/components/UI/Earn/utils/tron.ts
+++ b/app/components/UI/Earn/utils/tron.ts
@@ -13,24 +13,24 @@ import { TokenI } from '../../Tokens/types';
import Engine from '../../../../core/Engine';
import Logger from '../../../../util/Logger';
import { safeParseBigNumber } from '../../../../util/number/bignumber';
-import type { TronResourcesMap } from '../../../../selectors/assets/assets-list';
+import type { TronSpecialAssetsMap } from '../../../../selectors/assets/assets-list';
/**
- * Returns the total staked TRX (sTRX) amount from TRON resources.
+ * Returns the total staked TRX (sTRX) amount from Tron special assets.
* This is pre-computed in the selector using BigNumber to avoid floating-point precision errors.
*/
-export function getStakedTrxTotalFromResources(
- resources?: TronResourcesMap | null,
+export function getStakedTrxTotalFromSpecialAssets(
+ specialAssets?: TronSpecialAssetsMap | null,
): number {
- return resources?.totalStakedTrx ?? 0;
+ return specialAssets?.totalStakedTrx ?? 0;
}
/**
- * True if the user holds any sTRX according to TRON resources.
+ * True if the user holds any sTRX according to Tron special assets.
*/
export const hasStakedTrxPositions = (
- resources?: TronResourcesMap | null,
-): boolean => getStakedTrxTotalFromResources(resources) > 0;
+ specialAssets?: TronSpecialAssetsMap | null,
+): boolean => getStakedTrxTotalFromSpecialAssets(specialAssets) > 0;
export const buildTronEarnTokenIfEligible = (
token: TokenI,
diff --git a/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts b/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts
index c200b5e18e4..9e5a51bf45c 100644
--- a/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts
+++ b/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts
@@ -3,12 +3,12 @@ import { useTokenBalance } from './useTokenBalance';
import { TokenI } from '../../Tokens/types';
import {
selectAsset,
- selectTronResourcesBySelectedAccountGroup,
- TronResourcesMap,
+ selectTronSpecialAssetsBySelectedAccountGroup,
+ TronSpecialAssetsMap,
} from '../../../../selectors/assets/assets-list';
import { createStakedTrxAsset } from '../../AssetOverview/utils/createStakedTrxAsset';
-const createEmptyResourcesMap = (): TronResourcesMap => ({
+const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -16,12 +16,15 @@ const createEmptyResourcesMap = (): TronResourcesMap => ({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
jest.mock('../../../../selectors/assets/assets-list', () => ({
selectAsset: jest.fn(),
- selectTronResourcesBySelectedAccountGroup: jest.fn(
- (): TronResourcesMap => ({
+ selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(
+ (): TronSpecialAssetsMap => ({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -29,6 +32,9 @@ jest.mock('../../../../selectors/assets/assets-list', () => ({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
}),
),
}));
@@ -39,14 +45,14 @@ jest.mock('../../AssetOverview/utils/createStakedTrxAsset', () => ({
const mockSelectAsset = jest.mocked(selectAsset);
const mockSelectTronResources = jest.mocked(
- selectTronResourcesBySelectedAccountGroup,
+ selectTronSpecialAssetsBySelectedAccountGroup,
);
const mockCreateStakedTrxAsset = jest.mocked(createStakedTrxAsset);
describe('useTokenBalance', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockSelectTronResources.mockReturnValue(createEmptyResourcesMap());
+ mockSelectTronResources.mockReturnValue(createEmptySpecialAssetsMap());
});
afterEach(() => {
@@ -119,10 +125,10 @@ describe('useTokenBalance', () => {
} as TokenI);
mockSelectTronResources.mockReturnValue({
- ...createEmptyResourcesMap(),
+ ...createEmptySpecialAssetsMap(),
stakedTrxForEnergy: { symbol: 'strx-energy', balance: '100' },
stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '200' },
- } as TronResourcesMap);
+ } as TronSpecialAssetsMap);
mockCreateStakedTrxAsset.mockReturnValue(mockStakedAsset);
diff --git a/app/components/UI/TokenDetails/hooks/useTokenBalance.ts b/app/components/UI/TokenDetails/hooks/useTokenBalance.ts
index 11fc6171b22..c036b368ab4 100644
--- a/app/components/UI/TokenDetails/hooks/useTokenBalance.ts
+++ b/app/components/UI/TokenDetails/hooks/useTokenBalance.ts
@@ -5,7 +5,7 @@ import { TokenI } from '../../Tokens/types';
import {
selectAsset,
///: BEGIN:ONLY_INCLUDE_IF(tron)
- selectTronResourcesBySelectedAccountGroup,
+ selectTronSpecialAssetsBySelectedAccountGroup,
///: END:ONLY_INCLUDE_IF
} from '../../../../selectors/assets/assets-list';
import { toFormattedAddress } from '../../../../util/address';
@@ -34,7 +34,7 @@ export const useTokenBalance = (token: TokenI): UseTokenBalanceResult => {
///: BEGIN:ONLY_INCLUDE_IF(tron)
const { stakedTrxForEnergy, stakedTrxForBandwidth } = useSelector(
- selectTronResourcesBySelectedAccountGroup,
+ selectTronSpecialAssetsBySelectedAccountGroup,
);
const isTronNative =
diff --git a/app/core/Multichain/constants.ts b/app/core/Multichain/constants.ts
index 1612297e1be..ff6ac441341 100644
--- a/app/core/Multichain/constants.ts
+++ b/app/core/Multichain/constants.ts
@@ -140,14 +140,21 @@ export const PRICE_API_CURRENCIES = [
'zar',
];
-// Tron resource asset symbols
-export const TRON_RESOURCE = {
+/**
+ * Tron special asset types that should be filtered out from asset selectors.
+ * These are virtual resources and staking state assets passed from the Tron Snap
+ * to the extension for informational purposes, not actual tradeable tokens.
+ */
+export const TRON_SPECIAL_ASSET_SYMBOLS = {
ENERGY: 'energy',
BANDWIDTH: 'bandwidth',
MAX_ENERGY: 'max-energy',
MAX_BANDWIDTH: 'max-bandwidth',
STRX_ENERGY: 'strx-energy',
STRX_BANDWIDTH: 'strx-bandwidth',
+ TRX_READY_FOR_WITHDRAWAL: 'trx-ready-for-withdrawal',
+ TRX_STAKING_REWARDS: 'trx-staking-rewards',
+ TRX_IN_LOCK_PERIOD: 'trx-in-lock-period',
} as const;
export enum TronResourceType {
@@ -155,11 +162,10 @@ export enum TronResourceType {
BANDWIDTH = 'BANDWIDTH',
}
-export type TronResourceSymbol =
- (typeof TRON_RESOURCE)[keyof typeof TRON_RESOURCE];
+export type TronSpecialAssetSymbol =
+ (typeof TRON_SPECIAL_ASSET_SYMBOLS)[keyof typeof TRON_SPECIAL_ASSET_SYMBOLS];
-export const TRON_RESOURCE_SYMBOLS = Object.values(
- TRON_RESOURCE,
-) as readonly TronResourceSymbol[];
-export const TRON_RESOURCE_SYMBOLS_SET: ReadonlySet =
- new Set(TRON_RESOURCE_SYMBOLS);
+export const TRON_SPECIAL_ASSET_SYMBOLS_SET: ReadonlySet =
+ new Set(
+ Object.values(TRON_SPECIAL_ASSET_SYMBOLS) as TronSpecialAssetSymbol[],
+ );
diff --git a/app/core/Multichain/utils.ts b/app/core/Multichain/utils.ts
index 1ffa1c805db..3959b535417 100644
--- a/app/core/Multichain/utils.ts
+++ b/app/core/Multichain/utils.ts
@@ -10,7 +10,11 @@ import { isAddress as isSolanaAddress } from '@solana/addresses';
import Engine from '../Engine';
import { CaipChainId, Hex } from '@metamask/utils';
import { validate, Network } from 'bitcoin-address-validation';
-import { MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP } from './constants';
+import {
+ MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP,
+ TRON_SPECIAL_ASSET_SYMBOLS_SET,
+ TronSpecialAssetSymbol,
+} from './constants';
import { formatAddress, isEthAddress } from '../../util/address';
import {
formatBlockExplorerAddressUrl,
@@ -257,3 +261,23 @@ export function shortenTransactionId(txId: string) {
// For transactions we use a similar output for now, but shortenTransactionId will be added later.
return formatAddress(txId, 'short');
}
+
+/**
+ * Checks if a token is a Tron special asset (resources, staking state, etc.)
+ * that should be filtered out from user-facing asset lists.
+ *
+ * @param chainId - The chain ID to check
+ * @param symbol - The token symbol to check
+ * @returns true if the token is a Tron special asset
+ */
+export const isTronSpecialAsset = (
+ chainId: string | undefined,
+ symbol: string | undefined,
+): boolean => {
+ if (!chainId?.startsWith('tron:') || !symbol) {
+ return false;
+ }
+ return TRON_SPECIAL_ASSET_SYMBOLS_SET.has(
+ symbol.toLowerCase() as TronSpecialAssetSymbol,
+ );
+};
diff --git a/app/selectors/assets/assets-list.test.ts b/app/selectors/assets/assets-list.test.ts
index 478d1cb0ca7..605d39b1c26 100644
--- a/app/selectors/assets/assets-list.test.ts
+++ b/app/selectors/assets/assets-list.test.ts
@@ -14,7 +14,7 @@ import {
selectAsset,
selectAssetsBySelectedAccountGroup,
selectSortedAssetsBySelectedAccountGroup,
- selectTronResourcesBySelectedAccountGroup,
+ selectTronSpecialAssetsBySelectedAccountGroup,
} from './assets-list';
import I18n from '../../../locales/i18n';
@@ -588,7 +588,7 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => {
]);
});
- it('filters out Tron Energy and Bandwidth resources from assets', () => {
+ it('filters out Tron special assets from the sorted asset list', () => {
const stateWithTronAssets = {
...mockState(),
engine: {
@@ -600,6 +600,9 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => {
'2d89e6a0-b4e6-45a8-a707-f10cef143b42': [
'tron:728126428/slip44:energy',
'tron:728126428/slip44:bandwidth',
+ 'tron:728126428/slip44:195-ready-for-withdrawal',
+ 'tron:728126428/slip44:195-staking-rewards',
+ 'tron:728126428/slip44:195-in-lock-period',
'tron:728126428/slip44:195',
],
},
@@ -620,6 +623,45 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => {
{ name: 'Bandwidth', symbol: 'BANDWIDTH', decimals: 0 },
],
},
+ 'tron:728126428/slip44:195-ready-for-withdrawal': {
+ name: 'Ready for Withdrawal',
+ symbol: 'TRX-READY-FOR-WITHDRAWAL',
+ fungible: true as const,
+ iconUrl: 'test-url',
+ units: [
+ {
+ name: 'Ready for Withdrawal',
+ symbol: 'TRX-READY-FOR-WITHDRAWAL',
+ decimals: 6,
+ },
+ ],
+ },
+ 'tron:728126428/slip44:195-staking-rewards': {
+ name: 'Staking Rewards',
+ symbol: 'TRX-STAKING-REWARDS',
+ fungible: true as const,
+ iconUrl: 'test-url',
+ units: [
+ {
+ name: 'Staking Rewards',
+ symbol: 'TRX-STAKING-REWARDS',
+ decimals: 6,
+ },
+ ],
+ },
+ 'tron:728126428/slip44:195-in-lock-period': {
+ name: 'In Lock Period',
+ symbol: 'TRX-IN-LOCK-PERIOD',
+ fungible: true as const,
+ iconUrl: 'test-url',
+ units: [
+ {
+ name: 'In Lock Period',
+ symbol: 'TRX-IN-LOCK-PERIOD',
+ decimals: 6,
+ },
+ ],
+ },
'tron:728126428/slip44:195': {
name: 'TRON',
symbol: 'TRX',
@@ -641,6 +683,18 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => {
amount: '604',
unit: 'BANDWIDTH',
},
+ 'tron:728126428/slip44:195-ready-for-withdrawal': {
+ amount: '10',
+ unit: 'TRX-READY-FOR-WITHDRAWAL',
+ },
+ 'tron:728126428/slip44:195-staking-rewards': {
+ amount: '5',
+ unit: 'TRX-STAKING-REWARDS',
+ },
+ 'tron:728126428/slip44:195-in-lock-period': {
+ amount: '20',
+ unit: 'TRX-IN-LOCK-PERIOD',
+ },
'tron:728126428/slip44:195': { amount: '1000', unit: 'TRX' },
},
},
@@ -670,21 +724,34 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => {
const tronAssets = result.filter((asset) =>
asset.chainId?.includes('tron:'),
);
- const energyAsset = result.find((asset) =>
- asset.address?.includes('energy'),
+
+ const trxAsset = tronAssets.find(
+ (asset) => asset.address === 'tron:728126428/slip44:195',
+ );
+ const energyAsset = tronAssets.find(
+ (asset) => asset.address === 'tron:728126428/slip44:energy',
+ );
+ const bandwidthAsset = tronAssets.find(
+ (asset) => asset.address === 'tron:728126428/slip44:bandwidth',
);
- const bandwidthAsset = result.find((asset) =>
- asset.address?.includes('bandwidth'),
+ const readyForWithdrawalAsset = tronAssets.find(
+ (asset) =>
+ asset.address === 'tron:728126428/slip44:195-ready-for-withdrawal',
);
- const trxAsset = result.find((asset) =>
- asset.address?.includes('slip44:195'),
+ const stakingRewardsAsset = tronAssets.find(
+ (asset) => asset.address === 'tron:728126428/slip44:195-staking-rewards',
+ );
+ const inLockPeriodAsset = tronAssets.find(
+ (asset) => asset.address === 'tron:728126428/slip44:195-in-lock-period',
);
+ expect(trxAsset).toBeDefined();
expect(energyAsset).toBeUndefined();
expect(bandwidthAsset).toBeUndefined();
- expect(trxAsset).toBeDefined();
+ expect(readyForWithdrawalAsset).toBeUndefined();
+ expect(stakingRewardsAsset).toBeUndefined();
+ expect(inLockPeriodAsset).toBeUndefined();
- // Only TRX is in the list after filtering
expect(tronAssets).toHaveLength(1);
});
});
@@ -1027,7 +1094,7 @@ describe('selectAsset', () => {
});
});
-describe('selectTronResourcesBySelectedAccountGroup', () => {
+describe('selectTronSpecialAssetsBySelectedAccountGroup', () => {
it('returns Tron energy and bandwidth resources when Tron network is enabled', () => {
const stateWithTronAssets = {
...mockState(),
@@ -1105,7 +1172,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
} as unknown as RootState;
const result =
- selectTronResourcesBySelectedAccountGroup(stateWithTronAssets);
+ selectTronSpecialAssetsBySelectedAccountGroup(stateWithTronAssets);
// Verify the object structure with named properties
expect(result.energy?.assetId).toBe('tron:728126428/slip44:energy');
@@ -1117,7 +1184,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
});
it('maps all resource types and computes totalStakedTrx with BigNumber precision', () => {
- const stateWithAllResources = {
+ const stateWithAllSpecialAssets = {
...mockState(),
engine: {
...mockState().engine,
@@ -1132,6 +1199,9 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
'tron:728126428/slip44:max-bandwidth',
'tron:728126428/slip44:strx-energy',
'tron:728126428/slip44:strx-bandwidth',
+ 'tron:728126428/slip44:195-ready-for-withdrawal',
+ 'tron:728126428/slip44:195-staking-rewards',
+ 'tron:728126428/slip44:195-in-lock-period',
'tron:728126428/slip44:195',
],
},
@@ -1200,6 +1270,45 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
},
],
},
+ 'tron:728126428/slip44:195-ready-for-withdrawal': {
+ name: 'Ready for Withdrawal',
+ symbol: 'TRX-READY-FOR-WITHDRAWAL',
+ fungible: true as const,
+ iconUrl: 'test-url',
+ units: [
+ {
+ name: 'Ready for Withdrawal',
+ symbol: 'TRX-READY-FOR-WITHDRAWAL',
+ decimals: 6,
+ },
+ ],
+ },
+ 'tron:728126428/slip44:195-staking-rewards': {
+ name: 'Staking Rewards',
+ symbol: 'TRX-STAKING-REWARDS',
+ fungible: true as const,
+ iconUrl: 'test-url',
+ units: [
+ {
+ name: 'Staking Rewards',
+ symbol: 'TRX-STAKING-REWARDS',
+ decimals: 6,
+ },
+ ],
+ },
+ 'tron:728126428/slip44:195-in-lock-period': {
+ name: 'In Lock Period',
+ symbol: 'TRX-IN-LOCK-PERIOD',
+ fungible: true as const,
+ iconUrl: 'test-url',
+ units: [
+ {
+ name: 'In Lock Period',
+ symbol: 'TRX-IN-LOCK-PERIOD',
+ decimals: 6,
+ },
+ ],
+ },
'tron:728126428/slip44:195': {
name: 'TRON',
symbol: 'TRX',
@@ -1237,6 +1346,18 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
amount: '65.48463',
unit: 'STRX-BANDWIDTH',
},
+ 'tron:728126428/slip44:195-ready-for-withdrawal': {
+ amount: '25.5',
+ unit: 'TRX-READY-FOR-WITHDRAWAL',
+ },
+ 'tron:728126428/slip44:195-staking-rewards': {
+ amount: '12.3',
+ unit: 'TRX-STAKING-REWARDS',
+ },
+ 'tron:728126428/slip44:195-in-lock-period': {
+ amount: '50',
+ unit: 'TRX-IN-LOCK-PERIOD',
+ },
'tron:728126428/slip44:195': {
amount: '1000',
unit: 'TRX',
@@ -1263,11 +1384,11 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
},
} as unknown as RootState;
- const result = selectTronResourcesBySelectedAccountGroup(
- stateWithAllResources,
+ const result = selectTronSpecialAssetsBySelectedAccountGroup(
+ stateWithAllSpecialAssets,
);
- // All 6 resource types should be mapped
+ // All 9 special assets should be mapped
expect(result.energy?.assetId).toBe('tron:728126428/slip44:energy');
expect(result.bandwidth?.assetId).toBe('tron:728126428/slip44:bandwidth');
expect(result.maxEnergy?.assetId).toBe('tron:728126428/slip44:max-energy');
@@ -1280,6 +1401,15 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
expect(result.stakedTrxForBandwidth?.assetId).toBe(
'tron:728126428/slip44:strx-bandwidth',
);
+ expect(result.trxReadyForWithdrawal?.assetId).toBe(
+ 'tron:728126428/slip44:195-ready-for-withdrawal',
+ );
+ expect(result.trxStakingRewards?.assetId).toBe(
+ 'tron:728126428/slip44:195-staking-rewards',
+ );
+ expect(result.trxInLockPeriod?.assetId).toBe(
+ 'tron:728126428/slip44:195-in-lock-period',
+ );
// totalStakedTrx computed via BigNumber avoids floating-point errors
// 65.48463 + 65.48463 = 130.96926 (not 130.96926000000002)
@@ -1345,7 +1475,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
},
} as unknown as RootState;
- const result = selectTronResourcesBySelectedAccountGroup(
+ const result = selectTronSpecialAssetsBySelectedAccountGroup(
stateWithTronDisabled,
);
@@ -1358,6 +1488,9 @@ describe('selectTronResourcesBySelectedAccountGroup', () => {
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
});
});
diff --git a/app/selectors/assets/assets-list.ts b/app/selectors/assets/assets-list.ts
index 4bda702c7b6..27f14fb1552 100644
--- a/app/selectors/assets/assets-list.ts
+++ b/app/selectors/assets/assets-list.ts
@@ -27,10 +27,11 @@ import {
import { safeParseBigNumber } from '../../util/number/bignumber';
import { selectAccountsByChainId } from '../accountTrackerController';
import {
- TRON_RESOURCE,
- TRON_RESOURCE_SYMBOLS_SET,
- TronResourceSymbol,
+ TRON_SPECIAL_ASSET_SYMBOLS,
+ TRON_SPECIAL_ASSET_SYMBOLS_SET,
+ TronSpecialAssetSymbol,
} from '../../core/Multichain/constants';
+import { isTronSpecialAsset } from '../../core/Multichain/utils';
import { sortAssetsWithPriority } from '../../components/UI/Tokens/util/sortAssetsWithPriority';
import { selectAllTokens } from '../tokensController';
import { selectSelectedInternalAccountAddress } from '../accountsController';
@@ -38,10 +39,14 @@ import { selectSelectedInternalAccountByScope } from '../multichainAccounts/acco
import { getLocaleLanguageCode } from '../../components/hooks/useFormatters';
/**
- * Structured map of Tron resources for efficient access.
- * Each property corresponds to a specific Tron resource type.
+ * Structured map of Tron special assets for efficient access.
+ *
+ * Includes network resources (Energy, Bandwidth and their max capacities),
+ * staking-related assets (staked TRX for Energy/Bandwidth, total staked TRX),
+ * and additional staking lifecycle assets (Ready for Withdrawal, Staking
+ * Rewards, In Lock Period).
*/
-export interface TronResourcesMap {
+export interface TronSpecialAssetsMap {
/** Current available energy */
energy: Asset | undefined;
/** Current available bandwidth */
@@ -56,12 +61,18 @@ export interface TronResourcesMap {
stakedTrxForBandwidth: Asset | undefined;
/** Total staked TRX (sum of energy + bandwidth staking) */
totalStakedTrx: number;
+ /** TRX ready for withdrawal (unstaked TRX that has completed the lock period) */
+ trxReadyForWithdrawal: Asset | undefined;
+ /** TRX staking rewards */
+ trxStakingRewards: Asset | undefined;
+ /** TRX in lock period (unstaked but waiting for lock period to end) */
+ trxInLockPeriod: Asset | undefined;
}
/**
* Empty constant to avoid creating new objects on each call when no Tron networks are enabled.
*/
-const EMPTY_TRON_RESOURCES_MAP: TronResourcesMap = Object.freeze({
+const EMPTY_TRON_SPECIAL_ASSETS_MAP: TronSpecialAssetsMap = Object.freeze({
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -69,6 +80,9 @@ const EMPTY_TRON_RESOURCES_MAP: TronResourcesMap = Object.freeze({
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
});
const getStateForAssetSelector = (state: RootState) => {
@@ -245,7 +259,11 @@ export const selectSortedAssetsBySelectedAccountGroup = createDeepEqualSelector(
(bip44Assets, enabledNetworks, tokenSortConfig, stakedAssets) => {
const assets = Object.entries(bip44Assets)
.filter(([networkId, _]) => enabledNetworks.includes(networkId))
- .flatMap(([_, chainAssets]) => chainAssets);
+ .flatMap(([_, chainAssets]) =>
+ chainAssets.filter(
+ (asset) => !isTronSpecialAsset(asset.chainId, asset.symbol),
+ ),
+ );
const stakedAssetsArray = [];
for (const asset of assets) {
@@ -425,22 +443,26 @@ function assetToToken(
}
/**
- * Selects Tron resources (Energy, Bandwidth, Max values, and staked TRX) for the
- * currently selected account group.
+ * Selects Tron special assets for the currently selected account group.
+ *
+ * This includes:
+ * - **Network resources**: Energy, Bandwidth, and their maximum capacities.
+ * - **Staking assets**: TRX staked for Energy/Bandwidth and a pre-computed `totalStakedTrx` sum.
+ * - **Staking lifecycle assets**: TRX Ready for Withdrawal, Staking Rewards, and TRX In Lock Period.
*
- * Returns a structured object with all resources pre-mapped for efficient access,
- * eliminating the need for consumers to iterate/search the array.
+ * Returns a structured {@link TronSpecialAssetsMap} with all assets pre-mapped by type
+ * for efficient access, eliminating the need for consumers to iterate or search the array.
*/
-export const selectTronResourcesBySelectedAccountGroup =
+export const selectTronSpecialAssetsBySelectedAccountGroup =
createDeepEqualSelector(
[getStateForAssetSelector, selectEnabledNetworks],
- (assetsState, enabledNetworks): TronResourcesMap => {
+ (assetsState, enabledNetworks): TronSpecialAssetsMap => {
const enabledTronNetworks = enabledNetworks.filter((networkId) =>
networkId.startsWith('tron:'),
);
if (enabledTronNetworks.length === 0) {
- return EMPTY_TRON_RESOURCES_MAP;
+ return EMPTY_TRON_SPECIAL_ASSETS_MAP;
}
const allAssets = _selectAssetsBySelectedAccountGroup(assetsState, {
@@ -449,7 +471,7 @@ export const selectTronResourcesBySelectedAccountGroup =
const enabledTronNetworksSet = new Set(enabledTronNetworks);
- const resourceMap: TronResourcesMap = {
+ const specialAssetsMap: TronSpecialAssetsMap = {
energy: undefined,
bandwidth: undefined,
maxEnergy: undefined,
@@ -457,33 +479,45 @@ export const selectTronResourcesBySelectedAccountGroup =
stakedTrxForEnergy: undefined,
stakedTrxForBandwidth: undefined,
totalStakedTrx: 0,
+ trxReadyForWithdrawal: undefined,
+ trxStakingRewards: undefined,
+ trxInLockPeriod: undefined,
};
for (const [networkId, chainAssets] of Object.entries(allAssets)) {
if (!enabledTronNetworksSet.has(networkId)) continue;
for (const asset of chainAssets) {
- const symbol = asset.symbol?.toLowerCase() as TronResourceSymbol;
- if (!TRON_RESOURCE_SYMBOLS_SET.has(symbol)) continue;
+ const symbol = asset.symbol?.toLowerCase() as TronSpecialAssetSymbol;
+ if (!TRON_SPECIAL_ASSET_SYMBOLS_SET.has(symbol)) continue;
switch (symbol) {
- case TRON_RESOURCE.ENERGY:
- resourceMap.energy = asset;
+ case TRON_SPECIAL_ASSET_SYMBOLS.ENERGY:
+ specialAssetsMap.energy = asset;
+ break;
+ case TRON_SPECIAL_ASSET_SYMBOLS.BANDWIDTH:
+ specialAssetsMap.bandwidth = asset;
+ break;
+ case TRON_SPECIAL_ASSET_SYMBOLS.MAX_ENERGY:
+ specialAssetsMap.maxEnergy = asset;
+ break;
+ case TRON_SPECIAL_ASSET_SYMBOLS.MAX_BANDWIDTH:
+ specialAssetsMap.maxBandwidth = asset;
break;
- case TRON_RESOURCE.BANDWIDTH:
- resourceMap.bandwidth = asset;
+ case TRON_SPECIAL_ASSET_SYMBOLS.STRX_ENERGY:
+ specialAssetsMap.stakedTrxForEnergy = asset;
break;
- case TRON_RESOURCE.MAX_ENERGY:
- resourceMap.maxEnergy = asset;
+ case TRON_SPECIAL_ASSET_SYMBOLS.STRX_BANDWIDTH:
+ specialAssetsMap.stakedTrxForBandwidth = asset;
break;
- case TRON_RESOURCE.MAX_BANDWIDTH:
- resourceMap.maxBandwidth = asset;
+ case TRON_SPECIAL_ASSET_SYMBOLS.TRX_READY_FOR_WITHDRAWAL:
+ specialAssetsMap.trxReadyForWithdrawal = asset;
break;
- case TRON_RESOURCE.STRX_ENERGY:
- resourceMap.stakedTrxForEnergy = asset;
+ case TRON_SPECIAL_ASSET_SYMBOLS.TRX_STAKING_REWARDS:
+ specialAssetsMap.trxStakingRewards = asset;
break;
- case TRON_RESOURCE.STRX_BANDWIDTH:
- resourceMap.stakedTrxForBandwidth = asset;
+ case TRON_SPECIAL_ASSET_SYMBOLS.TRX_IN_LOCK_PERIOD:
+ specialAssetsMap.trxInLockPeriod = asset;
break;
}
}
@@ -493,17 +527,17 @@ export const selectTronResourcesBySelectedAccountGroup =
* Compute total staked TRX using BigNumber to avoid floating-point precision errors
*/
const stakedTrxForEnergyBN = safeParseBigNumber(
- resourceMap.stakedTrxForEnergy?.balance,
+ specialAssetsMap.stakedTrxForEnergy?.balance,
);
const stakedTrxForBandwidthBN = safeParseBigNumber(
- resourceMap.stakedTrxForBandwidth?.balance,
+ specialAssetsMap.stakedTrxForBandwidth?.balance,
);
const totalStakedTrxBN = stakedTrxForEnergyBN.plus(
stakedTrxForBandwidthBN,
);
- resourceMap.totalStakedTrx = totalStakedTrxBN.toNumber();
+ specialAssetsMap.totalStakedTrx = totalStakedTrxBN.toNumber();
- return resourceMap;
+ return specialAssetsMap;
},
);
diff --git a/app/selectors/multichain/multichain.ts b/app/selectors/multichain/multichain.ts
index 141bb524dbe..21b67677f7c 100644
--- a/app/selectors/multichain/multichain.ts
+++ b/app/selectors/multichain/multichain.ts
@@ -46,11 +46,8 @@ import { TokenI } from '../../components/UI/Tokens/types';
import { createSelector } from 'reselect';
import { selectSelectedAccountGroupInternalAccounts } from '../multichainAccounts/accountTreeController';
import { selectAccountTokensAcrossChains } from '../multichain';
-import {
- MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET,
- TRON_RESOURCE_SYMBOLS_SET,
- TronResourceSymbol,
-} from '../../core/Multichain/constants';
+import { MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET } from '../../core/Multichain/constants';
+import { isTronSpecialAsset } from '../../core/Multichain/utils';
export const selectMultichainDefaultToken = createDeepEqualSelector(
selectIsEvmNetworkSelected,
@@ -366,12 +363,7 @@ export const selectAccountTokensAcrossChainsUnified = createDeepEqualSelector(
selectMultichainTokenListForAccountsAnyChain(state, [account]) || [];
for (const token of nonEvmTokensForAccount) {
- if (
- String(token.chainId).includes('tron:') &&
- TRON_RESOURCE_SYMBOLS_SET.has(
- (token.symbol || '').toLowerCase() as TronResourceSymbol,
- )
- ) {
+ if (isTronSpecialAsset(String(token.chainId), token.symbol)) {
continue;
}
// We just need tron mainnet, at least for now
From a472bd4f68b451a5a5c629b1ae5ba88f73eb790d Mon Sep 17 00:00:00 2001
From: Bryan Fullam
Date: Fri, 6 Mar 2026 12:05:32 +0100
Subject: [PATCH 8/9] feat: improve price impact row and warnings (#26390)
## **Description**
Previously, the price impact value color was driven by a warning boolean
and effectively rendered as default or red. This change updates the
value color by threshold so the UI communicates risk levels more
clearly:
- Price impact `>= 5%` shows warning color (yellow)
- Price impact `>= 25%` shows error color (red)
- Otherwise it remains the default alternative text color
## **Changelog**
CHANGELOG entry: Updated swap price impact text coloring.
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4020
https://consensyssoftware.atlassian.net/browse/SWAPS-4024
## **Manual testing steps**
```gherkin
Ensure acceptance criteria pass.
```
## **Screenshots/Recordings**
### **Before**
Price impact value color only switched between default and red.
### **After**
Price impact value color now maps to thresholds:
- default: `< 5%`
- yellow: `>= 5%`
- red: `>= 25%`
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Changes the Bridge confirmation flow to conditionally gate execution
behind a new price-impact modal and adds navigation resets on quote
expiry, which can affect swap completion and modal routing. Risk is
mitigated by extensive new unit tests but touches user-critical
transaction submission UX.
>
> **Overview**
> Adds a new `PriceImpactModal` (with header/description/footer) and
wires it into Bridge modal routes, including new i18n copy for *info*,
*warning*, and *high price impact* states.
>
> Updates `QuoteDetailsCard` to always show a **Price impact** row with
an info button that opens the modal, and switches price-impact
coloring/iconography to threshold-based view data (warning at `>=5%`,
error at `>=25%`) plus safer formatting for missing/invalid/negative
values.
>
> Refactors swap submission by introducing `useBridgeConfirm` and
updating `SwapsConfirmButton` to accept/forward an explicit analytics
`location`; if price impact meets the error threshold it now navigates
to the modal (`Execution` type) instead of submitting immediately. Adds
`useModalCloseOnQuoteExpiry` and applies it across Bridge modals to
reset the modal stack to `QuoteExpiredModal` when quotes expire.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
39416418d9216457216259d5efcc20da85a7e034. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: GeorgeGkas
---
.../Views/BridgeView/BridgeView.test.tsx | 65 ++-
.../UI/Bridge/Views/BridgeView/index.tsx | 10 +-
.../PriceImpactDescription.test.tsx | 177 ++++++++
.../PriceImpactDescription.tsx | 36 ++
.../PriceImpactFooter.test.tsx | 258 +++++++++++
.../PriceImpactModal/PriceImpactFooter.tsx | 67 +++
.../PriceImpactHeader.test.tsx | 161 +++++++
.../PriceImpactModal/PriceImpactHeader.tsx | 77 ++++
.../components/PriceImpactModal/constants.ts | 4 +
.../PriceImpactModal/index.test.tsx | 416 ++++++++++++++++++
.../components/PriceImpactModal/index.tsx | 71 +++
.../components/PriceImpactModal/types.ts | 9 +
.../QuoteDetailsCard.test.tsx | 106 +++--
.../QuoteDetailsCard/QuoteDetailsCard.tsx | 200 +++++----
.../QuoteDetailsCard.types.ts | 3 +
.../QuoteDetailsCard.test.tsx.snap | 162 ++++---
.../CustomSlippageModal.test.tsx | 23 +
.../SlippageModal/CustomSlippageModal.tsx | 2 +
.../DefaultSlippageModal.test.tsx | 23 +
.../SlippageModal/DefaultSlippageModal.tsx | 2 +
.../SwapsConfirmButton.test.tsx | 394 +++++++++++++++--
.../components/SwapsConfirmButton/index.tsx | 72 +--
.../UI/Bridge/hooks/useBridgeConfirm/index.ts | 51 +++
.../useBridgeConfirm/useBridgeConfirm.test.ts | 280 ++++++++++++
.../useModalCloseOnQuoteExpiry/index.test.ts | 137 ++++++
.../hooks/useModalCloseOnQuoteExpiry/index.ts | 28 ++
app/components/UI/Bridge/routes.tsx | 5 +
.../UI/Bridge/utils/formatPriceImpact.test.ts | 43 ++
.../UI/Bridge/utils/formatPriceImpact.ts | 13 +
.../utils/getPriceImpactViewData.test.ts | 44 ++
.../UI/Bridge/utils/getPriceImpactViewData.ts | 46 ++
app/constants/navigation/Routes.ts | 1 +
app/core/AppConstants.ts | 2 +
locales/languages/en.json | 9 +-
34 files changed, 2726 insertions(+), 271 deletions(-)
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/constants.ts
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/index.tsx
create mode 100644 app/components/UI/Bridge/components/PriceImpactModal/types.ts
create mode 100644 app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts
create mode 100644 app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts
create mode 100644 app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts
create mode 100644 app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts
create mode 100644 app/components/UI/Bridge/utils/formatPriceImpact.test.ts
create mode 100644 app/components/UI/Bridge/utils/formatPriceImpact.ts
create mode 100644 app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts
create mode 100644 app/components/UI/Bridge/utils/getPriceImpactViewData.ts
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
index 05e9736bff4..5996af0a583 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
@@ -14,7 +14,11 @@ import { Hex } from '@metamask/utils';
import BridgeView from '.';
import type { BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation';
import { createBridgeTestState } from '../../testUtils';
-import { RequestStatus, type QuoteResponse } from '@metamask/bridge-controller';
+import {
+ MetaMetricsSwapsEventSource,
+ RequestStatus,
+ type QuoteResponse,
+} from '@metamask/bridge-controller';
import { SolScope } from '@metamask/keyring-api';
import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock';
import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
@@ -1577,6 +1581,65 @@ describe('BridgeView', () => {
});
});
+ describe('location forwarding', () => {
+ it('forwards route.params.location to SwapsConfirmButton via price impact modal navigation', async () => {
+ mockRoute.params = {
+ sourcePage: 'test',
+ location: MetaMetricsSwapsEventSource.MainView,
+ } as BridgeRouteParams;
+
+ // A priceImpact above the error threshold (25) causes handleContinue to
+ // navigate to the PriceImpactModal — the location value is embedded in
+ // the navigation params, making this the easiest observable side-effect
+ // to assert for location forwarding.
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockQuoteWithMetadata,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: '30%',
+ },
+ }));
+
+ const testState = createBridgeTestState(
+ {
+ bridgeControllerOverrides: {
+ quotesLoadingStatus: RequestStatus.FETCHED,
+ quotes: [mockQuoteWithMetadata as unknown as QuoteResponse],
+ quotesLastFetched: Date.now(),
+ },
+ bridgeReducerOverrides: {
+ sourceAmount: '1.0',
+ },
+ },
+ mockState,
+ );
+
+ const { getByTestId } = renderScreen(
+ BridgeView,
+ { name: Routes.BRIDGE.ROOT },
+ { state: testState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.BRIDGE.MODALS.ROOT,
+ expect.objectContaining({
+ params: expect.objectContaining({
+ location: MetaMetricsSwapsEventSource.MainView,
+ }),
+ }),
+ );
+ });
+ });
+ });
+
describe('gas included support hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx
index 927a0cc952d..a4c641bb749 100644
--- a/app/components/UI/Bridge/Views/BridgeView/index.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx
@@ -147,6 +147,9 @@ const BridgeView = () => {
const isSolanaSourced = useSelector(selectIsSolanaSourced);
const isDestNetworkEnabled = useIsNetworkEnabled(destToken?.chainId);
+ /** The entry point location for analytics (e.g. Main View, Token View, Trending Explore) */
+ const location = route.params?.location;
+
// inputRef is used to programmatically blur the input field after a delay
// This gives users time to type before the keyboard disappears
// The ref is typed to only expose the blur method we need
@@ -419,7 +422,10 @@ const BridgeView = () => {
/>
)}
-
+
{hasFee
@@ -536,6 +542,7 @@ const BridgeView = () => {
{contentMode === 'quote' ? (
@@ -557,6 +564,7 @@ const BridgeView = () => {
>
{sourceAmount && sourceAmount !== '0' ? (
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx
new file mode 100644
index 00000000000..17fc7d2c1ab
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { PriceImpactDescription } from './PriceImpactDescription';
+import { PriceImpactModalType } from './constants';
+import { strings } from '../../../../../../locales/i18n';
+
+describe('PriceImpactDescription', () => {
+ describe('Execution type', () => {
+ it('renders the execution description with the given priceImpact', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(
+ strings('bridge.price_impact_execution_description', {
+ priceImpact: '-30%',
+ }),
+ ),
+ ).toBeTruthy();
+ });
+
+ it('renders the execution description with "0" when priceImpact is undefined', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(
+ strings('bridge.price_impact_execution_description', {
+ priceImpact: '0',
+ }),
+ ),
+ ).toBeTruthy();
+ });
+
+ it('does not render the info description', () => {
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(
+ queryByText(strings('bridge.price_impact_info_description')),
+ ).toBeNull();
+ });
+ });
+
+ describe('Info type — with priceImpact (warning state)', () => {
+ it('renders the warning description with the given priceImpact', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(
+ strings('bridge.price_impact_warning_description', {
+ priceImpact: '-10%',
+ }),
+ ),
+ ).toBeTruthy();
+ });
+
+ it('does not render the info description when priceImpact is provided', () => {
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(
+ queryByText(strings('bridge.price_impact_info_description')),
+ ).toBeNull();
+ });
+
+ it('treats the string "0" as a truthy priceImpact and renders the warning description', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(
+ strings('bridge.price_impact_warning_description', {
+ priceImpact: '0',
+ }),
+ ),
+ ).toBeTruthy();
+ });
+ });
+
+ describe('Info type — without priceImpact (info state)', () => {
+ it('renders the info description when priceImpact is undefined', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(strings('bridge.price_impact_info_description')),
+ ).toBeTruthy();
+ });
+
+ it('renders the info description when priceImpact is an empty string', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(strings('bridge.price_impact_info_description')),
+ ).toBeTruthy();
+ });
+
+ it('does not render the warning description when priceImpact is absent', () => {
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(
+ queryByText(
+ strings('bridge.price_impact_warning_description', {
+ priceImpact: undefined,
+ }),
+ ),
+ ).toBeNull();
+ });
+ });
+
+ describe('priority — Execution type takes precedence over warning state', () => {
+ it('renders the execution description rather than the warning description when type is Execution and priceImpact is provided', () => {
+ const { getByText, queryByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(
+ strings('bridge.price_impact_execution_description', {
+ priceImpact: '-10%',
+ }),
+ ),
+ ).toBeTruthy();
+
+ expect(
+ queryByText(
+ strings('bridge.price_impact_warning_description', {
+ priceImpact: '-10%',
+ }),
+ ),
+ ).toBeNull();
+ });
+ });
+});
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx
new file mode 100644
index 00000000000..db6947603a4
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx
@@ -0,0 +1,36 @@
+import React, { useMemo } from 'react';
+import { strings } from '../../../../../../locales/i18n';
+import { Box, Text, TextColor } from '@metamask/design-system-react-native';
+import { PriceImpactModalType } from './constants';
+
+interface PriceImpactDescriptionProps {
+ type: PriceImpactModalType;
+ priceImpact?: string;
+}
+
+export function PriceImpactDescription({
+ type,
+ priceImpact,
+}: PriceImpactDescriptionProps) {
+ const isWarning = Boolean(priceImpact);
+
+ const body = useMemo(() => {
+ if (type === PriceImpactModalType.Execution) {
+ return strings('bridge.price_impact_execution_description', {
+ priceImpact: priceImpact ?? '0',
+ });
+ }
+ if (isWarning) {
+ return strings('bridge.price_impact_warning_description', {
+ priceImpact: priceImpact ?? '0',
+ });
+ }
+ return strings('bridge.price_impact_info_description');
+ }, [type, priceImpact, isWarning]);
+
+ return (
+
+ {body}
+
+ );
+}
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx
new file mode 100644
index 00000000000..63671a34227
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx
@@ -0,0 +1,258 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import { PriceImpactFooter } from './PriceImpactFooter';
+import { PriceImpactModalType } from './constants';
+import { strings } from '../../../../../../locales/i18n';
+
+const onConfirm = jest.fn();
+const onCancel = jest.fn();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('PriceImpactFooter', () => {
+ describe('Info type', () => {
+ it('renders the Got it button', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText(strings('bridge.got_it'))).toBeTruthy();
+ });
+
+ it('does not render the Proceed button', () => {
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(queryByText(strings('bridge.proceed'))).toBeNull();
+ });
+
+ it('does not render the Cancel button', () => {
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(queryByText(strings('bridge.cancel'))).toBeNull();
+ });
+
+ it('calls onConfirm when Got it is pressed', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('bridge.got_it')));
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ it('does not pass loading or disabled props to the Got it button', () => {
+ // loading prop is not forwarded for the Info type layout — no isLoading/disabled
+ // on the button. Rendering with loading=true should not affect the button state.
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('bridge.got_it')));
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Execution type', () => {
+ it('renders the Proceed button', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText(strings('bridge.proceed'))).toBeTruthy();
+ });
+
+ it('renders the Cancel button', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText(strings('bridge.cancel'))).toBeTruthy();
+ });
+
+ it('does not render the Got it button', () => {
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(queryByText(strings('bridge.got_it'))).toBeNull();
+ });
+
+ it('calls onCancel when Proceed is pressed', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('bridge.proceed')));
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it('calls onConfirm when Cancel is pressed', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('bridge.cancel')));
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ describe('loading state', () => {
+ it('disables the Proceed button while loading', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+
+ // Execution layout renders Proceed first, Cancel second
+ const [proceedButton] = getAllByRole('button');
+ expect(proceedButton.props.accessibilityState?.disabled).toBe(true);
+ });
+
+ it('disables the Cancel button while loading', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+
+ const [, cancelButton] = getAllByRole('button');
+ expect(cancelButton.props.accessibilityState?.disabled).toBe(true);
+ });
+
+ it('does not fire onCancel when Proceed is pressed while loading', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('bridge.proceed')));
+
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ it('does not fire onConfirm when Cancel is pressed while loading', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('bridge.cancel')));
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it('does not mark buttons as disabled when not loading', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+
+ const [proceedButton, cancelButton] = getAllByRole('button');
+
+ // ButtonBase only adds `disabled: true` to accessibilityState when
+ // disabled; the key is absent (undefined) when the button is enabled.
+ expect(proceedButton.props.accessibilityState?.disabled).not.toBe(true);
+ expect(cancelButton.props.accessibilityState?.disabled).not.toBe(true);
+ });
+
+ it('sets busy accessibilityState on the Proceed button but not on the Cancel button', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+
+ const [proceedButton, cancelButton] = getAllByRole('button');
+
+ // Proceed (onCancel) shows a loading spinner — accessibilityState.busy = true
+ expect(proceedButton.props.accessibilityState?.busy).toBe(true);
+ // Cancel (onConfirm) is disabled but has no loading spinner
+ expect(cancelButton.props.accessibilityState?.busy).not.toBe(true);
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx
new file mode 100644
index 00000000000..0da2d903ee5
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { strings } from '../../../../../../locales/i18n';
+import {
+ Box,
+ BoxFlexDirection,
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
+import { PriceImpactModalType } from './constants';
+
+export interface PriceImpactFooterProps {
+ type: PriceImpactModalType;
+ onConfirm: () => void;
+ onCancel: () => void;
+ loading: boolean;
+}
+
+export function PriceImpactFooter({
+ type,
+ onConfirm,
+ onCancel,
+ loading,
+}: PriceImpactFooterProps) {
+ if (type === PriceImpactModalType.Execution) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx
new file mode 100644
index 00000000000..9330591264e
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import { PriceImpactHeader } from './PriceImpactHeader';
+import { PriceImpactModalType } from './constants';
+import { strings } from '../../../../../../locales/i18n';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
+
+// Render the warning Icon with a testID derived from the icon name so it can
+// be queried in tests without coupling to SVG internals.
+jest.mock('../../../../../component-library/components/Icons/Icon', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({ name, testID }: { name: string; testID?: string }) => (
+
+ ),
+ IconName: jest.requireActual(
+ '../../../../../component-library/components/Icons/Icon',
+ ).IconName,
+ IconSize: jest.requireActual(
+ '../../../../../component-library/components/Icons/Icon',
+ ).IconSize,
+ };
+});
+
+const onClose = jest.fn();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('PriceImpactHeader', () => {
+ describe('title', () => {
+ it('renders "Price impact" for the Info type', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText(strings('bridge.price_impact'))).toBeTruthy();
+ });
+
+ it('renders "High price impact" for the Execution type', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText(strings('bridge.price_impact_high'))).toBeTruthy();
+ });
+ });
+
+ describe('close button', () => {
+ it('renders the close button', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('button-icon')).toBeTruthy();
+ });
+
+ it('calls onClose when the close button is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('button-icon'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose on the Execution type as well', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('button-icon'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('warning icon', () => {
+ it('renders the warning icon when both warningIconName and warningIconColor are provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(`icon-${IconName.Danger}`)).toBeTruthy();
+ });
+
+ it('does not render the warning icon when warningIconName is absent', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId(`icon-${IconName.Danger}`)).toBeNull();
+ });
+
+ it('does not render the warning icon when warningIconColor is absent', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId(`icon-${IconName.Warning}`)).toBeNull();
+ });
+
+ it('does not render the warning icon when neither prop is provided', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId(`icon-${IconName.Danger}`)).toBeNull();
+ expect(queryByTestId(`icon-${IconName.Warning}`)).toBeNull();
+ });
+
+ it('renders a Warning icon for the Info type when both props are provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(`icon-${IconName.Warning}`)).toBeTruthy();
+ });
+ });
+});
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx
new file mode 100644
index 00000000000..9938b1b44d6
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import Icon, {
+ IconName,
+ IconSize,
+} from '../../../../../component-library/components/Icons/Icon';
+import { strings } from '../../../../../../locales/i18n';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ ButtonIcon,
+ ButtonIconSize,
+ FontWeight,
+ IconName as DSIconName,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { PriceImpactModalType } from './constants';
+
+interface PriceImpactHeaderProps {
+ type: PriceImpactModalType;
+ onClose: () => void;
+ warningIconName?: IconName;
+ warningIconColor?: string;
+}
+
+export function PriceImpactHeader({
+ type,
+ onClose,
+ warningIconName,
+ warningIconColor,
+}: PriceImpactHeaderProps) {
+ const isWarning = Boolean(warningIconName && warningIconColor);
+ const title =
+ type === PriceImpactModalType.Execution
+ ? strings('bridge.price_impact_high')
+ : strings('bridge.price_impact');
+
+ return (
+
+
+
+ {isWarning && warningIconName && warningIconColor && (
+
+ )}
+
+ {title}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/constants.ts b/app/components/UI/Bridge/components/PriceImpactModal/constants.ts
new file mode 100644
index 00000000000..a00a7aaee25
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/constants.ts
@@ -0,0 +1,4 @@
+export enum PriceImpactModalType {
+ Info = 'info',
+ Execution = 'execution',
+}
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx
new file mode 100644
index 00000000000..443139395de
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx
@@ -0,0 +1,416 @@
+import React from 'react';
+import { render, fireEvent, waitFor } from '@testing-library/react-native';
+import { PriceImpactModal } from './index';
+import { PriceImpactModalType } from './constants';
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import { TextColor } from '../../../../../component-library/components/Texts/Text';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
+
+// Mock BottomSheet
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+
+ return {
+ __esModule: true,
+ default: ReactModule.forwardRef(
+ (props: { children: unknown }, _ref: unknown) => (
+ {props.children as React.ReactNode}
+ ),
+ ),
+ };
+ },
+);
+
+// Mock sub-components so we can assert on the props they receive
+jest.mock('./PriceImpactHeader', () => ({
+ PriceImpactHeader: jest.fn(
+ ({ type, onClose }: { type: string; onClose: () => void }) => {
+ const { View, TouchableOpacity, Text } =
+ jest.requireActual('react-native');
+ return (
+
+ {type}
+
+ Close
+
+
+ );
+ },
+ ),
+}));
+
+jest.mock('./PriceImpactDescription', () => ({
+ PriceImpactDescription: jest.fn(
+ ({ type, priceImpact }: { type: string; priceImpact?: string }) => {
+ const { View, Text } = jest.requireActual('react-native');
+ return (
+
+ {type}
+ {priceImpact ? (
+ {priceImpact}
+ ) : null}
+
+ );
+ },
+ ),
+}));
+
+jest.mock('./PriceImpactFooter', () => ({
+ PriceImpactFooter: jest.fn(
+ ({
+ type,
+ onConfirm,
+ onCancel,
+ loading,
+ }: {
+ type: string;
+ onConfirm: () => void;
+ onCancel: () => Promise;
+ loading: boolean;
+ }) => {
+ const { View, TouchableOpacity, Text } =
+ jest.requireActual('react-native');
+ return (
+
+ {type}
+ {String(loading)}
+
+ Confirm
+
+
+ Cancel
+
+
+ );
+ },
+ ),
+}));
+
+// Mock hooks
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: jest.fn(),
+}));
+
+jest.mock('../../hooks/useLatestBalance', () => ({
+ useLatestBalance: jest.fn(),
+}));
+
+jest.mock('../../hooks/useBridgeConfirm', () => ({
+ useBridgeConfirm: jest.fn(),
+}));
+
+jest.mock('../../hooks/useBridgeQuoteData', () => ({
+ useBridgeQuoteData: jest.fn(),
+}));
+
+jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({
+ useModalCloseOnQuoteExpiry: jest.fn(),
+}));
+
+jest.mock('../../utils/getPriceImpactViewData', () => ({
+ getPriceImpactViewData: jest.fn(),
+}));
+
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { useLatestBalance } from '../../hooks/useLatestBalance';
+import { useBridgeConfirm } from '../../hooks/useBridgeConfirm';
+import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
+import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry';
+import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData';
+import { PriceImpactHeader } from './PriceImpactHeader';
+import { PriceImpactDescription } from './PriceImpactDescription';
+import { PriceImpactFooter } from './PriceImpactFooter';
+
+const mockUseParams = useParams as jest.MockedFunction;
+const mockUseLatestBalance = useLatestBalance as jest.MockedFunction<
+ typeof useLatestBalance
+>;
+const mockUseBridgeConfirm = useBridgeConfirm as jest.MockedFunction<
+ typeof useBridgeConfirm
+>;
+const mockUseBridgeQuoteData = useBridgeQuoteData as jest.MockedFunction<
+ typeof useBridgeQuoteData
+>;
+const mockUseModalCloseOnQuoteExpiry =
+ useModalCloseOnQuoteExpiry as jest.MockedFunction<
+ typeof useModalCloseOnQuoteExpiry
+ >;
+const mockGetPriceImpactViewData =
+ getPriceImpactViewData as jest.MockedFunction;
+const mockPriceImpactHeader = PriceImpactHeader as jest.MockedFunction<
+ typeof PriceImpactHeader
+>;
+const mockPriceImpactDescription =
+ PriceImpactDescription as jest.MockedFunction;
+const mockPriceImpactFooter = PriceImpactFooter as jest.MockedFunction<
+ typeof PriceImpactFooter
+>;
+
+const mockConfirmBridge = jest.fn();
+
+const mockToken = {
+ address: '0xabc',
+ decimals: 18,
+ chainId: '0x1' as `0x${string}`,
+ symbol: 'ETH',
+ name: 'Ether',
+ image: '',
+};
+
+const defaultParams = {
+ type: PriceImpactModalType.Info,
+ token: mockToken,
+ location: MetaMetricsSwapsEventSource.MainView,
+};
+
+const defaultViewData = {
+ textColor: TextColor.Alternative,
+ icon: undefined,
+};
+
+describe('PriceImpactModal', () => {
+ beforeEach(() => {
+ mockUseParams.mockReturnValue(defaultParams);
+ mockUseLatestBalance.mockReturnValue(undefined);
+ mockUseBridgeConfirm.mockReturnValue(mockConfirmBridge);
+ mockUseBridgeQuoteData.mockReturnValue({
+ formattedQuoteData: undefined,
+ } as ReturnType);
+ mockGetPriceImpactViewData.mockReturnValue(
+ defaultViewData as ReturnType,
+ );
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('useModalCloseOnQuoteExpiry', () => {
+ it('calls useModalCloseOnQuoteExpiry on render', () => {
+ render();
+
+ expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled();
+ });
+
+ it('calls useModalCloseOnQuoteExpiry exactly once per render', () => {
+ render();
+
+ expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('component structure', () => {
+ it('renders PriceImpactHeader', () => {
+ const { getByTestId } = render();
+
+ expect(getByTestId('price-impact-header')).toBeTruthy();
+ });
+
+ it('renders PriceImpactDescription', () => {
+ const { getByTestId } = render();
+
+ expect(getByTestId('price-impact-description')).toBeTruthy();
+ });
+
+ it('renders PriceImpactFooter', () => {
+ const { getByTestId } = render();
+
+ expect(getByTestId('price-impact-footer')).toBeTruthy();
+ });
+ });
+
+ describe('props passed to sub-components', () => {
+ it('passes type to PriceImpactHeader', () => {
+ mockUseParams.mockReturnValue({
+ ...defaultParams,
+ type: PriceImpactModalType.Execution,
+ });
+
+ render();
+
+ expect(mockPriceImpactHeader).toHaveBeenCalledWith(
+ expect.objectContaining({ type: PriceImpactModalType.Execution }),
+ expect.anything(),
+ );
+ });
+
+ it('passes type to PriceImpactDescription', () => {
+ render();
+
+ expect(mockPriceImpactDescription).toHaveBeenCalledWith(
+ expect.objectContaining({ type: PriceImpactModalType.Info }),
+ expect.anything(),
+ );
+ });
+
+ it('passes type to PriceImpactFooter', () => {
+ render();
+
+ expect(mockPriceImpactFooter).toHaveBeenCalledWith(
+ expect.objectContaining({ type: PriceImpactModalType.Info }),
+ expect.anything(),
+ );
+ });
+
+ it('passes priceImpact to PriceImpactDescription when warningIcon is present', () => {
+ mockGetPriceImpactViewData.mockReturnValue({
+ textColor: TextColor.Error,
+ icon: { name: IconName.Danger, color: TextColor.Error },
+ } as ReturnType);
+ mockUseBridgeQuoteData.mockReturnValue({
+ formattedQuoteData: { priceImpact: '5%' },
+ } as ReturnType);
+
+ render();
+
+ expect(mockPriceImpactDescription).toHaveBeenCalledWith(
+ expect.objectContaining({ priceImpact: '5%' }),
+ expect.anything(),
+ );
+ });
+
+ it('passes undefined priceImpact to PriceImpactDescription when warningIcon is absent', () => {
+ mockGetPriceImpactViewData.mockReturnValue({
+ textColor: TextColor.Alternative,
+ icon: undefined,
+ } as ReturnType);
+ mockUseBridgeQuoteData.mockReturnValue({
+ formattedQuoteData: { priceImpact: '5%' },
+ } as ReturnType);
+
+ render();
+
+ expect(mockPriceImpactDescription).toHaveBeenCalledWith(
+ expect.objectContaining({ priceImpact: undefined }),
+ expect.anything(),
+ );
+ });
+
+ it('passes warningIconName and warningIconColor to PriceImpactHeader from view data', () => {
+ mockGetPriceImpactViewData.mockReturnValue({
+ textColor: TextColor.Warning,
+ icon: { name: IconName.Warning, color: TextColor.Warning },
+ } as ReturnType);
+
+ render();
+
+ expect(mockPriceImpactHeader).toHaveBeenCalledWith(
+ expect.objectContaining({
+ warningIconName: IconName.Warning,
+ warningIconColor: TextColor.Warning,
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('passes undefined warningIconName and warningIconColor when icon is absent', () => {
+ render();
+
+ expect(mockPriceImpactHeader).toHaveBeenCalledWith(
+ expect.objectContaining({
+ warningIconName: undefined,
+ warningIconColor: undefined,
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('starts with loading false', () => {
+ render();
+
+ expect(mockPriceImpactFooter).toHaveBeenCalledWith(
+ expect.objectContaining({ loading: false }),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('handleClose', () => {
+ it('does not call confirmBridge when close is pressed', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId('price-impact-header-close'));
+
+ expect(mockConfirmBridge).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('handleProceed', () => {
+ it('calls confirmBridge when the proceed (cancel) button is pressed', async () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId('price-impact-footer-cancel'));
+
+ await waitFor(() => {
+ expect(mockConfirmBridge).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('sets loading to true while proceeding', async () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId('price-impact-footer-cancel'));
+
+ await waitFor(() => {
+ expect(mockPriceImpactFooter).toHaveBeenCalledWith(
+ expect.objectContaining({ loading: true }),
+ expect.anything(),
+ );
+ });
+ });
+ });
+
+ describe('hook wiring', () => {
+ it('passes token address, decimals, and chainId to useLatestBalance', () => {
+ render();
+
+ expect(mockUseLatestBalance).toHaveBeenCalledWith({
+ address: mockToken.address,
+ decimals: mockToken.decimals,
+ chainId: mockToken.chainId,
+ });
+ });
+
+ it('passes location to useBridgeConfirm', () => {
+ render();
+
+ expect(mockUseBridgeConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ location: MetaMetricsSwapsEventSource.MainView,
+ }),
+ );
+ });
+
+ it('calls getPriceImpactViewData with the priceImpact from formattedQuoteData', () => {
+ mockUseBridgeQuoteData.mockReturnValue({
+ formattedQuoteData: { priceImpact: '12%' },
+ } as ReturnType);
+
+ render();
+
+ expect(mockGetPriceImpactViewData).toHaveBeenCalledWith('12%');
+ });
+
+ it('calls getPriceImpactViewData with undefined when formattedQuoteData is absent', () => {
+ mockUseBridgeQuoteData.mockReturnValue({
+ formattedQuoteData: undefined,
+ } as ReturnType);
+
+ render();
+
+ expect(mockGetPriceImpactViewData).toHaveBeenCalledWith(undefined);
+ });
+ });
+});
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx
new file mode 100644
index 00000000000..30cfebb4749
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx
@@ -0,0 +1,71 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
+import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData';
+import { PriceImpactModalRouterParams } from './types';
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { PriceImpactHeader } from './PriceImpactHeader';
+import { PriceImpactDescription } from './PriceImpactDescription';
+import { PriceImpactFooter } from './PriceImpactFooter';
+import { useLatestBalance } from '../../hooks/useLatestBalance';
+import { useBridgeConfirm } from '../../hooks/useBridgeConfirm';
+import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry';
+
+export const PriceImpactModal = () => {
+ const [loading, setLoading] = useState(false);
+ const { type, token, location } = useParams();
+ const sheetRef = useRef(null);
+ const tokenBalance = useLatestBalance({
+ address: token?.address,
+ decimals: token?.decimals,
+ chainId: token?.chainId,
+ });
+
+ const confirmBridge = useBridgeConfirm({
+ latestSourceBalance: tokenBalance,
+ location,
+ });
+
+ const { formattedQuoteData } = useBridgeQuoteData();
+
+ const priceImpactViewData = useMemo(
+ () => getPriceImpactViewData(formattedQuoteData?.priceImpact),
+ [formattedQuoteData?.priceImpact],
+ );
+
+ const handleClose = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet();
+ }, []);
+
+ const handleProceed = useCallback(async () => {
+ setLoading(true);
+ await confirmBridge();
+ }, [confirmBridge]);
+
+ const warningIcon = priceImpactViewData.icon;
+
+ useModalCloseOnQuoteExpiry();
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/app/components/UI/Bridge/components/PriceImpactModal/types.ts b/app/components/UI/Bridge/components/PriceImpactModal/types.ts
new file mode 100644
index 00000000000..ff5ed695782
--- /dev/null
+++ b/app/components/UI/Bridge/components/PriceImpactModal/types.ts
@@ -0,0 +1,9 @@
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import { BridgeToken } from '../../types';
+import { PriceImpactModalType } from './constants';
+
+export interface PriceImpactModalRouterParams {
+ type: PriceImpactModalType;
+ token: BridgeToken;
+ location: MetaMetricsSwapsEventSource;
+}
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx
index 9e4c3348eca..d29acb19232 100644
--- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx
@@ -9,6 +9,8 @@ import mockQuotes from '../../_mocks_/mock-quotes-sol-sol.json';
import mockQuotesGasIncluded from '../../_mocks_/mock-quotes-gas-included.json';
import { createBridgeTestState } from '../../testUtils';
import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import { PriceImpactModalType } from '../PriceImpactModal/constants';
jest.mock(
'../../../../../animations/rewards_icon_animations.riv',
@@ -263,7 +265,10 @@ const testState = createBridgeTestState({
});
const QuoteDetailsCardTestScreen = () => (
-
+
);
describe('QuoteDetailsCard', () => {
@@ -283,7 +288,7 @@ describe('QuoteDetailsCard', () => {
});
it('displays fee amount', () => {
- const { getByText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{
name: Routes.BRIDGE.ROOT,
@@ -292,10 +297,12 @@ describe('QuoteDetailsCard', () => {
);
expect(getByText('0.01')).toBeDefined();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
it('displays quote rate', () => {
- const { getByText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{
name: Routes.BRIDGE.ROOT,
@@ -304,10 +311,12 @@ describe('QuoteDetailsCard', () => {
);
expect(getByText('1 ETH = 24.4 USDC')).toBeDefined();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
it('navigates to slippage modal on edit press', () => {
- const { getByTestId } = renderScreen(
+ const { getByTestId, getByText } = renderScreen(
QuoteDetailsCardTestScreen,
{
name: Routes.BRIDGE.ROOT,
@@ -327,10 +336,12 @@ describe('QuoteDetailsCard', () => {
destChainId: 'evm:1',
},
});
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
it('displays slippage value', () => {
- const { getByText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{
name: Routes.BRIDGE.ROOT,
@@ -340,6 +351,8 @@ describe('QuoteDetailsCard', () => {
// Verify slippage value
expect(getByText('0.5%')).toBeDefined();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
it('displays "Included" fee when gasIncluded7702 is true', () => {
@@ -368,7 +381,7 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { getByText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{
name: Routes.BRIDGE.ROOT,
@@ -378,6 +391,8 @@ describe('QuoteDetailsCard', () => {
// Verify "Included" text is displayed
expect(getByText(strings('bridge.included'))).toBeDefined();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
// Restore original implementation
mockModule.useBridgeQuoteData.mockImplementation(originalImpl);
@@ -402,7 +417,7 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { getByText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{
name: Routes.BRIDGE.ROOT,
@@ -412,6 +427,8 @@ describe('QuoteDetailsCard', () => {
// Verify "Included" text is displayed
expect(getByText(strings('bridge.included'))).toBeDefined();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
// Restore original implementation
mockModule.useBridgeQuoteData.mockImplementation(originalImpl);
@@ -442,7 +459,7 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { getByText, queryByText } = renderScreen(
+ const { getByText, getByTestId, queryByText } = renderScreen(
QuoteDetailsCardTestScreen,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
@@ -451,6 +468,8 @@ describe('QuoteDetailsCard', () => {
expect(getByText(strings('bridge.network_fee'))).toBeOnTheScreen();
expect(getByText(strings('bridge.gas_fees_sponsored'))).toBeOnTheScreen();
expect(queryByText('0.01')).toBeNull();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
mockModule.useBridgeQuoteData.mockImplementation(originalImpl);
});
@@ -480,7 +499,7 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { getByLabelText } = renderScreen(
+ const { getByLabelText, getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
@@ -502,6 +521,8 @@ describe('QuoteDetailsCard', () => {
},
screen: 'tooltipModal',
});
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
mockModule.useBridgeQuoteData.mockImplementation(originalImpl);
});
@@ -526,7 +547,7 @@ describe('QuoteDetailsCard', () => {
expect(queryByTestId('quote-details-card')).toBeNull();
});
- it('handles price impact warning navigation', () => {
+ it('handles price impact info button navigation', () => {
const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData');
mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({
quoteFetchError: null,
@@ -550,27 +571,33 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { getByLabelText } = renderScreen(
+ const { getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
);
- try {
- const priceImpactTooltip = getByLabelText(
- /Price Impact Warning tooltip/i,
- );
- fireEvent.press(priceImpactTooltip);
- expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
- params: { isGasIncluded: false },
- });
- } catch {
- // Component rendered with high price impact logic
- }
+ const priceImpactInfoButton = getByTestId('price-impact-info-button');
+ fireEvent.press(priceImpactInfoButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
+ screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL,
+ params: {
+ type: PriceImpactModalType.Info,
+ token: {
+ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ address: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ symbol: 'SOL',
+ decimals: 9,
+ name: 'Solana',
+ },
+ location: MetaMetricsSwapsEventSource.MainView,
+ },
+ });
});
it('handles quote info navigation', () => {
- const { getByLabelText } = renderScreen(
+ const { getByLabelText, getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
@@ -588,10 +615,11 @@ describe('QuoteDetailsCard', () => {
},
screen: 'tooltipModal',
});
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
- it('handles shouldShowPriceImpactWarning false branch', () => {
- // Test with low price impact to ensure shouldShowPriceImpactWarning is false
+ it('renders price impact info button for low price impact values', () => {
const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData');
mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({
quoteFetchError: null,
@@ -615,18 +643,17 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { queryByLabelText } = renderScreen(
+ const { getByTestId, getByText } = renderScreen(
QuoteDetailsCardTestScreen,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
);
- // With low price impact, the warning tooltip should not exist
- expect(queryByLabelText(/Price Impact Warning tooltip/i)).toBeNull();
+ expect(getByText('Price impact')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
- it('handles shouldShowPriceImpactWarning true branch with color', () => {
- // Test with very high price impact to ensure shouldShowPriceImpactWarning is true
+ it('renders price impact row with info button for high price impact values', () => {
const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData');
mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({
quoteFetchError: null,
@@ -650,28 +677,15 @@ describe('QuoteDetailsCard', () => {
},
}));
- const { getByText, queryByLabelText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCardTestScreen,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
);
- // The key is testing the shouldShowPriceImpactWarning conditional branches
- // Verify the Price Impact section is visible (this exercises the component logic)
expect(getByText('Price impact')).toBeTruthy();
-
- // Test the shouldShowPriceImpactWarning branches by checking for tooltip presence
- const hasWarningTooltip =
- queryByLabelText(/Price Impact Warning tooltip/i) !== null;
-
- // Either way, we're testing both branches of the conditional
- if (hasWarningTooltip) {
- // True branch - warning tooltip exists
- expect(queryByLabelText(/Price Impact Warning tooltip/i)).toBeTruthy();
- } else {
- // False branch - no warning tooltip
- expect(queryByLabelText(/Price Impact Warning tooltip/i)).toBeNull();
- }
+ expect(getByText('25%')).toBeTruthy();
+ expect(getByTestId('price-impact-info-button')).toBeTruthy();
});
describe('rewards functionality', () => {
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
index 68f838f74d1..ecbba26db57 100644
--- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
@@ -2,24 +2,22 @@ import React, { useMemo } from 'react';
import { TouchableOpacity, Platform, UIManager } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { strings } from '../../../../../../locales/i18n';
-import Text, {
- TextColor,
- TextVariant,
-} from '../../../../../component-library/components/Texts/Text';
import { useTheme } from '../../../../../util/theme';
import createStyles from './QuoteDetailsCard.styles';
-import Icon, {
- IconColor,
- IconName,
- IconSize,
-} from '../../../../../component-library/components/Icons/Icon';
-import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow';
+import { IconName as IconNameLegacy } from '../../../../../component-library/components/Icons/Icon';
import { TooltipSizes } from '../../../../../component-library/components-temp/KeyValueRow/KeyValueRow.types';
import {
Box,
BoxFlexDirection,
BoxAlignItems,
BoxJustifyContent,
+ Text,
+ TextVariant,
+ TextColor,
+ Icon,
+ IconName,
+ IconSize,
+ IconColor,
} from '@metamask/design-system-react-native';
import Routes from '../../../../../constants/navigation/Routes';
import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
@@ -45,6 +43,14 @@ import TagColored, {
import { useShouldRenderGasSponsoredBanner } from '../../hooks/useShouldRenderGasSponsoredBanner';
import { isGaslessQuote } from '../../utils/isGaslessQuote';
import { QuoteDetailsCardProps } from './QuoteDetailsCard.types';
+import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData';
+import {
+ TextVariant as TextVariantLegacy,
+ TextColor as TextColorLegacy,
+} from '../../../../../component-library/components/Texts/Text';
+import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow';
+import { PriceImpactModalType } from '../PriceImpactModal/constants';
+import { formatPriceImpact } from '../../utils/formatPriceImpact';
if (
Platform.OS === 'android' &&
@@ -55,6 +61,7 @@ if (
const QuoteDetailsCard: React.FC = ({
hasInsufficientBalance,
+ location,
}) => {
const theme = useTheme();
const navigation = useNavigation();
@@ -64,7 +71,6 @@ const QuoteDetailsCard: React.FC = ({
formattedQuoteData,
activeQuote,
isLoading: isQuoteLoading,
- shouldShowPriceImpactWarning,
} = useBridgeQuoteData();
const sourceToken = useSelector(selectSourceToken);
const destToken = useSelector(selectDestToken);
@@ -103,7 +109,28 @@ const QuoteDetailsCard: React.FC = ({
});
};
- // Early return for invalid states
+ const handlePriceImpactPress = () => {
+ navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
+ screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL,
+ params: {
+ type: PriceImpactModalType.Info,
+ token: sourceToken,
+ location,
+ },
+ });
+ };
+
+ const isGasless = isGaslessQuote(activeQuote?.quote);
+
+ const formattedMinToTokenAmount = formatMinimumReceived(
+ activeQuote?.minToTokenAmount?.amount || '0',
+ );
+
+ const priceImactViewData = useMemo(
+ () => getPriceImpactViewData(formattedQuoteData?.priceImpact),
+ [formattedQuoteData?.priceImpact],
+ );
+
if (
!sourceToken?.chainId ||
!destToken?.chainId ||
@@ -113,14 +140,6 @@ const QuoteDetailsCard: React.FC = ({
return null;
}
- const { networkFee, rate, priceImpact, slippage } = formattedQuoteData;
-
- const isGasless = isGaslessQuote(activeQuote?.quote);
-
- const formattedMinToTokenAmount = formatMinimumReceived(
- activeQuote?.minToTokenAmount?.amount || '0',
- );
-
return (
@@ -133,8 +152,8 @@ const QuoteDetailsCard: React.FC = ({
gap={1}
>
{strings('bridge.rate')}
@@ -145,19 +164,19 @@ const QuoteDetailsCard: React.FC = ({
title: strings('bridge.quote_info_title'),
content: strings('bridge.quote_info_content'),
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}}
value={{
label: (
- {rate}
+ {formattedQuoteData.rate}
),
}}
@@ -167,7 +186,7 @@ const QuoteDetailsCard: React.FC = ({
field={{
label: {
text: strings('bridge.network_fee'),
- variant: TextVariant.BodyMDMedium,
+ variant: TextVariantLegacy.BodyMDMedium,
},
tooltip: {
title: strings('bridge.network_fee_info_title'),
@@ -175,7 +194,7 @@ const QuoteDetailsCard: React.FC = ({
nativeToken: nativeTokenName,
}),
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}}
value={{
@@ -183,7 +202,7 @@ const QuoteDetailsCard: React.FC = ({
= ({
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Between}
>
-
+
{toSentenceCase(strings('bridge.network_fee'))}
= ({
gap={2}
>
- {networkFee}
+ {formattedQuoteData.networkFee}
-
+
{strings('bridge.included')}
@@ -228,21 +253,21 @@ const QuoteDetailsCard: React.FC = ({
field={{
label: {
text: toSentenceCase(strings('bridge.network_fee')),
- variant: TextVariant.BodyMD,
- color: TextColor.Alternative,
+ variant: TextVariantLegacy.BodyMD,
+ color: TextColorLegacy.Alternative,
},
tooltip: {
title: strings('bridge.network_fee_info_title'),
content: strings('bridge.network_fee_info_content'),
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}}
value={{
label: {
- text: networkFee,
- variant: TextVariant.BodyMD,
- color: TextColor.Alternative,
+ text: formattedQuoteData.networkFee,
+ variant: TextVariantLegacy.BodyMD,
+ color: TextColorLegacy.Alternative,
},
}}
/>
@@ -252,14 +277,14 @@ const QuoteDetailsCard: React.FC = ({
field={{
label: {
text: strings('bridge.slippage'),
- variant: TextVariant.BodyMD,
- color: TextColor.Alternative,
+ variant: TextVariantLegacy.BodyMD,
+ color: TextColorLegacy.Alternative,
},
tooltip: {
title: strings('bridge.slippage_info_title'),
content: strings('bridge.slippage_info_description'),
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}}
value={{
@@ -271,15 +296,15 @@ const QuoteDetailsCard: React.FC = ({
style={styles.slippageButton}
>
- {slippage}
+ {formattedQuoteData.slippage}
),
@@ -291,54 +316,63 @@ const QuoteDetailsCard: React.FC = ({
field={{
label: {
text: toSentenceCase(strings('bridge.minimum_received')),
- variant: TextVariant.BodyMD,
- color: TextColor.Alternative,
+ variant: TextVariantLegacy.BodyMD,
+ color: TextColorLegacy.Alternative,
},
tooltip: {
title: strings('bridge.minimum_received_tooltip_title'),
content: strings('bridge.minimum_received_tooltip_content'),
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}}
value={{
label: {
text: `${formattedMinToTokenAmount} ${destToken?.symbol}`,
- variant: TextVariant.BodyMD,
- color: TextColor.Alternative,
+ variant: TextVariantLegacy.BodyMD,
+ color: TextColorLegacy.Alternative,
},
}}
/>
)}
- {priceImpact && (
-
- )}
+
+
+ {toSentenceCase(strings('bridge.price_impact'))}
+
+
+
+
+
+ ),
+ }}
+ value={{
+ icon: priceImactViewData.icon,
+ label: {
+ text: formatPriceImpact(formattedQuoteData.priceImpact),
+ variant: TextVariantLegacy.BodyMD,
+ color: priceImactViewData.textColor,
+ },
+ }}
+ />
@@ -349,7 +383,7 @@ const QuoteDetailsCard: React.FC = ({
field={{
label: {
text: toSentenceCase(strings('bridge.points')),
- variant: TextVariant.BodyMD,
+ variant: TextVariantLegacy.BodyMD,
},
tooltip: {
title: strings('bridge.points_tooltip'),
@@ -357,7 +391,7 @@ const QuoteDetailsCard: React.FC = ({
'bridge.points_tooltip_content_1',
)}\n\n${strings('bridge.points_tooltip_content_2')}`,
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}}
value={{
@@ -394,7 +428,7 @@ const QuoteDetailsCard: React.FC = ({
title: strings('bridge.points_error'),
content: strings('bridge.points_error_content'),
size: TooltipSizes.Sm,
- iconName: IconName.Info,
+ iconName: IconNameLegacy.Info,
},
}),
}}
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts
index eb576aa6fa6..913f3b5cae7 100644
--- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts
@@ -1,3 +1,6 @@
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+
export interface QuoteDetailsCardProps {
hasInsufficientBalance: boolean;
+ location: MetaMetricsSwapsEventSource;
}
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap
index 93f612e2d8b..994ecb77ca2 100644
--- a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap
@@ -395,13 +395,17 @@ exports[`QuoteDetailsCard renders initial state 1`] = `
Rate
@@ -502,13 +506,17 @@ exports[`QuoteDetailsCard renders initial state 1`] = `
minimumFontScale={0.8}
numberOfLines={1}
style={
- {
- "color": "#66676a",
- "fontFamily": "Geist-Regular",
- "fontSize": 16,
- "letterSpacing": 0,
- "lineHeight": 24,
- }
+ [
+ {
+ "color": "#66676a",
+ "fontFamily": "Geist-Regular",
+ "fontSize": 16,
+ "fontWeight": 400,
+ "letterSpacing": 0,
+ "lineHeight": 24,
+ },
+ undefined,
+ ]
}
>
1 ETH = 24.4 USDC
@@ -785,29 +793,34 @@ exports[`QuoteDetailsCard renders initial state 1`] = `
0.5%
@@ -854,55 +867,58 @@ exports[`QuoteDetailsCard renders initial state 1`] = `
}
}
>
-
- Price impact
-
-
-
-
+ >
+ Price impact
+
+
+
+
+
@@ -944,7 +960,7 @@ exports[`QuoteDetailsCard renders initial state 1`] = `
}
testID="label"
>
- -0.06%
+ 0%
diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx
index a054df0a5e6..6780302c682 100644
--- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx
+++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx
@@ -140,6 +140,10 @@ jest.mock('../../hooks/useSlippageConfig', () => ({
useSlippageConfig: jest.fn(),
}));
+jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({
+ useModalCloseOnQuoteExpiry: jest.fn(),
+}));
+
jest.mock('../../hooks/useShouldDisableCustomSlippageConfirm', () => ({
useShouldDisableCustomSlippageConfirm: jest.fn(),
}));
@@ -180,10 +184,15 @@ import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDes
import { useParams } from '../../../../../util/navigation/navUtils';
import { InputStepper } from '../InputStepper';
import Keypad from '../../../../Base/Keypad';
+import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry';
const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction<
typeof useSlippageConfig
>;
+const mockUseModalCloseOnQuoteExpiry =
+ useModalCloseOnQuoteExpiry as jest.MockedFunction<
+ typeof useModalCloseOnQuoteExpiry
+ >;
const mockUseShouldDisableCustomSlippageConfirm =
useShouldDisableCustomSlippageConfirm as jest.MockedFunction<
typeof useShouldDisableCustomSlippageConfirm
@@ -946,6 +955,20 @@ describe('CustomSlippageModal', () => {
});
});
+ describe('useModalCloseOnQuoteExpiry', () => {
+ it('calls useModalCloseOnQuoteExpiry on render', () => {
+ render();
+
+ expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled();
+ });
+
+ it('calls useModalCloseOnQuoteExpiry exactly once per render', () => {
+ render();
+
+ expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('handleClose functionality', () => {
it('closes modal via header close button', () => {
const { getByLabelText } = render();
diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx
index b94daaa162a..243b1aa1849 100644
--- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx
+++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx
@@ -23,9 +23,11 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDescription';
import { useShouldDisableCustomSlippageConfirm } from '../../hooks/useShouldDisableCustomSlippageConfirm';
+import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry';
export const CustomSlippageModal = () => {
const dispatch = useDispatch();
+ useModalCloseOnQuoteExpiry();
const sheetRef = useRef(null);
const { sourceChainId, destChainId } =
useParams();
diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx
index fc5ba455bef..774e5839ded 100644
--- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx
+++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx
@@ -74,6 +74,10 @@ jest.mock('../../hooks/useSlippageConfig', () => ({
useSlippageConfig: jest.fn(),
}));
+jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({
+ useModalCloseOnQuoteExpiry: jest.fn(),
+}));
+
jest.mock('../../../../../util/navigation/navUtils', () => ({
useParams: jest.fn(),
}));
@@ -115,6 +119,7 @@ import { useGetSlippageOptions } from '../../hooks/useGetSlippageOptions';
import { useSlippageConfig } from '../../hooks/useSlippageConfig';
import { useParams } from '../../../../../util/navigation/navUtils';
import { AUTO_SLIPPAGE_VALUE } from './constants';
+import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry';
const mockUseGetSlippageOptions = useGetSlippageOptions as jest.MockedFunction<
typeof useGetSlippageOptions
@@ -123,6 +128,10 @@ const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction<
typeof useSlippageConfig
>;
const mockUseParams = useParams as jest.MockedFunction;
+const mockUseModalCloseOnQuoteExpiry =
+ useModalCloseOnQuoteExpiry as jest.MockedFunction<
+ typeof useModalCloseOnQuoteExpiry
+ >;
describe('DefaultSlippageModal', () => {
const mockSlippageConfig = {
@@ -635,6 +644,20 @@ describe('DefaultSlippageModal', () => {
});
});
+ describe('useModalCloseOnQuoteExpiry', () => {
+ it('calls useModalCloseOnQuoteExpiry on render', () => {
+ render();
+
+ expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled();
+ });
+
+ it('calls useModalCloseOnQuoteExpiry exactly once per render', () => {
+ render();
+
+ expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('auto slippage behavior', () => {
it('dispatches undefined for auto slippage on submit', () => {
mockSelector.mockReturnValue(undefined);
diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx
index 2d34b5caa77..9dda2b94e51 100644
--- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx
+++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx
@@ -26,10 +26,12 @@ import { DefaultSlippageModalParams } from './types';
import { useParams } from '../../../../../util/navigation/navUtils';
import { useSlippageConfig } from '../../hooks/useSlippageConfig';
import { SlippageType } from '../../types';
+import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry';
export const DefaultSlippageModal = () => {
const navigation = useNavigation();
const dispatch = useDispatch();
+ useModalCloseOnQuoteExpiry();
const sheetRef = useRef(null);
const slippage = useSelector(selectSlippage);
const [selectedSlippage, setSelectedSlippage] = useState(
diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
index c41b8c1c411..9d48dd0ce1f 100644
--- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
+++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
@@ -21,6 +21,8 @@ import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/te
import { BigNumber } from 'ethers';
import Engine from '../../../../../core/Engine';
import { setSourceAmount } from '../../../../../core/redux/slices/bridge';
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import { PriceImpactModalType } from '../PriceImpactModal/constants';
// Mock the account-tree-controller file that imports the problematic module
jest.mock(
@@ -250,7 +252,10 @@ describe('SwapsConfirmButton', () => {
describe('Button Label', () => {
it('displays "Confirm swap" label by default', () => {
const { getByText } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -263,7 +268,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(useIsInsufficientBalance).mockReturnValue(true);
const { getByText } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -276,7 +284,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(useHasSufficientGas).mockReturnValue(false);
const { getByText } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -295,7 +306,10 @@ describe('SwapsConfirmButton', () => {
};
const { queryByText } = renderWithProvider(
- ,
+ ,
{
state: submittingState,
},
@@ -318,7 +332,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -332,7 +349,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(useIsInsufficientBalance).mockReturnValue(true);
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -352,7 +372,10 @@ describe('SwapsConfirmButton', () => {
};
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: submittingState,
},
@@ -381,7 +404,10 @@ describe('SwapsConfirmButton', () => {
};
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: solanaState,
},
@@ -400,7 +426,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -414,7 +443,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(useHasSufficientGas).mockReturnValue(false);
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -428,7 +460,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(selectSourceWalletAddress).mockReturnValue(undefined);
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -450,7 +485,10 @@ describe('SwapsConfirmButton', () => {
}));
const { queryByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -473,7 +511,10 @@ describe('SwapsConfirmButton', () => {
};
const { queryByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: submittingState,
},
@@ -495,7 +536,10 @@ describe('SwapsConfirmButton', () => {
}));
const { queryByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState, // sourceAmount: '1.0'
},
@@ -525,7 +569,10 @@ describe('SwapsConfirmButton', () => {
};
const { getByText } = renderWithProvider(
- ,
+ ,
{
state: emptyAmountState,
},
@@ -555,7 +602,10 @@ describe('SwapsConfirmButton', () => {
};
const { getByText } = renderWithProvider(
- ,
+ ,
{
state: zeroAmountState,
},
@@ -576,7 +626,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -593,7 +646,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(useIsInsufficientBalance).mockReturnValue(true);
const { getByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -611,7 +667,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(useHasSufficientGas).mockReturnValue(false);
const { getByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -628,7 +687,10 @@ describe('SwapsConfirmButton', () => {
it('shows loading when amount changes to non-zero and quote is stale', () => {
// First render with sourceAmount='1.0' — settledAmountRef latches to '1.0'
const { queryByText, getByTestId, store } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -649,7 +711,10 @@ describe('SwapsConfirmButton', () => {
it('disables button without loading when amount changes to zero and quote is stale', () => {
// First render with sourceAmount='1.0' — settledAmountRef latches to '1.0'
const { getByText, getByTestId, store } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -671,7 +736,10 @@ describe('SwapsConfirmButton', () => {
it('is not stale when amount matches the quote', () => {
// sourceAmount='1.0' matches the mock quote's srcTokenAmount
const { getByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -694,7 +762,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByText } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -715,7 +786,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -735,7 +809,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -763,7 +840,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByText, getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -789,7 +869,10 @@ describe('SwapsConfirmButton', () => {
}));
const { queryByText } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -819,7 +902,10 @@ describe('SwapsConfirmButton', () => {
};
const { queryByText } = renderWithProvider(
- ,
+ ,
{
state: submittingState,
},
@@ -835,7 +921,10 @@ describe('SwapsConfirmButton', () => {
describe('handleContinue', () => {
it('submits transaction and navigates to transactions view', async () => {
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -895,7 +984,10 @@ describe('SwapsConfirmButton', () => {
};
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: solanaState,
},
@@ -926,7 +1018,10 @@ describe('SwapsConfirmButton', () => {
mockSubmitBridgeTx.mockRejectedValue(new Error('Network error'));
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -961,7 +1056,10 @@ describe('SwapsConfirmButton', () => {
}));
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -979,7 +1077,10 @@ describe('SwapsConfirmButton', () => {
jest.mocked(selectSourceWalletAddress).mockReturnValue(undefined);
const { getByTestId } = renderWithProvider(
- ,
+ ,
{
state: mockState,
},
@@ -993,4 +1094,231 @@ describe('SwapsConfirmButton', () => {
expect(mockSubmitBridgeTx).not.toHaveBeenCalled();
});
});
+
+ describe('handleContinue — price impact routing', () => {
+ it('navigates to PriceImpactModal when priceImpact exceeds the error threshold', async () => {
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: '30%', // 30 > PRICE_IMPACT_ERROR_THRESHOLD (25)
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
+ screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL,
+ params: {
+ type: PriceImpactModalType.Execution,
+ token: mockState.bridge?.sourceToken,
+ location: MetaMetricsSwapsEventSource.MainView,
+ },
+ });
+ });
+
+ it('does not submit the transaction when navigating to PriceImpactModal', async () => {
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: '30%',
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ expect(mockSubmitBridgeTx).not.toHaveBeenCalled();
+ });
+
+ it('navigates to PriceImpactModal when priceImpact is exactly at the threshold', async () => {
+ // 25 >= 25, so the modal IS shown and the transaction is not submitted
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: '25%',
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
+ screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL,
+ params: {
+ type: PriceImpactModalType.Execution,
+ token: mockState.bridge?.sourceToken,
+ location: MetaMetricsSwapsEventSource.MainView,
+ },
+ });
+ expect(mockSubmitBridgeTx).not.toHaveBeenCalled();
+ });
+
+ it('submits the transaction when priceImpact is below the threshold', async () => {
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: '10%',
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ await waitFor(() => {
+ expect(mockSubmitBridgeTx).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
+ });
+ });
+
+ it('submits the transaction when priceImpact is undefined', async () => {
+ // Falsy priceImpact defaults to 0, which is not > 25
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: undefined,
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ await waitFor(() => {
+ expect(mockSubmitBridgeTx).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('submits the transaction when priceImpact is not a finite number', async () => {
+ // Number.parseFloat('NaN%') → NaN → Number.isFinite(NaN) = false → skip modal
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: 'NaN%',
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ await waitFor(() => {
+ expect(mockSubmitBridgeTx).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ Routes.BRIDGE.MODALS.ROOT,
+ expect.anything(),
+ );
+ });
+ });
+
+ it('passes the location prop into the PriceImpactModal params', async () => {
+ jest
+ .mocked(useBridgeQuoteData as unknown as jest.Mock)
+ .mockImplementation(() => ({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockActiveQuote,
+ formattedQuoteData: {
+ ...mockUseBridgeQuoteData.formattedQuoteData,
+ priceImpact: '30%',
+ },
+ }));
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.BRIDGE.MODALS.ROOT,
+ expect.objectContaining({
+ params: expect.objectContaining({
+ location: MetaMetricsSwapsEventSource.MainView,
+ }),
+ }),
+ );
+ });
+ });
});
diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx
index bd9616ba848..df0c29372ea 100644
--- a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx
+++ b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx
@@ -6,44 +6,48 @@ import Button, {
} from '../../../../../component-library/components/Buttons/Button';
import { strings } from '../../../../../../locales/i18n';
import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds';
-import { useDispatch, useSelector } from 'react-redux';
+import { useSelector } from 'react-redux';
import {
selectIsSolanaSourced,
selectIsSubmittingTx,
selectSourceAmount,
selectSourceToken,
- setIsSubmittingTx,
} from '../../../../../core/redux/slices/bridge';
import useIsInsufficientBalance from '../../hooks/useInsufficientBalance';
import { useLatestBalance } from '../../hooks/useLatestBalance';
import { useHasSufficientGas } from '../../hooks/useHasSufficientGas';
import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest';
-import useSubmitBridgeTx from '../../../../../util/bridge/hooks/useSubmitBridgeTx';
import { selectSourceWalletAddress } from '../../../../../selectors/bridge';
import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController';
import { isHardwareAccount } from '../../../../../util/address';
-import { BridgeQuoteResponse } from '../../types';
-import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
-import Routes from '../../../../../constants/navigation/Routes';
import Engine from '../../../../../core/Engine';
-import { BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation';
import { calcTokenValue } from '../../../../../util/transactions';
+import { useBridgeConfirm } from '../../hooks/useBridgeConfirm';
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import Routes from '../../../../../constants/navigation/Routes';
+import { PriceImpactModalType } from '../PriceImpactModal/constants';
+import { useNavigation } from '@react-navigation/native';
+import AppConstants from '../../../../../core/AppConstants';
interface Props {
latestSourceBalance: ReturnType;
/** Optional testID override (e.g. when rendered inside keypad to avoid duplicate IDs in E2E) */
testID?: string;
+ location: MetaMetricsSwapsEventSource;
}
-export const SwapsConfirmButton = ({ latestSourceBalance, testID }: Props) => {
- const dispatch = useDispatch();
+export const SwapsConfirmButton = ({
+ latestSourceBalance,
+ testID,
+ location,
+}: Props) => {
const navigation = useNavigation();
- const route = useRoute>();
- /** The entry point location for analytics (e.g. Main View, Token View, Trending Explore) */
- const location = route.params?.location;
+ const handleConfirm = useBridgeConfirm({
+ latestSourceBalance,
+ location,
+ });
- const { submitBridgeTx } = useSubmitBridgeTx();
const updateQuoteParams = useBridgeQuoteRequest();
const sourceAmount = useSelector(selectSourceAmount);
const sourceToken = useSelector(selectSourceToken);
@@ -70,6 +74,7 @@ export const SwapsConfirmButton = ({ latestSourceBalance, testID }: Props) => {
blockaidError,
quoteFetchError,
isNoQuotesAvailable,
+ formattedQuoteData,
} = useBridgeQuoteData({
latestSourceAtomicBalance: latestSourceBalance?.atomicBalance,
});
@@ -147,27 +152,30 @@ export const SwapsConfirmButton = ({ latestSourceBalance, testID }: Props) => {
!walletAddress;
const handleContinue = async () => {
- try {
- if (activeQuote && walletAddress) {
- dispatch(setIsSubmittingTx(true));
-
- const quoteResponse: BridgeQuoteResponse = {
- ...activeQuote,
- aggregator: activeQuote.quote.bridgeId,
- walletAddress,
- };
-
- await submitBridgeTx({
- quoteResponse,
+ const priceImpact = !formattedQuoteData?.priceImpact
+ ? // Default to zero to bypass swap friction.
+ // This callback is always called when active quote exists,
+ // thus this check is not expected to be used, but we introduce
+ // it regardless as a defensive mechanism.
+ 0
+ : Number.parseFloat(formattedQuoteData.priceImpact.replace('%', ''));
+
+ if (
+ Number.isFinite(priceImpact) &&
+ priceImpact >= AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD
+ ) {
+ navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
+ screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL,
+ params: {
+ type: PriceImpactModalType.Execution,
+ token: sourceToken,
location,
- });
- }
- } catch (error) {
- console.error('Error submitting bridge tx', error);
- } finally {
- dispatch(setIsSubmittingTx(false));
- navigation.navigate(Routes.TRANSACTIONS_VIEW);
+ },
+ });
+ return;
}
+
+ await handleConfirm();
};
const handleGetNewQuote = () => {
diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts
new file mode 100644
index 00000000000..d449a326fe4
--- /dev/null
+++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts
@@ -0,0 +1,51 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { useBridgeQuoteData } from '../useBridgeQuoteData';
+import { useNavigation } from '@react-navigation/native';
+import { setIsSubmittingTx } from '../../../../../core/redux/slices/bridge';
+import Routes from '../../../../../constants/navigation/Routes';
+import { BridgeQuoteResponse } from '../../types';
+import useSubmitBridgeTx from '../../../../../util/bridge/hooks/useSubmitBridgeTx';
+import { selectSourceWalletAddress } from '../../../../../selectors/bridge';
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import { useLatestBalance } from '../useLatestBalance';
+
+interface Params {
+ location: MetaMetricsSwapsEventSource;
+ latestSourceBalance: ReturnType;
+}
+
+export const useBridgeConfirm = ({ latestSourceBalance, location }: Params) => {
+ const dispatch = useDispatch();
+ const navigation = useNavigation();
+ const { submitBridgeTx } = useSubmitBridgeTx();
+ const walletAddress = useSelector(selectSourceWalletAddress);
+ const { activeQuote } = useBridgeQuoteData({
+ latestSourceAtomicBalance: latestSourceBalance?.atomicBalance,
+ });
+
+ const handleConfirm = async () => {
+ try {
+ if (activeQuote && walletAddress) {
+ dispatch(setIsSubmittingTx(true));
+
+ const quoteResponse: BridgeQuoteResponse = {
+ ...activeQuote,
+ aggregator: activeQuote.quote.bridgeId,
+ walletAddress,
+ };
+
+ await submitBridgeTx({
+ quoteResponse,
+ location,
+ });
+ }
+ } catch (error) {
+ console.error('Error submitting bridge tx', error);
+ } finally {
+ dispatch(setIsSubmittingTx(false));
+ navigation.navigate(Routes.TRANSACTIONS_VIEW);
+ }
+ };
+
+ return handleConfirm;
+};
diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts
new file mode 100644
index 00000000000..23ee2efeea4
--- /dev/null
+++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts
@@ -0,0 +1,280 @@
+import { act, waitFor } from '@testing-library/react-native';
+import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
+import { useBridgeConfirm } from './index';
+import { useBridgeQuoteData } from '../useBridgeQuoteData';
+import { selectSourceWalletAddress } from '../../../../../selectors/bridge';
+import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller';
+import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata';
+import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock';
+import Routes from '../../../../../constants/navigation/Routes';
+import { BigNumber } from 'ethers';
+
+const WALLET_ADDRESS = '0x1234567890123456789012345678901234567890';
+
+const mockLatestSourceBalance = {
+ displayBalance: '2.0',
+ atomicBalance: BigNumber.from('2000000000000000000'),
+};
+
+const defaultParams = {
+ location: MetaMetricsSwapsEventSource.MainView,
+ latestSourceBalance: mockLatestSourceBalance,
+};
+
+// Navigation
+const mockNavigate = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+// useBridgeQuoteData
+jest.mock('../useBridgeQuoteData', () => ({
+ useBridgeQuoteData: jest.fn(),
+}));
+
+// selectSourceWalletAddress
+jest.mock('../../../../../selectors/bridge', () => ({
+ ...jest.requireActual('../../../../../selectors/bridge'),
+ selectSourceWalletAddress: jest.fn(),
+}));
+
+// useSubmitBridgeTx
+const mockSubmitBridgeTx = jest.fn();
+jest.mock('../../../../../util/bridge/hooks/useSubmitBridgeTx', () => ({
+ __esModule: true,
+ default: () => ({ submitBridgeTx: mockSubmitBridgeTx }),
+}));
+
+// Engine (required by store / other transitive deps)
+jest.mock('../../../../../core/Engine', () => ({
+ controllerMessenger: {
+ call: jest.fn(),
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+ },
+ context: {
+ KeyringController: { state: { keyrings: [] } },
+ BridgeController: { resetState: jest.fn() },
+ },
+}));
+
+jest.mock(
+ '../../../../../multichain-accounts/controllers/account-tree-controller',
+ () => ({
+ accountTreeControllerInit: jest.fn(() => ({
+ controller: { state: { accountTree: { wallets: {} } } },
+ })),
+ }),
+);
+
+jest.mock('../../../../../selectors/confirmTransaction');
+
+function renderHook(
+ params: Parameters[0] = defaultParams,
+) {
+ return renderHookWithProvider(() => useBridgeConfirm(params), { state: {} });
+}
+
+describe('useBridgeConfirm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ activeQuote: mockQuoteWithMetadata,
+ } as ReturnType);
+ jest.mocked(selectSourceWalletAddress).mockReturnValue(WALLET_ADDRESS);
+ mockSubmitBridgeTx.mockResolvedValue({ success: true });
+ });
+
+ it('returns a function', () => {
+ const { result } = renderHook();
+
+ expect(typeof result.current).toBe('function');
+ });
+
+ describe('successful submission', () => {
+ it('calls submitBridgeTx with the correct quoteResponse and location', async () => {
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSubmitBridgeTx).toHaveBeenCalledWith({
+ quoteResponse: {
+ ...mockQuoteWithMetadata,
+ aggregator: mockQuoteWithMetadata.quote.bridgeId,
+ walletAddress: WALLET_ADDRESS,
+ },
+ location: MetaMetricsSwapsEventSource.MainView,
+ });
+ });
+
+ it('passes the location prop through to submitBridgeTx', async () => {
+ const { result } = renderHook({
+ ...defaultParams,
+ location: MetaMetricsSwapsEventSource.MainView,
+ });
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSubmitBridgeTx).toHaveBeenCalledWith(
+ expect.objectContaining({
+ location: MetaMetricsSwapsEventSource.MainView,
+ }),
+ );
+ });
+
+ it('navigates to TRANSACTIONS_VIEW after submission', async () => {
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
+ });
+
+ it('resets isSubmittingTx to false after submission', async () => {
+ const { result, store } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ await waitFor(() => {
+ expect(
+ (store.getState() as { bridge: { isSubmittingTx: boolean } }).bridge
+ .isSubmittingTx,
+ ).toBe(false);
+ });
+ });
+
+ it('passes latestSourceAtomicBalance to useBridgeQuoteData', () => {
+ renderHook();
+
+ expect(jest.mocked(useBridgeQuoteData)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ latestSourceAtomicBalance: mockLatestSourceBalance.atomicBalance,
+ }),
+ );
+ });
+
+ it('passes undefined atomicBalance when latestSourceBalance is undefined', () => {
+ renderHook({ ...defaultParams, latestSourceBalance: undefined });
+
+ expect(jest.mocked(useBridgeQuoteData)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ latestSourceAtomicBalance: undefined,
+ }),
+ );
+ });
+ });
+
+ describe('when activeQuote is null', () => {
+ beforeEach(() => {
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ activeQuote: null,
+ } as ReturnType);
+ });
+
+ it('does not call submitBridgeTx', async () => {
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSubmitBridgeTx).not.toHaveBeenCalled();
+ });
+
+ it('still navigates to TRANSACTIONS_VIEW', async () => {
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
+ });
+ });
+
+ describe('when walletAddress is missing', () => {
+ beforeEach(() => {
+ jest.mocked(selectSourceWalletAddress).mockReturnValue(undefined);
+ });
+
+ it('does not call submitBridgeTx', async () => {
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSubmitBridgeTx).not.toHaveBeenCalled();
+ });
+
+ it('still navigates to TRANSACTIONS_VIEW', async () => {
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
+ });
+ });
+
+ describe('when submitBridgeTx throws', () => {
+ beforeEach(() => {
+ mockSubmitBridgeTx.mockRejectedValue(new Error('Network error'));
+ });
+
+ it('logs the error', async () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Error submitting bridge tx',
+ expect.any(Error),
+ );
+
+ consoleSpy.mockRestore();
+ });
+
+ it('still navigates to TRANSACTIONS_VIEW after the error', async () => {
+ jest.spyOn(console, 'error').mockImplementation();
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
+ });
+
+ it('resets isSubmittingTx to false after the error', async () => {
+ jest.spyOn(console, 'error').mockImplementation();
+ const { result, store } = renderHook();
+
+ await act(async () => {
+ await result.current();
+ });
+
+ await waitFor(() => {
+ expect(
+ (store.getState() as { bridge: { isSubmittingTx: boolean } }).bridge
+ .isSubmittingTx,
+ ).toBe(false);
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts
new file mode 100644
index 00000000000..271db4145ca
--- /dev/null
+++ b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts
@@ -0,0 +1,137 @@
+import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
+import { useModalCloseOnQuoteExpiry } from './index';
+import { useBridgeQuoteData } from '../useBridgeQuoteData';
+import Routes from '../../../../../constants/navigation/Routes';
+import { CommonActions } from '@react-navigation/native';
+
+jest.mock('../useBridgeQuoteData', () => ({
+ useBridgeQuoteData: jest.fn(),
+}));
+
+const mockDispatch = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ dispatch: mockDispatch,
+ }),
+}));
+
+const mockUseBridgeQuoteData = {
+ isExpired: false,
+ willRefresh: false,
+};
+
+describe('useModalCloseOnQuoteExpiry', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .mocked(useBridgeQuoteData)
+ .mockReturnValue(
+ mockUseBridgeQuoteData as ReturnType,
+ );
+ });
+
+ it('dispatches a reset to QuoteExpiredModal when quote is expired and will not refresh', () => {
+ // Arrange
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ isExpired: true,
+ willRefresh: false,
+ } as ReturnType);
+
+ // Act
+ renderHookWithProvider(() => useModalCloseOnQuoteExpiry());
+
+ // Assert
+ expect(mockDispatch).toHaveBeenCalledWith(
+ CommonActions.reset({
+ index: 0,
+ routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }],
+ }),
+ );
+ });
+
+ it('does not dispatch when quote is not expired', () => {
+ // Arrange
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ isExpired: false,
+ willRefresh: false,
+ } as ReturnType);
+
+ // Act
+ renderHookWithProvider(() => useModalCloseOnQuoteExpiry());
+
+ // Assert
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it('does not dispatch when quote is expired but will refresh', () => {
+ // Arrange
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ isExpired: true,
+ willRefresh: true,
+ } as ReturnType);
+
+ // Act
+ renderHookWithProvider(() => useModalCloseOnQuoteExpiry());
+
+ // Assert
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it('dispatches again when quote transitions from not-expired to expired', () => {
+ // Arrange – start with not expired
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ isExpired: false,
+ willRefresh: false,
+ } as ReturnType);
+
+ const { rerender } = renderHookWithProvider(() =>
+ useModalCloseOnQuoteExpiry(),
+ );
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+
+ // Quote expires
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ isExpired: true,
+ willRefresh: false,
+ } as ReturnType);
+
+ // Act
+ rerender({});
+
+ // Assert
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(
+ CommonActions.reset({
+ index: 0,
+ routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }],
+ }),
+ );
+ });
+
+ it('dispatches reset with index 0 so QuoteExpiredModal is the only route', () => {
+ // Arrange
+ jest.mocked(useBridgeQuoteData).mockReturnValue({
+ ...mockUseBridgeQuoteData,
+ isExpired: true,
+ willRefresh: false,
+ } as ReturnType);
+
+ // Act
+ renderHookWithProvider(() => useModalCloseOnQuoteExpiry());
+
+ // Assert
+ const dispatchedAction = mockDispatch.mock.calls[0][0];
+ expect(dispatchedAction.payload.index).toBe(0);
+ expect(dispatchedAction.payload.routes).toHaveLength(1);
+ expect(dispatchedAction.payload.routes[0].name).toBe(
+ Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL,
+ );
+ });
+});
diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts
new file mode 100644
index 00000000000..a5f4b7394c9
--- /dev/null
+++ b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts
@@ -0,0 +1,28 @@
+import { useEffect } from 'react';
+import { CommonActions, useNavigation } from '@react-navigation/native';
+import { useBridgeQuoteData } from '../useBridgeQuoteData';
+import Routes from '../../../../../constants/navigation/Routes';
+
+/**
+ * Resets the BridgeModalStack to show only QuoteExpiredModal when quotes expire.
+ *
+ * Must be called from a screen that lives inside BridgeModalStack so that
+ * CommonActions.reset targets BridgeModalStack (not the root navigator).
+ * This prevents the previous modal's BottomSheetOverlay from remaining
+ * visible behind QuoteExpiredModal.
+ */
+export const useModalCloseOnQuoteExpiry = () => {
+ const navigation = useNavigation();
+ const { isExpired, willRefresh } = useBridgeQuoteData();
+
+ useEffect(() => {
+ if (isExpired && !willRefresh) {
+ navigation.dispatch(
+ CommonActions.reset({
+ index: 0,
+ routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }],
+ }),
+ );
+ }
+ }, [isExpired, willRefresh, navigation]);
+};
diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx
index e23b2756d9d..5a6daffdd06 100644
--- a/app/components/UI/Bridge/routes.tsx
+++ b/app/components/UI/Bridge/routes.tsx
@@ -11,6 +11,7 @@ import MarketClosedBottomSheet from './components/MarketClosedBottomSheets/Marke
import { DefaultSlippageModal } from './components/SlippageModal/DefaultSlippageModal';
import { CustomSlippageModal } from './components/SlippageModal/CustomSlippageModal';
import NetworkListModal from './components/BridgeTokenSelector/NetworkListModal';
+import { PriceImpactModal } from './components/PriceImpactModal';
const clearStackNavigatorOptions = {
headerShown: false,
@@ -79,5 +80,9 @@ export const BridgeModalStack = () => (
name={Routes.BRIDGE.MODALS.NETWORK_LIST_MODAL}
component={NetworkListModal}
/>
+
);
diff --git a/app/components/UI/Bridge/utils/formatPriceImpact.test.ts b/app/components/UI/Bridge/utils/formatPriceImpact.test.ts
new file mode 100644
index 00000000000..220400deaa0
--- /dev/null
+++ b/app/components/UI/Bridge/utils/formatPriceImpact.test.ts
@@ -0,0 +1,43 @@
+import { formatPriceImpact } from './formatPriceImpact';
+
+describe('formatPriceImpact', () => {
+ it('returns "0%" when called with undefined', () => {
+ expect(formatPriceImpact(undefined)).toBe('0%');
+ });
+
+ it('returns "0%" when called with an empty string', () => {
+ expect(formatPriceImpact('')).toBe('0%');
+ });
+
+ it('returns "0%" when value is zero', () => {
+ expect(formatPriceImpact('0%')).toBe('0%');
+ });
+
+ it('returns "0%" when value is negative', () => {
+ expect(formatPriceImpact('-1.5%')).toBe('0%');
+ });
+
+ it('returns "0%" for a negative value without percent sign', () => {
+ expect(formatPriceImpact('-3')).toBe('0%');
+ });
+
+ it('returns "0%" for a non-numeric string', () => {
+ expect(formatPriceImpact('abc')).toBe('0%');
+ });
+
+ it('appends "%" when given a positive numeric string without percent sign', () => {
+ expect(formatPriceImpact('2.5')).toBe('2.5%');
+ });
+
+ it('preserves the value and appends "%" when given a positive value with percent sign', () => {
+ expect(formatPriceImpact('3.14%')).toBe('3.14%');
+ });
+
+ it('handles whole number positive values', () => {
+ expect(formatPriceImpact('5%')).toBe('5%');
+ });
+
+ it('handles very small positive values', () => {
+ expect(formatPriceImpact('0.01%')).toBe('0.01%');
+ });
+});
diff --git a/app/components/UI/Bridge/utils/formatPriceImpact.ts b/app/components/UI/Bridge/utils/formatPriceImpact.ts
new file mode 100644
index 00000000000..f4f7b0dcafe
--- /dev/null
+++ b/app/components/UI/Bridge/utils/formatPriceImpact.ts
@@ -0,0 +1,13 @@
+export const formatPriceImpact = (priceImpact?: string) => {
+ if (!priceImpact) {
+ return '0%';
+ }
+
+ const value = Number.parseFloat(priceImpact.replace('%', ''));
+
+ if (!Number.isFinite(value)) {
+ return '0%';
+ }
+
+ return value < 0 ? '0%' : value + '%';
+};
diff --git a/app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts b/app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts
new file mode 100644
index 00000000000..b1d39c54bee
--- /dev/null
+++ b/app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts
@@ -0,0 +1,44 @@
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { TextColor } from '../../../../component-library/components/Texts/Text';
+import { getPriceImpactViewData } from './getPriceImpactViewData';
+
+describe('getPriceImpactViewData', () => {
+ it.each([
+ { priceImpact: undefined },
+ { priceImpact: '-0.06%' },
+ { priceImpact: '4.99%' },
+ { priceImpact: 'invalid' },
+ ])(
+ 'returns alternative text color and no icon for $priceImpact',
+ ({ priceImpact }) => {
+ expect(getPriceImpactViewData(priceImpact)).toEqual({
+ textColor: TextColor.Alternative,
+ icon: undefined,
+ });
+ },
+ );
+
+ it.each([
+ { priceImpact: '5.00%' },
+ { priceImpact: '5.01%' },
+ { priceImpact: '24.99%' },
+ ])(
+ 'returns warning text color and warning icon for $priceImpact',
+ ({ priceImpact }) => {
+ expect(getPriceImpactViewData(priceImpact)).toEqual({
+ textColor: TextColor.Warning,
+ icon: { name: IconName.Warning, color: TextColor.Warning },
+ });
+ },
+ );
+
+ it.each([{ priceImpact: '25.00%' }, { priceImpact: '25.01%' }])(
+ 'returns error text color and danger icon for $priceImpact',
+ ({ priceImpact }) => {
+ expect(getPriceImpactViewData(priceImpact)).toEqual({
+ textColor: TextColor.Error,
+ icon: { name: IconName.Danger, color: TextColor.Error },
+ });
+ },
+ );
+});
diff --git a/app/components/UI/Bridge/utils/getPriceImpactViewData.ts b/app/components/UI/Bridge/utils/getPriceImpactViewData.ts
new file mode 100644
index 00000000000..30f1214065b
--- /dev/null
+++ b/app/components/UI/Bridge/utils/getPriceImpactViewData.ts
@@ -0,0 +1,46 @@
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { TextColor } from '../../../../component-library/components/Texts/Text';
+import AppConstants from '../../../../core/AppConstants';
+
+export const getPriceImpactViewData = (priceImpactValue?: string) => {
+ if (!priceImpactValue) {
+ return {
+ textColor: TextColor.Alternative,
+ icon: undefined,
+ };
+ }
+
+ const priceImpact = Number.parseFloat(priceImpactValue.replace('%', ''));
+
+ if (!Number.isFinite(priceImpact)) {
+ return {
+ textColor: TextColor.Alternative,
+ icon: undefined,
+ };
+ }
+
+ if (priceImpact >= AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD) {
+ return {
+ textColor: TextColor.Error,
+ icon: {
+ name: IconName.Danger,
+ color: TextColor.Error,
+ },
+ };
+ }
+
+ if (priceImpact >= AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD) {
+ return {
+ textColor: TextColor.Warning,
+ icon: {
+ name: IconName.Warning,
+ color: TextColor.Warning,
+ },
+ };
+ }
+
+ return {
+ textColor: TextColor.Alternative,
+ icon: undefined,
+ };
+};
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 1cb0823d519..4421af0948d 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -283,6 +283,7 @@ const Routes = {
RECIPIENT_SELECTOR_MODAL: 'RecipientSelectorModal',
MARKET_CLOSED_MODAL: 'MarketClosedModal',
NETWORK_LIST_MODAL: 'NetworkListModal',
+ PRICE_IMPACT_MODAL: 'PriceImpactModal',
},
BRIDGE_TRANSACTION_DETAILS: 'BridgeTransactionDetails',
},
diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts
index 00f25c183e8..1a77bae5ef9 100644
--- a/app/core/AppConstants.ts
+++ b/app/core/AppConstants.ts
@@ -33,6 +33,8 @@ export default {
BRIDGE: {
ACTIVE: true,
URL: `${PORTFOLIO_URL}/bridge`,
+ PRICE_IMPACT_WARNING_THRESHOLD: 5,
+ PRICE_IMPACT_ERROR_THRESHOLD: 25,
// Check app/components/UI/Bridge/types.ts
// for interface definition.
SLIPPAGE_CONFIG: {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 500950ec214..1ef43c2e1fa 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -6573,6 +6573,11 @@
"price_impact_info_title": "Price impact",
"price_impact_info_description": "Price impact reflects how your swap order affects the market price of the asset. It depends on the trade size and the available liquidity in the pool. MetaMask does not influence or control price impact.",
"price_impact_info_gasless_description": "Price impact reflects how your swap order affects the market price of the asset. If you don't hold enough funds for gas, part of your source token is automatically allocated to cover fees, which increases price impact. MetaMask does not influence or control price impact.",
+ "price_impact_warning_description": "This trade has an estimated {{priceImpact}} price impact, which reflects how much your trade changes the market price. The quote already reflects this.",
+ "price_impact_high": "High price impact",
+ "price_impact_execution_description": "You'll lose approximately {{priceImpact}} of your token's value on this swap. Try lowering the amount or choosing a more liquid route.",
+ "proceed": "Proceed",
+ "cancel": "Cancel",
"slippage_info_title": "Slippage",
"slippage_info_description": "The % change in price you're willing to allow before your transaction is canceled.",
"blockaid_error_title": "This transaction will be reverted",
@@ -6592,14 +6597,14 @@
},
"submit": "Submit",
"default_slippage_description": "Your transaction won't go through if the price changes more than the slippage percent.",
- "cancel": "Cancel",
"confirm": "Confirm",
"exceeding_upper_slippage_warning": "High slippage, this may result in a unfavourable swap",
"exceeding_lower_slippage_warning": "Low slippage, this may result in a unfavourable swap",
"exceeding_lower_slippage_error": "Enter a value greater than {{value}}%",
"exceeding_upper_slippage_error": "You cannot enter a value greater than {{value}}%",
"custom": "Custom",
- "invalid_recipient_address": "Invalid address"
+ "invalid_recipient_address": "Invalid address",
+ "got_it": "Got it"
},
"quote_expired_modal": {
"title": "New quotes are available",
From a2902143bea51e7e3254557f83e5dba94942f310 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patryk=20=C5=81ucka?=
<5708018+PatrykLucka@users.noreply.github.com>
Date: Fri, 6 Mar 2026 12:30:02 +0100
Subject: [PATCH 9/9] refactor: PerpsSection performance improvements (#26973)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
# Perpetuals section – performance and stability
Performance audit follow-up: fewer re-renders, no redundant
subscriptions on the homepage, and safer selectors/hooks.
## Homepage Perps
- **Carousel: static data only** – Removed live price subscription from
`PerpsMarketTileCard`. Tiles use the market snapshot from
`usePerpsMarkets()` (price, change24hPercent). No WebSocket per symbol
on the homepage; fewer subscriptions and re-renders.
- **Tile card** – Dropped `livePrices` / `disableLivePrices`; component
always uses static market data. Removed `TileCardWithLivePrices` and
`usePerpsLivePrices` usage there.
- **Position rows** – `PositionCardItem` with a `positionDisplayKey`
(symbol, entryPrice, size, unrealizedPnl, takeProfitPrice,
stopLossPrice) and custom `React.memo` compare so only cards whose
display data changed re-render on stream updates.
- **Defensive defaults** – `?? []` for watchlist/carousel arrays and
`carouselSymbols` so selectors or partial state (e.g. E2E/minimal
fixtures) never pass `undefined` into hooks or `.map()`.
- **Sparklines** – `useHomepageSparklines`: guard `candleData?.candles`
(fixes E2E crash when `candles` is undefined), `safeSymbols` so
`symbols` is never undefined, and microtask batching so multiple symbol
callbacks trigger one state update.
## Perps selectors & components
- **perpsController selectors** – Try/catch and defaults when state is
missing or partial (e.g. before Engine init or in E2E). Avoids calling
package selectors with `undefined` and normalizes return values (`?? []`
/ default prefs).
- **PerpsCard / PerpsPositionCard** – Wrapped with `React.memo` to avoid
unnecessary re-renders when parent updates.
- **usePerpsMarketListView** – `savedSortPreference.optionId` cast to
`SortOptionId` for type safety.
## Tests
- **PerpsSection** – Tests for `positionDisplayKey` (stable key,
optional fields, TP/SL, same key when only non-display fields differ).
- **PerpsMarketTileCard** – Removed live-price mock and related test;
added “displays market change24hPercent” for static data.
- **useHomepageSparklines** – Async `act` where needed for
microtask-flushed updates.
- **Homepage** – `useFocusEffect` mock simplified (invoke callback
once).
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-512
## **Manual testing steps**
```gherkin
Feature: Perpetuals section performance and data freshness
Scenario: Homepage carousel shows static data and does not re-render on a timer
Given the user is on the homepage with no open perps positions/orders
When the trending perps carousel is visible
Then the section title and carousel tiles show market data (symbol, price, 24h change)
And the section does not re-render every few seconds (observe in React DevTools or logs)
And navigating away and back to the homepage refreshes carousel data
Scenario: Homepage positions list re-renders only when position data changes
Given the user has open perps positions and is on the homepage
When the positions stream emits updates (e.g. every 5s)
Then the section title does not flicker or re-render
And only position cards whose data actually changed re-render
Scenario: Pull-to-refresh updates markets and sparklines
Given the user is on the homepage with the Perps section visible
When the user triggers the section refresh (e.g. pull-to-refresh if wired)
Then market data and sparklines are refetched
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Moderate risk because it changes rendering/memoization behavior and
introduces a module-level TTL cache that could cause stale data or
missed UI updates if the cache keys/comparators are wrong.
>
> **Overview**
> **Perps homepage performance improvements.** Position/order rows now
avoid unnecessary re-renders via `React.memo` (including a new
`positionDisplayKey` comparator) and null-safe carousel list handling;
`PerpsCard`/`PerpsPositionCard` are also exported as memoized
components.
>
> **Trending carousel simplification.** `PerpsMarketTileCard` no longer
subscribes to live prices and drops the `disableLivePrices` prop; it
always renders from the passed market snapshot, and tests are updated
accordingly.
>
> **Fewer update storms.** `useHomepageSparklines` batches per-symbol
candle callbacks into a single microtask-flushed state update, and
related tests are adjusted.
>
> **Stability + networking.** Perps Redux selectors now defensively
default/guard against missing controller state, and `useRampTokens` adds
a 5-minute, module-level cache that deduplicates identical requests
(including in-flight), with comprehensive cache behavior tests.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
49cecc38c0775c9e9ad2ed1ef667ab8b807b2c99. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../Perps/components/PerpsCard/PerpsCard.tsx | 2 +-
.../PerpsPositionCard/PerpsPositionCard.tsx | 2 +-
.../UI/Perps/hooks/usePerpsMarketListView.ts | 2 +-
.../Perps/selectors/perpsController/index.ts | 54 +++++++++++++--
.../Sections/Perpetuals/PerpsSection.test.tsx | 69 ++++++++++++++++++-
.../Sections/Perpetuals/PerpsSection.tsx | 51 +++++++++++---
.../PerpsMarketTileCard.test.tsx | 30 +-------
.../PerpsMarketTileCard.tsx | 65 +++--------------
.../PerpsMarketTileCard.types.ts | 2 -
.../hooks/useHomepageSparklines.test.ts | 17 +++--
.../Perpetuals/hooks/useHomepageSparklines.ts | 26 +++++--
11 files changed, 204 insertions(+), 116 deletions(-)
diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx
index dd9010402ee..2fd709dc42e 100644
--- a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx
+++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx
@@ -206,4 +206,4 @@ const PerpsCard: React.FC = ({
);
};
-export default PerpsCard;
+export default React.memo(PerpsCard);
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
index 33856867418..8ce1d84f2e0 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
@@ -612,4 +612,4 @@ const PerpsPositionCard: React.FC = ({
);
};
-export default PerpsPositionCard;
+export default React.memo(PerpsPositionCard);
diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts
index f82653d2243..6953520563c 100644
--- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts
+++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts
@@ -198,7 +198,7 @@ export const usePerpsMarketListView = ({
// Use sorting hook for sort state and sorting logic
const sortingHook = usePerpsSorting({
- initialOptionId: savedSortPreference.optionId,
+ initialOptionId: savedSortPreference.optionId as SortOptionId,
initialDirection: savedSortPreference.direction,
});
diff --git a/app/components/UI/Perps/selectors/perpsController/index.ts b/app/components/UI/Perps/selectors/perpsController/index.ts
index 7939229f090..2634f317814 100644
--- a/app/components/UI/Perps/selectors/perpsController/index.ts
+++ b/app/components/UI/Perps/selectors/perpsController/index.ts
@@ -56,19 +56,55 @@ const selectPerpsBalances = createSelector(
(perpsControllerState) => perpsControllerState?.perpsBalances || {},
);
+const DEFAULT_MARKET_FILTER_PREFERENCES = {
+ optionId: 'volume',
+ direction: 'desc' as const,
+};
+
+// When PerpsController state is missing or partial (e.g. before Engine init, rehydration, or minimal E2E fixtures),
+// avoid calling perps-controller selectors with undefined (they may access .length etc. on nested props).
+// Normalize return values (?? []) so we're safe even when the package returns undefined for partial state.
const selectIsFirstTimePerpsUser = createSelector(
selectPerpsControllerState,
- (perpsControllerState) => selectIsFirstTimeUser(perpsControllerState),
+ (perpsControllerState) => {
+ try {
+ return perpsControllerState
+ ? selectIsFirstTimeUser(perpsControllerState)
+ : true;
+ } catch {
+ return true;
+ }
+ },
);
const selectPerpsWatchlistMarkets = createSelector(
selectPerpsControllerState,
- (perpsControllerState) => selectWatchlistMarkets(perpsControllerState),
+ (perpsControllerState) => {
+ try {
+ return (
+ (perpsControllerState
+ ? selectWatchlistMarkets(perpsControllerState)
+ : undefined) ?? []
+ );
+ } catch {
+ return [];
+ }
+ },
);
const selectPerpsMarketFilterPreferences = createSelector(
selectPerpsControllerState,
- (perpsControllerState) => selectMarketFilterPreferences(perpsControllerState),
+ (perpsControllerState) => {
+ try {
+ return (
+ (perpsControllerState
+ ? selectMarketFilterPreferences(perpsControllerState)
+ : undefined) ?? DEFAULT_MARKET_FILTER_PREFERENCES
+ );
+ } catch {
+ return DEFAULT_MARKET_FILTER_PREFERENCES;
+ }
+ },
);
/**
@@ -102,9 +138,15 @@ const selectPerpsInitializationState = createSelector(
// Factory function to create selector for specific market
export const createSelectIsWatchlistMarket = (symbol: string) =>
- createSelector(selectPerpsControllerState, (perpsControllerState) =>
- selectIsWatchlistMarket(perpsControllerState, symbol),
- );
+ createSelector(selectPerpsControllerState, (perpsControllerState) => {
+ try {
+ return perpsControllerState
+ ? selectIsWatchlistMarket(perpsControllerState, symbol)
+ : false;
+ } catch {
+ return false;
+ }
+ });
export {
selectPerpsProvider,
diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx
index 9c5ee0a1197..4d4fd194854 100644
--- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx
+++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { screen, fireEvent, act } from '@testing-library/react-native';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import PerpsSection from './PerpsSection';
+import PerpsSection, { positionDisplayKey } from './PerpsSection';
import Routes from '../../../../../constants/navigation/Routes';
import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events';
import {
@@ -241,6 +241,73 @@ jest.mock('../../hooks/useHomeViewedEvent', () => ({
},
}));
+describe('positionDisplayKey', () => {
+ it('returns stable key from position display fields', () => {
+ const position = makePosition({
+ symbol: 'BTC',
+ entryPrice: '98500',
+ size: '-0.0015',
+ unrealizedPnl: '9.4',
+ takeProfitPrice: undefined,
+ stopLossPrice: undefined,
+ }) as Parameters[0];
+ expect(positionDisplayKey(position)).toBe('BTC:98500:-0.0015:9.4::');
+ });
+
+ it('uses empty string for undefined optional fields', () => {
+ const position = makePosition({
+ symbol: 'ETH',
+ entryPrice: undefined,
+ size: '1',
+ unrealizedPnl: undefined,
+ takeProfitPrice: undefined,
+ stopLossPrice: undefined,
+ }) as Parameters[0];
+ expect(positionDisplayKey(position)).toBe('ETH::1:::');
+ });
+
+ it('includes takeProfitPrice and stopLossPrice when set', () => {
+ const position = makePosition({
+ symbol: 'SOL',
+ entryPrice: '180',
+ size: '10',
+ unrealizedPnl: '-5',
+ takeProfitPrice: '200',
+ stopLossPrice: '160',
+ }) as Parameters[0];
+ expect(positionDisplayKey(position)).toBe('SOL:180:10:-5:200:160');
+ });
+
+ it('returns different keys when display-relevant fields differ', () => {
+ const base = makePosition({ symbol: 'BTC' }) as Parameters<
+ typeof positionDisplayKey
+ >[0];
+ const withPnl = makePosition({
+ symbol: 'BTC',
+ unrealizedPnl: '100',
+ }) as Parameters[0];
+ expect(positionDisplayKey(base)).not.toBe(positionDisplayKey(withPnl));
+ });
+
+ it('returns same key when only non-display fields differ', () => {
+ const a = makePosition({
+ symbol: 'BTC',
+ entryPrice: '50000',
+ size: '1',
+ unrealizedPnl: '100',
+ positionValue: '50000',
+ }) as Parameters[0];
+ const b = makePosition({
+ symbol: 'BTC',
+ entryPrice: '50000',
+ size: '1',
+ unrealizedPnl: '100',
+ positionValue: '99999',
+ }) as Parameters[0];
+ expect(positionDisplayKey(a)).toBe(positionDisplayKey(b));
+ });
+});
+
describe('PerpsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx
index 680f000973c..fad25d38c2d 100644
--- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx
+++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx
@@ -52,6 +52,41 @@ const MAX_ITEMS = 5;
const MAX_TRENDING_MARKETS = 5;
const HOMEPAGE_THROTTLE_MS = 5000;
+/** Key fields that affect position card display; skip re-render if unchanged. Exported for testing. */
+export function positionDisplayKey(p: Position): string {
+ return `${p.symbol}:${p.entryPrice ?? ''}:${p.size ?? ''}:${p.unrealizedPnl ?? ''}:${p.takeProfitPrice ?? ''}:${p.stopLossPrice ?? ''}`;
+}
+
+/**
+ * Memoized row so only the position card whose data changed re-renders on stream updates.
+ */
+const PositionCardItem = React.memo<{
+ position: Position;
+ tpSlLoading: boolean;
+ onPositionPress: (position: Position) => void;
+}>(
+ ({ position, tpSlLoading, onPositionPress }) => {
+ const handlePress = useCallback(
+ () => onPositionPress(position),
+ [onPositionPress, position],
+ );
+ return (
+
+ );
+ },
+ (prev, next) =>
+ prev.tpSlLoading === next.tpSlLoading &&
+ prev.onPositionPress === next.onPositionPress &&
+ positionDisplayKey(prev.position) === positionDisplayKey(next.position),
+);
+
/**
* PerpsSection — single "Perpetuals" section on the homepage.
*
@@ -172,7 +207,7 @@ const PerpsSection = forwardRef(
const allCarouselMarkets = useMemo(
() =>
- [...watchlistMarkets, ...trendingMarkets].slice(
+ [...(watchlistMarkets ?? []), ...(trendingMarkets ?? [])].slice(
0,
MAX_TRENDING_MARKETS,
),
@@ -180,12 +215,13 @@ const PerpsSection = forwardRef(
);
const watchlistSymbolSet = useMemo(
- () => new Set(watchlistMarkets.map((m) => m.symbol)),
+ () => new Set((watchlistMarkets ?? []).map((m) => m.symbol)),
[watchlistMarkets],
);
const carouselSymbols = useMemo(
- () => (showTrending ? allCarouselMarkets.map((m) => m.symbol) : []),
+ () =>
+ showTrending ? (allCarouselMarkets ?? []).map((m) => m.symbol) : [],
[showTrending, allCarouselMarkets],
);
const { sparklines, refresh: refreshSparklines } =
@@ -298,14 +334,11 @@ const PerpsSection = forwardRef(
{displayPositions.map((position) => (
- handlePositionPress(position)}
- testID={`perps-position-row-${position.symbol}`}
+ onPositionPress={handlePositionPress}
/>
))}
{displayOrders.map((order) => (
@@ -327,7 +360,7 @@ const PerpsSection = forwardRef(
testID="homepage-trending-perps-carousel"
{...scrollProps}
>
- {allCarouselMarkets.map((market) => (
+ {(allCarouselMarkets ?? []).map((market) => (
{
};
});
-jest.mock('../../../../../../UI/Perps/hooks/stream', () => ({
- usePerpsLivePrices: jest.fn(() => ({})),
-}));
-
-const { usePerpsLivePrices } = jest.requireMock(
- '../../../../../../UI/Perps/hooks/stream',
-);
-const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction<
- typeof usePerpsLivePrices
->;
-
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(() => false),
@@ -120,7 +109,6 @@ describe('PerpsMarketTileCard', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUsePerpsLivePrices.mockReturnValue({});
});
it('renders market symbol and leverage', () => {
@@ -188,23 +176,7 @@ describe('PerpsMarketTileCard', () => {
expect(mockOnPress).toHaveBeenCalledWith(mockMarketData);
});
- it('uses live percentage change when available', () => {
- mockUsePerpsLivePrices.mockReturnValue({
- BTC: {
- price: '55000',
- percentChange24h: '5.50',
- volume24h: 3000000000,
- },
- });
-
- render();
-
- expect(screen.getByText('+5.50%')).toBeOnTheScreen();
- });
-
- it('falls back to market data when no live prices', () => {
- mockUsePerpsLivePrices.mockReturnValue({});
-
+ it('displays market change24hPercent', () => {
render();
expect(screen.getByText('+4.00%')).toBeOnTheScreen();
diff --git a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx
index 53eb5d43b2e..f600bd69166 100644
--- a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx
+++ b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx
@@ -11,12 +11,7 @@ import {
IconColor,
} from '@metamask/design-system-react-native';
import { useStyles } from '../../../../../../hooks/useStyles';
-import {
- getPerpsDisplaySymbol,
- type PriceUpdate,
-} from '@metamask/perps-controller';
-import { usePerpsLivePrices } from '../../../../../../UI/Perps/hooks/stream';
-import { formatPercentage } from '../../../../../../UI/Perps/utils/formatUtils';
+import { getPerpsDisplaySymbol } from '@metamask/perps-controller';
import PerpsLeverage from '../../../../../../UI/Perps/components/PerpsLeverage/PerpsLeverage';
import PerpsTokenLogo from '../../../../../../UI/Perps/components/PerpsTokenLogo';
import SparklineChart from '../SparklineChart';
@@ -29,42 +24,29 @@ const SPARKLINE_HEIGHT = 80;
const SPARKLINE_STROKE_WIDTH = 2;
const TOKEN_LOGO_SIZE = 40;
const SHIMMER_PULSE_DURATION = 900;
-const LIVE_PRICES_THROTTLE_MS = 3000;
-
-const EMPTY_PRICES: Record = {};
/**
- * Inner tile card that accepts pre-resolved live prices.
- * Extracted so it doesn't depend on stream context.
+ * PerpsMarketTileCard — compact card for horizontal carousels.
+ * Uses static market data only (no live price subscription).
*/
-const TileCardInner: React.FC<
- PerpsMarketTileCardProps & { livePrices: Record }
-> = ({
+const PerpsMarketTileCard: React.FC = ({
market,
sparklineData,
onPress,
cardWidth = DEFAULT_CARD_WIDTH,
cardHeight = DEFAULT_CARD_HEIGHT,
- livePrices,
showFavoriteTag = false,
testID = 'perps-market-tile-card',
}) => {
const { styles, theme } = useStyles(styleSheet, { cardWidth, cardHeight });
- const { changePercent, isPositive } = useMemo(() => {
- const livePrice = livePrices[market.symbol];
-
- let percent = market.change24hPercent;
- if (livePrice?.percentChange24h) {
- const changeVal = parseFloat(livePrice.percentChange24h);
- percent = formatPercentage(changeVal);
- }
-
- return {
- changePercent: percent,
- isPositive: !percent.startsWith('-'),
- };
- }, [market, livePrices]);
+ const { changePercent, isPositive } = useMemo(
+ () => ({
+ changePercent: market.change24hPercent,
+ isPositive: !market.change24hPercent.startsWith('-'),
+ }),
+ [market.change24hPercent],
+ );
const sparklineColor = isPositive
? theme.colors.success.default
@@ -197,29 +179,4 @@ const TileCardInner: React.FC<
);
};
-/**
- * Wrapper that subscribes to live prices via the stream provider.
- * Only used when disableLivePrices is false (default).
- */
-const TileCardWithLivePrices: React.FC = (props) => {
- const livePrices = usePerpsLivePrices({
- symbols: [props.market.symbol],
- throttleMs: LIVE_PRICES_THROTTLE_MS,
- });
- return ;
-};
-
-/**
- * PerpsMarketTileCard — compact card for horizontal carousels.
- *
- * When disableLivePrices is true, uses static market data and skips the
- * WebSocket stream subscription (safe to use outside PerpsStreamProvider).
- */
-const PerpsMarketTileCard: React.FC = (props) => {
- if (props.disableLivePrices) {
- return ;
- }
- return ;
-};
-
export default React.memo(PerpsMarketTileCard);
diff --git a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts
index 6939a678688..e29b59f230c 100644
--- a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts
+++ b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts
@@ -11,8 +11,6 @@ export interface PerpsMarketTileCardProps {
cardWidth?: number;
/** Card height in pixels (default: 180) */
cardHeight?: number;
- /** Skip live price WebSocket subscription (use static market data instead) */
- disableLivePrices?: boolean;
/** Show a "Favorite" tag */
showFavoriteTag?: boolean;
/** Test ID for E2E testing */
diff --git a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts
index e1c573d4363..4cddbeffd8a 100644
--- a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts
+++ b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts
@@ -57,7 +57,7 @@ describe('useHomepageSparklines', () => {
);
});
- it('returns downsampled close prices when callback fires', () => {
+ it('returns downsampled close prices when callback fires', async () => {
mockSubscribe.mockImplementation(
(params: { callback: (candleData: CandleData) => void }) => {
params.callback({
@@ -71,6 +71,9 @@ describe('useHomepageSparklines', () => {
const { result } = renderHook(() => useHomepageSparklines(['BTC']));
+ // Sparkline updates are batched via queueMicrotask — flush with async act.
+ await act(async () => null);
+
expect(result.current.sparklines.BTC).toBeDefined();
expect(result.current.sparklines.BTC.length).toBe(50);
});
@@ -109,7 +112,7 @@ describe('useHomepageSparklines', () => {
expect(mockSubscribe).not.toHaveBeenCalled();
});
- it('accumulates sparklines from multiple symbol callbacks', () => {
+ it('accumulates sparklines from multiple symbol callbacks', async () => {
const callbacks: Record void> = {};
mockSubscribe.mockImplementation(
(params: {
@@ -123,7 +126,7 @@ describe('useHomepageSparklines', () => {
const { result } = renderHook(() => useHomepageSparklines(['BTC', 'ETH']));
- act(() => {
+ await act(async () => {
callbacks.BTC({
symbol: 'BTC',
interval: CandlePeriod.FifteenMinutes,
@@ -134,7 +137,7 @@ describe('useHomepageSparklines', () => {
expect(result.current.sparklines.BTC).toBeDefined();
expect(result.current.sparklines.ETH).toBeUndefined();
- act(() => {
+ await act(async () => {
callbacks.ETH({
symbol: 'ETH',
interval: CandlePeriod.FifteenMinutes,
@@ -146,7 +149,7 @@ describe('useHomepageSparklines', () => {
expect(result.current.sparklines.ETH).toBeDefined();
});
- it('refresh clears data and resubscribes', () => {
+ it('refresh clears data and resubscribes', async () => {
const callbacks: Record void> = {};
mockSubscribe.mockImplementation(
(params: {
@@ -160,7 +163,7 @@ describe('useHomepageSparklines', () => {
const { result } = renderHook(() => useHomepageSparklines(['BTC']));
- act(() => {
+ await act(async () => {
callbacks.BTC({
symbol: 'BTC',
interval: CandlePeriod.FifteenMinutes,
@@ -170,7 +173,7 @@ describe('useHomepageSparklines', () => {
expect(result.current.sparklines.BTC).toBeDefined();
- act(() => {
+ await act(async () => {
result.current.refresh();
});
diff --git a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts
index 73ea016798e..706ca25ad46 100644
--- a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts
+++ b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts
@@ -36,36 +36,52 @@ function extractCloses(candleData: CandleData): number[] {
* Uses the existing CandleStreamChannel which handles caching, ref-counting,
* and reconnection automatically.
*
+ * Candle callbacks arrive independently per symbol. A microtask-based flush
+ * coalesces rapid-fire arrivals into a single React state update so the
+ * parent component re-renders once instead of N times (one per symbol).
+ *
* @param symbols - Market symbols to fetch sparklines for
*/
export function useHomepageSparklines(
symbols: string[],
): UseHomepageSparklinesResult {
+ const safeSymbols = useMemo(() => symbols ?? [], [symbols]);
const stream = usePerpsStream();
const [sparklines, setSparklines] = useState>({});
const dataRef = useRef>({});
+ const flushScheduledRef = useRef(false);
const [refreshKey, setRefreshKey] = useState(0);
// Stable string key so the effect doesn't re-run on every render
- // when the caller produces a new array reference with the same contents.
- const symbolsKey = useMemo(() => symbols.join(','), [symbols]);
+ const symbolsKey = useMemo(() => safeSymbols.join(','), [safeSymbols]);
useEffect(() => {
if (!symbolsKey) return undefined;
dataRef.current = {};
+ flushScheduledRef.current = false;
setSparklines({});
+ const scheduleFlush = () => {
+ if (flushScheduledRef.current) return;
+ flushScheduledRef.current = true;
+ queueMicrotask(() => {
+ flushScheduledRef.current = false;
+ setSparklines({ ...dataRef.current });
+ });
+ };
+
const unsubscribes: (() => void)[] = [];
+ const syms = symbolsKey.split(',').filter(Boolean);
- for (const symbol of symbols) {
+ for (const symbol of syms) {
const unsubscribe = stream.candles.subscribe({
symbol,
interval: CandlePeriod.FifteenMinutes,
duration: TimeDuration.OneDay,
callback: (candleData: CandleData) => {
if (dataRef.current[symbol]) return;
- if (!candleData || candleData.candles.length < 2) return;
+ if (!candleData?.candles || candleData.candles.length < 2) return;
const closes = extractCloses(candleData);
if (closes.length < 2) return;
@@ -74,7 +90,7 @@ export function useHomepageSparklines(
...dataRef.current,
[symbol]: downsample(closes, SPARKLINE_TARGET_POINTS),
};
- setSparklines({ ...dataRef.current });
+ scheduleFlush();
},
});
unsubscribes.push(unsubscribe);