From af42dafdc691b4ca3cd81f768956a2421bafb641 Mon Sep 17 00:00:00 2001
From: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Date: Wed, 26 Nov 2025 14:51:48 +0100
Subject: [PATCH 01/16] chore: re-organize trending and bug fixes (#23280)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
File changes:
- Moved Sites outside of Trending folder since it should be its own
screen
- Flattened out index files to continue with the pattern we have been
following until now
Improvements:
- Updated **prediction** market single and multiple components to handle
correct navigation and allow removing prediction navigation from the
Trending stack
- Made Sites screen full-screen like the rest of screens
- Made a reusable footer component for sites to be reused in Trending to
avoid duplication
- Moved BrowserWrapper from of the Trending navigation to the main
navigation for full-screen support
- Removed shadow from bottom browser
- Stop using explorerSearchBar in browser and instead use the correct
header bar
Bug fixes:
- When opening a site from the sites full view and clicking on the back
button it takes users to the main trending screen. Expected to go one
step back
## **Changelog**
CHANGELOG entry: improvements for trending feature
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1837
## **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**
https://github.com/user-attachments/assets/b936b347-4c25-4187-82e4-9a2b8ff9d619
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Extracts Sites into a new full-screen flow with reusable list/search,
moves the Trending browser to main navigation, updates Predict
navigation, and removes bottom bar shadow.
>
> - **Navigation/Routes**:
> - Add `TrendingBrowser` screen in main navigator using
`BrowserWrapper` for full-screen browser and back behavior.
> - Replace `Routes.SITES_LIST_VIEW` with `Routes.SITES_FULL_VIEW`; wire
up stack transition in `MainNavigator`.
> - Simplify `TrendingView` stack to `TrendingFeed` and `ExploreSearch`
only; remove in-stack browser and Predict screens.
>
> - **Sites (extracted from Trending)**:
> - New full-screen `SitesFullView` with header search, pull-to-refresh,
filtering, and footer actions.
> - New reusable components: `SitesList` (FlashList), `SiteRowItem`,
`SiteRowItemWrapper`, `SiteSkeleton`, and `SitesSearchFooter`.
> - New `useSitesData` hook path (`UI/Sites/...`) and API fetch with
limit param; update references across app and search.
> - Update Trending sections config to use new Sites components and
`viewAll` to `SITES_FULL_VIEW`.
>
> - **Search**:
> - Reuse `SitesSearchFooter` in Explore search results; footer only
when query present.
>
> - **Predict**:
> - Update `PredictMarketSingle`/`Multiple` to navigate via
`Routes.PREDICT.ROOT` nested screens.
>
> - **Browser UI**:
> - Replace `ElevatedView` with `View` in `BrowserBottomBar` (remove
shadow) and adjust snapshots.
>
> - **Removals/cleanup**:
> - Delete legacy `TrendingView/SitesListView` and related
indices/exports; update tests and imports accordingly.
>
> - **Tests**:
> - Add comprehensive tests for new Sites views/components and update
snapshots for navigation and browser bottom bar.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
45fc7bb0098153c83aa05374911fcfb8ebbf432e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/components/Nav/Main/MainNavigator.js | 64 +-
.../__snapshots__/MainNavigator.test.tsx.snap | 21 +
.../__snapshots__/index.test.tsx.snap | 216 ++----
app/components/UI/BrowserBottomBar/index.tsx | 7 +-
.../PredictMarketMultiple.test.tsx | 29 +-
.../PredictMarketMultiple.tsx | 26 +-
.../PredictMarketSingle.test.tsx | 29 +-
.../PredictMarketSingle.tsx | 26 +-
.../SiteRowItem/SiteRowItem.test.tsx | 38 -
.../components}/SiteRowItem/SiteRowItem.tsx | 9 +-
.../SiteRowItemWrapper.test.tsx | 77 +-
.../SiteRowItemWrapper.tsx | 10 +-
.../SiteSkeleton/SiteSkeleton.test.tsx | 34 -
.../components}/SiteSkeleton/SiteSkeleton.tsx | 11 +-
.../components/SitesList/SitesList.test.tsx | 197 ++++++
.../Sites/components/SitesList/SitesList.tsx | 41 ++
.../SitesSearchFooter.test.tsx | 246 +++++++
.../SitesSearchFooter/SitesSearchFooter.tsx | 123 ++++
.../hooks/useSiteData}/useSiteData.test.ts | 0
.../Sites/hooks/useSiteData}/useSitesData.ts | 2 +-
.../__snapshots__/index.test.tsx.snap | 27 +-
.../SitesFullView/SitesFullView.test.tsx | 465 +++++++++++++
.../Views/SitesFullView/SitesFullView.tsx | 154 ++++
.../ExploreSearchResults.test.tsx | 248 +------
.../ExploreSearchResults.tsx | 106 +--
.../config/useExploreSearch.test.ts | 2 +-
.../SectionSites/SiteRowItem/index.ts | 2 -
.../SectionSites/SiteSkeleton/index.ts | 1 -
.../TrendingView/SectionSites/hooks/index.ts | 1 -
.../Views/TrendingView/SectionSites/index.ts | 5 -
.../SitesListView/SitesListView.test.tsx | 656 ------------------
.../SitesListView/SitesListView.tsx | 226 ------
.../Views/TrendingView/SitesListView/index.ts | 1 -
.../Views/TrendingView/TrendingView.test.tsx | 166 +----
.../Views/TrendingView/TrendingView.tsx | 66 +-
.../BrowserWrapper/BrowserWrapper.tsx | 37 +
.../TrendingView/config/sections.config.tsx | 10 +-
app/constants/navigation/Routes.ts | 2 +-
38 files changed, 1535 insertions(+), 1846 deletions(-)
rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteRowItem/SiteRowItem.test.tsx (76%)
rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteRowItem/SiteRowItem.tsx (91%)
rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components/SiteRowItemWrapper}/SiteRowItemWrapper.test.tsx (78%)
rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components/SiteRowItemWrapper}/SiteRowItemWrapper.tsx (70%)
rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteSkeleton/SiteSkeleton.test.tsx (62%)
rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteSkeleton/SiteSkeleton.tsx (77%)
create mode 100644 app/components/UI/Sites/components/SitesList/SitesList.test.tsx
create mode 100644 app/components/UI/Sites/components/SitesList/SitesList.tsx
create mode 100644 app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx
create mode 100644 app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx
rename app/components/{Views/TrendingView/SectionSites/hooks => UI/Sites/hooks/useSiteData}/useSiteData.test.ts (100%)
rename app/components/{Views/TrendingView/SectionSites/hooks => UI/Sites/hooks/useSiteData}/useSitesData.ts (97%)
create mode 100644 app/components/Views/SitesFullView/SitesFullView.test.tsx
create mode 100644 app/components/Views/SitesFullView/SitesFullView.tsx
delete mode 100644 app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts
delete mode 100644 app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts
delete mode 100644 app/components/Views/TrendingView/SectionSites/hooks/index.ts
delete mode 100644 app/components/Views/TrendingView/SectionSites/index.ts
delete mode 100644 app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx
delete mode 100644 app/components/Views/TrendingView/SitesListView/SitesListView.tsx
delete mode 100644 app/components/Views/TrendingView/SitesListView/index.ts
create mode 100644 app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index 470de7876337..2ea2e0cb4cbe 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -52,7 +52,8 @@ import ContactForm from '../../Views/Settings/Contacts/ContactForm';
import ActivityView from '../../Views/ActivityView';
import RewardsNavigator from '../../UI/Rewards/RewardsNavigator';
import TrendingView from '../../Views/TrendingView/TrendingView';
-import SitesListView from '../../Views/TrendingView/SitesListView';
+import SwapsAmountView from '../../UI/Swaps';
+import SwapsQuotesView from '../../UI/Swaps/QuotesView';
import CollectiblesDetails from '../../UI/CollectibleModal';
import OptinMetrics from '../../UI/OptinMetrics';
@@ -131,6 +132,8 @@ import {
TOKEN,
} from '../../Views/AddAsset/AddAsset.constants';
import { strings } from '../../../../locales/i18n';
+import SitesFullView from '../../Views/SitesFullView/SitesFullView';
+import BrowserWrapper from '../../Views/TrendingView/components/BrowserWrapper/BrowserWrapper';
import BridgeView from '../../UI/Bridge/Views/BridgeView';
const Stack = createStackNavigator();
@@ -291,26 +294,6 @@ const TrendingHome = () => (
component={TrendingView}
options={{ headerShown: false }}
/>
- ({
- cardStyle: {
- transform: [
- {
- translateX: current.progress.interpolate({
- inputRange: [0, 1],
- outputRange: [layouts.screen.width, 0],
- }),
- },
- ],
- },
- }),
- }}
- />
);
@@ -967,6 +950,26 @@ const MainNavigator = () => {
}}
/>
+ ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [layouts.screen.width, 0],
+ }),
+ },
+ ],
+ },
+ }),
+ }}
+ />
{
}),
}}
/>
+ ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [layouts.screen.width, 0],
+ }),
+ },
+ ],
+ },
+ }),
+ }}
+ />
+
+
= ({
const optionsDisabled = !toggleOptions;
return (
-
+
= ({
style={[styles.icon, optionsDisabled && styles.disabledIcon]}
/>
-
+
);
};
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
index b552bd41f9a5..c05c0db60bba 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
@@ -119,27 +119,27 @@ describe('PredictMarketMultiple', () => {
// Press the "Yes" button
fireEvent.press(buttons[0]);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockMarket.outcomes[0],
outcomeToken: mockMarket.outcomes[0].tokens[0],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
// Press the "No" button
fireEvent.press(buttons[1]);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockMarket.outcomes[0],
outcomeToken: mockMarket.outcomes[0].tokens[1],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
});
it('handle missing or invalid market data gracefully', () => {
@@ -296,11 +296,14 @@ describe('PredictMarketMultiple', () => {
);
fireEvent.press(marketTitle);
- expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_DETAILS, {
- marketId: mockMarket.id,
- entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
- title: mockMarket.title,
- image: mockMarket.image,
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: mockMarket.id,
+ entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ title: mockMarket.title,
+ image: mockMarket.image,
+ },
});
});
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
index 5374c0910ed5..68bb4241a3ff 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
@@ -125,11 +125,14 @@ const PredictMarketMultiple: React.FC = ({
) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
- market,
- outcome,
- outcomeToken,
- entryPoint,
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
+ market,
+ outcome,
+ outcomeToken,
+ entryPoint,
+ },
});
},
{
@@ -148,11 +151,14 @@ const PredictMarketMultiple: React.FC = ({
{
- navigation.navigate(Routes.PREDICT.MARKET_DETAILS, {
- marketId: market.id,
- entryPoint,
- title: market.title,
- image: market.image,
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: market.id,
+ entryPoint,
+ title: market.title,
+ image: market.image,
+ },
});
}}
>
diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
index 2a657c6fa82c..956b7f2bf232 100644
--- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
@@ -140,26 +140,26 @@ describe('PredictMarketSingle', () => {
const noButton = getByText('No');
fireEvent.press(yesButton);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockOutcome,
outcomeToken: mockOutcome.tokens[0],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
fireEvent.press(noButton);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockOutcome,
outcomeToken: mockOutcome.tokens[1],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
});
it('handle missing or invalid market data gracefully', () => {
@@ -311,11 +311,14 @@ describe('PredictMarketSingle', () => {
);
fireEvent.press(marketTitle);
- expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_DETAILS, {
- marketId: mockMarket.id,
- entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
- title: mockMarket.title,
- image: mockMarket.image,
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: mockMarket.id,
+ entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ title: mockMarket.title,
+ image: mockMarket.image,
+ },
});
});
diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
index 101529f8d632..0ab281675351 100644
--- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
@@ -175,11 +175,14 @@ const PredictMarketSingle: React.FC = ({
const handleBuy = (token: PredictOutcomeToken) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
- market,
- outcome,
- outcomeToken: token,
- entryPoint,
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
+ market,
+ outcome,
+ outcomeToken: token,
+ entryPoint,
+ },
});
},
{
@@ -193,11 +196,14 @@ const PredictMarketSingle: React.FC = ({
{
- navigation.navigate(Routes.PREDICT.MARKET_DETAILS, {
- marketId: market.id,
- entryPoint,
- title: market.title,
- image: getImageUrl(),
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: market.id,
+ entryPoint,
+ title: market.title,
+ image: getImageUrl(),
+ },
});
}}
>
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx
similarity index 76%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx
rename to app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx
index 37af81dc712a..d5e205d53083 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx
+++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx
@@ -66,44 +66,6 @@ describe('SiteRowItem', () => {
});
});
- describe('padding behavior', () => {
- it('renders with isViewAll prop set to true', () => {
- const site = createSite();
-
- const { getByTestId } = render(
- ,
- );
-
- const pressable = getByTestId('site-row-item');
- expect(pressable).toBeOnTheScreen();
- // Component renders successfully with isViewAll={true}
- });
-
- it('renders with isViewAll prop set to false', () => {
- const site = createSite();
-
- const { getByTestId } = render(
- ,
- );
-
- const pressable = getByTestId('site-row-item');
- expect(pressable).toBeOnTheScreen();
- // Component renders successfully with isViewAll={false}
- });
-
- it('renders with isViewAll prop not provided', () => {
- const site = createSite();
-
- const { getByTestId } = render(
- ,
- );
-
- const pressable = getByTestId('site-row-item');
- expect(pressable).toBeOnTheScreen();
- // Component renders successfully with default isViewAll
- });
- });
-
describe('interaction', () => {
it('calls onPress when pressed', () => {
const site = createSite();
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx
similarity index 91%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx
rename to app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx
index 72e0d5b414b7..4f49b2a1d50d 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx
+++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx
@@ -22,14 +22,9 @@ export interface SiteData {
interface SiteRowItemProps {
site: SiteData;
onPress: () => void;
- isViewAll?: boolean;
}
-const SiteRowItem = ({
- site,
- onPress,
- isViewAll = false,
-}: SiteRowItemProps) => {
+const SiteRowItem = ({ site, onPress }: SiteRowItemProps) => {
const tw = useTailwind();
const [imageError, setImageError] = useState(false);
@@ -42,7 +37,7 @@ const SiteRowItem = ({
{/* Logo */}
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx
similarity index 78%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx
rename to app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx
index df79947456c3..90762de9a586 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx
+++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx
@@ -1,27 +1,26 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import SiteRowItemWrapper from './SiteRowItemWrapper';
-import { updateLastTrendingScreen } from '../../../Nav/Main/MainNavigator';
+import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator';
import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import type { SiteData } from './SiteRowItem/SiteRowItem';
+import type { SiteData } from '../SiteRowItem/SiteRowItem';
// Mock the dependencies
-jest.mock('../../../Nav/Main/MainNavigator', () => ({
+jest.mock('../../../../Nav/Main/MainNavigator', () => ({
updateLastTrendingScreen: jest.fn(),
}));
-jest.mock('./SiteRowItem/SiteRowItem', () => {
+jest.mock('../SiteRowItem/SiteRowItem', () => {
const { TouchableOpacity, Text } = jest.requireActual('react-native');
return {
__esModule: true,
- default: jest.fn(({ onPress, site, isViewAll }) => (
+ default: jest.fn(({ onPress, site }) => (
{site.id}
{site.name}
{site.url}
{site.displayUrl}
- {String(isViewAll)}
{site.logoUrl && {site.logoUrl}}
{site.featured && Featured}
@@ -87,26 +86,6 @@ describe('SiteRowItemWrapper', () => {
);
});
- it('should pass isViewAll as false by default', () => {
- const { getByTestId } = render(
- ,
- );
-
- expect(getByTestId('is-view-all').props.children).toBe('false');
- });
-
- it('should pass isViewAll as true when provided', () => {
- const { getByTestId } = render(
- ,
- );
-
- expect(getByTestId('is-view-all').props.children).toBe('true');
- });
-
it('should render site with logoUrl', () => {
const { getByTestId } = render(
,
@@ -271,40 +250,6 @@ describe('SiteRowItemWrapper', () => {
expect(mockNavigation.navigate).toHaveBeenCalledTimes(3);
});
- it('should use current timestamp on each press', () => {
- dateNowSpy.mockRestore();
- const timestamps = [1000000000, 2000000000, 3000000000];
- let callCount = 0;
-
- dateNowSpy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => timestamps[callCount++]);
-
- const { getByTestId } = render(
- ,
- );
-
- const siteRowItem = getByTestId('site-row-item');
-
- fireEvent.press(siteRowItem);
- expect(mockNavigation.navigate).toHaveBeenLastCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({ timestamp: 1000000000 }),
- );
-
- fireEvent.press(siteRowItem);
- expect(mockNavigation.navigate).toHaveBeenLastCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({ timestamp: 2000000000 }),
- );
-
- fireEvent.press(siteRowItem);
- expect(mockNavigation.navigate).toHaveBeenLastCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({ timestamp: 3000000000 }),
- );
- });
-
it('should always pass fromTrending as true', () => {
const { getByTestId } = render(
,
@@ -343,17 +288,5 @@ describe('SiteRowItemWrapper', () => {
fromTrending: true,
});
});
-
- it('should work with isViewAll explicitly set to false', () => {
- const { getByTestId } = render(
- ,
- );
-
- expect(getByTestId('is-view-all').props.children).toBe('false');
- });
});
});
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx
similarity index 70%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx
rename to app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx
index 8221682c7c12..38ce335a0573 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx
+++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx
@@ -1,18 +1,16 @@
import React from 'react';
import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import SiteRowItem, { type SiteData } from './SiteRowItem/SiteRowItem';
-import { updateLastTrendingScreen } from '../../../Nav/Main/MainNavigator';
+import SiteRowItem, { type SiteData } from '../SiteRowItem/SiteRowItem';
+import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator';
interface SiteRowItemWrapperProps {
site: SiteData;
navigation: NavigationProp;
- isViewAll?: boolean;
}
const SiteRowItemWrapper: React.FC = ({
site,
navigation,
- isViewAll = false,
}) => {
const handlePress = () => {
// Update last trending screen state
@@ -26,9 +24,7 @@ const SiteRowItemWrapper: React.FC = ({
});
};
- return (
-
- );
+ return ;
};
export default SiteRowItemWrapper;
diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx
similarity index 62%
rename from app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx
rename to app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx
index b249be13ed7b..7a418ceb5356 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx
+++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx
@@ -74,38 +74,4 @@ describe('SiteSkeleton', () => {
});
});
});
-
- describe('padding behavior', () => {
- it('does not apply horizontal padding when isViewAll is false', () => {
- const { getAllByTestId } = render();
-
- const skeletons = getAllByTestId('skeleton');
- const container = skeletons[0].parent;
- const styles = Array.isArray(container?.props.style)
- ? container?.props.style
- : [container?.props.style];
- const hasPaddingHorizontal = styles.some(
- (style: { paddingHorizontal?: number }) =>
- style?.paddingHorizontal === 8,
- );
-
- expect(hasPaddingHorizontal).toBe(false);
- });
-
- it('does not apply horizontal padding when isViewAll is not provided', () => {
- const { getAllByTestId } = render();
-
- const skeletons = getAllByTestId('skeleton');
- const container = skeletons[0].parent;
- const styles = Array.isArray(container?.props.style)
- ? container?.props.style
- : [container?.props.style];
- const hasPaddingHorizontal = styles.some(
- (style: { paddingHorizontal?: number }) =>
- style?.paddingHorizontal === 8,
- );
-
- expect(hasPaddingHorizontal).toBe(false);
- });
- });
});
diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
similarity index 77%
rename from app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx
rename to app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
index f2165549503f..130d3a817225 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx
+++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
@@ -8,9 +8,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingVertical: 16,
},
- containerViewAll: {
- paddingHorizontal: 8,
- },
iconSkeleton: {
borderRadius: 20,
marginBottom: 0,
@@ -27,12 +24,8 @@ const styles = StyleSheet.create({
},
});
-interface SiteSkeletonProps {
- isViewAll?: boolean;
-}
-
-const SiteSkeleton = ({ isViewAll = false }: SiteSkeletonProps) => (
-
+const SiteSkeleton = () => (
+
{/* Logo skeleton */}
diff --git a/app/components/UI/Sites/components/SitesList/SitesList.test.tsx b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx
new file mode 100644
index 000000000000..d6607f79cbde
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import SitesList from './SitesList';
+import { useNavigation } from '@react-navigation/native';
+import type { SiteData } from '../SiteRowItem/SiteRowItem';
+
+// Mock FlashList to render items in tests
+jest.mock('@shopify/flash-list', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ FlashList: ({
+ data,
+ renderItem,
+ keyExtractor,
+ testID,
+ refreshControl,
+ ListFooterComponent,
+ }: {
+ data: SiteData[];
+ renderItem: ({ item }: { item: SiteData }) => React.ReactElement;
+ keyExtractor: (item: SiteData) => string;
+ testID: string;
+ refreshControl?: React.ReactElement;
+ ListFooterComponent?: React.ReactElement | null;
+ showsVerticalScrollIndicator?: boolean;
+ }) => (
+
+ {data.map((item: SiteData) => {
+ const key = keyExtractor(item);
+ return (
+
+ {renderItem({ item })}
+
+ );
+ })}
+ {refreshControl}
+ {ListFooterComponent}
+
+ ),
+ };
+});
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(),
+}));
+
+jest.mock('../SiteRowItemWrapper/SiteRowItemWrapper', () => {
+ const { View, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ site,
+ navigation,
+ }: {
+ site: SiteData;
+ navigation: unknown;
+ }) => (
+
+ {site.id}
+ {site.name}
+ {String(!!navigation)}
+
+ ),
+ };
+});
+
+describe('SitesList', () => {
+ const mockNavigation = {
+ navigate: jest.fn(),
+ };
+
+ const createSite = (
+ id: string,
+ overrides: Partial = {},
+ ): SiteData => ({
+ id,
+ name: `Site ${id}`,
+ url: `https://site${id}.com`,
+ displayUrl: `site${id}.com`,
+ ...overrides,
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue(mockNavigation);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders with empty sites array', () => {
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('sites-list')).toBeOnTheScreen();
+ expect(queryByTestId('site-wrapper-1')).toBeNull();
+ });
+
+ it('renders with single site', () => {
+ const sites = [createSite('1')];
+
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('site-wrapper-1')).toBeOnTheScreen();
+ expect(queryByTestId('site-wrapper-2')).toBeNull();
+ });
+
+ it('renders multiple sites', () => {
+ const sites = [
+ createSite('1'),
+ createSite('2'),
+ createSite('3'),
+ createSite('4'),
+ createSite('5'),
+ ];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-wrapper-1')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-2')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-3')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-4')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-5')).toBeOnTheScreen();
+ });
+ });
+
+ describe('props passthrough', () => {
+ it('passes navigation to SiteRowItemWrapper', () => {
+ const sites = [createSite('1')];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('has-navigation-1').props.children).toBe('true');
+ });
+ });
+
+ describe('site data rendering', () => {
+ it('renders sites with correct data', () => {
+ const sites = [
+ createSite('1', { name: 'MetaMask' }),
+ createSite('unique-id-2', { name: 'Uniswap' }),
+ createSite('3', { name: 'OpenSea' }),
+ ];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-name-1').props.children).toBe('MetaMask');
+ expect(getByTestId('site-name-unique-id-2').props.children).toBe(
+ 'Uniswap',
+ );
+ expect(getByTestId('site-id-unique-id-2').props.children).toBe(
+ 'unique-id-2',
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('renders with sites containing special characters in IDs', () => {
+ const sites = [
+ createSite('site-with-dash'),
+ createSite('site_with_underscore'),
+ createSite('site.with.dot'),
+ ];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-wrapper-site-with-dash')).toBeOnTheScreen();
+ expect(
+ getByTestId('site-wrapper-site_with_underscore'),
+ ).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-site.with.dot')).toBeOnTheScreen();
+ });
+
+ it('renders with large number of sites', () => {
+ const sites = Array.from({ length: 50 }, (_, i) => createSite(`${i}`));
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-wrapper-0')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-25')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-49')).toBeOnTheScreen();
+ });
+
+ it('renders when navigation is not provided', () => {
+ (useNavigation as jest.Mock).mockReturnValue(undefined);
+ const sites = [createSite('1')];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('has-navigation-1').props.children).toBe('false');
+ });
+ });
+});
diff --git a/app/components/UI/Sites/components/SitesList/SitesList.tsx b/app/components/UI/Sites/components/SitesList/SitesList.tsx
new file mode 100644
index 000000000000..a6e783458985
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesList/SitesList.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { FlashList } from '@shopify/flash-list';
+import { useNavigation } from '@react-navigation/native';
+// eslint-disable-next-line no-duplicate-imports
+import type { NavigationProp, ParamListBase } from '@react-navigation/native';
+import SiteRowItemWrapper from '../SiteRowItemWrapper/SiteRowItemWrapper';
+import type { SiteData } from '../SiteRowItem/SiteRowItem';
+
+export interface SitesListProps {
+ sites: SiteData[];
+ refreshControl?: React.ReactElement;
+ ListFooterComponent?: React.ReactElement | null;
+}
+
+const SitesList: React.FC = ({
+ sites,
+ refreshControl,
+ ListFooterComponent,
+}) => {
+ const navigation = useNavigation>();
+
+ const renderSiteItem = ({ item }: { item: SiteData }) => (
+
+ );
+
+ return (
+ item.id}
+ showsVerticalScrollIndicator={false}
+ refreshControl={refreshControl}
+ ListFooterComponent={ListFooterComponent}
+ />
+ );
+};
+
+SitesList.displayName = 'SitesList';
+
+export default SitesList;
diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx
new file mode 100644
index 000000000000..434ffbc62ade
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx
@@ -0,0 +1,246 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { useNavigation } from '@react-navigation/native';
+// eslint-disable-next-line no-duplicate-imports
+import type { NavigationProp, ParamListBase } from '@react-navigation/native';
+import SitesSearchFooter from './SitesSearchFooter';
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(),
+}));
+
+describe('SitesSearchFooter', () => {
+ let mockNavigation: jest.Mocked>;
+ let dateNowSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockNavigation = {
+ navigate: jest.fn(),
+ } as unknown as jest.Mocked>;
+
+ (useNavigation as jest.Mock).mockReturnValue(mockNavigation);
+ dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890);
+ });
+
+ afterEach(() => {
+ dateNowSpy.mockRestore();
+ jest.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('returns null when searchQuery is empty', () => {
+ const { queryByTestId } = render();
+
+ expect(queryByTestId('trending-search-footer-google-link')).toBeNull();
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+
+ it('renders Google search link when query is provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders URL link when query looks like a URL', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('does not render URL link when query is plain text', () => {
+ const { queryByTestId, getByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('URL detection', () => {
+ it('detects URLs with http protocol', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects URLs with https protocol', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects URLs with path', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects URLs with query parameters', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects subdomains as URLs', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('does not detect plain text as URL', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+
+ it('does not detect single word as URL', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+ });
+
+ describe('navigation', () => {
+ it('navigates to URL when URL link is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('trending-search-footer-url-link'));
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'metamask.io',
+ timestamp: 1234567890,
+ fromTrending: true,
+ });
+ expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('navigates to Google search when Google link is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('trending-search-footer-google-link'));
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'https://www.google.com/search?q=ethereum',
+ timestamp: 1234567890,
+ fromTrending: true,
+ });
+ expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('encodes special characters in Google search query', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('trending-search-footer-google-link'));
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'https://www.google.com/search?q=ethereum%20%26%20bitcoin',
+ timestamp: 1234567890,
+ fromTrending: true,
+ });
+ });
+ });
+
+ describe('text display', () => {
+ it('displays search query in Google search link', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('ethereum')).toBeOnTheScreen();
+ expect(getByText(/on Google/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles URL with uppercase characters', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('handles query with leading/trailing spaces', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ // Component trims or handles spaces, but doesn't return null
+ expect(
+ queryByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('handles URL with multiple subdomains', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('handles URL with port number', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+
+ it('handles special characters in query', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('handles query with emojis', () => {
+ const { getByText, getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ expect(getByText('ethereum 🚀')).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx
new file mode 100644
index 000000000000..d773c31e4c48
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback } from 'react';
+import { TouchableOpacity } from 'react-native';
+import {
+ Box,
+ Text,
+ TextVariant,
+ Icon,
+ IconName,
+ IconSize,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ NavigationProp,
+ ParamListBase,
+ useNavigation,
+} from '@react-navigation/native';
+
+export interface SitesSearchFooterProps {
+ searchQuery: string;
+}
+
+/**
+ * Checks if a string looks like a URL
+ */
+function looksLikeUrl(str: string): boolean {
+ return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
+}
+
+const SitesSearchFooter: React.FC = ({
+ searchQuery,
+}) => {
+ const tw = useTailwind();
+ const navigation = useNavigation>();
+
+ const onPressLink = useCallback(
+ (url: string) => {
+ navigation.navigate('TrendingBrowser', {
+ newTabUrl: url,
+ timestamp: Date.now(),
+ fromTrending: true,
+ });
+ },
+ [navigation],
+ );
+
+ if (!searchQuery || searchQuery.length === 0) {
+ return null;
+ }
+
+ const isUrl = looksLikeUrl(searchQuery.toLowerCase());
+
+ return (
+
+ {isUrl && (
+ onPressLink(searchQuery)}
+ testID="trending-search-footer-url-link"
+ >
+
+
+ {searchQuery}
+
+
+
+
+
+
+ )}
+
+
+ onPressLink(
+ `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
+ )
+ }
+ testID="trending-search-footer-google-link"
+ >
+
+
+ Search for {'"'}
+
+
+ {searchQuery}
+
+
+ {'"'} on Google
+
+
+
+
+
+
+
+ );
+};
+
+SitesSearchFooter.displayName = 'SitesSearchFooter';
+
+export default SitesSearchFooter;
diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSiteData.test.ts b/app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts
similarity index 100%
rename from app/components/Views/TrendingView/SectionSites/hooks/useSiteData.test.ts
rename to app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts
diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
similarity index 97%
rename from app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts
rename to app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
index 21b042710f54..5b2372f49465 100644
--- a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts
+++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import Logger from '../../../../../util/Logger';
-import type { SiteData } from '../SiteRowItem/SiteRowItem';
+import type { SiteData } from '../../components/SiteRowItem/SiteRowItem';
interface ApiDappResponse {
id: string;
diff --git a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap
index a92d3c1dbd5d..4003ff63656e 100644
--- a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap
@@ -413,24 +413,15 @@ exports[`BrowserTab render Browser 1`] = `
({
+ SafeAreaView: jest.requireActual('react-native').View,
+ useSafeAreaInsets: () => ({ top: 50, bottom: 34, left: 0, right: 0 }),
+}));
+
+const mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ }),
+}));
+
+jest.mock('../../../util/theme', () => ({
+ useAppThemeFromContext: () => ({
+ colors: {
+ background: { default: '#FFFFFF' },
+ primary: { default: '#037DD6' },
+ icon: { default: '#24272A' },
+ },
+ }),
+}));
+
+jest.mock('../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch', () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(
+ ({
+ defaultTitle,
+ isSearchVisible,
+ searchQuery,
+ onSearchQueryChange,
+ onBack,
+ onSearchToggle,
+ testID,
+ }) => (
+
+ {!isSearchVisible ? (
+ <>
+
+ Back
+
+
+ {defaultTitle}
+
+
+ Search
+
+ >
+ ) : (
+ <>
+
+
+ Cancel
+
+ >
+ )}
+
+ ),
+ );
+});
+
+jest.mock('../../UI/Sites/components/SitesList/SitesList', () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(({ sites, refreshControl, ListFooterComponent }) => (
+
+ {sites.map((site: SiteData) => (
+
+ {site.name}
+
+ ))}
+ {refreshControl && (
+
+ {refreshControl}
+
+ )}
+ {ListFooterComponent}
+
+ ));
+});
+
+jest.mock('../../UI/Sites/components/SiteSkeleton/SiteSkeleton', () =>
+ jest.fn(() => {
+ const ReactNative = jest.requireActual('react-native');
+ return (
+
+ Loading...
+
+ );
+ }),
+);
+
+jest.mock(
+ '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter',
+ () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(({ searchQuery }) =>
+ searchQuery ? (
+
+ {searchQuery}
+
+ ) : null,
+ );
+ },
+);
+
+const mockUseSitesData = useSitesData as jest.Mock;
+const mockRefetch = jest.fn();
+
+describe('SitesFullView', () => {
+ const mockSites: SiteData[] = [
+ {
+ id: '1',
+ name: 'MetaMask',
+ url: 'https://metamask.io',
+ displayUrl: 'metamask.io',
+ logoUrl: 'https://example.com/metamask.png',
+ featured: true,
+ },
+ {
+ id: '2',
+ name: 'OpenSea',
+ url: 'https://opensea.io',
+ displayUrl: 'opensea.io',
+ logoUrl: 'https://example.com/opensea.png',
+ featured: false,
+ },
+ {
+ id: '3',
+ name: 'Uniswap',
+ url: 'https://uniswap.org',
+ displayUrl: 'uniswap.org',
+ logoUrl: 'https://example.com/uniswap.png',
+ featured: true,
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRefetch.mockClear();
+ });
+
+ describe('Rendering', () => {
+ it('renders header with back button and title', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('sites-full-view-header')).toBeOnTheScreen();
+ expect(
+ getByTestId('sites-full-view-header-back-button'),
+ ).toBeOnTheScreen();
+ expect(getByTestId('sites-full-view-header-title')).toBeOnTheScreen();
+ expect(
+ getByTestId('sites-full-view-header-search-toggle'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders SitesList component with all site items', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('sites-list')).toBeOnTheScreen();
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(getByTestId('site-item-2')).toBeOnTheScreen();
+ expect(getByTestId('site-item-3')).toBeOnTheScreen();
+ });
+
+ it('renders skeletons when loading', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: [],
+ isLoading: true,
+ refetch: mockRefetch,
+ });
+
+ const { getAllByTestId } = render();
+
+ const skeletons = getAllByTestId('site-skeleton');
+ expect(skeletons.length).toBe(10);
+ });
+
+ it('renders RefreshControl', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('refresh-control')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Navigation', () => {
+ it('navigates back when back button is pressed', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+ const backButton = getByTestId('sites-full-view-header-back-button');
+
+ fireEvent.press(backButton);
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('filters sites by name, URL, and display URL', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Search by name
+ fireEvent.changeText(searchInput, 'Meta');
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(queryByTestId('site-item-2')).toBeNull();
+
+ // Search by URL
+ fireEvent.changeText(searchInput, 'opensea');
+ expect(queryByTestId('site-item-1')).toBeNull();
+ expect(getByTestId('site-item-2')).toBeOnTheScreen();
+
+ // Search by display URL
+ fireEvent.changeText(searchInput, 'uniswap.org');
+ expect(queryByTestId('site-item-2')).toBeNull();
+ expect(getByTestId('site-item-3')).toBeOnTheScreen();
+ });
+
+ it('shows all sites when search query is empty', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Empty search
+ fireEvent.changeText(searchInput, '');
+
+ // All sites should be visible
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(getByTestId('site-item-2')).toBeOnTheScreen();
+ expect(getByTestId('site-item-3')).toBeOnTheScreen();
+ });
+
+ it('clears search query when search is closed', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Type search query
+ fireEvent.changeText(searchInput, 'test');
+
+ // Close search
+ fireEvent.press(getByTestId('sites-full-view-header-search-close'));
+
+ // Reopen search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+
+ // Search input should be empty
+ const newSearchInput = getByTestId('sites-full-view-header-search-bar');
+ expect(newSearchInput.props.value).toBe('');
+ });
+
+ it('displays SitesSearchFooter when search is active', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Initially no footer
+ expect(queryByTestId('sites-search-footer')).toBeNull();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Type search query
+ fireEvent.changeText(searchInput, 'test');
+
+ // Footer should appear
+ expect(getByTestId('sites-search-footer')).toBeOnTheScreen();
+ });
+
+ it('hides SitesSearchFooter when search query is empty or search is inactive', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Footer should not appear when search is inactive
+ expect(queryByTestId('sites-search-footer')).toBeNull();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+
+ // Footer should not appear with empty query
+ expect(queryByTestId('sites-search-footer')).toBeNull();
+ });
+ });
+
+ describe('Data Fetching', () => {
+ it('fetches sites with limit of 100', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ render();
+
+ expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 });
+ });
+
+ it('calls refetch when refresh is triggered', async () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ render();
+
+ const SitesListMock = jest.requireMock(
+ '../../UI/Sites/components/SitesList/SitesList',
+ );
+
+ // Get the refreshControl prop passed to SitesList
+ const sitesListProps = SitesListMock.mock.calls[0][0];
+ const refreshControl = sitesListProps.refreshControl;
+
+ expect(refreshControl).toBeDefined();
+
+ // Simulate refresh
+ await act(async () => {
+ await refreshControl.props.onRefresh();
+ });
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles sites with missing optional fields', () => {
+ const minimalSites: SiteData[] = [
+ {
+ id: '1',
+ name: 'Test',
+ url: 'https://test.com',
+ displayUrl: 'test.com',
+ },
+ ];
+
+ mockUseSitesData.mockReturnValue({
+ sites: minimalSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ });
+
+ it('handles empty sites array', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: [],
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('sites-list')).toBeOnTheScreen();
+ expect(queryByTestId('site-item-1')).toBeNull();
+ });
+
+ it('performs case-insensitive search', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Search with different case
+ fireEvent.changeText(searchInput, 'METAMASK');
+
+ // MetaMask should still be found
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(queryByTestId('site-item-2')).toBeNull();
+ });
+ });
+});
diff --git a/app/components/Views/SitesFullView/SitesFullView.tsx b/app/components/Views/SitesFullView/SitesFullView.tsx
new file mode 100644
index 000000000000..abc6783059fb
--- /dev/null
+++ b/app/components/Views/SitesFullView/SitesFullView.tsx
@@ -0,0 +1,154 @@
+import React, { useCallback, useState, useMemo } from 'react';
+import { StyleSheet, View, RefreshControl } from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+// eslint-disable-next-line no-duplicate-imports
+import type { NavigationProp, ParamListBase } from '@react-navigation/native';
+import {
+ SafeAreaView,
+ useSafeAreaInsets,
+} from 'react-native-safe-area-context';
+import { useAppThemeFromContext } from '../../../util/theme';
+import { Theme } from '../../../util/theme/models';
+import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData';
+import SitesList from '../../UI/Sites/components/SitesList/SitesList';
+import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton';
+import SitesSearchFooter from '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter';
+import { strings } from '../../../../locales/i18n';
+import ListHeaderWithSearch from '../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch';
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: theme.colors.background.default,
+ paddingBottom: 16,
+ },
+ headerContainer: {
+ backgroundColor: theme.colors.background.default,
+ },
+ listContainer: {
+ flex: 1,
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
+ });
+
+const SitesFullView: React.FC = () => {
+ const theme = useAppThemeFromContext();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation>();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isSearchActive, setIsSearchActive] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+
+ // Fetch all sites (no limit)
+ const {
+ sites,
+ isLoading,
+ refetch: refetchSites,
+ } = useSitesData({ limit: 100 });
+
+ // Filter sites based on search query
+ const filteredSites = useMemo(() => {
+ if (!searchQuery.trim()) {
+ return sites;
+ }
+
+ const query = searchQuery.toLowerCase();
+ return sites.filter(
+ (site) =>
+ site.name.toLowerCase().includes(query) ||
+ site.displayUrl.toLowerCase().includes(query) ||
+ site.url.toLowerCase().includes(query),
+ );
+ }, [sites, searchQuery]);
+
+ const handleBackPress = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleSearchToggle = useCallback(() => {
+ setIsSearchActive((prev) => {
+ if (prev) {
+ // Closing search, clear the query
+ setSearchQuery('');
+ }
+ return !prev;
+ });
+ }, []);
+
+ // Handle pull-to-refresh
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ refetchSites?.();
+ } catch (error) {
+ console.warn('Failed to refresh sites:', error);
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refetchSites]);
+
+ const renderSkeleton = () => (
+ <>
+ {[...Array(10)].map((_, index) => (
+
+ ))}
+ >
+ );
+
+ const renderFooter = useMemo(() => {
+ if (!isSearchActive) return null;
+
+ return ;
+ }, [isSearchActive, searchQuery]);
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+ {renderSkeleton()}
+ ) : (
+
+
+ }
+ ListFooterComponent={renderFooter}
+ />
+
+ )}
+
+ );
+};
+
+SitesFullView.displayName = 'SitesFullView';
+
+export default SitesFullView;
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
index 7827383f36a3..139b6a2b501d 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
@@ -1,14 +1,12 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { render } from '@testing-library/react-native';
import ExploreSearchResults from './ExploreSearchResults';
import { useExploreSearch } from './config/useExploreSearch';
-const mockNavigate = jest.fn();
-
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
- navigate: mockNavigate,
+ navigate: jest.fn(),
}),
}));
@@ -33,82 +31,26 @@ jest.mock(
() => () => null,
);
+jest.mock(
+ '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter',
+ () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(({ searchQuery }) =>
+ searchQuery ? (
+
+ {searchQuery}
+
+ ) : null,
+ );
+ },
+);
+
describe('ExploreSearchResults', () => {
beforeEach(() => {
jest.clearAllMocks();
});
- it('renders list when data is available', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [
- { assetId: '1', symbol: 'BTC', name: 'Bitcoin' },
- { assetId: '2', symbol: 'ETH', name: 'Ethereum' },
- ],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('trending-search-results-list')).toBeDefined();
- });
-
- it('renders section headers when sections have data', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }],
- perps: [{ symbol: 'ETH-USD', name: 'Ethereum' }],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByText } = render();
-
- expect(getByText('Tokens')).toBeDefined();
- expect(getByText('Perps')).toBeDefined();
- });
-
- it('displays skeleton loaders when loading', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: true,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId, getByText } = render(
- ,
- );
-
- expect(getByTestId('trending-search-results-list')).toBeDefined();
- expect(getByText('Tokens')).toBeDefined();
- });
-
- it('renders multiple sections with data simultaneously', () => {
+ it('renders section headers for sections with data or loading', () => {
mockUseExploreSearch.mockReturnValue({
data: {
tokens: [
@@ -127,8 +69,11 @@ describe('ExploreSearchResults', () => {
},
});
- const { getByText } = render();
+ const { getByText, getByTestId } = render(
+ ,
+ );
+ expect(getByTestId('trending-search-results-list')).toBeDefined();
expect(getByText('Tokens')).toBeDefined();
expect(getByText('Perps')).toBeDefined();
expect(getByText('Predictions')).toBeDefined();
@@ -180,33 +125,8 @@ describe('ExploreSearchResults', () => {
expect(mockUseExploreSearch).toHaveBeenCalledWith('ethereum');
});
- it('handles empty query by displaying top results', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [
- { assetId: '1', symbol: 'BTC', name: 'Bitcoin' },
- { assetId: '2', symbol: 'ETH', name: 'Ethereum' },
- { assetId: '3', symbol: 'SOL', name: 'Solana' },
- ],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('trending-search-results-list')).toBeDefined();
- });
-
describe('Footer', () => {
- it('displays Google search option when search query is provided and loading is finished', () => {
+ it('displays SitesSearchFooter when search query is provided', () => {
mockUseExploreSearch.mockReturnValue({
data: {
tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }],
@@ -222,38 +142,11 @@ describe('ExploreSearchResults', () => {
},
});
- const { getByTestId, getByText } = render(
+ const { getByTestId } = render(
,
);
- expect(getByTestId('trending-search-footer-google-link')).toBeDefined();
- expect(getByText('bitcoin')).toBeDefined();
- expect(getByText(/on Google/)).toBeDefined();
- });
-
- it('displays direct URL link when search query looks like a URL', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId, getAllByText } = render(
- ,
- );
-
- expect(getByTestId('trending-search-footer-url-link')).toBeDefined();
- expect(getByTestId('trending-search-footer-google-link')).toBeDefined();
- expect(getAllByText('example.com').length).toBeGreaterThan(0);
+ expect(getByTestId('sites-search-footer')).toBeDefined();
});
it('does not display footer when search query is empty', () => {
@@ -272,98 +165,9 @@ describe('ExploreSearchResults', () => {
},
});
- const { queryByText } = render();
-
- expect(queryByText('Search for')).toBeNull();
- expect(queryByText('on Google')).toBeNull();
- });
-
- it('does not display footer when still loading', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: true,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { queryByText } = render(
- ,
- );
-
- expect(queryByText('Search for')).toBeNull();
- expect(queryByText('on Google')).toBeNull();
- });
-
- it('navigates to Google search when Google search option is pressed', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render(
- ,
- );
+ const { queryByTestId } = render();
- const googleSearchButton = getByTestId(
- 'trending-search-footer-google-link',
- );
-
- fireEvent.press(googleSearchButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: 'https://www.google.com/search?q=ethereum',
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- });
-
- it('navigates to URL when direct URL link is pressed', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render(
- ,
- );
-
- const urlButton = getByTestId('trending-search-footer-url-link');
-
- fireEvent.press(urlButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: 'example.com',
- timestamp: expect.any(Number),
- fromTrending: true,
- });
+ expect(queryByTestId('sites-search-footer')).toBeNull();
});
});
});
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
index 2c3ff842f2a9..9e1d187d894b 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
@@ -1,15 +1,7 @@
import React, { useMemo, useCallback, useRef, useEffect } from 'react';
-import { TouchableOpacity } from 'react-native';
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';
-import {
- Box,
- Text,
- TextVariant,
- Icon,
- IconName,
- IconSize,
-} from '@metamask/design-system-react-native';
+import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
SECTIONS_CONFIG,
@@ -17,10 +9,8 @@ import {
type SectionId,
} from '../../../config/sections.config';
import { useExploreSearch } from './config/useExploreSearch';
+import SitesSearchFooter from '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter';
-function looksLikeUrl(str: string): boolean {
- return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
-}
interface ExploreSearchResultsProps {
searchQuery: string;
}
@@ -52,17 +42,6 @@ const ExploreSearchResults: React.FC = ({
const { data, isLoading } = useExploreSearch(searchQuery);
const flashListRef = useRef>(null);
- const handlePressFooterLink = useCallback(
- (url: string) => {
- navigation.navigate('TrendingBrowser', {
- newTabUrl: url,
- timestamp: Date.now(),
- fromTrending: true,
- });
- },
- [navigation],
- );
-
const renderSectionHeader = useCallback(
(title: string) => (
@@ -125,84 +104,11 @@ const ExploreSearchResults: React.FC = ({
}
}, [searchQuery, flatData.length]);
- const finishedLoading = useMemo(
- () => Object.values(isLoading).every((value) => !value),
- [isLoading],
- );
-
const renderFooter = useMemo(() => {
- if (!finishedLoading || searchQuery.length === 0) return null;
-
- const isUrl = looksLikeUrl(searchQuery.toLowerCase());
-
- return (
-
- {isUrl && (
- handlePressFooterLink(searchQuery)}
- testID="trending-search-footer-url-link"
- >
-
-
- {searchQuery}
-
-
-
-
-
-
- )}
-
-
- handlePressFooterLink(
- `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
- )
- }
- testID="trending-search-footer-google-link"
- >
-
-
- Search for {'"'}
-
-
- {searchQuery}
-
-
- {'"'} on Google
-
-
-
-
-
-
-
- );
- }, [finishedLoading, searchQuery, handlePressFooterLink, tw]);
+ if (searchQuery.length === 0) return null;
+
+ return ;
+ }, [searchQuery]);
const renderFlatItem: ListRenderItem = useCallback(
({ item }) => {
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
index 7afd60bad5b0..cc4eca8f44c1 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
@@ -66,7 +66,7 @@ jest.mock('../../../../../../UI/Predict/hooks/usePredictMarketData', () => ({
usePredictMarketData: () => mockUsePredictMarketData(),
}));
-jest.mock('../../../../SectionSites/hooks/useSitesData', () => ({
+jest.mock('../../../../../../UI/Sites/hooks/useSiteData/useSitesData', () => ({
useSitesData: () => mockUseSitesData(),
}));
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts b/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts
deleted file mode 100644
index 7e23bce8fbeb..000000000000
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from './SiteRowItem';
-export type { SiteData } from './SiteRowItem';
diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts b/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts
deleted file mode 100644
index 4ac14e2d69fe..000000000000
--- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './SiteSkeleton';
diff --git a/app/components/Views/TrendingView/SectionSites/hooks/index.ts b/app/components/Views/TrendingView/SectionSites/hooks/index.ts
deleted file mode 100644
index 9d5449d252ab..000000000000
--- a/app/components/Views/TrendingView/SectionSites/hooks/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useSitesData } from './useSitesData';
diff --git a/app/components/Views/TrendingView/SectionSites/index.ts b/app/components/Views/TrendingView/SectionSites/index.ts
deleted file mode 100644
index bbeccb8d542f..000000000000
--- a/app/components/Views/TrendingView/SectionSites/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { default as SiteRowItem } from './SiteRowItem';
-export { default as SiteRowItemWrapper } from './SiteRowItemWrapper';
-export { default as SiteSkeleton } from './SiteSkeleton';
-export { useSitesData } from './hooks/useSitesData';
-export type { SiteData } from './SiteRowItem';
diff --git a/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx b/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx
deleted file mode 100644
index 4101ce7dff5f..000000000000
--- a/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx
+++ /dev/null
@@ -1,656 +0,0 @@
-import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
-import SitesListView from './SitesListView';
-import { useSitesData } from '../SectionSites/hooks/useSitesData';
-import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem';
-
-// Mock dependencies
-jest.mock('../SectionSites/hooks/useSitesData');
-
-jest.mock('react-native-safe-area-context', () => ({
- useSafeAreaInsets: () => ({ top: 50, bottom: 34, left: 0, right: 0 }),
-}));
-
-const mockGoBack = jest.fn();
-const mockNavigate = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: mockNavigate,
- goBack: mockGoBack,
- }),
-}));
-
-const mockTwStyle = jest.fn((...args: unknown[]) => {
- const flatArgs = args.flat().filter(Boolean);
- return flatArgs.reduce((acc: Record, arg) => {
- if (typeof arg === 'string') {
- return { ...acc, [arg]: true };
- }
- if (typeof arg === 'object') {
- return { ...acc, ...arg };
- }
- return acc;
- }, {});
-});
-
-// Make mockTw callable as both function and object with style method
-const mockTw = Object.assign(mockTwStyle, { style: mockTwStyle });
-
-jest.mock('@metamask/design-system-twrnc-preset', () => ({
- useTailwind: () => mockTw,
-}));
-
-jest.mock('../SectionSites/SiteRowItemWrapper', () => {
- const ReactNative = jest.requireActual('react-native');
- return jest.fn(({ site }) => (
-
- {site.name}
-
- ));
-});
-
-jest.mock('../SectionSites/SiteSkeleton/SiteSkeleton', () =>
- jest.fn(() => {
- const ReactNative = jest.requireActual('react-native');
- return (
-
- Loading...
-
- );
- }),
-);
-
-jest.mock('../../../../component-library/components/HeaderBase', () => ({
- __esModule: true,
- default: jest.fn(({ children, startAccessory, endAccessory }) => {
- const ReactNative = jest.requireActual('react-native');
- return (
-
- {startAccessory}
- {children}
- {endAccessory}
-
- );
- }),
- HeaderBaseVariant: {
- Display: 'Display',
- },
-}));
-
-jest.mock(
- '../../../../component-library/components/Buttons/ButtonIcon',
- () => ({
- __esModule: true,
- default: jest.fn(({ onPress, iconName, testID }) => {
- const ReactNative = jest.requireActual('react-native');
- return (
-
- {iconName}
-
- );
- }),
- ButtonIconSizes: {
- Lg: 'Lg',
- },
- }),
-);
-
-jest.mock('../ExploreSearchBar/ExploreSearchBar', () => {
- const ReactNative = jest.requireActual('react-native');
- return jest.fn(({ searchQuery, onSearchChange, onCancel, placeholder }) => (
-
-
-
- Cancel
-
-
- ));
-});
-
-const mockUseSitesData = useSitesData as jest.Mock;
-
-describe('SitesListView', () => {
- const mockSites: SiteData[] = [
- {
- id: '1',
- name: 'MetaMask',
- url: 'https://metamask.io',
- displayUrl: 'metamask.io',
- logoUrl: 'https://example.com/metamask.png',
- featured: true,
- },
- {
- id: '2',
- name: 'OpenSea',
- url: 'https://opensea.io',
- displayUrl: 'opensea.io',
- logoUrl: 'https://example.com/opensea.png',
- featured: false,
- },
- {
- id: '3',
- name: 'Uniswap',
- url: 'https://uniswap.org',
- displayUrl: 'uniswap.org',
- logoUrl: 'https://example.com/uniswap.png',
- featured: true,
- },
- ];
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe('Rendering', () => {
- it('renders header with back and search buttons', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('header-base')).toBeOnTheScreen();
- expect(getByTestId('back-button')).toBeOnTheScreen();
- expect(getByTestId('search-button')).toBeOnTheScreen();
- });
-
- it('renders all site items', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- expect(getByTestId('site-item-2')).toBeOnTheScreen();
- expect(getByTestId('site-item-3')).toBeOnTheScreen();
- });
-
- it('renders skeletons when loading', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: true,
- error: null,
- });
-
- const { getAllByTestId } = render();
-
- const skeletons = getAllByTestId('site-skeleton');
- expect(skeletons.length).toBe(10);
- });
- });
-
- describe('Navigation', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('navigates back when back button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
- const backButton = getByTestId('back-button');
-
- fireEvent.press(backButton);
-
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- });
-
- it('activates search mode when search button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
- const searchButton = getByTestId('search-button');
-
- expect(queryByTestId('explore-search-bar')).toBeNull();
-
- fireEvent.press(searchButton);
-
- expect(getByTestId('explore-search-bar')).toBeOnTheScreen();
- });
-
- it('closes search mode when cancel button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- expect(getByTestId('explore-search-bar')).toBeOnTheScreen();
-
- // Press cancel
- fireEvent.press(getByTestId('explore-search-cancel-button'));
-
- // Search should be closed
- expect(queryByTestId('explore-search-bar')).toBeNull();
- expect(mockGoBack).not.toHaveBeenCalled();
- });
- });
-
- describe('Search Functionality', () => {
- it('filters sites by name', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
-
- // Search for "Meta"
- fireEvent.changeText(searchInput, 'Meta');
-
- // Only MetaMask should be visible
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- expect(queryByTestId('site-item-2')).toBeNull();
- expect(queryByTestId('site-item-3')).toBeNull();
- });
-
- it('filters sites by URL', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
-
- // Search for "opensea"
- fireEvent.changeText(searchInput, 'opensea');
-
- // Only OpenSea should be visible
- expect(queryByTestId('site-item-1')).toBeNull();
- expect(getByTestId('site-item-2')).toBeOnTheScreen();
- expect(queryByTestId('site-item-3')).toBeNull();
- });
-
- it('shows all sites when search query is empty', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
-
- // Empty search
- fireEvent.changeText(searchInput, '');
-
- // All sites should be visible
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- expect(getByTestId('site-item-2')).toBeOnTheScreen();
- expect(getByTestId('site-item-3')).toBeOnTheScreen();
- });
-
- it('shows cancel button when search is active', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Initially no cancel button
- expect(queryByTestId('explore-search-cancel-button')).toBeNull();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Cancel button should appear
- expect(getByTestId('explore-search-cancel-button')).toBeOnTheScreen();
- });
-
- it('clears search and closes search mode when cancel button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search and type
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
- fireEvent.changeText(searchInput, 'test');
-
- // Cancel
- fireEvent.press(getByTestId('explore-search-cancel-button'));
-
- // Search should be closed
- expect(queryByTestId('explore-search-bar')).toBeNull();
- });
-
- it('shows search on Google option when there is a search query', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Initially no Google search option
- expect(queryByTestId('search-on-google-button')).toBeNull();
-
- // Type any search query
- fireEvent.changeText(getByTestId('explore-view-search-input'), 'test');
-
- // Google search option should appear
- expect(getByTestId('search-on-google-button')).toBeOnTheScreen();
- });
-
- it('navigates to Google search when search on Google button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search and type
- fireEvent.press(getByTestId('search-button'));
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'test query',
- );
-
- // Press Google search button
- fireEvent.press(getByTestId('search-on-google-button'));
-
- // Should navigate to TrendingBrowser with Google search URL
- expect(mockNavigate).toHaveBeenCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({
- newTabUrl: 'https://www.google.com/search?q=test%20query',
- fromTrending: true,
- }),
- );
- });
-
- it('displays URL item when search query is a valid URL', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a valid URL
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'example.com',
- );
-
- // Should show the URL item
- expect(getByTestId('url-item')).toBeOnTheScreen();
- });
-
- it('displays URL item with https protocol', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a valid URL with protocol
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'https://example.com',
- );
-
- // Should show the URL item
- expect(getByTestId('url-item')).toBeOnTheScreen();
- });
-
- it('shows URL item separately from matching sites', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a URL that also matches existing sites
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'metamask.io',
- );
-
- // URL item should appear
- expect(getByTestId('url-item')).toBeOnTheScreen();
- // Original matching sites should still appear
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
-
- it('shows both URL item and Google search option for valid URLs', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a valid URL
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'example.com',
- );
-
- // Should show both URL item
- expect(getByTestId('url-item')).toBeOnTheScreen();
-
- // AND Google search option
- expect(getByTestId('search-on-google-button')).toBeOnTheScreen();
- });
-
- it('navigates to URL when URL item is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search and type URL
- fireEvent.press(getByTestId('search-button'));
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'example.com',
- );
-
- // Press URL item
- fireEvent.press(getByTestId('url-item'));
-
- // Should navigate to the URL
- expect(mockNavigate).toHaveBeenCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({
- newTabUrl: 'https://example.com',
- fromTrending: true,
- }),
- );
- });
-
- it('does not show URL item for non-URL search queries', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a non-URL query
- fireEvent.changeText(getByTestId('explore-view-search-input'), 'meta');
-
- // URL item should not appear
- expect(queryByTestId('url-item')).toBeNull();
-
- // But matching sites should appear
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
-
- it('hides Google search option when search query is empty', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Google search should not appear with empty query
- expect(queryByTestId('search-on-google-button')).toBeNull();
- });
- });
-
- describe('Data Fetching', () => {
- it('fetches sites with limit of 100', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- render();
-
- expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 });
- });
-
- it('passes isViewAll prop to child components', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const SiteRowItemWrapper = jest.requireMock(
- '../SectionSites/SiteRowItemWrapper',
- );
-
- render();
-
- expect(SiteRowItemWrapper).toHaveBeenCalledWith(
- expect.objectContaining({
- isViewAll: true,
- }),
- expect.anything(),
- );
- });
- });
-
- describe('Edge Cases', () => {
- it('handles transition from loading to loaded', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: true,
- error: null,
- });
-
- const { rerender, getAllByTestId, queryByTestId, getByTestId } = render(
- ,
- );
-
- expect(getAllByTestId('site-skeleton').length).toBe(10);
-
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- rerender();
-
- expect(queryByTestId('site-skeleton')).toBeNull();
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
-
- it('handles sites with missing optional fields', () => {
- const minimalSites: SiteData[] = [
- {
- id: '1',
- name: 'Test',
- url: 'https://test.com',
- displayUrl: 'test.com',
- },
- ];
-
- mockUseSitesData.mockReturnValue({
- sites: minimalSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
- });
-});
diff --git a/app/components/Views/TrendingView/SitesListView/SitesListView.tsx b/app/components/Views/TrendingView/SitesListView/SitesListView.tsx
deleted file mode 100644
index d4c7e83bee91..000000000000
--- a/app/components/Views/TrendingView/SitesListView/SitesListView.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import React, { useCallback, useState, useMemo } from 'react';
-import { FlatList, TouchableOpacity } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-// eslint-disable-next-line no-duplicate-imports
-import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import {
- Box,
- Icon,
- IconName,
- IconSize,
-} from '@metamask/design-system-react-native';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useSitesData } from '../SectionSites/hooks/useSitesData';
-import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper';
-import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton';
-import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem';
-import HeaderBase, {
- HeaderBaseVariant,
-} from '../../../../component-library/components/HeaderBase';
-import ButtonIcon, {
- ButtonIconSizes,
-} from '../../../../component-library/components/Buttons/ButtonIcon';
-import { IconName as IconNameType } from '../../../../component-library/components/Icons/Icon';
-import Text, {
- TextColor,
- TextVariant,
-} from '../../../../component-library/components/Texts/Text';
-import { strings } from '../../../../../locales/i18n';
-import ExploreSearchBar from '../ExploreSearchBar/ExploreSearchBar';
-
-function looksLikeUrl(str: string): boolean {
- return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
-}
-
-const SitesListView: React.FC = () => {
- const tw = useTailwind();
- const insets = useSafeAreaInsets();
- const navigation = useNavigation>();
- const [searchQuery, setSearchQuery] = useState('');
- const [isSearchActive, setIsSearchActive] = useState(false);
-
- // Fetch all sites (no limit)
- const { sites, isLoading } = useSitesData({ limit: 100 });
-
- // Filter sites based on search query
- const filteredSites = useMemo(() => {
- if (!searchQuery.trim()) {
- return sites;
- }
-
- const query = searchQuery.toLowerCase();
- return sites.filter(
- (site) =>
- site.name.toLowerCase().includes(query) ||
- site.displayUrl.toLowerCase().includes(query) ||
- site.url.toLowerCase().includes(query),
- );
- }, [sites, searchQuery]);
-
- const handleBackPress = useCallback(() => {
- if (isSearchActive) {
- setIsSearchActive(false);
- setSearchQuery('');
- } else {
- navigation.goBack();
- }
- }, [navigation, isSearchActive]);
-
- const handleSearchPress = useCallback(() => {
- setIsSearchActive(true);
- }, []);
-
- const handleCancelSearch = useCallback(() => {
- setIsSearchActive(false);
- setSearchQuery('');
- }, []);
-
- const handlePressFooterLink = useCallback(
- (url: string) => {
- navigation.navigate('TrendingBrowser', {
- newTabUrl: url,
- timestamp: Date.now(),
- fromTrending: true,
- });
- },
- [navigation],
- );
-
- const renderSiteItem = ({ item }: { item: SiteData }) => (
-
- );
-
- const renderSkeleton = () => (
- <>
- {[...Array(10)].map((_, index) => (
-
- ))}
- >
- );
-
- const renderFooter = useMemo(() => {
- if (!isSearchActive || searchQuery.length === 0) return null;
-
- const isUrl = looksLikeUrl(searchQuery.toLowerCase());
- const urlWithProtocol =
- isUrl && !searchQuery.startsWith('http')
- ? `https://${searchQuery}`
- : searchQuery;
-
- return (
-
- {isUrl && (
- handlePressFooterLink(urlWithProtocol)}
- testID="url-item"
- >
-
-
- {searchQuery}
-
-
-
-
-
-
- )}
-
-
- handlePressFooterLink(
- `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
- )
- }
- testID="search-on-google-button"
- >
-
-
- Search for {'"'}
-
-
- {searchQuery}
-
-
- {'"'} on Google
-
-
-
-
-
-
-
- );
- }, [isSearchActive, searchQuery, handlePressFooterLink, tw]);
-
- return (
-
- {/* Header */}
-
- {isSearchActive ? (
-
- ) : (
-
- }
- endAccessory={
-
- }
- style={tw.style('flex-row items-center gap-1')}
- >
-
- {strings('trending.popular_sites')}
-
-
- )}
-
-
- {/* Sites List */}
-
- {isLoading ? (
- renderSkeleton()
- ) : (
- item.id}
- contentContainerStyle={tw.style('pb-4')}
- showsVerticalScrollIndicator={false}
- ListFooterComponent={renderFooter}
- />
- )}
-
-
- );
-};
-
-export default SitesListView;
diff --git a/app/components/Views/TrendingView/SitesListView/index.ts b/app/components/Views/TrendingView/SitesListView/index.ts
deleted file mode 100644
index f705e80c2ab1..000000000000
--- a/app/components/Views/TrendingView/SitesListView/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './SitesListView';
diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx
index 327b39c09049..6a21ab1058f2 100644
--- a/app/components/Views/TrendingView/TrendingView.test.tsx
+++ b/app/components/Views/TrendingView/TrendingView.test.tsx
@@ -27,7 +27,6 @@ jest.mock('react-redux', () => ({
}));
import TrendingView from './TrendingView';
-import { updateLastTrendingScreen } from '../../Nav/Main/MainNavigator';
import {
selectChainId,
selectPopularNetworkConfigurationsByCaipChainId,
@@ -52,11 +51,6 @@ jest.mock('../../../util/browser', () => ({
})),
}));
-jest.mock('../Browser', () => ({
- __esModule: true,
- default: jest.fn(() => null),
-}));
-
// Mock the network hooks used by useTrendingRequest
jest.mock(
'../../../components/hooks/useNetworksByNamespace/useNetworksByNamespace',
@@ -387,52 +381,7 @@ describe('TrendingView', () => {
expect(getByText('99')).toBeDefined();
});
- it('navigates to TrendingBrowser when button is pressed with no tabs', () => {
- mockUseSelector.mockImplementation((selector) => {
- // Handle browser tabs count selector
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (selectorStr.includes('browser') && selectorStr.includes('tabs')) {
- return 0;
- }
- if (selectorStr.includes('dataCollectionForMarketing')) {
- return false;
- }
- }
- // Return default mock values for other selectors
- if (selector === selectChainId) {
- return '0x1';
- }
- if (selector === selectIsEvmNetworkSelected) {
- return true;
- }
- if (selector === selectEnabledNetworksByNamespace) {
- return { eip155: { '0x1': true } };
- }
- if (selector === selectPopularNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectCustomNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectMultichainAccountsState2Enabled) {
- return false;
- }
- if (selector === selectSelectedInternalAccountByScope) {
- return (_scope: string) => null;
- }
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (
- selectorStr.includes('selectSelectedInternalAccountByScope') ||
- selectorStr.includes('SelectedInternalAccountByScope')
- ) {
- return (_scope: string) => null;
- }
- }
- return undefined;
- });
-
+ it('navigates to TrendingBrowser when button is pressed', () => {
const { getByTestId } = render(
@@ -442,75 +391,13 @@ describe('TrendingView', () => {
const browserButton = getByTestId('trending-view-browser-button');
fireEvent.press(browserButton);
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser');
- });
-
- it('navigates to TrendingBrowser when button is pressed with existing tabs', () => {
- mockUseSelector.mockImplementation((selector) => {
- // Handle browser tabs count selector
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (selectorStr.includes('browser') && selectorStr.includes('tabs')) {
- return 3;
- }
- if (selectorStr.includes('dataCollectionForMarketing')) {
- return false;
- }
- }
- // Return default mock values for other selectors
- if (selector === selectChainId) {
- return '0x1';
- }
- if (selector === selectIsEvmNetworkSelected) {
- return true;
- }
- if (selector === selectEnabledNetworksByNamespace) {
- return { eip155: { '0x1': true } };
- }
- if (selector === selectPopularNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectCustomNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectMultichainAccountsState2Enabled) {
- return false;
- }
- if (selector === selectSelectedInternalAccountByScope) {
- return (_scope: string) => null;
- }
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (
- selectorStr.includes('selectSelectedInternalAccountByScope') ||
- selectorStr.includes('SelectedInternalAccountByScope')
- ) {
- return (_scope: string) => null;
- }
- }
- return undefined;
- });
-
- const { getByTestId } = render(
-
-
- ,
+ expect(mockNavigate).toHaveBeenCalledWith(
+ 'TrendingBrowser',
+ expect.objectContaining({
+ newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
+ fromTrending: true,
+ }),
);
-
- const browserButton = getByTestId('trending-view-browser-button');
- fireEvent.press(browserButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser');
});
});
@@ -524,45 +411,6 @@ describe('TrendingView', () => {
expect(getByText('Explore')).toBeDefined();
});
- it('navigates to TrendingBrowser route when browser button is pressed', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const browserButton = getByTestId('trending-view-browser-button');
-
- fireEvent.press(browserButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser');
- });
-
- it('includes portfolio URL with correct parameters when browser button is pressed', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const browserButton = getByTestId('trending-view-browser-button');
-
- fireEvent.press(browserButton);
-
- expect(mockNavigate).toHaveBeenCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({
- newTabUrl: expect.stringContaining('metamaskEntry=mobile'),
- fromTrending: true,
- }),
- );
- });
-
it('renders search bar button', () => {
const { getByTestId } = render(
diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx
index 5593c6cd6446..5eea579782d0 100644
--- a/app/components/Views/TrendingView/TrendingView.tsx
+++ b/app/components/Views/TrendingView/TrendingView.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -18,7 +18,6 @@ import AppConstants from '../../../core/AppConstants';
import { appendURLParams } from '../../../util/browser';
import { useMetrics } from '../../hooks/useMetrics';
import { useTheme } from '../../../util/theme';
-import Browser from '../Browser';
import Routes from '../../../constants/navigation/Routes';
import {
lastTrendingScreenRef,
@@ -26,42 +25,12 @@ import {
} from '../../Nav/Main/MainNavigator';
import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen';
import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar';
-import {
- PredictModalStack,
- PredictMarketDetails,
- PredictSellPreview,
-} from '../../UI/Predict';
-import PredictBuyPreview from '../../UI/Predict/views/PredictBuyPreview/PredictBuyPreview';
import QuickActions from './components/QuickActions/QuickActions';
import SectionHeader from './components/SectionHeader/SectionHeader';
import { HOME_SECTIONS_ARRAY } from './config/sections.config';
const Stack = createStackNavigator();
-// Wrapper component to intercept navigation
-const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => {
- const navigation = useNavigation();
-
- // Create a custom navigation object that intercepts navigate calls
- const customNavigation = useMemo(() => {
- const originalNavigate = navigation.navigate.bind(navigation);
-
- return {
- ...navigation,
- navigate: (routeName: string, params?: object) => {
- // If trying to navigate to TRENDING_VIEW, go back in stack instead
- if (routeName === Routes.TRENDING_VIEW) {
- navigation.goBack();
- } else {
- originalNavigate(routeName, params);
- }
- },
- };
- }, [navigation]);
-
- return ;
-};
-
const TrendingFeed: React.FC = () => {
const tw = useTailwind();
const insets = useSafeAreaInsets();
@@ -198,43 +167,10 @@ const TrendingView: React.FC = () => {
}}
>
-
-
-
-
-
);
};
diff --git a/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx
new file mode 100644
index 000000000000..ab877cef7edf
--- /dev/null
+++ b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx
@@ -0,0 +1,37 @@
+import React, { useMemo } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import Browser from '../../../Browser';
+import Routes from '../../../../../constants/navigation/Routes';
+
+// Wrapper component to intercept navigation
+const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => {
+ const navigation = useNavigation();
+ const tw = useTailwind();
+
+ // Create a custom navigation object that intercepts navigate calls
+ const customNavigation = useMemo(() => {
+ const originalNavigate = navigation.navigate.bind(navigation);
+
+ return {
+ ...navigation,
+ navigate: (routeName: string, params?: object) => {
+ // If trying to navigate to TRENDING_VIEW, go back in stack instead
+ if (routeName === Routes.TRENDING_VIEW) {
+ navigation.goBack();
+ } else {
+ originalNavigate(routeName, params);
+ }
+ },
+ };
+ }, [navigation]);
+
+ return (
+
+
+
+ );
+};
+
+export default BrowserWrapper;
diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx
index 989a94b1b1b7..45887c5c11d3 100644
--- a/app/components/Views/TrendingView/config/sections.config.tsx
+++ b/app/components/Views/TrendingView/config/sections.config.tsx
@@ -19,10 +19,10 @@ import { usePerpsMarkets } from '../../../UI/Perps/hooks';
import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider';
import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager';
import { Box, IconName } from '@metamask/design-system-react-native';
-import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem';
-import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper';
-import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton';
-import { useSitesData } from '../SectionSites/hooks/useSitesData';
+import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem';
+import SiteRowItemWrapper from '../../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper';
+import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton';
+import { useSitesData } from '../../../UI/Sites/hooks/useSiteData/useSitesData';
import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch';
export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites';
@@ -180,7 +180,7 @@ export const SECTIONS_CONFIG: Record = {
title: strings('trending.sites'),
icon: IconName.Global,
viewAllAction: (navigation) => {
- navigation.navigate(Routes.SITES_LIST_VIEW);
+ navigation.navigate(Routes.SITES_FULL_VIEW);
},
RowItem: ({ item, navigation }) => (
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 74a47afa24ac..dc3108646399 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -79,7 +79,7 @@ const Routes = {
REWARDS_SETTINGS_VIEW: 'RewardsSettingsView',
REWARDS_DASHBOARD: 'RewardsDashboard',
TRENDING_VIEW: 'TrendingView',
- SITES_LIST_VIEW: 'SitesListView',
+ SITES_FULL_VIEW: 'SitesFullView',
EXPLORE_SEARCH: 'ExploreSearch',
REWARDS_ONBOARDING_FLOW: 'RewardsOnboardingFlow',
REWARDS_ONBOARDING_INTRO: 'RewardsOnboardingIntro',
From ace43469b039669ae34dbafcd79298a204656944 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patryk=20=C5=81ucka?=
<5708018+PatrykLucka@users.noreply.github.com>
Date: Wed, 26 Nov 2025 15:18:33 +0100
Subject: [PATCH 02/16] refactor: update AccountSelector animations to use
screen width instead of height (#23313)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Updated the Account List transition animation from bottom-top (vertical
slide) to right-left (horizontal slide) to align with the new design
system patterns. The change affects the full-page account list view when
the `fullPageAccountList` feature flag is enabled.
**What is the reason for the change?**
- The design system has been updated to use right-left transitions for
full-page modals instead of the previous bottom-top pattern
- This change ensures consistency with the new design system guidelines
**What is the improvement/solution?**
- Changed animation from `translateY` (vertical) to `translateX`
(horizontal)
- Updated initial animation value from `screenHeight` to `screenWidth`
- Updated backdrop opacity interpolation to work with horizontal
translation
- Maintained the same spring animation configuration for smooth
transitions
## **Changelog**
CHANGELOG entry: Updated Account List transition animation to slide in
from the right instead of from the bottom (behind feature flag)
## **Related issues**
Fixes:
[TMCU-223](https://consensyssoftware.atlassian.net/browse/TMCU-223)
## **Manual testing steps**
```gherkin
Feature: Account List Transition Animation
Scenario: user opens account list with full-page feature flag enabled
Given the app is running with the `fullPageAccountList` feature flag enabled
And the user is on the wallet/home screen
When user taps on the account selector/account name
Then the account list should slide in from the right (left-to-right animation)
And the backdrop should fade in smoothly
And when user selects an account or taps back, the list should slide out to the right
```
## **Screenshots/Recordings**
### **Before**
https://github.com/user-attachments/assets/fcc87382-b920-4a12-94a1-2caf3e6e7f7d
### **After**
https://github.com/user-attachments/assets/494ccf12-2648-4a40-b8cd-4ecc151aff12
## **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.
[TMCU-223]:
https://consensyssoftware.atlassian.net/browse/TMCU-223?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
---
> [!NOTE]
> Switches full-page AccountSelector transition from vertical
(height/translateY) to horizontal (width/translateX), updating
animations and backdrop interpolation accordingly.
>
> - **AccountSelector
(`app/components/Views/AccountSelector/AccountSelector.tsx`)**:
> - Replace vertical animation with horizontal:
> - Use `screenWidth` instead of `screenHeight` and `translateX` instead
of `translateY`.
> - Update enter/exit animations to spring/timing on `translateX`.
> - Adjust backdrop opacity interpolation to `[screenWidth, 0]` on
`translateX`.
> - Update animated styles to transform with `translateX`.
> - Applies only when `fullPageAccountList` feature flag is enabled.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7028f659afb40ea2a3efb3b3cfb9a185dce328ea. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../Views/AccountSelector/AccountSelector.tsx | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx
index da16539bf735..a38b2ba43ebf 100644
--- a/app/components/Views/AccountSelector/AccountSelector.tsx
+++ b/app/components/Views/AccountSelector/AccountSelector.tsx
@@ -90,7 +90,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
const dispatch = useDispatch();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- const { height: screenHeight } = useWindowDimensions();
+ const { width: screenWidth } = useWindowDimensions();
const { trackEvent, createEventBuilder } = useMetrics();
const routeParams = useMemo(() => route?.params, [route?.params]);
@@ -162,11 +162,11 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
useState(false);
// Animation using react-native-reanimated - only for full-page version
- const translateY = useSharedValue(screenHeight);
+ const translateX = useSharedValue(screenWidth);
- // Backdrop opacity animation - fades in as screen slides up
+ // Backdrop opacity animation - fades in as screen slides in from right
const backdropOpacity = useDerivedValue(() =>
- interpolate(translateY.value, [screenHeight, 0], [0, 0.5]),
+ interpolate(translateX.value, [screenWidth, 0], [0, 0.5]),
);
useEffect(() => {
@@ -185,7 +185,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
});
};
- translateY.value = withSpring(
+ translateX.value = withSpring(
0,
{
damping: 20,
@@ -204,8 +204,8 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
navigation.goBack();
};
- translateY.value = withTiming(
- screenHeight,
+ translateX.value = withTiming(
+ screenWidth,
{ duration: AnimationDuration.Fast },
() => runOnJS(onCloseComplete)(),
);
@@ -213,7 +213,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
// BottomSheet version: close the sheet
sheetRef.current?.onCloseBottomSheet();
}
- }, [isFullPageAccountList, translateY, navigation, screenHeight]);
+ }, [isFullPageAccountList, translateX, navigation, screenWidth]);
const _onSelectAccount = useCallback(
(address: string) => {
@@ -412,7 +412,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
]);
const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ translateY: translateY.value }],
+ transform: [{ translateX: translateX.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
From 97fe70301b8fe33f8e4c9fd7941ba4769dfbdbc9 Mon Sep 17 00:00:00 2001
From: Charly Chevalier
Date: Wed, 26 Nov 2025 15:50:15 +0100
Subject: [PATCH 03/16] chore: bump `eth-snap-keyring` (to enable
`:accountCreated` idempotency) cp-7.60.0 (#23310)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Bumping `@metamask/eth-snap-keyring` to enable `notify:accountCreated`
idempotency which is required by the Bitcoin Snap.
This will reduce the number of "misaligned" warnings we had with
Bitcoin.
Similar to:
- https://github.com/MetaMask/metamask-extension/pull/38292
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
- https://github.com/MetaMask/metamask-mobile/issues/23324
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Upgrade `@metamask/eth-snap-keyring` to ^18.0.2 and refresh related
keyring dependencies in `yarn.lock`.
>
> - **Dependencies**:
> - Bump `@metamask/eth-snap-keyring` from `^18.0.0` to `^18.0.2` in
`package.json`.
> - Update lockfile to resolve to `18.0.2` and align transitive deps:
> - `@metamask/keyring-api` -> `^21.2.0`
> - `@metamask/keyring-internal-api` -> `9.1.1`
> - `@metamask/keyring-internal-snap-client` -> `8.0.1`
> - `@metamask/keyring-snap-client` -> `8.1.1`
> - `@metamask/keyring-snap-sdk` -> `7.1.1`
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
02bd3bf893a7763b30270c8de3441666ba73b1d2. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
yarn.lock | 66 ++++++++++++++++++++++++++--------------------------
2 files changed, 34 insertions(+), 34 deletions(-)
diff --git a/package.json b/package.json
index d93573cf1b9c..058289542c4d 100644
--- a/package.json
+++ b/package.json
@@ -223,7 +223,7 @@
"@metamask/eth-qr-keyring": "^1.1.0",
"@metamask/eth-query": "^4.0.0",
"@metamask/eth-sig-util": "^8.0.0",
- "@metamask/eth-snap-keyring": "^18.0.0",
+ "@metamask/eth-snap-keyring": "^18.0.2",
"@metamask/etherscan-link": "^2.0.0",
"@metamask/ethjs-contract": "^0.4.1",
"@metamask/ethjs-query": "^0.7.1",
diff --git a/yarn.lock b/yarn.lock
index 37ac84c03151..73ba24ac5cc3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8160,16 +8160,16 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/eth-snap-keyring@npm:^18.0.0":
- version: 18.0.0
- resolution: "@metamask/eth-snap-keyring@npm:18.0.0"
+"@metamask/eth-snap-keyring@npm:^18.0.0, @metamask/eth-snap-keyring@npm:^18.0.2":
+ version: 18.0.2
+ resolution: "@metamask/eth-snap-keyring@npm:18.0.2"
dependencies:
"@ethereumjs/tx": "npm:^5.4.0"
"@metamask/eth-sig-util": "npm:^8.2.0"
- "@metamask/keyring-api": "npm:^21.1.0"
- "@metamask/keyring-internal-api": "npm:^9.1.0"
- "@metamask/keyring-internal-snap-client": "npm:^8.0.0"
- "@metamask/keyring-snap-sdk": "npm:^7.1.0"
+ "@metamask/keyring-api": "npm:^21.2.0"
+ "@metamask/keyring-internal-api": "npm:^9.1.1"
+ "@metamask/keyring-internal-snap-client": "npm:^8.0.1"
+ "@metamask/keyring-snap-sdk": "npm:^7.1.1"
"@metamask/keyring-utils": "npm:^3.1.0"
"@metamask/messenger": "npm:^0.3.0"
"@metamask/superstruct": "npm:^3.1.0"
@@ -8177,8 +8177,8 @@ __metadata:
"@types/uuid": "npm:^9.0.8"
uuid: "npm:^9.0.1"
peerDependencies:
- "@metamask/keyring-api": ^21.1.0
- checksum: 10/39a6380e351997e53776c8db9d1558769517a1a12ec1431c40cedb516d90ae447a81b7b1c21bc8d8ffcbc31188cf52f17057a1416d509013cfe8b2f46b314e02
+ "@metamask/keyring-api": ^21.2.0
+ checksum: 10/2c37e55cf4b56089fb5a081d3809b9004b8bbe2822267fbe5b8884cd687da4a43e122b053ebbc418173353232066a4763edc90002f51ce55a84e53a7009c16e6
languageName: node
linkType: hard
@@ -8391,7 +8391,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.1.0, @metamask/keyring-api@npm:^21.2.0":
+"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.2.0":
version: 21.2.0
resolution: "@metamask/keyring-api@npm:21.2.0"
dependencies:
@@ -8426,35 +8426,35 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/keyring-internal-api@npm:^9.0.0, @metamask/keyring-internal-api@npm:^9.1.0":
- version: 9.1.0
- resolution: "@metamask/keyring-internal-api@npm:9.1.0"
+"@metamask/keyring-internal-api@npm:^9.0.0, @metamask/keyring-internal-api@npm:^9.1.0, @metamask/keyring-internal-api@npm:^9.1.1":
+ version: 9.1.1
+ resolution: "@metamask/keyring-internal-api@npm:9.1.1"
dependencies:
- "@metamask/keyring-api": "npm:^21.1.0"
+ "@metamask/keyring-api": "npm:^21.2.0"
"@metamask/keyring-utils": "npm:^3.1.0"
"@metamask/superstruct": "npm:^3.1.0"
- checksum: 10/6b19f35f57bc1b5dc73957d7f3185236780c93e6292678e22d84f9eb2fe92e15a98437a9bc4fbe5e5e10143d4db36afa2c420636f2cca4bd984e8455ca4332c6
+ checksum: 10/ab0fb8e153a02d3d0acf739d77356a1c60e0a7bf998dcbba9468f9f231605beaed472d8bff27dc56323d0a2529167336499e23dcad911fa8c3e37999ed14d2d1
languageName: node
linkType: hard
-"@metamask/keyring-internal-snap-client@npm:^8.0.0":
- version: 8.0.0
- resolution: "@metamask/keyring-internal-snap-client@npm:8.0.0"
+"@metamask/keyring-internal-snap-client@npm:^8.0.1":
+ version: 8.0.1
+ resolution: "@metamask/keyring-internal-snap-client@npm:8.0.1"
dependencies:
- "@metamask/keyring-api": "npm:^21.1.0"
- "@metamask/keyring-internal-api": "npm:^9.1.0"
- "@metamask/keyring-snap-client": "npm:^8.1.0"
+ "@metamask/keyring-api": "npm:^21.2.0"
+ "@metamask/keyring-internal-api": "npm:^9.1.1"
+ "@metamask/keyring-snap-client": "npm:^8.1.1"
"@metamask/keyring-utils": "npm:^3.1.0"
"@metamask/messenger": "npm:^0.3.0"
- checksum: 10/7a4aa08ac6ac1bda064182420af01b785aaaff37068d14577007ce40e53f4da33b3bbc1a18625ebd75cee6d08c34de8dc860e6c927477335d5f1df72328b563a
+ checksum: 10/40a686cd3d1f49accde83bb2a983ac9e897498e1de5a0ccb0768e382d44dd4c273230db95bcd6eace4ad8a184e7ab4fc780770f617994a2ca29b4302890f31b6
languageName: node
linkType: hard
-"@metamask/keyring-snap-client@npm:^8.0.0, @metamask/keyring-snap-client@npm:^8.1.0":
- version: 8.1.0
- resolution: "@metamask/keyring-snap-client@npm:8.1.0"
+"@metamask/keyring-snap-client@npm:^8.0.0, @metamask/keyring-snap-client@npm:^8.1.0, @metamask/keyring-snap-client@npm:^8.1.1":
+ version: 8.1.1
+ resolution: "@metamask/keyring-snap-client@npm:8.1.1"
dependencies:
- "@metamask/keyring-api": "npm:^21.1.0"
+ "@metamask/keyring-api": "npm:^21.2.0"
"@metamask/keyring-utils": "npm:^3.1.0"
"@metamask/superstruct": "npm:^3.1.0"
"@types/uuid": "npm:^9.0.8"
@@ -8462,13 +8462,13 @@ __metadata:
webextension-polyfill: "npm:^0.12.0"
peerDependencies:
"@metamask/providers": ^19.0.0
- checksum: 10/e92aa7f6e1454150870e8e0a6d9cf4fac7bbc22280d85a252ca7ee428842dfbaaaccae78dfc5ad773e21d757febfcbe6933a72b966c4478f1a2b3fc0088419a1
+ checksum: 10/dcdc9a286137a4ae884b709e565b988fb2e555a8a80db5d2ed3e93ee5262c81567a4efac6ff663b6751caf5b1173f92bc8437a395696058018a3b6e93fc30b35
languageName: node
linkType: hard
-"@metamask/keyring-snap-sdk@npm:^7.1.0":
- version: 7.1.0
- resolution: "@metamask/keyring-snap-sdk@npm:7.1.0"
+"@metamask/keyring-snap-sdk@npm:^7.1.1":
+ version: 7.1.1
+ resolution: "@metamask/keyring-snap-sdk@npm:7.1.1"
dependencies:
"@metamask/keyring-utils": "npm:^3.1.0"
"@metamask/snaps-sdk": "npm:^9.0.0"
@@ -8476,9 +8476,9 @@ __metadata:
"@metamask/utils": "npm:^11.1.0"
webextension-polyfill: "npm:^0.12.0"
peerDependencies:
- "@metamask/keyring-api": ^21.1.0
+ "@metamask/keyring-api": ^21.2.0
"@metamask/providers": ^19.0.0
- checksum: 10/1a1809733c1f21af87f3491d292c499c5441afa0780e848718ec2b6aff50d76bb03ea44ee93ecaa80d79453a98926d84cd13ff406256ab6a2136d9e31250faa8
+ checksum: 10/ac4ce050f4647096ef66ebd04d99d1423c002ca0fb05bd83e11caec59754b56d73bb8a95ac3a76f64472713256205e889d6785003dfe2c35f5f1d67c2f2efd12
languageName: node
linkType: hard
@@ -35667,7 +35667,7 @@ __metadata:
"@metamask/eth-qr-keyring": "npm:^1.1.0"
"@metamask/eth-query": "npm:^4.0.0"
"@metamask/eth-sig-util": "npm:^8.0.0"
- "@metamask/eth-snap-keyring": "npm:^18.0.0"
+ "@metamask/eth-snap-keyring": "npm:^18.0.2"
"@metamask/etherscan-link": "npm:^2.0.0"
"@metamask/ethjs-contract": "npm:^0.4.1"
"@metamask/ethjs-query": "npm:^0.7.1"
From 011271d8f68ad9235d1daa244a2c76f4f68d8a67 Mon Sep 17 00:00:00 2001
From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com>
Date: Wed, 26 Nov 2025 15:57:41 +0100
Subject: [PATCH 04/16] feat: track RPC update from network connection banner
(#22879)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR tracks when users complete the RPC update flow initiated from
the network connection banner, matching the implementation in
metamask-extension
https://github.com/MetaMask/metamask-extension/pull/37751.
**Context**: Currently we track when users click "Update RPC" from the
network connection banner (`NetworkConnectionBannerUpdateRpcClicked`),
but we don't track whether they actually complete the update by saving
changes in the network settings form.
**Solution**: Added the `NetworkConnectionBannerRpcUpdated` MetaMetrics
event that fires when:
1. User clicks "Update RPC" from the network connection banner (sets
`trackRpcUpdateFromBanner: true` in navigation params)
2. User modifies the RPC endpoint in the NetworkSettings form
3. User saves the changes
This provides complete funnel tracking for the network banner → RPC
update flow.
## **Changelog**
CHANGELOG entry: null
(Internal analytics tracking - not user-facing)
## **Related issues**
Fixes:
[ttps://consensyssoftware.atlassian.net/browse/WPC-172](https://consensyssoftware.atlassian.net/browse/WPC-172)
## **Manual testing steps**
Feature: Track RPC update from network connection banner
Scenario: user completes RPC update from network connection banner
Given user has a network with degraded or unavailable status
And network connection banner is shown
When user clicks "Update RPC" button in the banner
And user changes the RPC endpoint in the network settings form
And user saves the network settings
Then NetworkConnectionBannerRpcUpdated event is tracked
And event includes chain_id_caip, from_rpc_domain, and to_rpc_domain
properties
Scenario: user saves network settings without banner tracking flag
Given user is in network settings form
And trackRpcUpdateFromBanner is not set
When user modifies RPC endpoint
And user saves the network settings
Then NetworkConnectionBannerRpcUpdated event is not tracked
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Tracks a new "Network Connection Banner RPC Updated" event when a
banner-initiated RPC change is saved, with sanitized domains and CAIP
chain IDs, and wires the tracking flag through navigation with
comprehensive tests.
>
> - **Analytics**
> - Add `MetaMetricsEvents.NetworkConnectionBannerRpcUpdated` and expose
in events map.
> - **Settings › NetworkSettings**
> - On save, when `trackRpcUpdateFromBanner` is true, emit
`NetworkConnectionBannerRpcUpdated` with `chain_id_caip`,
`from_rpc_domain`, `to_rpc_domain`.
> - Sanitize endpoints via `isPublicEndpointUrl`/`onlyKeepHost`; compute
CAIP chain ID with `hexToNumber`.
> - **Hooks**
> - `useNetworkConnectionBanner.updateRpc` now navigates to
`Routes.EDIT_NETWORK` with `trackRpcUpdateFromBanner: true`.
> - **Tests**
> - Add/extend tests for event emission, non-emission, URL sanitization
(`custom`), `unknown` fallback, and navigation param propagation.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5f125ba1c7ee1ce0b682ad65ced0609960d1f759. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../NetworksSettings/NetworkSettings/index.js | 41 ++-
.../NetworkSettings/index.test.tsx | 295 +++++++++++++++++-
.../useNetworkConnectionBanner.test.tsx | 1 +
.../useNetworkConnectionBanner.ts | 1 +
app/core/Analytics/MetaMetrics.events.ts | 4 +
5 files changed, 335 insertions(+), 7 deletions(-)
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
index 5ba833a4d429..0e49cbc92bc6 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
@@ -32,6 +32,7 @@ import sanitizeUrl, {
compareSanitizedUrl,
} from '../../../../../util/sanitizeUrl';
import onlyKeepHost from '../../../../../util/onlyKeepHost';
+import { isPublicEndpointUrl } from '../../../../../core/Engine/controllers/network-controller/utils';
import { themeAppearanceLight } from '../../../../../constants/storage';
import CustomNetwork from './CustomNetworkView/CustomNetwork';
import Button, {
@@ -48,6 +49,7 @@ import { selectIsRpcFailoverEnabled } from '../../../../../selectors/featureFlag
import { regex } from '../../../../../../app/util/regex';
import { NetworksViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/NetworksView.selectors';
import { isSafeChainId, toHex } from '@metamask/controller-utils';
+import { hexToNumber } from '@metamask/utils';
import { CustomDefaultNetworkIDs } from '../../../../../../e2e/selectors/Onboarding/CustomDefaultNetwork.selectors';
import { updateIncomingTransactions } from '../../../../../util/transaction-controller';
import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics';
@@ -83,7 +85,7 @@ import Tag from '../../../../../component-library/components/Tags/Tag/Tag';
import { CellComponentSelectorsIDs } from '../../../../../../e2e/selectors/wallet/CellComponent.selectors';
import stripProtocol from '../../../../../util/stripProtocol';
import stripKeyFromInfuraUrl from '../../../../../util/stripKeyFromInfuraUrl';
-import { MetaMetrics } from '../../../../../core/Analytics';
+import { MetaMetrics, MetaMetricsEvents } from '../../../../../core/Analytics';
import {
addItemToChainIdList,
removeItemFromChainIdList,
@@ -532,6 +534,7 @@ export class NetworkSettings extends PureComponent {
isCustomMainnet,
shouldNetworkSwitchPopToWallet,
navigation,
+ trackRpcUpdateFromBanner,
}) => {
const { NetworkController } = Engine.context;
@@ -569,6 +572,38 @@ export class NetworkSettings extends PureComponent {
}
: undefined,
);
+
+ // Track RPC update from network connection banner
+ if (trackRpcUpdateFromBanner) {
+ const newRpcEndpoint =
+ networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex];
+ const oldRpcEndpoint =
+ existingNetwork.rpcEndpoints?.[
+ existingNetwork.defaultRpcEndpointIndex ?? 0
+ ];
+
+ const chainIdAsDecimal = hexToNumber(chainId);
+
+ const sanitizeRpcUrl = (url) =>
+ isPublicEndpointUrl(url, infuraProjectId)
+ ? onlyKeepHost(url)
+ : 'custom';
+
+ this.props.metrics.trackEvent(
+ this.props.metrics
+ .createEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: `eip155:${chainIdAsDecimal}`,
+ from_rpc_domain: oldRpcEndpoint?.url
+ ? sanitizeRpcUrl(oldRpcEndpoint.url)
+ : 'unknown',
+ to_rpc_domain: sanitizeRpcUrl(newRpcEndpoint.url),
+ })
+ .build(),
+ );
+ }
} else {
await NetworkController.addNetwork({
...networkConfig,
@@ -614,6 +649,9 @@ export class NetworkSettings extends PureComponent {
const shouldNetworkSwitchPopToWallet =
route.params?.shouldNetworkSwitchPopToWallet ?? true;
+
+ const trackRpcUpdateFromBanner =
+ route.params?.trackRpcUpdateFromBanner ?? false;
// Check if CTA is disabled
const isCtaDisabled =
!enableAction || this.disabledByChainId() || this.disabledBySymbol();
@@ -684,6 +722,7 @@ export class NetworkSettings extends PureComponent {
networkType,
networkUrl,
showNetworkOnboarding,
+ trackRpcUpdateFromBanner,
});
};
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
index 21847e0f60c5..b2cc6d34a4bb 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
@@ -24,6 +24,7 @@ import * as jsonRequest from '../../../../../util/jsonRpcRequest';
import Logger from '../../../../../util/Logger';
import Engine from '../../../../../core/Engine';
import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
const { PreferencesController } = Engine.context;
jest.mock(
@@ -38,14 +39,28 @@ jest.mock(
}),
);
+const mockTrackEvent = jest.fn();
+
+const mockCreateEventBuilder = jest.fn((eventName) => {
+ let properties = {};
+ return {
+ addProperties(props: Record) {
+ properties = { ...properties, ...props };
+ return this;
+ },
+ build() {
+ return {
+ name: eventName,
+ properties,
+ };
+ },
+ };
+});
+
jest.mock('../../../../../components/hooks/useMetrics', () => ({
useMetrics: () => ({
- trackEvent: jest.fn(),
- createEventBuilder: jest.fn(() => ({
- addProperties: jest.fn(() => ({
- build: jest.fn(),
- })),
- })),
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
}),
withMetricsAwareness: (Component: unknown) => Component,
}));
@@ -1410,6 +1425,274 @@ describe('NetworkSettings', () => {
{ replacementSelectedRpcEndpointIndex: 0 },
);
});
+
+ it('tracks RPC update event when trackRpcUpdateFromBanner is true', async () => {
+ const PROPS_WITH_METRICS = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ chainId: '0x64',
+ rpcEndpoints: [
+ {
+ networkClientId: 'custom',
+ type: 'custom',
+ url: 'https://mainnet.infura.io/v3/',
+ },
+ ],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper5 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper5.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://monad-mainnet.infura.io/v3/',
+ rpcUrls: [
+ {
+ url: 'https://monad-mainnet.infura.io/v3/',
+ type: 'custom',
+ name: 'Monad RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: true,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ mockCreateEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: 'eip155:100',
+ from_rpc_domain: 'mainnet.infura.io',
+ to_rpc_domain: 'monad-mainnet.infura.io',
+ })
+ .build(),
+ );
+ });
+
+ it('does not track RPC update event when trackRpcUpdateFromBanner is false', async () => {
+ const PROPS_WITHOUT_TRACKING = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ chainId: '0x64',
+ rpcEndpoints: [
+ {
+ networkClientId: 'custom',
+ type: 'custom',
+ url: 'https://mainnet.infura.io/v3/',
+ },
+ ],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper6 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper6.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://monad-mainnet.infura.io/v3/',
+ rpcUrls: [
+ {
+ url: 'https://monad-mainnet.infura.io/v3/',
+ type: 'custom',
+ name: 'Monad RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: false,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+
+ it('sanitizes custom RPC URLs as "custom" in tracking event', async () => {
+ const PROPS_WITH_CUSTOM_RPC = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ chainId: '0x64',
+ rpcEndpoints: [
+ {
+ networkClientId: 'custom',
+ type: 'custom',
+ url: 'https://my-private-rpc.com',
+ },
+ ],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper7 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper7.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://another-private-rpc.com',
+ rpcUrls: [
+ {
+ url: 'https://another-private-rpc.com',
+ type: 'custom',
+ name: 'Another Custom RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: true,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ mockCreateEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: 'eip155:100',
+ from_rpc_domain: 'custom',
+ to_rpc_domain: 'custom',
+ })
+ .build(),
+ );
+ });
+
+ it('tracks unknown for missing old RPC endpoint', async () => {
+ const PROPS_WITHOUT_OLD_ENDPOINT = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: undefined,
+ chainId: '0x64',
+ rpcEndpoints: [],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper8 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper8.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://new-rpc.infura.io/v3/',
+ rpcUrls: [
+ {
+ url: 'https://new-rpc.infura.io/v3/',
+ type: 'custom',
+ name: 'New RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: true,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ mockCreateEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: 'eip155:100',
+ from_rpc_domain: 'unknown',
+ to_rpc_domain: 'new-rpc.infura.io',
+ })
+ .build(),
+ );
+ });
});
describe('checkIfRpcUrlExists', () => {
diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx
index 94ef0fa137ee..b310f5e227b9 100644
--- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx
+++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx
@@ -226,6 +226,7 @@ describe('useNetworkConnectionBanner', () => {
network: rpcUrl,
shouldNetworkSwitchPopToWallet: false,
shouldShowPopularNetworks: false,
+ trackRpcUpdateFromBanner: true,
},
);
});
diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts
index 4cae292f9294..0e4f2c518cff 100644
--- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts
+++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts
@@ -65,6 +65,7 @@ const useNetworkConnectionBanner = (): {
network: rpcUrl,
shouldNetworkSwitchPopToWallet: false,
shouldShowPopularNetworks: false,
+ trackRpcUpdateFromBanner: true,
});
trackEvent(
diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts
index 3c3783c2035e..104af28f0f0e 100644
--- a/app/core/Analytics/MetaMetrics.events.ts
+++ b/app/core/Analytics/MetaMetrics.events.ts
@@ -514,6 +514,7 @@ enum EVENT_NAME {
// NETWORK CONNECTION BANNER
NETWORK_CONNECTION_BANNER_SHOWN = 'Network Connection Banner Shown',
NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED = 'Network Connection Banner Update RPC Clicked',
+ NetworkConnectionBannerRpcUpdated = 'Network Connection Banner RPC Updated',
// Deep Link Modal Viewed
DEEP_LINK_PRIVATE_MODAL_VIEWED = 'Deep Link Private Modal Viewed',
@@ -1332,6 +1333,9 @@ const events = {
NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED: generateOpt(
EVENT_NAME.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED,
),
+ NetworkConnectionBannerRpcUpdated: generateOpt(
+ EVENT_NAME.NetworkConnectionBannerRpcUpdated,
+ ),
// Multi SRP
IMPORT_SECRET_RECOVERY_PHRASE_CLICKED: generateOpt(
From dc09bef86fe3f71c91fcd1046f65f56298925f8c Mon Sep 17 00:00:00 2001
From: sahar-fehri
Date: Wed, 26 Nov 2025 16:11:27 +0100
Subject: [PATCH 05/16] feat: basic functionality toggle for trending (#23252)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
When basic functionality toggle is on, user should only be able to
search within sites.
## **Changelog**
CHANGELOG entry: User should be prompted to enable BFT when its off
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/15c342bb-40a0-4e77-8dff-5f33c88c5bbe
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Explore now respects the basic functionality toggle: hides content
when off, shows an enable-CTA empty state, and limits search UI to
sites.
>
> - **Trending/Explore**:
> - **Toggle support**: Use `selectBasicFunctionalityEnabled` to control
Explore rendering and search behavior.
> - When off:
> - Show `BasicFunctionalityEmptyState` with CTA navigating to
`Routes.SHEET.BASIC_FUNCTIONALITY`.
> - Hide Explore home sections (`HOME_SECTIONS_ARRAY`).
> - In search results (`ExploreSearchResults`), filter out all sections.
> - In search bar (`ExploreSearchBar`), switch placeholder to
`strings('trending.search_sites')`.
> - When on: existing Explore content and multi-section search remain.
> - **i18n**: Add `trending.enable_basic_functionality`,
`trending.basic_functionality_disabled_title`, and
`trending.basic_functionality_disabled_description`.
> - **Tests**: Expand unit tests for toggle behavior across
`TrendingView`, `ExploreSearchBar`, and `ExploreSearchResults`; add
tests for new empty state component and navigation.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2998dd5e6951ecaa9d2aa8b143a8da6013584480. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../ExploreSearchBar.test.tsx | 39 ++++++++++++++
.../ExploreSearchBar/ExploreSearchBar.tsx | 9 +++-
.../ExploreSearchResults.test.tsx | 16 ++++++
.../ExploreSearchResults.tsx | 12 ++++-
.../Views/TrendingView/TrendingView.test.tsx | 24 +++++++++
.../Views/TrendingView/TrendingView.tsx | 52 +++++++++++--------
.../BasicFunctionalityEmptyState.test.tsx | 43 +++++++++++++++
.../BasicFunctionalityEmptyState.tsx | 52 +++++++++++++++++++
locales/languages/en.json | 5 +-
9 files changed, 227 insertions(+), 25 deletions(-)
create mode 100644 app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx
create mode 100644 app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx
diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
index 26856254d1ca..eac3a5eb2a46 100644
--- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
@@ -1,8 +1,28 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import ExploreSearchBar from './ExploreSearchBar';
+import { useSelector } from 'react-redux';
+import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+const mockUseSelector = useSelector as jest.MockedFunction;
describe('ExploreSearchBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Mock selectBasicFunctionalityEnabled to return true by default
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return true;
+ }
+ return undefined;
+ });
+ });
describe('Button Mode', () => {
it('renders button with placeholder text', () => {
const mockOnPress = jest.fn();
@@ -203,4 +223,23 @@ describe('ExploreSearchBar', () => {
expect(input.props.autoFocus).toBe(true);
});
});
+
+ describe('basic functionality toggle', () => {
+ it('displays sites-only placeholder when basic functionality is disabled', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const mockOnPress = jest.fn();
+
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('Search sites')).toBeDefined();
+ });
+ });
});
diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx
index 56360565a429..009fbafcff6c 100644
--- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx
@@ -15,6 +15,8 @@ import {
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { useTheme } from '../../../../util/theme';
import { strings } from '../../../../../locales/i18n';
+import { useSelector } from 'react-redux';
+import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings';
interface ExploreSearchBarButtonProps {
type: 'button';
@@ -38,10 +40,15 @@ const ExploreSearchBar: React.FC = (props) => {
const tw = useTailwind();
const { colors } = useTheme();
+ const isBasicFunctionalityEnabled = useSelector(
+ selectBasicFunctionalityEnabled,
+ );
const isInteractiveMode = props.type === 'interactive';
const isButtonMode = props.type === 'button';
const placeholder =
- props.placeholder || strings('trending.search_placeholder');
+ props.placeholder || isBasicFunctionalityEnabled
+ ? strings('trending.search_placeholder')
+ : strings('trending.search_sites');
const handleCancel = () => {
if (isInteractiveMode) {
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
index 139b6a2b501d..547b1b3ccb87 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { render } from '@testing-library/react-native';
import ExploreSearchResults from './ExploreSearchResults';
import { useExploreSearch } from './config/useExploreSearch';
+import { useSelector } from 'react-redux';
+import { selectBasicFunctionalityEnabled } from '../../../../../../selectors/settings';
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
@@ -10,10 +12,16 @@ jest.mock('@react-navigation/native', () => ({
}),
}));
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
jest.mock('./config/useExploreSearch');
const mockUseExploreSearch = useExploreSearch as jest.MockedFunction<
typeof useExploreSearch
>;
+const mockUseSelector = useSelector as jest.MockedFunction;
// Mock child components that render individual items
jest.mock(
@@ -48,6 +56,14 @@ jest.mock(
describe('ExploreSearchResults', () => {
beforeEach(() => {
jest.clearAllMocks();
+
+ // Mock selectBasicFunctionalityEnabled to return true by default
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return true;
+ }
+ return undefined;
+ });
});
it('renders section headers for sections with data or loading', () => {
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
index 9e1d187d894b..a9bce9bcdda1 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
@@ -9,7 +9,9 @@ import {
type SectionId,
} from '../../../config/sections.config';
import { useExploreSearch } from './config/useExploreSearch';
+import { selectBasicFunctionalityEnabled } from '../../../../../../selectors/settings';
import SitesSearchFooter from '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter';
+import { useSelector } from 'react-redux';
interface ExploreSearchResultsProps {
searchQuery: string;
@@ -41,6 +43,9 @@ const ExploreSearchResults: React.FC = ({
const tw = useTailwind();
const { data, isLoading } = useExploreSearch(searchQuery);
const flashListRef = useRef>(null);
+ const isBasicFunctionalityEnabled = useSelector(
+ selectBasicFunctionalityEnabled,
+ );
const renderSectionHeader = useCallback(
(title: string) => (
@@ -57,7 +62,10 @@ const ExploreSearchResults: React.FC = ({
const flatData = useMemo(() => {
const result: FlatListItem[] = [];
- SECTIONS_ARRAY.forEach((section) => {
+ // Filter sections based on basic functionality toggle
+ const sectionsToShow = isBasicFunctionalityEnabled ? SECTIONS_ARRAY : [];
+
+ sectionsToShow.forEach((section) => {
const items = data[section.id];
const sectionIsLoading = isLoading[section.id];
@@ -92,7 +100,7 @@ const ExploreSearchResults: React.FC = ({
});
return result;
- }, [data, isLoading]);
+ }, [data, isLoading, isBasicFunctionalityEnabled]);
// Scroll to top when search query changes
useEffect(() => {
diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx
index 6a21ab1058f2..61e2865b41ed 100644
--- a/app/components/Views/TrendingView/TrendingView.test.tsx
+++ b/app/components/Views/TrendingView/TrendingView.test.tsx
@@ -36,6 +36,7 @@ import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetwork
import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController';
import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts';
import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts';
+import { selectBasicFunctionalityEnabled } from '../../../selectors/settings';
import { useSelector } from 'react-redux';
jest.mock('../../../components/hooks/useMetrics', () => ({
@@ -137,6 +138,10 @@ describe('TrendingView', () => {
// Return false to use default networks behavior
return false;
}
+ if (selector === selectBasicFunctionalityEnabled) {
+ // Return true by default (enabled)
+ return true;
+ }
// Handle selectSelectedInternalAccountByScope which is a selector factory
// It returns a function that takes a scope and returns an account
if (selector === selectSelectedInternalAccountByScope) {
@@ -436,4 +441,23 @@ describe('TrendingView', () => {
expect(mockNavigate).toHaveBeenCalledWith('ExploreSearch');
});
+
+ describe('basic functionality toggle', () => {
+ it('displays empty state when basic functionality is disabled', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('basic-functionality-empty-state')).toBeDefined();
+ });
+ });
});
diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx
index 5eea579782d0..7c6b5a6ec35c 100644
--- a/app/components/Views/TrendingView/TrendingView.tsx
+++ b/app/components/Views/TrendingView/TrendingView.tsx
@@ -28,6 +28,8 @@ import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar';
import QuickActions from './components/QuickActions/QuickActions';
import SectionHeader from './components/SectionHeader/SectionHeader';
import { HOME_SECTIONS_ARRAY } from './config/sections.config';
+import { selectBasicFunctionalityEnabled } from '../../../selectors/settings';
+import BasicFunctionalityEmptyState from './components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState';
const Stack = createStackNavigator();
@@ -57,6 +59,10 @@ const TrendingFeed: React.FC = () => {
const browserTabsCount = useSelector(
(state: { browser: { tabs: unknown[] } }) => state.browser.tabs.length,
);
+ // check if basic functionality toggle is on
+ const isBasicFunctionalityEnabled = useSelector(
+ selectBasicFunctionalityEnabled,
+ );
const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, {
metamaskEntry: 'mobile',
@@ -131,27 +137,31 @@ const TrendingFeed: React.FC = () => {
-
- }
- >
-
-
- {HOME_SECTIONS_ARRAY.map((section) => (
-
-
-
-
- ))}
-
+ {isBasicFunctionalityEnabled ? (
+
+ }
+ >
+
+
+ {HOME_SECTIONS_ARRAY.map((section) => (
+
+
+
+
+ ))}
+
+ ) : (
+
+ )}
);
};
diff --git a/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx
new file mode 100644
index 000000000000..beabeff54da7
--- /dev/null
+++ b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import BasicFunctionalityEmptyState from './BasicFunctionalityEmptyState';
+import Routes from '../../../../../constants/navigation/Routes';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+}));
+
+describe('BasicFunctionalityEmptyState', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders empty state', () => {
+ const { getByText } = render();
+
+ expect(getByText('Explore is not available')).toBeDefined();
+ expect(
+ getByText(
+ "We can't fetch the required metadata when basic functionality is disabled.",
+ ),
+ ).toBeDefined();
+ expect(getByText('Enable basic functionality')).toBeDefined();
+ });
+
+ it('navigates to basic functionality settings when button is pressed', () => {
+ const { getByText } = render();
+
+ const enableButton = getByText('Enable basic functionality');
+
+ fireEvent.press(enableButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, {
+ screen: Routes.SHEET.BASIC_FUNCTIONALITY,
+ });
+ });
+});
diff --git a/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx
new file mode 100644
index 000000000000..d4217104fddc
--- /dev/null
+++ b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx
@@ -0,0 +1,52 @@
+import React, { useCallback } from 'react';
+import {
+ Box,
+ Text,
+ TextVariant,
+ Button,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../locales/i18n';
+import { useNavigation } from '@react-navigation/native';
+import Routes from '../../../../../constants/navigation/Routes';
+
+const BasicFunctionalityEmptyState = () => {
+ const navigation = useNavigation();
+
+ const handleEnableBasicFunctionality = useCallback(() => {
+ navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
+ screen: Routes.SHEET.BASIC_FUNCTIONALITY,
+ });
+ }, [navigation]);
+
+ return (
+
+
+
+ {strings('trending.basic_functionality_disabled_title')}
+
+
+ {strings('trending.basic_functionality_disabled_description')}
+
+
+
+
+ );
+};
+
+export default BasicFunctionalityEmptyState;
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 54f24dcaed19..560fdc71a27f 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -7003,6 +7003,9 @@
"no_results": "No results found",
"sites": "Sites",
"popular_sites": "Popular Sites",
- "search_sites": "Search sites"
+ "search_sites": "Search sites",
+ "enable_basic_functionality": "Enable basic functionality",
+ "basic_functionality_disabled_title": "Explore is not available",
+ "basic_functionality_disabled_description": "We can't fetch the required metadata when basic functionality is disabled."
}
}
From 2528ac4716f1f32c7ceaa266edf5e851d4363e48 Mon Sep 17 00:00:00 2001
From: Vince Howard
Date: Wed, 26 Nov 2025 08:31:31 -0700
Subject: [PATCH 06/16] fix: correct token/fiat toggle background (#23228)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fixed token/fiat toggle background
## **Changelog**
CHANGELOG entry: Fixed token/fiat toggle background
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MDP-254
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
`~`
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [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]
> Switches `currencyTag` background to `theme.colors.background.section`
to correct the token/fiat toggle appearance.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
08a15a0450df8845d2a7213fea21b6342eaf81e7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../Views/confirmations/components/send/amount/amount.styles.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/components/Views/confirmations/components/send/amount/amount.styles.ts b/app/components/Views/confirmations/components/send/amount/amount.styles.ts
index c2f36fc2956c..85bf4053f242 100644
--- a/app/components/Views/confirmations/components/send/amount/amount.styles.ts
+++ b/app/components/Views/confirmations/components/send/amount/amount.styles.ts
@@ -49,7 +49,7 @@ export const styleSheet = (params: {
},
currencyTag: {
alignSelf: 'center',
- backgroundColor: theme.colors.background.alternative,
+ backgroundColor: theme.colors.background.section,
color: theme.colors.text.alternative,
flexDirection: FlexDirection.Row,
justifyContent: JustifyContent.center,
From df8533b5393a537125becab668140a0300ff1166 Mon Sep 17 00:00:00 2001
From: Fred
Date: Wed, 26 Nov 2025 16:43:32 +0100
Subject: [PATCH 07/16] fix: bump bitcoin (#23317)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Updates bitcoin to 1.7.0, which adds signRewardsMessage method.
## **Changelog**
CHANGELOG entry: Add `signRewardsMessage` method
([#566](https://github.com/MetaMask/snap-bitcoin-wallet/pull/566))
## **Related issues**
Fixes: Add `signRewardsMessage` method.
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Bumps @metamask/bitcoin-wallet-snap from ^1.6.0 to ^1.7.0.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2405f60da87634d3c8df5e0fca0b22dc87b37780. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
yarn.lock | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index 058289542c4d..d5275774147e 100644
--- a/package.json
+++ b/package.json
@@ -199,7 +199,7 @@
"@metamask/approval-controller": "^8.0.0",
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch",
"@metamask/base-controller": "^9.0.0",
- "@metamask/bitcoin-wallet-snap": "^1.6.0",
+ "@metamask/bitcoin-wallet-snap": "^1.7.0",
"@metamask/bridge-controller": "^61.0.0",
"@metamask/bridge-status-controller": "^61.0.0",
"@metamask/chain-agnostic-permission": "^1.2.2",
diff --git a/yarn.lock b/yarn.lock
index 73ba24ac5cc3..bb24dc284980 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7574,10 +7574,10 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bitcoin-wallet-snap@npm:^1.6.0":
- version: 1.6.0
- resolution: "@metamask/bitcoin-wallet-snap@npm:1.6.0"
- checksum: 10/e5d391ecc88c52fa56b888e0a341331da8c8fec18a228ae3238f9ace9c0216012ef2af06134cab25fe251e6e829a14db706aa8d01ed70976fe47fd40017a6c8d
+"@metamask/bitcoin-wallet-snap@npm:^1.7.0":
+ version: 1.7.0
+ resolution: "@metamask/bitcoin-wallet-snap@npm:1.7.0"
+ checksum: 10/34943af054bdceeaf11ca6ed876f582194257c1bdc06e37cca06aabf650c6f541fe0f9bfaef07c8fe5e4179354f0ae81445194ce6c77a526f53221d3deb6a26c
languageName: node
linkType: hard
@@ -35639,7 +35639,7 @@ __metadata:
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch"
"@metamask/auto-changelog": "npm:^5.1.0"
"@metamask/base-controller": "npm:^9.0.0"
- "@metamask/bitcoin-wallet-snap": "npm:^1.6.0"
+ "@metamask/bitcoin-wallet-snap": "npm:^1.7.0"
"@metamask/bridge-controller": "npm:^61.0.0"
"@metamask/bridge-status-controller": "npm:^61.0.0"
"@metamask/browser-passworder": "npm:^5.0.0"
From f5e436ebb16e1894026edf60f2015fd2692f385e Mon Sep 17 00:00:00 2001
From: imblue <106779544+imblue-dabadee@users.noreply.github.com>
Date: Wed, 26 Nov 2025 09:48:12 -0600
Subject: [PATCH 08/16] feat: malicious token screening on transactions
(#22688)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Introduces token screening on incoming tokens received in transactions.
This comes in the form of two different alerts (yellow and red).
## **Changelog**
CHANGELOG entry: Added an alert if an incoming token is malicious or
suspicious.
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Token screening on incoming tokens in transactions
Scenario: user initiates a transaction where they receive malicious tokens
Given the user is on the `Transaction request` screen
And they are receiving tokens that is flagged as malicious
When user views the screen
Then they will see a red alert on `You receive`
And a red `Review alert` button
Scenario: user initiates a transaction where they receive suspicious tokens
Given the user is on the `Transaction request` screen
And they are receiving tokens that is flagged as suspicious
When user views the screen
Then they will see a yellow alert on `You receive`
```
- For a malicious token, you can swap for
`0x69e8b9528cabda89fe846c67675b5d73d463a916` on a swap website.
- For a suspicious token, you can swap for
`0xd0cd466b34a24fcb2f87676278af2005ca8a78c4` on a swap website.
## **Screenshots/Recordings**
### **Before**
**1. No yellow alert**
**2. No red alert**
### **After**
**1. Yellow alert**
**2. Red alert**
**When you click the inline alert**
**When you click the 'Review alert' button**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Adds malicious/suspicious token screening to transaction confirmations
with inline alerts, selectors, strings, and tests.
>
> - **Confirmations UI/UX**:
> - Show `AlertRow` on `BalanceChangeRow` label when `hasIncomingTokens`
is true, with style override in `alert-row.styles`.
> - Compute `hasIncomingTokens` in `BalanceChangeList` and pass to rows.
> - **Alerts System**:
> - New `useTokenTrustSignalAlerts` hook to derive alerts from token
scan results; integrated into `useConfirmationAlerts`.
> - Added `RowAlertKey.IncomingTokens` and new alert keys
`TokenTrustSignalMalicious`/`TokenTrustSignalWarning` with metrics
mappings.
> - **State Selectors**:
> - New `selectMultipleTokenScanResults` to read
`PhishingController.tokenScanCache` for multiple tokens.
> - **Localization**:
> - English strings for malicious/suspicious token alerts.
> - **Dependencies**:
> - Upgrade `@metamask/phishing-controller` to `^16.1.0`.
> - **Tests**:
> - Unit tests for `BalanceChangeRow`, `AlertRow`,
`useTokenTrustSignalAlerts`, `useConfirmationAlerts`, and phishing
selectors.
> - **Test fixtures**:
> - Update initial background state to include `addressScanCache` and
`tokenScanCache`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ff7ae25421cd55a871411b09dc71cb062a48fbf6. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: sethkfman
---
.../BalanceChangeList/BalanceChangeList.tsx | 8 +
.../BalanceChangeRow.test.tsx | 37 ++
.../BalanceChangeRow/BalanceChangeRow.tsx | 41 ++-
.../UI/info-row/alert-row/alert-row.styles.ts | 4 +
.../UI/info-row/alert-row/alert-row.test.tsx | 16 +
.../UI/info-row/alert-row/alert-row.tsx | 4 +-
.../UI/info-row/alert-row/constants.ts | 1 +
.../Views/confirmations/constants/alerts.ts | 2 +
.../alerts/useConfirmationAlerts.test.ts | 16 +
.../hooks/alerts/useConfirmationAlerts.ts | 4 +
.../alerts/useTokenTrustSignalAlerts.test.ts | 348 ++++++++++++++++++
.../hooks/alerts/useTokenTrustSignalAlerts.ts | 101 +++++
.../metrics/useConfirmationAlertMetrics.ts | 2 +
app/selectors/phishingController.test.ts | 124 +++++++
app/selectors/phishingController.ts | 54 +++
app/util/test/initial-background-state.json | 1 +
locales/languages/en.json | 10 +
package.json | 2 +-
yarn.lock | 28 +-
19 files changed, 786 insertions(+), 17 deletions(-)
create mode 100644 app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts
create mode 100644 app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts
create mode 100644 app/selectors/phishingController.test.ts
create mode 100644 app/selectors/phishingController.ts
diff --git a/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx b/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx
index 84a8b5dea79e..09e903599515 100644
--- a/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx
+++ b/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx
@@ -8,6 +8,7 @@ import BalanceChangeRow from '../BalanceChangeRow/BalanceChangeRow';
import { BalanceChange } from '../types';
import { TotalFiatDisplay } from '../FiatDisplay/FiatDisplay';
import styleSheet from './BalanceChangeList.styles';
+
interface BalanceChangeListProperties extends ViewProps {
heading: string;
balanceChanges: BalanceChange[];
@@ -28,6 +29,12 @@ const BalanceChangeList: React.FC = ({
[sortedBalanceChanges],
);
+ const hasIncomingTokens = useMemo(
+ () =>
+ balanceChanges.some((balanceChange) => balanceChange.amount.isPositive()),
+ [balanceChanges],
+ );
+
if (sortedBalanceChanges.length === 0) {
return null;
}
@@ -45,6 +52,7 @@ const BalanceChangeList: React.FC = ({
label={index === 0 ? heading : undefined}
balanceChange={balanceChange}
showFiat={!showFiatTotal}
+ hasIncomingTokens={hasIncomingTokens}
/>
))}
{showFiatTotal && (
diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx
index 5ec3376df6c2..ff254ed18d0a 100644
--- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx
+++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx
@@ -80,4 +80,41 @@ describe('BalanceChangeList', () => {
expect(getByTestId('edit-spending-cap-button')).toBeTruthy();
});
+
+ it('renders an alert row if there are incoming tokens and a label is provided', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+ expect(getByTestId('info-row')).toBeTruthy();
+ expect(queryByTestId('balance-change-row-label')).toBeNull();
+ });
+
+ it('does not render an alert row if there are no incoming tokens', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+ expect(getByTestId('balance-change-row-label')).toBeTruthy();
+ expect(queryByTestId('info-row')).toBeNull();
+ });
+
+ it('does not render an alert row if no label is provided', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+ expect(queryByTestId('info-row')).toBeNull();
+ });
});
diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx
index 232f012d7cd5..c8d5b74bf9d9 100644
--- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx
+++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx
@@ -14,6 +14,9 @@ import AmountPill from '../AmountPill/AmountPill';
import AssetPill from '../AssetPill/AssetPill';
import { IndividualFiatDisplay } from '../FiatDisplay/FiatDisplay';
import styleSheet from './BalanceChangeRow.styles';
+import AlertRow from '../../../Views/confirmations/components/UI/info-row/alert-row';
+import { RowAlertKey } from '../../../Views/confirmations/components/UI/info-row/alert-row/constants';
+import alertRowStyleSheet from '../../../Views/confirmations/components/UI/info-row/alert-row/alert-row.styles';
interface BalanceChangeRowProperties extends ViewProps {
approveMethod?: ApproveMethod;
@@ -24,6 +27,7 @@ interface BalanceChangeRowProperties extends ViewProps {
newSpendingCap: string,
) => Promise;
showFiat?: boolean;
+ hasIncomingTokens?: boolean;
}
const BalanceChangeRow: React.FC = ({
@@ -32,23 +36,42 @@ const BalanceChangeRow: React.FC = ({
label,
onApprovalAmountUpdate,
showFiat,
+ hasIncomingTokens,
}) => {
const { styles } = useStyles(styleSheet, {});
+ const { styles: alertRowStyles } = useStyles(alertRowStyleSheet, {});
const { asset, amount, fiatAmount, isAllApproval, isUnlimitedApproval } =
balanceChange;
const isERC20 = balanceChange.asset.type === AssetType.ERC20;
const shouldShowEditSpendingCapButton = isERC20 && onApprovalAmountUpdate;
+
+ const renderLabel = () => {
+ if (!label) {
+ return null;
+ }
+ if (hasIncomingTokens) {
+ return (
+
+ );
+ }
+ return (
+
+ {label}
+
+ );
+ };
+
return (
- {label && (
-
- {label}
-
- )}
+ {renderLabel()}
{shouldShowEditSpendingCapButton ? (
diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts
index 458de4fbe272..b711f41152d2 100644
--- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts
+++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts
@@ -6,6 +6,10 @@ const styleSheet = () =>
paddingBottom: 4,
paddingHorizontal: 8,
},
+ alertRowOverride: {
+ marginLeft: 0,
+ paddingLeft: 0,
+ },
});
export default styleSheet;
diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx
index 4c3c4a332628..7364c13e41cb 100755
--- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx
+++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx
@@ -6,6 +6,7 @@ import { Severity } from '../../../../types/alerts';
import { IconName } from '../../../../../../../component-library/components/Icons/Icon';
import { useConfirmationAlertMetrics } from '../../../../hooks/metrics/useConfirmationAlertMetrics';
import { InfoRowVariant } from '../info-row';
+import styleSheet from './alert-row.styles';
jest.mock('../../../../context/alert-system-context', () => ({
useAlerts: jest.fn(),
@@ -135,4 +136,19 @@ describe('AlertRow', () => {
expect(getByText(CHILDREN_MOCK)).toBeDefined();
expect(queryByTestId('inline-alert')).toBeNull();
});
+
+ it('renders with the given style if provided', () => {
+ const props = { ...baseProps, style: { backgroundColor: 'red' } };
+ const { getByTestId } = render();
+ const infoRow = getByTestId('info-row');
+ expect(infoRow.props.style.backgroundColor).toBe('red');
+ });
+
+ it('renders with styles.infoRowOverride if no style is provided', () => {
+ const styles = styleSheet();
+ const { getByTestId } = render();
+ const infoRow = getByTestId('info-row');
+
+ expect(infoRow.props.style).toMatchObject(styles.infoRowOverride);
+ });
});
diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx
index df3f82862ab6..78684ad61b61 100755
--- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx
+++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx
@@ -44,7 +44,7 @@ const AlertRow = ({
const { fieldAlerts } = useAlerts();
const alertSelected = fieldAlerts.find((a) => a.field === alertField);
const { styles } = useStyles(styleSheet, {});
- const { rowVariant } = props;
+ const { rowVariant, style } = props;
if (!alertSelected && isShownWithAlertsOnly) {
return null;
@@ -66,7 +66,7 @@ const AlertRow = ({
return (
);
diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts
index adae95330f7e..2567101efa72 100755
--- a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts
+++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts
@@ -9,4 +9,5 @@ export enum RowAlertKey {
PayWithFee = 'payWithFee',
PendingTransaction = 'pendingTransaction',
RequestFrom = 'requestFrom',
+ IncomingTokens = 'incomingTokens',
}
diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts
index b16baefc8d28..e975afe2e2d1 100644
--- a/app/components/Views/confirmations/constants/alerts.ts
+++ b/app/components/Views/confirmations/constants/alerts.ts
@@ -13,4 +13,6 @@ export enum AlertKeys {
PerpsDepositMinimum = 'perps_deposit_minimum',
PerpsHardwareAccount = 'perps_hardware_account',
SignedOrSubmitted = 'signed_or_submitted',
+ TokenTrustSignalMalicious = 'token_trust_signal_malicious',
+ TokenTrustSignalWarning = 'token_trust_signal_warning',
}
diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts
index 36e111f040a0..09f95eed771a 100644
--- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts
@@ -17,6 +17,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa
import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert';
import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert';
import { useBurnAddressAlert } from './useBurnAddressAlert';
+import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts';
jest.mock('./useBlockaidAlerts');
jest.mock('./useDomainMismatchAlerts');
@@ -29,6 +30,7 @@ jest.mock('./useInsufficientPayTokenBalanceAlert');
jest.mock('./useNoPayTokenQuotesAlert');
jest.mock('./useInsufficientPredictBalanceAlert');
jest.mock('./useBurnAddressAlert');
+jest.mock('./useTokenTrustSignalAlerts');
describe('useConfirmationAlerts', () => {
const ALERT_MESSAGE_MOCK = 'This is a test alert message.';
@@ -133,6 +135,15 @@ describe('useConfirmationAlerts', () => {
},
];
+ const mockTokenTrustSignalAlerts: Alert[] = [
+ {
+ key: 'TokenTrustSignalAlert',
+ title: 'Test Token Trust Signal Alert',
+ message: ALERT_MESSAGE_MOCK,
+ severity: Severity.Danger,
+ },
+ ];
+
beforeEach(() => {
jest.clearAllMocks();
(useBlockaidAlerts as jest.Mock).mockReturnValue([]);
@@ -146,6 +157,7 @@ describe('useConfirmationAlerts', () => {
(useNoPayTokenQuotesAlert as jest.Mock).mockReturnValue([]);
(useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue([]);
(useBurnAddressAlert as jest.Mock).mockReturnValue([]);
+ (useTokenTrustSignalAlerts as jest.Mock).mockReturnValue([]);
});
it('returns empty array if no alerts', () => {
@@ -211,6 +223,9 @@ describe('useConfirmationAlerts', () => {
mockInsufficientPredictBalanceAlert,
);
(useBurnAddressAlert as jest.Mock).mockReturnValue(mockBurnAddressAlert);
+ (useTokenTrustSignalAlerts as jest.Mock).mockReturnValue(
+ mockTokenTrustSignalAlerts,
+ );
const { result } = renderHookWithProvider(() => useConfirmationAlerts(), {
state: siweSignatureConfirmationState,
});
@@ -225,6 +240,7 @@ describe('useConfirmationAlerts', () => {
...mockNoPayTokenQuotesAlert,
...mockInsufficientPredictBalanceAlert,
...mockBurnAddressAlert,
+ ...mockTokenTrustSignalAlerts,
...mockUpgradeAccountAlert,
]);
});
diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts
index 981a76bf5f91..6341378dc378 100644
--- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts
@@ -11,6 +11,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa
import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert';
import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert';
import { useBurnAddressAlert } from './useBurnAddressAlert';
+import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts';
function useSignatureAlerts(): Alert[] {
const domainMismatchAlerts = useDomainMismatchAlerts();
@@ -28,6 +29,7 @@ function useTransactionAlerts(): Alert[] {
const noPayTokenQuotesAlert = useNoPayTokenQuotesAlert();
const insufficientPredictBalanceAlert = useInsufficientPredictBalanceAlert();
const burnAddressAlert = useBurnAddressAlert();
+ const tokenTrustSignalAlerts = useTokenTrustSignalAlerts();
return useMemo(
() => [
@@ -39,6 +41,7 @@ function useTransactionAlerts(): Alert[] {
...noPayTokenQuotesAlert,
...insufficientPredictBalanceAlert,
...burnAddressAlert,
+ ...tokenTrustSignalAlerts,
],
[
insufficientBalanceAlert,
@@ -49,6 +52,7 @@ function useTransactionAlerts(): Alert[] {
noPayTokenQuotesAlert,
insufficientPredictBalanceAlert,
burnAddressAlert,
+ tokenTrustSignalAlerts,
],
);
}
diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts
new file mode 100644
index 000000000000..68fa932da159
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts
@@ -0,0 +1,348 @@
+import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
+import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants';
+import { AlertKeys } from '../../constants/alerts';
+import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts';
+import { Severity } from '../../types/alerts';
+import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
+import { TransactionMeta } from '@metamask/transaction-controller';
+
+jest.mock('../transactions/useTransactionMetadataRequest', () => ({
+ useTransactionMetadataRequest: jest.fn(),
+}));
+
+describe('useTokenTrustSignalAlerts', () => {
+ const mockUseTransactionMetadataRequest = jest.mocked(
+ useTransactionMetadataRequest,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseTransactionMetadataRequest.mockReturnValue({
+ simulationData: {
+ tokenBalanceChanges: [
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ },
+ ],
+ },
+ chainId: '0x1',
+ } as unknown as TransactionMeta);
+ });
+
+ it('returns a malicious alert if the token scan result is malicious', () => {
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x1234567890123456789012345678901234567890': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current).toEqual([
+ {
+ key: AlertKeys.TokenTrustSignalMalicious,
+ field: RowAlertKey.IncomingTokens,
+ message:
+ 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.',
+ title: 'Malicious token',
+ severity: Severity.Danger,
+ isBlocking: false,
+ },
+ ]);
+ });
+
+ it('returns a warning alert if the token scan result is warning', () => {
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x1234567890123456789012345678901234567890': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Warning',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current).toEqual([
+ {
+ key: AlertKeys.TokenTrustSignalWarning,
+ field: RowAlertKey.IncomingTokens,
+ message:
+ 'This token shows strong signs of malicious behavior. Continuing may result in loss of funds.',
+ title: 'Suspicious token',
+ severity: Severity.Warning,
+ isBlocking: false,
+ },
+ ]);
+ });
+
+ it('returns no alerts if the token scan result is benign', () => {
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x1234567890123456789012345678901234567890': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Benign',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('returns no alerts if the token scan result does not exist', () => {
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {},
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('returns no alerts if the transaction metadata is undefined', () => {
+ mockUseTransactionMetadataRequest.mockReturnValue(undefined);
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x1234567890123456789012345678901234567890': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Benign',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+ expect(result.current).toEqual([]);
+ });
+
+ it('detects malicious token when it is not the first incoming token', () => {
+ mockUseTransactionMetadataRequest.mockReturnValue({
+ simulationData: {
+ tokenBalanceChanges: [
+ {
+ address: '0x0000000000000000000000000000000000000001',
+ isDecrease: false,
+ },
+ {
+ address: '0x0000000000000000000000000000000000000002',
+ isDecrease: false,
+ },
+ ],
+ },
+ chainId: '0x1',
+ } as unknown as TransactionMeta);
+
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x0000000000000000000000000000000000000001': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Benign',
+ },
+ },
+ '0x1:0x0000000000000000000000000000000000000002': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current).toEqual([
+ {
+ key: AlertKeys.TokenTrustSignalMalicious,
+ field: RowAlertKey.IncomingTokens,
+ message:
+ 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.',
+ title: 'Malicious token',
+ severity: Severity.Danger,
+ isBlocking: false,
+ },
+ ]);
+ });
+
+ it('returns the highest severity alert if there are multiple tokens that are flagged as malicious or warning', () => {
+ mockUseTransactionMetadataRequest.mockReturnValue({
+ simulationData: {
+ tokenBalanceChanges: [
+ {
+ address: '0x0000000000000000000000000000000000000001',
+ isDecrease: false,
+ },
+ {
+ address: '0x0000000000000000000000000000000000000002',
+ isDecrease: false,
+ },
+ ],
+ },
+ chainId: '0x1',
+ } as unknown as TransactionMeta);
+
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x0000000000000000000000000000000000000001': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Warning',
+ },
+ },
+ '0x1:0x0000000000000000000000000000000000000002': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current).toEqual([
+ {
+ key: AlertKeys.TokenTrustSignalMalicious,
+ field: RowAlertKey.IncomingTokens,
+ message:
+ 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.',
+ title: 'Malicious token',
+ severity: Severity.Danger,
+ isBlocking: false,
+ },
+ ]);
+ });
+
+ it('returns exactly one alert if there are multiple tokens that are flagged as malicious or warning', () => {
+ mockUseTransactionMetadataRequest.mockReturnValue({
+ simulationData: {
+ tokenBalanceChanges: [
+ {
+ address: '0x0000000000000000000000000000000000000001',
+ isDecrease: false,
+ },
+ {
+ address: '0x0000000000000000000000000000000000000002',
+ isDecrease: false,
+ },
+ {
+ address: '0x0000000000000000000000000000000000000003',
+ isDecrease: false,
+ },
+ ],
+ },
+ chainId: '0x1',
+ } as unknown as TransactionMeta);
+
+ const { result } = renderHookWithProvider(
+ () => useTokenTrustSignalAlerts(),
+ {
+ state: {
+ engine: {
+ backgroundState: {
+ PhishingController: {
+ tokenScanCache: {
+ '0x1:0x0000000000000000000000000000000000000001': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Warning',
+ },
+ },
+ '0x1:0x0000000000000000000000000000000000000002': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ '0x1:0x0000000000000000000000000000000000000003': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.current.length).toBe(1);
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts
new file mode 100644
index 000000000000..d5a5607e00d3
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts
@@ -0,0 +1,101 @@
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { Alert, Severity } from '../../types/alerts';
+import { AlertKeys } from '../../constants/alerts';
+import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants';
+import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
+import { selectMultipleTokenScanResults } from '../../../../../selectors/phishingController';
+import { RootState } from '../../../../../reducers';
+import { strings } from '../../../../../../locales/i18n';
+
+export function useTokenTrustSignalAlerts(): Alert[] {
+ const transactionMetadata = useTransactionMetadataRequest();
+
+ const incomingTokens = useMemo(() => {
+ const tokens: { address: string; chainId: string }[] = [];
+ const tokenBalanceChanges =
+ transactionMetadata?.simulationData?.tokenBalanceChanges;
+
+ if (
+ !tokenBalanceChanges ||
+ !Array.isArray(tokenBalanceChanges) ||
+ !transactionMetadata?.chainId
+ ) {
+ return tokens;
+ }
+
+ const chainId = transactionMetadata.chainId;
+
+ tokenBalanceChanges.forEach((change) => {
+ if (!change.isDecrease) {
+ tokens.push({
+ address: change.address || '',
+ chainId,
+ });
+ }
+ });
+
+ return tokens;
+ }, [transactionMetadata]);
+
+ const tokenScanResults = useSelector((state: RootState) =>
+ selectMultipleTokenScanResults(state, { tokens: incomingTokens }),
+ );
+
+ const alerts = useMemo(() => {
+ const alertsList: Alert[] = [];
+ let highestSeverity: Severity | null = null;
+
+ tokenScanResults.forEach(({ scanResult }) => {
+ if (!scanResult) {
+ return;
+ }
+
+ const resultType = scanResult.result_type;
+ let severity: Severity | null = null;
+
+ if (resultType === 'Malicious') {
+ severity = Severity.Danger;
+ } else if (resultType === 'Warning') {
+ severity = Severity.Warning;
+ }
+
+ if (!severity) {
+ return;
+ }
+
+ if (!highestSeverity || severity === Severity.Danger) {
+ highestSeverity = severity;
+ }
+ });
+
+ if (highestSeverity) {
+ const isDanger = highestSeverity === Severity.Danger;
+
+ const alertKey = isDanger
+ ? AlertKeys.TokenTrustSignalMalicious
+ : AlertKeys.TokenTrustSignalWarning;
+
+ const message = isDanger
+ ? strings('alert_system.token_trust_signal.malicious.message')
+ : strings('alert_system.token_trust_signal.warning.message');
+
+ const title = isDanger
+ ? strings('alert_system.token_trust_signal.malicious.title')
+ : strings('alert_system.token_trust_signal.warning.title');
+
+ alertsList.push({
+ key: alertKey,
+ field: RowAlertKey.IncomingTokens,
+ message,
+ title,
+ severity: highestSeverity,
+ isBlocking: false,
+ });
+ }
+
+ return alertsList;
+ }, [tokenScanResults]);
+
+ return alerts;
+}
diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
index 63fb70b90842..a998f0a08fc5 100644
--- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
+++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
@@ -120,6 +120,8 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = {
[AlertKeys.PerpsDepositMinimum]: 'minimum_deposit',
[AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account',
[AlertKeys.SignedOrSubmitted]: 'signed_or_submitted',
+ [AlertKeys.TokenTrustSignalMalicious]: 'token_trust_signal_malicious',
+ [AlertKeys.TokenTrustSignalWarning]: 'token_trust_signal_warning',
};
function getAlertName(alertKey: string): string {
diff --git a/app/selectors/phishingController.test.ts b/app/selectors/phishingController.test.ts
new file mode 100644
index 000000000000..f5c9230bbf7b
--- /dev/null
+++ b/app/selectors/phishingController.test.ts
@@ -0,0 +1,124 @@
+import { PhishingControllerState } from '@metamask/phishing-controller';
+import { RootState } from '../reducers';
+import { selectMultipleTokenScanResults } from './phishingController';
+
+describe('PhishingController Selectors', () => {
+ const createMockRootState = (
+ phishingControllerState: Partial = {},
+ ): RootState =>
+ ({
+ engine: {
+ backgroundState: {
+ PhishingController: phishingControllerState,
+ },
+ },
+ }) as RootState;
+
+ describe('selectMultipleTokenScanResults', () => {
+ it('returns the scan result for one token', () => {
+ const state = createMockRootState({
+ tokenScanCache: {
+ '0x1:0x1234567890123456789012345678901234567890': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ },
+ });
+
+ const result = selectMultipleTokenScanResults(state, {
+ tokens: [
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ },
+ ],
+ });
+
+ expect(result).toEqual([
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ scanResult: {
+ result_type: 'Malicious',
+ },
+ },
+ ]);
+ });
+
+ it('returns multiple scan results for multiple tokens', () => {
+ const state = createMockRootState({
+ tokenScanCache: {
+ '0x1:0x1234567890123456789012345678901234567890': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ '0x1:0x1234567890123456789012345678901234567891': {
+ data: {
+ // @ts-expect-error - TokenScanResultType is not exported in PhishingController
+ result_type: 'Malicious',
+ },
+ },
+ },
+ });
+
+ const result = selectMultipleTokenScanResults(state, {
+ tokens: [
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ },
+ {
+ address: '0x1234567890123456789012345678901234567891',
+ chainId: '0x1',
+ },
+ ],
+ });
+
+ expect(result).toEqual([
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ scanResult: {
+ result_type: 'Malicious',
+ },
+ },
+ {
+ address: '0x1234567890123456789012345678901234567891',
+ chainId: '0x1',
+ scanResult: {
+ result_type: 'Malicious',
+ },
+ },
+ ]);
+ });
+
+ it('returns an empty array if no tokens are provided', () => {
+ const state = createMockRootState();
+ const result = selectMultipleTokenScanResults(state, { tokens: [] });
+ expect(result).toEqual([]);
+ });
+
+ it('returns an empty array if no scan results are found', () => {
+ const state = createMockRootState();
+ const result = selectMultipleTokenScanResults(state, {
+ tokens: [
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ },
+ ],
+ });
+ expect(result).toEqual([
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ scanResult: undefined,
+ },
+ ]);
+ });
+ });
+});
diff --git a/app/selectors/phishingController.ts b/app/selectors/phishingController.ts
new file mode 100644
index 000000000000..fa913eb92223
--- /dev/null
+++ b/app/selectors/phishingController.ts
@@ -0,0 +1,54 @@
+import type { TokenScanCacheData } from '@metamask/phishing-controller';
+import { RootState } from '../reducers';
+import { createDeepEqualSelector } from './util';
+
+const selectPhishingControllerState = (state: RootState) =>
+ state.engine.backgroundState.PhishingController;
+
+/**
+ * Select the scan results for multiple token addresses
+ *
+ * @param state - Redux root state
+ * @param params - Parameters object
+ * @param params.tokens - Array of token objects with address and chainId
+ * @returns Array of scan results with their addresses
+ */
+export const selectMultipleTokenScanResults = createDeepEqualSelector(
+ selectPhishingControllerState,
+ (
+ _state: RootState,
+ params: { tokens: { address: string; chainId: string }[] },
+ ) => params.tokens,
+ (phishingControllerState, tokens) => {
+ if (!tokens || tokens.length === 0) {
+ return [];
+ }
+
+ const tokenScanCache = phishingControllerState?.tokenScanCache || {};
+
+ return tokens.reduce<
+ {
+ address: string;
+ chainId: string;
+ scanResult: TokenScanCacheData;
+ }[]
+ >((acc, token) => {
+ const { address, chainId } = token;
+
+ if (!address || !chainId) {
+ return acc;
+ }
+
+ const cacheKey = `${chainId}:${address.toLowerCase()}`;
+ const cacheEntry = tokenScanCache[cacheKey];
+
+ acc.push({
+ address: address.toLowerCase(),
+ chainId,
+ scanResult: cacheEntry?.data,
+ });
+
+ return acc;
+ }, []);
+ },
+);
diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json
index f516cba2fff4..2632734f4683 100644
--- a/app/util/test/initial-background-state.json
+++ b/app/util/test/initial-background-state.json
@@ -271,6 +271,7 @@
}
},
"PhishingController": {
+ "addressScanCache": {},
"c2DomainBlocklistLastFetched": 0,
"phishingLists": [],
"whitelist": [],
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 560fdc71a27f..f245f8bb0781 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -104,6 +104,16 @@
"burn_address": {
"message": "You're sending your assets to a burn address. If you continue, you'll lose your assets.",
"title": "Sending Assets to Burn Address"
+ },
+ "token_trust_signal": {
+ "malicious": {
+ "title": "Malicious token",
+ "message": "This token has been identified as malicious. Interacting with this token may result in a loss of funds."
+ },
+ "warning": {
+ "title": "Suspicious token",
+ "message": "This token shows strong signs of malicious behavior. Continuing may result in loss of funds."
+ }
}
},
"blockaid_banner": {
diff --git a/package.json b/package.json
index d5275774147e..43e78f137bc4 100644
--- a/package.json
+++ b/package.json
@@ -253,7 +253,7 @@
"@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch",
"@metamask/notification-services-controller": "^20.0.0",
"@metamask/permission-controller": "^12.1.0",
- "@metamask/phishing-controller": "^15.0.0",
+ "@metamask/phishing-controller": "^16.1.0",
"@metamask/post-message-stream": "^10.0.0",
"@metamask/preferences-controller": "^21.0.0",
"@metamask/preinstalled-example-snap": "^0.7.2",
diff --git a/yarn.lock b/yarn.lock
index bb24dc284980..9f622ebca3f4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8892,11 +8892,11 @@ __metadata:
linkType: hard
"@metamask/phishing-controller@npm:^15.0.0":
- version: 15.0.0
- resolution: "@metamask/phishing-controller@npm:15.0.0"
+ version: 15.0.1
+ resolution: "@metamask/phishing-controller@npm:15.0.1"
dependencies:
"@metamask/base-controller": "npm:^9.0.0"
- "@metamask/controller-utils": "npm:^11.14.1"
+ "@metamask/controller-utils": "npm:^11.15.0"
"@metamask/messenger": "npm:^0.3.0"
"@noble/hashes": "npm:^1.8.0"
"@types/punycode": "npm:^2.1.0"
@@ -8905,7 +8905,25 @@ __metadata:
punycode: "npm:^2.1.1"
peerDependencies:
"@metamask/transaction-controller": ^61.0.0
- checksum: 10/84e10ddcba9bb1351538c2de1105863dda030ad5f6dfa54bb17d731e436e948e6bcc4630fa914162046bda1b925514de37224f34cc00145e102b2f7f3f83059e
+ checksum: 10/2f3bc2946f8231256c4a17af8369637f9fc4e3beef31b30b45372059e899fedfa22261cf7b526db62fe607e752e74c63de4a0dea6bd811fae046aa677e4929d0
+ languageName: node
+ linkType: hard
+
+"@metamask/phishing-controller@npm:^16.1.0":
+ version: 16.1.0
+ resolution: "@metamask/phishing-controller@npm:16.1.0"
+ dependencies:
+ "@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/messenger": "npm:^0.3.0"
+ "@noble/hashes": "npm:^1.8.0"
+ "@types/punycode": "npm:^2.1.0"
+ ethereum-cryptography: "npm:^2.1.2"
+ fastest-levenshtein: "npm:^1.0.16"
+ punycode: "npm:^2.1.1"
+ peerDependencies:
+ "@metamask/transaction-controller": ^62.0.0
+ checksum: 10/af956177cd1a3dd10150cefd8895cc479bb35bddd4ae751031985a89d929a9f63febf55462d09d9e6970612b00f5b90e27ff84dbb82f5ce503f8d429a4b0803b
languageName: node
linkType: hard
@@ -35700,7 +35718,7 @@ __metadata:
"@metamask/notification-services-controller": "npm:^20.0.0"
"@metamask/object-multiplex": "npm:^1.1.0"
"@metamask/permission-controller": "npm:^12.1.0"
- "@metamask/phishing-controller": "npm:^15.0.0"
+ "@metamask/phishing-controller": "npm:^16.1.0"
"@metamask/post-message-stream": "npm:^10.0.0"
"@metamask/preferences-controller": "npm:^21.0.0"
"@metamask/preinstalled-example-snap": "npm:^0.7.2"
From ec748864c7322756aca8101a6a40ebca53e3c2a8 Mon Sep 17 00:00:00 2001
From: Alejandro Garcia Anglada
Date: Wed, 26 Nov 2025 16:54:08 +0100
Subject: [PATCH 09/16] fix: cp-7.60.0 non-evm accounts not found (#23318)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fix Non-EVM accounts not found
Fixes part of this
https://github.com/MetaMask/metamask-extension/issues/38104
## **Changelog**
CHANGELOG entry: fix: non-evm accounts not found
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-extension/issues/38104
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Updates Solana and Tron wallet snap dependencies to newer minor
versions.
>
> - **Dependencies**:
> - Bump `@metamask/solana-wallet-snap` from `^2.4.7` to `^2.5.0` in
`package.json`.
> - Bump `@metamask/tron-wallet-snap` from `^1.12.1` to `^1.13.0` in
`package.json`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
628c91e74c7d3c0ad6cc4ae1957080073dbabb81. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 4 ++--
yarn.lock | 20 ++++++++++----------
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/package.json b/package.json
index 43e78f137bc4..c1167de5e5c9 100644
--- a/package.json
+++ b/package.json
@@ -280,7 +280,7 @@
"@metamask/snaps-rpc-methods": "^14.1.1",
"@metamask/snaps-sdk": "^10.1.0",
"@metamask/snaps-utils": "^11.6.1",
- "@metamask/solana-wallet-snap": "^2.4.7",
+ "@metamask/solana-wallet-snap": "^2.5.0",
"@metamask/solana-wallet-standard": "^0.6.0",
"@metamask/stake-sdk": "^3.2.0",
"@metamask/swappable-obj-proxy": "^2.1.0",
@@ -288,7 +288,7 @@
"@metamask/token-search-discovery-controller": "^4.0.0",
"@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch",
"@metamask/transaction-pay-controller": "^10.1.0",
- "@metamask/tron-wallet-snap": "^1.12.1",
+ "@metamask/tron-wallet-snap": "^1.13.0",
"@metamask/utils": "^11.8.1",
"@ngraveio/bc-ur": "^1.1.6",
"@nktkas/hyperliquid": "^0.25.9",
diff --git a/yarn.lock b/yarn.lock
index 9f622ebca3f4..a2e2ad99328d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9454,10 +9454,10 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/solana-wallet-snap@npm:^2.4.7":
- version: 2.4.7
- resolution: "@metamask/solana-wallet-snap@npm:2.4.7"
- checksum: 10/3867ddf07c5cf2cdd50cd000b39c8e97a1fd6ef8d8270820c07f7b4d2edcc0fed383ac9015afe8827c0a46dc94ae9623c447dec32980219c5cd83a20cae145a0
+"@metamask/solana-wallet-snap@npm:^2.5.0":
+ version: 2.5.0
+ resolution: "@metamask/solana-wallet-snap@npm:2.5.0"
+ checksum: 10/cee4cbece192269fb02a59a90cbb8369dd6af3dab33eaecbb40fdb9723568c2da1dcd98b214063f34268696074a438a895cff40a421231e05cfab0afb1c71ea6
languageName: node
linkType: hard
@@ -9721,10 +9721,10 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/tron-wallet-snap@npm:^1.12.1":
- version: 1.12.1
- resolution: "@metamask/tron-wallet-snap@npm:1.12.1"
- checksum: 10/6f48c8dd6f625d7bb290bf3d39978839a0f4b905c14883e43fb35538b5ffa822f9611b8977fc54e9cb83711a95a9cbce93ad6a0149c4c31cfd1272af4b7055b0
+"@metamask/tron-wallet-snap@npm:^1.13.0":
+ version: 1.13.0
+ resolution: "@metamask/tron-wallet-snap@npm:1.13.0"
+ checksum: 10/de3fc0ab146e0fab5f8d2f69e6dda918c22f40158ec770b24850ddb424114116dd74b1efdae119ef3bb716e6b2c96b12eb131ea2d63da89f692a756208f6be90
languageName: node
linkType: hard
@@ -35746,7 +35746,7 @@ __metadata:
"@metamask/snaps-rpc-methods": "npm:^14.1.1"
"@metamask/snaps-sdk": "npm:^10.1.0"
"@metamask/snaps-utils": "npm:^11.6.1"
- "@metamask/solana-wallet-snap": "npm:^2.4.7"
+ "@metamask/solana-wallet-snap": "npm:^2.5.0"
"@metamask/solana-wallet-standard": "npm:^0.6.0"
"@metamask/stake-sdk": "npm:^3.2.0"
"@metamask/swappable-obj-proxy": "npm:^2.1.0"
@@ -35757,7 +35757,7 @@ __metadata:
"@metamask/token-search-discovery-controller": "npm:^4.0.0"
"@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
"@metamask/transaction-pay-controller": "npm:^10.1.0"
- "@metamask/tron-wallet-snap": "npm:^1.12.1"
+ "@metamask/tron-wallet-snap": "npm:^1.13.0"
"@metamask/utils": "npm:^11.8.1"
"@ngraveio/bc-ur": "npm:^1.1.6"
"@nktkas/hyperliquid": "npm:^0.25.9"
From 3af29ede8146d0592d61133ebb4f30bd34d4485f Mon Sep 17 00:00:00 2001
From: Kevin Bluer
Date: Wed, 26 Nov 2025 10:05:40 -0600
Subject: [PATCH 10/16] fix(predict): Resolves issue when selecting MAX on
market details chart few new-ish markets (#23077)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
- Resolves an issue wherein a given date would be repeated on the x-axis
when selecting `MAX` for the date range on the market details chart, if
the market is less than a month old. It also resulting in very sparse
data points being retrieved / plotted.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: NA
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [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]
> Deduplicates x-axis labels and adapts MAX timeframe label formatting
and data fidelity based on actual time span, with supporting timestamp
utilities and tests.
>
> - **PredictDetailsChart (`PredictDetailsChart.tsx`)**:
> - Compute chart time range via `getTimestampInMs` and pass to
`formatPriceHistoryLabel` for adaptive labels.
> - Deduplicate consecutive x-axis labels; update axis label generation
and keys.
> - **PredictMarketDetails (`PredictMarketDetails.tsx`)**:
> - Add adaptive fidelity for `MAX` interval: use higher fidelity
(`~240`) when history span < `30` days, default to `1440` otherwise.
> - Compute history span using `getTimestampInMs`; manage
`maxIntervalAdaptiveFidelity` state.
> - **Utils (`utils.ts`)**:
> - Add `MS_IN_SECOND`, `DAY_IN_MS`, `getTimestampInMs`.
> - Extend `formatPriceHistoryLabel` to accept `{ timeRangeMs }` and
adjust `MAX` formatting (month/day for <30 days; time for <1 day).
> - **Tests**:
> - Add axis label deduplication test in `PredictDetailsChart.test.tsx`.
> - Add adaptive `MAX` formatting tests and export of `DAY_IN_MS` in
`utils.test.ts`.
> - Add fidelity adjustment tests for short vs long `MAX` spans in
`PredictMarketDetails.test.tsx`; minor test name tweak.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
fa847f0731027b2eae10a0524dde0b9d69d10cc7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../PredictDetailsChart.test.tsx | 42 +++++++++++
.../PredictDetailsChart.tsx | 49 +++++++++---
.../PredictDetailsChart/utils.test.ts | 25 +++++++
.../components/PredictDetailsChart/utils.ts | 36 ++++++++-
.../PredictMarketDetails.test.tsx | 74 ++++++++++++++++++-
.../PredictMarketDetails.tsx | 67 ++++++++++++++++-
6 files changed, 280 insertions(+), 13 deletions(-)
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
index 162b6fa42b4d..a0ceee7db5b7 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
@@ -692,5 +692,47 @@ describe('PredictDetailsChart', () => {
expect(timeLabels.length).toBeGreaterThan(0);
});
});
+
+ describe('Axis Label Deduplication', () => {
+ it('removes consecutive duplicate axis labels', () => {
+ const axisData = [
+ { timestamp: 1740000000000, value: 0.2 },
+ { timestamp: 1740003600000, value: 0.3 },
+ { timestamp: 1740007200000, value: 0.4 },
+ { timestamp: 1740010800000, value: 0.5 },
+ ];
+
+ const labelByTimestamp = new Map([
+ [axisData[0].timestamp, 'AXIS_LABEL_ONE'],
+ [axisData[1].timestamp, 'AXIS_LABEL_ONE'],
+ [axisData[2].timestamp, 'AXIS_LABEL_TWO'],
+ [axisData[3].timestamp, 'AXIS_LABEL_TWO'],
+ ]);
+
+ const chartUtils =
+ jest.requireActual('./utils');
+ const formatSpy = jest
+ .spyOn(chartUtils, 'formatPriceHistoryLabel')
+ .mockImplementation(
+ (timestamp: number) =>
+ labelByTimestamp.get(Number(timestamp)) ?? 'AXIS_FALLBACK',
+ );
+
+ const { getAllByText } = setupTest({
+ data: [
+ {
+ label: 'Dedup Series',
+ color: '#123456',
+ data: axisData,
+ },
+ ],
+ });
+
+ expect(getAllByText('AXIS_LABEL_ONE')).toHaveLength(1);
+ expect(getAllByText('AXIS_LABEL_TWO')).toHaveLength(1);
+
+ formatSpy.mockRestore();
+ });
+ });
});
});
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
index 0aad87d77893..994c342406bc 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
@@ -30,6 +30,7 @@ import {
CHART_CONTENT_INSET,
MAX_SERIES,
formatPriceHistoryLabel,
+ getTimestampInMs,
} from './utils';
export interface ChartSeries {
@@ -279,16 +280,30 @@ const PredictDetailsChart: React.FC = ({
const isMultipleSeries = seriesToRender.length > 1;
// Process data with labels
+ const chartTimeRangeMs = React.useMemo(() => {
+ const timestamps = seriesToRender
+ .flatMap((series) => series.data)
+ .map((point) => getTimestampInMs(point.timestamp));
+
+ if (!timestamps.length) {
+ return 0;
+ }
+
+ return Math.max(...timestamps) - Math.min(...timestamps);
+ }, [seriesToRender]);
+
const seriesWithLabels = React.useMemo(
() =>
seriesToRender.map((series) => ({
...series,
data: series.data.map((point) => ({
...point,
- label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe),
+ label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe, {
+ timeRangeMs: chartTimeRangeMs,
+ }),
})),
})),
- [seriesToRender, selectedTimeframe],
+ [seriesToRender, selectedTimeframe, chartTimeRangeMs],
);
// Filter out empty series
@@ -459,10 +474,26 @@ const PredictDetailsChart: React.FC = ({
// Calculate axis labels
const axisLabelStep = Math.max(1, Math.floor(primaryData.length / 4) || 1);
- const axisLabels = primaryData.filter(
- (_, index) =>
- index % axisLabelStep === 0 || index === primaryData.length - 1,
- );
+ const axisLabelEntries = primaryData
+ .map((point, index) => ({
+ point,
+ label: point.label ?? '',
+ key: `${point.timestamp}-${index}`,
+ index,
+ }))
+ .filter(
+ (entry) =>
+ entry.index % axisLabelStep === 0 ||
+ entry.index === primaryData.length - 1,
+ );
+
+ const dedupedAxisLabels = axisLabelEntries.filter((entry, idx, arr) => {
+ if (!entry.label) {
+ return true;
+ }
+ const previous = arr[idx - 1];
+ return !previous || previous.label !== entry.label;
+ });
return (
@@ -540,13 +571,13 @@ const PredictDetailsChart: React.FC = ({
justifyContent={BoxJustifyContent.Between}
twClassName="px-4"
>
- {axisLabels.map((point, index) => (
+ {dedupedAxisLabels.map(({ label, key }) => (
- {point.label}
+ {label}
))}
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts
index ae8fa6183490..de7e611feda0 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts
+++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts
@@ -7,6 +7,7 @@ import {
MAX_SERIES,
formatPriceHistoryLabel,
formatTickValue,
+ DAY_IN_MS,
} from './utils';
describe('PredictDetailsChart utils', () => {
@@ -143,6 +144,30 @@ describe('PredictDetailsChart utils', () => {
expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/);
});
+ it('formats MAX interval with month and day when time range is under 30 days', () => {
+ const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z');
+
+ const result = formatPriceHistoryLabel(
+ timestamp,
+ PredictPriceHistoryInterval.MAX,
+ { timeRangeMs: 15 * DAY_IN_MS },
+ );
+
+ expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/);
+ });
+
+ it('formats MAX interval with time when time range is under 1 day', () => {
+ const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z');
+
+ const result = formatPriceHistoryLabel(
+ timestamp,
+ PredictPriceHistoryInterval.MAX,
+ { timeRangeMs: 0.5 * DAY_IN_MS },
+ );
+
+ expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/);
+ });
+
it('formats unknown interval as month and 2-digit year', () => {
const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z');
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts
index 37f80c73b033..4a3dd5a75ea0 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts
+++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts
@@ -11,13 +11,45 @@ export const CHART_CONTENT_INSET = {
right: 48,
};
export const MAX_SERIES = 3;
+export const MS_IN_SECOND = 1000;
+export const DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+const MAX_INTERVAL_SHORT_RANGE_THRESHOLD_IN_MS = 30 * DAY_IN_MS;
+
+export const getTimestampInMs = (timestamp: number): number =>
+ timestamp > 1_000_000_000_000 ? timestamp : timestamp * MS_IN_SECOND;
+
+export interface FormatPriceHistoryLabelOptions {
+ timeRangeMs?: number;
+}
export const formatPriceHistoryLabel = (
timestamp: number,
interval: PredictPriceHistoryInterval | string,
+ options?: FormatPriceHistoryLabelOptions,
) => {
- const isMilliseconds = timestamp > 1_000_000_000_000;
- const date = new Date(isMilliseconds ? timestamp : timestamp * 1000);
+ const date = new Date(getTimestampInMs(timestamp));
+ const timeRangeMs =
+ typeof options?.timeRangeMs === 'number' ? options.timeRangeMs : null;
+
+ const shouldUseShortMaxFormat =
+ interval === PredictPriceHistoryInterval.MAX &&
+ typeof timeRangeMs === 'number' &&
+ timeRangeMs > 0 &&
+ timeRangeMs < MAX_INTERVAL_SHORT_RANGE_THRESHOLD_IN_MS;
+
+ if (shouldUseShortMaxFormat) {
+ if (timeRangeMs !== null && timeRangeMs < DAY_IN_MS) {
+ return new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(date);
+ }
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+ }
switch (interval) {
case PredictPriceHistoryInterval.ONE_HOUR:
diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
index 4fe6b0195eab..1563e512eeb5 100644
--- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
+++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
@@ -9,6 +9,8 @@ import {
useRoute,
} from '@react-navigation/native';
import PredictMarketDetails from './PredictMarketDetails';
+import { PredictPriceHistoryInterval } from '../../types';
+import type { UsePredictPriceHistoryOptions } from '../../hooks/usePredictPriceHistory';
import { strings } from '../../../../../../locales/i18n';
import Routes from '../../../../../constants/navigation/Routes';
import { PredictEventValues } from '../../constants/eventNames';
@@ -926,7 +928,7 @@ describe('PredictMarketDetails', () => {
expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen();
});
- it('does not render chart when market has no open outcomes', () => {
+ it('removes chart when closed market lacks open outcomes', () => {
const emptyOutcomesMarket = createMockMarket({
status: 'closed',
outcomes: [
@@ -2097,6 +2099,76 @@ describe('PredictMarketDetails', () => {
expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen();
});
+ describe('Price history fidelity adjustments', () => {
+ const BASE_TIMESTAMP = 1_700_000_000_000;
+ const SHORT_RANGE_FIDELITY = 240;
+ const MAX_DEFAULT_FIDELITY = 1440;
+
+ const buildPriceHistory = (deltaMs: number) => [
+ [
+ { timestamp: BASE_TIMESTAMP, price: 0.5 },
+ { timestamp: BASE_TIMESTAMP + deltaMs, price: 0.6 },
+ ],
+ [],
+ ];
+
+ it('requests higher fidelity when MAX history span is shorter than a month', async () => {
+ const shortRangeHistory = buildPriceHistory(12 * 60 * 60 * 1000); // 12 hours
+
+ setupPredictMarketDetailsTest(
+ { status: 'closed' },
+ {},
+ { priceHistory: { priceHistories: shortRangeHistory } },
+ );
+
+ const { usePredictPriceHistory } = jest.requireMock(
+ '../../hooks/usePredictPriceHistory',
+ );
+
+ await waitFor(() => {
+ const maxCalls = usePredictPriceHistory.mock.calls.filter(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.interval === PredictPriceHistoryInterval.MAX,
+ );
+ expect(maxCalls.length).toBeGreaterThan(0);
+ expect(
+ maxCalls.some(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.fidelity === SHORT_RANGE_FIDELITY,
+ ),
+ ).toBe(true);
+ });
+ });
+
+ it('keeps default MAX fidelity when history span exceeds the threshold', async () => {
+ const longRangeHistory = buildPriceHistory(45 * 24 * 60 * 60 * 1000); // 45 days
+
+ setupPredictMarketDetailsTest(
+ { status: 'closed' },
+ {},
+ { priceHistory: { priceHistories: longRangeHistory } },
+ );
+
+ const { usePredictPriceHistory } = jest.requireMock(
+ '../../hooks/usePredictPriceHistory',
+ );
+
+ await waitFor(() => {
+ const maxCalls = usePredictPriceHistory.mock.calls.filter(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.interval === PredictPriceHistoryInterval.MAX,
+ );
+ expect(maxCalls.length).toBeGreaterThan(0);
+ expect(
+ maxCalls.every(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.fidelity === MAX_DEFAULT_FIDELITY,
+ ),
+ ).toBe(true);
+ });
+ });
+ });
+
it('handles no balance scenario for Yes button', () => {
const { usePredictBalance } = jest.requireMock(
'../../hooks/usePredictBalance',
diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx
index 80136750fbba..b1f6a10ed464 100644
--- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx
+++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx
@@ -50,6 +50,10 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset';
import PredictDetailsChart, {
ChartSeries,
} from '../../components/PredictDetailsChart/PredictDetailsChart';
+import {
+ DAY_IN_MS,
+ getTimestampInMs,
+} from '../../components/PredictDetailsChart/utils';
import PredictPositionDetail from '../../components/PredictPositionDetail';
import { usePredictMarket } from '../../hooks/usePredictMarket';
import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory';
@@ -91,6 +95,12 @@ const DEFAULT_FIDELITY_BY_INTERVAL: Partial<
[PredictPriceHistoryInterval.MAX]: 1440, // 24-hour resolution for max window
};
+const MAX_INTERVAL_SHORT_RANGE_THRESHOLD_DAYS = 30;
+const MAX_INTERVAL_SHORT_RANGE_MS =
+ MAX_INTERVAL_SHORT_RANGE_THRESHOLD_DAYS * DAY_IN_MS;
+const MAX_INTERVAL_SHORT_RANGE_FIDELITY =
+ DEFAULT_FIDELITY_BY_INTERVAL[PredictPriceHistoryInterval.ONE_WEEK] ?? 240;
+
// Use theme tokens instead of hex values for multi-series charts
interface PredictMarketDetailsProps {}
@@ -105,6 +115,8 @@ const PredictMarketDetails: React.FC = () => {
const tw = useTailwind();
const [selectedTimeframe, setSelectedTimeframe] =
useState(PredictPriceHistoryInterval.ONE_DAY);
+ const [maxIntervalAdaptiveFidelity, setMaxIntervalAdaptiveFidelity] =
+ useState(null);
const [activeTab, setActiveTab] = useState(null);
const [userSelectedTab, setUserSelectedTab] = useState(false);
const insets = useSafeAreaInsets();
@@ -315,7 +327,16 @@ const PredictMarketDetails: React.FC = () => {
[chartOpenOutcomes],
);
- const selectedFidelity = DEFAULT_FIDELITY_BY_INTERVAL[selectedTimeframe];
+ const selectedFidelity = useMemo(() => {
+ if (
+ selectedTimeframe === PredictPriceHistoryInterval.MAX &&
+ maxIntervalAdaptiveFidelity
+ ) {
+ return maxIntervalAdaptiveFidelity;
+ }
+
+ return DEFAULT_FIDELITY_BY_INTERVAL[selectedTimeframe];
+ }, [selectedTimeframe, maxIntervalAdaptiveFidelity]);
const {
priceHistories,
isFetching: isPriceHistoryFetching,
@@ -329,6 +350,50 @@ const PredictMarketDetails: React.FC = () => {
enabled: chartOutcomeTokenIds.length > 0,
});
+ const maxIntervalRangeMs = useMemo(() => {
+ if (selectedTimeframe !== PredictPriceHistoryInterval.MAX) {
+ return null;
+ }
+
+ const timestamps = priceHistories.flatMap((history) =>
+ history.map((point) => getTimestampInMs(point.timestamp)),
+ );
+
+ if (!timestamps.length) {
+ return null;
+ }
+
+ return Math.max(...timestamps) - Math.min(...timestamps);
+ }, [priceHistories, selectedTimeframe]);
+
+ useEffect(() => {
+ if (selectedTimeframe !== PredictPriceHistoryInterval.MAX) {
+ if (maxIntervalAdaptiveFidelity !== null) {
+ setMaxIntervalAdaptiveFidelity(null);
+ }
+ return;
+ }
+
+ if (
+ typeof maxIntervalRangeMs === 'number' &&
+ maxIntervalRangeMs > 0 &&
+ maxIntervalRangeMs < MAX_INTERVAL_SHORT_RANGE_MS
+ ) {
+ if (maxIntervalAdaptiveFidelity !== MAX_INTERVAL_SHORT_RANGE_FIDELITY) {
+ setMaxIntervalAdaptiveFidelity(MAX_INTERVAL_SHORT_RANGE_FIDELITY);
+ }
+ return;
+ }
+
+ if (
+ maxIntervalAdaptiveFidelity !== null &&
+ (maxIntervalRangeMs === null ||
+ maxIntervalRangeMs >= MAX_INTERVAL_SHORT_RANGE_MS)
+ ) {
+ setMaxIntervalAdaptiveFidelity(null);
+ }
+ }, [maxIntervalRangeMs, maxIntervalAdaptiveFidelity, selectedTimeframe]);
+
const chartData: ChartSeries[] = useMemo(() => {
const palette = [
colors.primary.default,
From d7464ad1cb41d3180ab010381ae2fa2e33349c2e Mon Sep 17 00:00:00 2001
From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com>
Date: Wed, 26 Nov 2025 17:08:31 +0100
Subject: [PATCH 11/16] feat(analytics): Add rpc_domain property to custom RPC
analytics events (#23322)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Adds `rpc_domain` property to custom RPC UX analytics events while
retaining the existing `rpc_endpoint_url` property.
## Changes
- Added `rpc_domain` property to RPC analytics events:
- `RPC_SERVICE_UNAVAILABLE`
- `RPC_SERVICE_DEGRADED`
- `NETWORK_CONNECTION_BANNER_SHOWN`
- `NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED`
- Both `rpc_domain` and `rpc_endpoint_url` now track the same value:
- `'custom'` for custom RPC endpoints
- Domain host for public endpoints (e.g., `'mainnet.infura.io'`)
- Marked `rpc_endpoint_url` as deprecated for future removal
- Updated unit tests to assert both properties
## Files Changed
-
`app/core/Engine/controllers/network-controller/messenger-action-handlers.ts`
-
`app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts`
- Corresponding test files
---
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/WPC-133
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Add rpc_domain to RPC analytics events (mirroring deprecated
rpc_endpoint_url) and update hook/controller logic and tests to use
sanitized domain values.
>
> - **Analytics**:
> - Add `rpc_domain` to RPC telemetry and align with deprecated
`rpc_endpoint_url` (same value: host for public endpoints, `custom` for
non-public).
> - Apply to `RPC_SERVICE_UNAVAILABLE`, `RPC_SERVICE_DEGRADED`,
`NETWORK_CONNECTION_BANNER_SHOWN`, and
`NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED`.
> - Centralize domain sanitization (via
`sanitizeRpcUrl`/`onlyKeepHost`); continue capturing `http_status` when
present.
> - **Hook (`useNetworkConnectionBanner`)**:
> - Compute `sanitizedUrl` once; include `rpc_domain` and deprecated
`rpc_endpoint_url` in tracked properties.
> - **Controller (`messenger-action-handlers`)**:
> - Derive `rpcDomain` once; add `rpc_domain` and keep deprecated
`rpc_endpoint_url`.
> - **Tests**:
> - Update assertions to include `rpc_domain` across scenarios
(public/custom, degraded/unavailable, with/without `http_status`).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
3414c3a8d05e55948db95393839e47f529fb8bbe. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../useNetworkConnectionBanner.test.tsx | 6 ++++++
.../useNetworkConnectionBanner.ts | 11 +++++++----
.../messenger-action-handlers.test.ts | 6 ++++++
.../network-controller/messenger-action-handlers.ts | 8 +++++---
4 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx
index b310f5e227b9..5555346fd19b 100644
--- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx
+++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx
@@ -259,6 +259,7 @@ describe('useNetworkConnectionBanner', () => {
banner_type: 'degraded',
chain_id_caip: 'eip155:1',
rpc_endpoint_url: 'mainnet.infura.io',
+ rpc_domain: 'mainnet.infura.io',
});
});
@@ -290,6 +291,7 @@ describe('useNetworkConnectionBanner', () => {
banner_type: 'unavailable',
chain_id_caip: 'eip155:1',
rpc_endpoint_url: 'mainnet.infura.io',
+ rpc_domain: 'mainnet.infura.io',
});
});
@@ -324,6 +326,7 @@ describe('useNetworkConnectionBanner', () => {
banner_type: 'degraded',
chain_id_caip: 'eip155:1',
rpc_endpoint_url: 'custom',
+ rpc_domain: 'custom',
});
});
@@ -598,6 +601,7 @@ describe('useNetworkConnectionBanner', () => {
banner_type: 'degraded',
chain_id_caip: 'eip155:137',
rpc_endpoint_url: 'polygon-rpc.com',
+ rpc_domain: 'polygon-rpc.com',
});
});
@@ -626,6 +630,7 @@ describe('useNetworkConnectionBanner', () => {
banner_type: 'unavailable',
chain_id_caip: 'eip155:137',
rpc_endpoint_url: 'polygon-rpc.com',
+ rpc_domain: 'polygon-rpc.com',
});
});
@@ -780,6 +785,7 @@ describe('useNetworkConnectionBanner', () => {
banner_type: 'degraded',
chain_id_caip: 'eip155:137',
rpc_endpoint_url: 'polygon-rpc.com',
+ rpc_domain: 'polygon-rpc.com',
});
});
diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts
index 0e4f2c518cff..f7a8ac0684e6 100644
--- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts
+++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts
@@ -68,6 +68,7 @@ const useNetworkConnectionBanner = (): {
trackRpcUpdateFromBanner: true,
});
+ const sanitizedUrl = sanitizeRpcUrl(rpcUrl);
trackEvent(
createEventBuilder(
MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED,
@@ -75,7 +76,9 @@ const useNetworkConnectionBanner = (): {
.addProperties({
banner_type: status,
chain_id_caip: `eip155:${hexToNumber(chainId)}`,
- rpc_endpoint_url: sanitizeRpcUrl(rpcUrl),
+ // @deprecated: will be removed in a future release
+ rpc_endpoint_url: sanitizedUrl,
+ rpc_domain: sanitizedUrl,
})
.build(),
);
@@ -197,6 +200,7 @@ const useNetworkConnectionBanner = (): {
useEffect(() => {
if (networkConnectionBannerState.visible) {
+ const sanitizedUrl = sanitizeRpcUrl(networkConnectionBannerState.rpcUrl);
trackEvent(
createEventBuilder(MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN)
.addProperties({
@@ -204,9 +208,8 @@ const useNetworkConnectionBanner = (): {
chain_id_caip: `eip155:${hexToNumber(
networkConnectionBannerState.chainId,
)}`,
- rpc_endpoint_url: sanitizeRpcUrl(
- networkConnectionBannerState.rpcUrl,
- ),
+ rpc_endpoint_url: sanitizedUrl,
+ rpc_domain: sanitizedUrl,
})
.build(),
);
diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts
index 29113a49e4b8..90318b1fe709 100644
--- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts
+++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts
@@ -79,6 +79,7 @@ describe('onRpcEndpointUnavailable', () => {
properties: {
chain_id_caip: 'eip155:11155111',
rpc_endpoint_url: 'example.com',
+ rpc_domain: 'example.com',
},
});
/* eslint-enable @typescript-eslint/naming-convention */
@@ -109,6 +110,7 @@ describe('onRpcEndpointUnavailable', () => {
chain_id_caip: 'eip155:11155111',
http_status: 420,
rpc_endpoint_url: 'example.com',
+ rpc_domain: 'example.com',
},
});
/* eslint-enable @typescript-eslint/naming-convention */
@@ -138,6 +140,7 @@ describe('onRpcEndpointUnavailable', () => {
properties: {
chain_id_caip: 'eip155:11155111',
rpc_endpoint_url: 'custom',
+ rpc_domain: 'custom',
},
});
/* eslint-enable @typescript-eslint/naming-convention */
@@ -236,6 +239,7 @@ describe('onRpcEndpointDegraded', () => {
properties: {
chain_id_caip: 'eip155:11155111',
rpc_endpoint_url: 'example.com',
+ rpc_domain: 'example.com',
},
});
/* eslint-enable @typescript-eslint/naming-convention */
@@ -266,6 +270,7 @@ describe('onRpcEndpointDegraded', () => {
chain_id_caip: 'eip155:11155111',
http_status: 420,
rpc_endpoint_url: 'example.com',
+ rpc_domain: 'example.com',
},
});
/* eslint-enable @typescript-eslint/naming-convention */
@@ -295,6 +300,7 @@ describe('onRpcEndpointDegraded', () => {
properties: {
chain_id_caip: 'eip155:11155111',
rpc_endpoint_url: 'custom',
+ rpc_domain: 'custom',
},
});
/* eslint-enable @typescript-eslint/naming-convention */
diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts
index d2917d59e074..0156be74b184 100644
--- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts
+++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts
@@ -146,13 +146,15 @@ export function trackRpcEndpointEvent(
return;
}
+ const isPublicEndpoint = isPublicEndpointUrl(endpointUrl, infuraProjectId);
+ const rpcDomain = isPublicEndpoint ? onlyKeepHost(endpointUrl) : 'custom';
// The names of Segment properties have a particular case.
/* eslint-disable @typescript-eslint/naming-convention */
const properties = {
chain_id_caip: `eip155:${hexToNumber(chainId)}`,
- rpc_endpoint_url: isPublicEndpointUrl(endpointUrl, infuraProjectId)
- ? onlyKeepHost(endpointUrl)
- : 'custom',
+ // @deprecated: will be removed in a future release
+ rpc_endpoint_url: rpcDomain,
+ rpc_domain: rpcDomain,
...(isObject(error) &&
'httpStatus' in error &&
isValidJson(error.httpStatus)
From 2ccb892d502a5c2002741f75890183947fa94127 Mon Sep 17 00:00:00 2001
From: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Date: Thu, 27 Nov 2025 00:49:05 +0800
Subject: [PATCH 12/16] feat(perps): design v2 for perps asset screen (#23230)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR implements a comprehensive redesign of the Perps Asset Details
screen (v2) with enhanced position management capabilities. When users
have an open position, the screen now provides intuitive actions for
position management including closing, modifying exposure, flipping
direction, and adjusting margin.
**Key improvements:**
1. **Dynamic button layout**: Replaces static Long/Short buttons with
context-aware Close/Modify/Share buttons when position is open
2. **Position modification modal**: New bottom sheet with 6 position
management options (Increase/Reduce exposure, Flip, Add TP/SL,
Add/Remove margin)
3. **Margin adjustment views**: New screens for adding and removing
margin with real-time impact visualization
4. **Position flipping**: One-tap position reversal while maintaining
size and leverage
5. **Improved scrollability**: Full asset page is now scrollable to
access all information
**Related Jira Tickets:**
- TAT-1910: Replace Long/Short buttons with Close/Modify/Share when
position is open
- TAT-2046: Enable scrolling through asset page for all relevant
information
- TAT-1541: Add or remove margin from open positions
- TAT-1690: Flip position functionality
## **Changelog**
CHANGELOG entry: Added enhanced position management UI with
Close/Modify/Share buttons, margin adjustment, and position flipping
capabilities for Perps trading
## **Related issues**
Fixes: TAT-1910, TAT-2046, TAT-1541, TAT-1690
## **Manual testing steps**
```gherkin
Feature: Enhanced Position Management on Asset Details Screen
Scenario: User views asset screen without open position
Given user is on Perps asset details screen
And user has no open position
When user views the screen
Then user sees Long and Short buttons
And user does not see Close/Modify/Share buttons
Scenario: User views asset screen with open position
Given user is on Perps asset details screen
And user has an open position
When user views the screen
Then user sees Close, Modify, and Share buttons
And user sees "Add Stop loss / Take profit" button below position card
And user does not see Long and Short buttons
Scenario: User closes position
Given user has an open position
And user is on asset details screen
When user taps Close button
Then close position screen opens
Scenario: User modifies position via bottom sheet
Given user has an open position
When user taps Modify button
Then bottom sheet opens with 6 options:
| Option |
| Increase exposure |
| Reduce exposure |
| Flip |
| Add TP/SL |
| Add margin |
| Remove margin |
Scenario: User increases exposure with existing TP/SL
Given user has position with TP/SL configured
When user selects "Increase exposure"
Then trade screen opens in same direction as current position
And TP/SL from position is pre-filled
And user can modify TP/SL for this specific trade
Scenario: User flips position
Given user has a LONG position of 2.5 ETH with 10x leverage
When user selects "Flip" from Modify menu
Then trade screen opens for SHORT direction
And position size remains 2.5 ETH
And leverage remains 10x
And user can use available margin + margin from closed position
And TP/SL are cancelled
Scenario: User adds margin to position
Given user has open position with $500 margin
And user has $300 available to add
When user selects "Add margin"
Then margin adjustment screen opens
And screen shows current margin: $500
And screen shows available to add: $300
And screen shows current liquidation price
And user can use slider or keyboard input
And user sees real-time impact on liquidation price and leverage
Scenario: User removes margin from position
Given user has open position with $500 margin
And user has $200 available to withdraw
When user selects "Remove margin"
Then margin adjustment screen opens
And screen shows current margin: $500
And screen shows available to withdraw: $200
And user can use slider or keyboard input
And user sees real-time impact on liquidation price
Scenario: User scrolls through asset page
Given user is on asset details screen
When user scrolls down
Then user can access all sections:
| Section |
| Price chart |
| Position card (if exists)|
| Market statistics |
| Order details |
```
## **Screenshots/Recordings**
### **Before**
Original asset screen with static Long/Short buttons
### **After**
**HIP-2 Markets (regular crypto):**

**HIP-3 - No Positions:**

**HIP-3 - With Active Positions:**

**With TP/SL and Limit Order:**

**Close Position Flow:**

**Flip Position Flow:**

**Add Margin Flow:**

## **Pre-merge author checklist**
- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
## **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.
---
## **Technical Notes**
### Architecture Changes:
- **PerpsMarketDetailsView**: Refactored to conditionally render button
sets based on position state
- **PerpsPositionCard**: Now uses callback pattern (`onAutoClosePress`,
`onFlipPress`, `onMarginPress`, `onSharePress`) instead of internal
navigation
- **New Views**:
- `PerpsAdjustMarginView` - For adding/removing margin
- `PerpsOrderDetailsView` - For viewing order details
- **New Components**:
- `PerpsFlipPositionConfirmSheet` - Confirmation sheet for position
flipping
### Test Updates:
- Fixed failing tests after merge with main branch
- Updated PerpsPositionCard tests to use testID pattern (removed 35
obsolete tests)
- Added 10 missing locale keys for new UI elements
- Added testIDs for PNL, ROE, Size, and Margin values
### Navigation Flow:
```
PerpsMarketDetailsView (with position)
|-- Close Button -> PerpsClosePositionView
|-- Modify Button -> Bottom Sheet
| |-- Increase exposure -> PerpsTradeView (same direction)
| |-- Reduce exposure -> PerpsClosePositionView
| |-- Flip -> PerpsFlipPositionConfirmSheet -> PerpsTradeView (opposite)
| |-- Add TP/SL -> PerpsTpSlView
| |-- Add margin -> PerpsAdjustMarginView (mode: add)
| `-- Remove margin -> PerpsAdjustMarginView (mode: remove)
`-- Share Button -> PerpsPnlHeroCard
```
---
> [!NOTE]
> Redesigns the perps asset screen with full position
management—Modify/Close/Share, margin add/remove, and flip—plus new
order details, hooks, services, routes, and tests.
>
> - **UI/Views**:
> - **Asset Details (MarketDetailsView)**: Reworked layout; shows
Modify/Close when a position exists; adds Position card, compact open
orders list, updated statistics (incl. oracle price), tooltips, and
scrollable sections; integrates bottom sheets for modify, adjust margin,
and flip confirm.
> - **New Screens**: `PerpsAdjustMarginView` (add/remove margin with
live impact), `PerpsOrderDetailsView` (view/cancel), selection sheets:
`PerpsSelectModifyActionView`, `PerpsSelectAdjustMarginActionView`,
`PerpsSelectOrderTypeView`.
> - **Components**: New `PerpsFlipPositionConfirmSheet`,
`PerpsAdjustMarginActionSheet`, `PerpsModifyActionSheet`,
`PerpsCompactOrderRow`; redesigned `PerpsPositionCard`; enhanced
`PerpsMarketStatisticsCard`; styling updates to trades/activity lists
and badges.
> - **Hooks/Logic**:
> - Added `usePerpsMarginAdjustment`, `usePerpsFlipPosition`,
`usePositionManagement`; extended `usePerpsTrading` and navigation
helpers.
> - New margin utilities: `calculateMaxRemovableMargin`,
`calculateNewLiquidationPrice`, `assessMarginRemovalRisk`.
> - **Controller/Provider**:
> - PerpsController/TradingService: new `updateMargin` and
`flipPosition`; HyperLiquidProvider implements `updateMargin`.
> - Config: `MARGIN_ADJUSTMENT_CONFIG`; new traces for adjust
margin/order details/flip.
> - **Navigation/Routes**: Added routes for adjust margin, order
details, and selection sheets.
> - **Toasts/i18n**: Margin adjustment toasts; new strings.
> - **Tests/Docs**: Extensive unit/e2e updates; new tests for
views/hooks/services; docs updated for new screens.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
a207786024ac28f0bcea3ad58b29113f18da1e93. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com>
Co-authored-by: Salim TOUBAL
Co-authored-by: George Gkasdrogkas
Co-authored-by: Matthew Walsh
Co-authored-by: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com>
Co-authored-by: metamaskbot
Co-authored-by: Michal Szorad
Co-authored-by: Patryk Łucka <5708018+PatrykLucka@users.noreply.github.com>
Co-authored-by: VGR
Co-authored-by: sahar-fehri
Co-authored-by: OGPoyraz
Co-authored-by: Pedro Pablo Aste Kompen
Co-authored-by: Aslau Mario-Daniel
Co-authored-by: Nico MASSART
Co-authored-by: Corey Janssen <107953793+coreyjanssen@users.noreply.github.com>
Co-authored-by: Bernardo Garces Chapero
Co-authored-by: António Regadas
Co-authored-by: Alejandro Garcia
Co-authored-by: Nicholas Smith
Co-authored-by: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Co-authored-by: Andre Pimenta
Co-authored-by: Vince Howard
Co-authored-by: Curtis David
Co-authored-by: Brian August Nguyen
Co-authored-by: Kylan Hurt <6249205+smilingkylan@users.noreply.github.com>
---
.../PerpsAdjustMarginView.styles.ts | 179 ++++
.../PerpsAdjustMarginView.test.tsx | 286 ++++++
.../PerpsAdjustMarginView.tsx | 547 +++++++++++
.../Views/PerpsAdjustMarginView/index.ts | 1 +
.../PerpsMarketDetailsView.styles.ts | 5 +-
.../PerpsMarketDetailsView.test.tsx | 57 +-
.../PerpsMarketDetailsView.tsx | 491 +++++-----
.../PerpsOrderDetailsView.styles.ts | 107 +++
.../PerpsOrderDetailsView.test.tsx | 320 +++++++
.../PerpsOrderDetailsView.tsx | 348 +++++++
.../Views/PerpsOrderDetailsView/index.ts | 1 +
.../Views/PerpsOrderView/PerpsOrderView.tsx | 107 ++-
...PerpsSelectAdjustMarginActionView.test.tsx | 192 ++++
.../PerpsSelectAdjustMarginActionView.tsx | 82 ++
.../index.ts | 1 +
.../PerpsSelectModifyActionView.test.tsx | 276 ++++++
.../PerpsSelectModifyActionView.tsx | 120 +++
.../PerpsSelectModifyActionView/index.ts | 1 +
.../PerpsSelectOrderTypeView.test.tsx | 193 ++++
.../PerpsSelectOrderTypeView.tsx | 61 ++
.../Views/PerpsSelectOrderTypeView/index.ts | 1 +
.../PerpsAdjustMarginActionSheet.styles.ts | 25 +
.../PerpsAdjustMarginActionSheet.test.tsx | 192 ++++
.../PerpsAdjustMarginActionSheet.tsx | 129 +++
.../PerpsAdjustMarginActionSheet.types.ts | 11 +
.../PerpsAdjustMarginActionSheet/index.ts | 5 +
.../PerpsBadge/PerpsBadge.styles.ts | 14 +-
.../components/PerpsBadge/PerpsBadge.types.ts | 2 +-
.../PerpsBottomSheetTooltip.types.ts | 1 +
.../content/contentRegistry.ts | 1 +
.../PerpsCompactOrderRow.styles.ts | 39 +
.../PerpsCompactOrderRow.test.tsx | 189 ++++
.../PerpsCompactOrderRow.tsx | 129 +++
.../components/PerpsCompactOrderRow/index.ts | 1 +
.../PerpsFlipPositionConfirmSheet.styles.ts | 64 ++
.../PerpsFlipPositionConfirmSheet.test.tsx | 374 ++++++++
.../PerpsFlipPositionConfirmSheet.tsx | 284 ++++++
.../PerpsFlipPositionConfirmSheet.types.ts | 9 +
.../PerpsFlipPositionConfirmSheet/index.ts | 2 +
.../PerpsMarketStatisticsCard.styles.ts | 44 +-
.../PerpsMarketStatisticsCard.test.tsx | 27 +-
.../PerpsMarketStatisticsCard.tsx | 256 ++---
.../PerpsMarketStatisticsCard.types.ts | 4 +
.../PerpsMarketTabs/PerpsMarketTabs.tsx | 111 ++-
.../PerpsMarketTradesList.styles.ts | 19 +-
.../PerpsMarketTradesList.tsx | 23 +-
.../PerpsModifyActionSheet.styles.ts | 38 +
.../PerpsModifyActionSheet.test.tsx | 329 +++++++
.../PerpsModifyActionSheet.tsx | 145 +++
.../PerpsModifyActionSheet.types.ts | 4 +
.../PerpsModifyActionSheet/index.ts | 2 +
.../PerpsOrderTypeBottomSheet.tsx | 23 +-
.../PerpsPositionCard.styles.ts | 178 ++--
.../PerpsPositionCard.test.tsx | 754 ++-------------
.../PerpsPositionCard/PerpsPositionCard.tsx | 877 ++++++++----------
.../PerpsRecentActivityList.styles.ts | 18 +-
.../PerpsRecentActivityList.tsx | 41 +-
.../PerpsTabControlBar/PerpsTabControlBar.tsx | 2 +-
.../UI/Perps/constants/perpsConfig.ts | 30 +
.../UI/Perps/contexts/PerpsOrderContext.tsx | 15 +-
.../Perps/controllers/PerpsController.test.ts | 134 +++
.../UI/Perps/controllers/PerpsController.ts | 40 +
.../providers/HyperLiquidProvider.ts | 89 ++
.../services/TradingService.test.ts | 361 +++++++
.../controllers/services/TradingService.ts | 229 +++++
.../UI/Perps/controllers/types/index.ts | 16 +
app/components/UI/Perps/hooks/index.ts | 1 +
.../Perps/hooks/usePerpsFlipPosition.test.ts | 319 +++++++
.../UI/Perps/hooks/usePerpsFlipPosition.ts | 129 +++
.../hooks/usePerpsMarginAdjustment.test.ts | 367 ++++++++
.../Perps/hooks/usePerpsMarginAdjustment.ts | 139 +++
.../UI/Perps/hooks/usePerpsNavigation.ts | 29 +-
.../UI/Perps/hooks/usePerpsOrderFees.test.ts | 6 +
.../UI/Perps/hooks/usePerpsPositions.test.ts | 2 +
.../UI/Perps/hooks/usePerpsToasts.test.ts | 87 ++
.../UI/Perps/hooks/usePerpsToasts.tsx | 36 +
.../UI/Perps/hooks/usePerpsTrading.test.ts | 119 +++
.../UI/Perps/hooks/usePerpsTrading.ts | 21 +
.../Perps/hooks/usePositionManagement.test.ts | 181 ++++
.../UI/Perps/hooks/usePositionManagement.ts | 141 +++
app/components/UI/Perps/routes/index.tsx | 47 +
app/components/UI/Perps/types/navigation.ts | 25 +-
.../UI/Perps/utils/marginUtils.test.ts | 390 ++++++++
app/components/UI/Perps/utils/marginUtils.ts | 191 ++++
app/constants/navigation/Routes.ts | 5 +
app/util/trace.ts | 5 +
docs/perps/perps-screens.md | 86 +-
docs/perps/perps-sentry-reference.md | 8 +-
e2e/pages/Perps/PerpsMarketDetailsView.ts | 3 +-
e2e/pages/Perps/PerpsView.ts | 6 +-
e2e/selectors/Perps/Perps.selectors.ts | 30 +-
locales/languages/en.json | 85 ++
92 files changed, 9346 insertions(+), 1768 deletions(-)
create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts
create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts
create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts
create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts
create mode 100644 app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts
create mode 100644 app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts
create mode 100644 app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx
create mode 100644 app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts
create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts
create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx
create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts
create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts
create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts
create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx
create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx
create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts
create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts
create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx
create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx
create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts
create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts
create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts
create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx
create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx
create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts
create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts
create mode 100644 app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts
create mode 100644 app/components/UI/Perps/hooks/usePerpsFlipPosition.ts
create mode 100644 app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts
create mode 100644 app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts
create mode 100644 app/components/UI/Perps/hooks/usePositionManagement.test.ts
create mode 100644 app/components/UI/Perps/hooks/usePositionManagement.ts
create mode 100644 app/components/UI/Perps/utils/marginUtils.test.ts
create mode 100644 app/components/UI/Perps/utils/marginUtils.ts
diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts
new file mode 100644
index 000000000000..c706f351a716
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts
@@ -0,0 +1,179 @@
+import { StyleSheet } from 'react-native';
+import { Theme } from '../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background.default,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ headerTitle: {
+ textAlign: 'center',
+ },
+ headerSpacer: {
+ width: 32,
+ },
+ contentContainer: {
+ flex: 1,
+ paddingHorizontal: 16,
+ justifyContent: 'space-between',
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: 16,
+ paddingBottom: 24,
+ },
+ section: {
+ marginTop: 24,
+ },
+ errorContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ },
+ amountSection: {
+ marginTop: 24,
+ marginBottom: 16,
+ },
+ sliderSection: {
+ marginBottom: 24,
+ },
+ infoSection: {
+ gap: 16,
+ marginBottom: 16,
+ },
+ labelWithIcon: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ infoIcon: {
+ marginLeft: 4,
+ padding: 4,
+ },
+ keypadFooter: {
+ paddingHorizontal: 16,
+ paddingTop: 16,
+ paddingBottom: 16,
+ backgroundColor: colors.background.default,
+ },
+ percentageButtonsContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ gap: 8,
+ marginBottom: 16,
+ },
+ percentageButton: {
+ flex: 1,
+ },
+ keypad: {},
+ infoCard: {
+ backgroundColor: colors.background.alternative,
+ borderRadius: 8,
+ padding: 16,
+ gap: 12,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ labelRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ maxButton: {
+ paddingHorizontal: 12,
+ paddingVertical: 4,
+ borderRadius: 4,
+ backgroundColor: colors.primary.muted,
+ },
+ amountDisplay: {
+ alignItems: 'center',
+ paddingVertical: 16,
+ },
+ slider: {
+ width: '100%',
+ height: 40,
+ },
+ sliderLabels: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: 8,
+ },
+ impactCard: {
+ backgroundColor: colors.info.muted,
+ borderRadius: 8,
+ padding: 16,
+ gap: 12,
+ },
+ impactCardWarning: {
+ backgroundColor: colors.warning.muted,
+ },
+ impactCardDanger: {
+ backgroundColor: colors.error.muted,
+ },
+ impactHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 4,
+ },
+ impactTitle: {
+ flex: 1,
+ },
+ impactRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ changeContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ strikethrough: {
+ textDecorationLine: 'line-through',
+ },
+ warningCard: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: 12,
+ backgroundColor: colors.warning.muted,
+ borderRadius: 8,
+ padding: 16,
+ },
+ warningCardDanger: {
+ backgroundColor: colors.error.muted,
+ },
+ warningText: {
+ flex: 1,
+ },
+ warningTextContainer: {
+ flex: 1,
+ gap: 4,
+ },
+ footer: {
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: colors.border.muted,
+ backgroundColor: colors.background.default,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx
new file mode 100644
index 000000000000..c544f4cc4949
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx
@@ -0,0 +1,286 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react-native';
+import PerpsAdjustMarginView from './PerpsAdjustMarginView';
+import type { Position } from '../../controllers/types';
+
+// Mock dependencies
+jest.mock('react-native-reanimated', () =>
+ jest.requireActual('react-native-reanimated/mock'),
+);
+
+jest.mock('react-native-gesture-handler', () => ({
+ GestureHandlerRootView: 'View',
+ GestureDetector: 'View',
+ Gesture: {
+ Pan: jest.fn().mockReturnValue({
+ onUpdate: jest.fn().mockReturnThis(),
+ onEnd: jest.fn().mockReturnThis(),
+ }),
+ },
+}));
+
+jest.mock('react-native-safe-area-context', () => {
+ const { View } = jest.requireActual('react-native');
+ const inset = { top: 0, right: 0, bottom: 0, left: 0 };
+ return {
+ SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children),
+ SafeAreaView: jest
+ .fn()
+ .mockImplementation(({ children, ...props }) => (
+ {children}
+ )),
+ useSafeAreaInsets: jest.fn().mockImplementation(() => inset),
+ };
+});
+
+const mockHandleAddMargin = jest.fn();
+const mockHandleRemoveMargin = jest.fn();
+const mockGoBack = jest.fn();
+const mockUsePerpsMarginAdjustment = jest.fn();
+
+jest.mock('../../hooks/usePerpsMarginAdjustment', () => ({
+ usePerpsMarginAdjustment: (opts: unknown) =>
+ mockUsePerpsMarginAdjustment(opts),
+}));
+
+const mockUsePerpsLiveAccount = jest.fn();
+const mockUsePerpsLivePrices = jest.fn();
+
+jest.mock('../../hooks/stream', () => ({
+ usePerpsLiveAccount: () => mockUsePerpsLiveAccount(),
+ usePerpsLivePrices: () => mockUsePerpsLivePrices(),
+}));
+
+const mockUsePerpsMarkets = jest.fn();
+
+jest.mock('../../hooks/usePerpsMarkets', () => ({
+ usePerpsMarkets: () => mockUsePerpsMarkets(),
+}));
+
+jest.mock('../../hooks/usePerpsMeasurement', () => ({
+ usePerpsMeasurement: jest.fn(),
+}));
+
+jest.mock('../../utils/marginUtils', () => ({
+ calculateMaxRemovableMargin: jest.fn(() => 200),
+ calculateNewLiquidationPrice: jest.fn(() => 1800),
+}));
+
+jest.mock('../../../../../util/Logger', () => ({
+ error: jest.fn(),
+}));
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPerpsFiat: jest.fn((value) => {
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ return `$${num.toFixed(2)}`;
+ }),
+ PRICE_RANGES_UNIVERSAL: {},
+ PRICE_RANGES_MINIMAL_VIEW: {},
+}));
+
+const mockNavigation = {
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ setOptions: jest.fn(),
+ addListener: jest.fn(),
+ canGoBack: jest.fn(() => true),
+};
+
+let mockRouteParams: Record = {};
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => mockNavigation,
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'PerpsAdjustMargin',
+ }),
+}));
+
+jest.mock('./PerpsAdjustMarginView.styles', () => ({
+ __esModule: true,
+ default: () => ({
+ container: {},
+ scrollView: {},
+ scrollContent: {},
+ amountSection: {},
+ sliderSection: {},
+ infoSection: {},
+ infoRow: {},
+ changeContainer: {},
+ footer: {},
+ errorContainer: {},
+ }),
+}));
+
+jest.mock('../../../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ icon: { alternative: '#888' },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+// Mock PerpsOrderHeader component to render title prop
+jest.mock('../../components/PerpsOrderHeader', () => {
+ const ReactModule = jest.requireActual('react');
+ const RNModule = jest.requireActual('react-native');
+ return function MockPerpsOrderHeader({ title }: { title: string }) {
+ return ReactModule.createElement(RNModule.Text, null, title);
+ };
+});
+jest.mock('../../components/PerpsAmountDisplay', () => 'PerpsAmountDisplay');
+jest.mock('../../components/PerpsSlider', () => 'PerpsSlider');
+
+describe('PerpsAdjustMarginView', () => {
+ const mockPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockHandleAddMargin.mockResolvedValue(undefined);
+ mockHandleRemoveMargin.mockResolvedValue(undefined);
+
+ // Set default mock return values
+ mockUsePerpsMarginAdjustment.mockReturnValue({
+ handleAddMargin: mockHandleAddMargin,
+ handleRemoveMargin: mockHandleRemoveMargin,
+ isAdjusting: false,
+ });
+
+ mockUsePerpsLiveAccount.mockReturnValue({
+ account: { availableBalance: '1000' },
+ });
+
+ mockUsePerpsLivePrices.mockReturnValue({
+ ETH: { price: '2000', markPrice: '2000', percentChange24h: '2.5' },
+ });
+
+ mockUsePerpsMarkets.mockReturnValue({
+ markets: [{ coin: 'ETH', maxLeverage: 50 }],
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('add mode', () => {
+ beforeEach(() => {
+ mockRouteParams = {
+ position: mockPosition,
+ mode: 'add',
+ };
+ });
+
+ it('renders add margin title', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.add_title'),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays perps balance available to add', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.perps_balance'),
+ ).toBeOnTheScreen();
+ expect(screen.getByText('$1000.00')).toBeOnTheScreen();
+ });
+
+ it('displays liquidation price label', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.liquidation_price'),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays liquidation distance label', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.liquidation_distance'),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays add margin button label', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.add_margin'),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('remove mode', () => {
+ beforeEach(() => {
+ mockRouteParams = {
+ position: mockPosition,
+ mode: 'remove',
+ };
+ });
+
+ it('renders remove margin title', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.remove_title'),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays current position margin', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.margin_in_position'),
+ ).toBeOnTheScreen();
+ expect(screen.getByText('$500.00')).toBeOnTheScreen();
+ });
+
+ it('displays reduce margin button label', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.adjust_margin.reduce_margin'),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('error handling', () => {
+ it('renders view when route params are provided', () => {
+ mockRouteParams = {
+ position: mockPosition,
+ mode: 'add',
+ };
+
+ render();
+
+ // Verify view rendered by checking for title
+ expect(
+ screen.getByText('perps.adjust_margin.add_title'),
+ ).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx
new file mode 100644
index 000000000000..609766be7652
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx
@@ -0,0 +1,547 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import { View, TouchableOpacity } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
+import { useStyles } from '../../../../../component-library/hooks';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import Button, {
+ ButtonVariants,
+ ButtonWidthTypes,
+ ButtonSize,
+} from '../../../../../component-library/components/Buttons/Button';
+import { strings } from '../../../../../../locales/i18n';
+import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream';
+import type { Position } from '../../controllers/types';
+import styleSheet from './PerpsAdjustMarginView.styles';
+import { useTheme } from '../../../../../util/theme';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../../../component-library/components/Icons/Icon';
+import ButtonIcon, {
+ ButtonIconSizes,
+} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import { usePerpsMarginAdjustment } from '../../hooks/usePerpsMarginAdjustment';
+import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
+import { usePerpsMarkets } from '../../hooks/usePerpsMarkets';
+import { TraceName } from '../../../../../util/trace';
+import Logger from '../../../../../util/Logger';
+import { ensureError } from '../../utils/perpsErrorHandler';
+import {
+ calculateMaxRemovableMargin,
+ calculateNewLiquidationPrice,
+} from '../../utils/marginUtils';
+import PerpsAmountDisplay from '../../components/PerpsAmountDisplay';
+import PerpsSlider from '../../components/PerpsSlider';
+import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip';
+import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
+import Keypad from '../../../../Base/Keypad';
+import {
+ formatPerpsFiat,
+ PRICE_RANGES_UNIVERSAL,
+ PRICE_RANGES_MINIMAL_VIEW,
+} from '../../utils/formatUtils';
+import { MARGIN_ADJUSTMENT_CONFIG } from '../../constants/perpsConfig';
+
+interface AdjustMarginRouteParams {
+ position: Position;
+ mode: 'add' | 'remove';
+}
+
+const PerpsAdjustMarginView: React.FC = () => {
+ const navigation = useNavigation();
+ const route =
+ useRoute>();
+ const { position, mode } = route.params || {};
+ const { styles } = useStyles(styleSheet, {});
+ const { colors } = useTheme();
+ const { account } = usePerpsLiveAccount();
+
+ const [marginAmountString, setMarginAmountString] = useState('0');
+ const [isInputFocused, setIsInputFocused] = useState(false);
+ const [selectedTooltip, setSelectedTooltip] =
+ useState(null);
+
+ // Derived numeric value from string
+ const marginAmount = useMemo(
+ () => parseFloat(marginAmountString) || 0,
+ [marginAmountString],
+ );
+
+ const isAddMode = mode === 'add';
+
+ // Use margin adjustment hook for handling margin operations
+ const { handleAddMargin, handleRemoveMargin, isAdjusting } =
+ usePerpsMarginAdjustment({
+ onSuccess: () => navigation.goBack(),
+ });
+
+ // Get market info for max leverage (needed for remove mode)
+ // Each token has different max leverage limits - must look up from markets
+ const { markets } = usePerpsMarkets();
+ const marketInfo = useMemo(
+ () =>
+ position?.coin ? markets.find((m) => m.symbol === position.coin) : null,
+ [position?.coin, markets],
+ );
+ // maxLeverage in PerpsMarketData is a formatted string (e.g., '40x'), parse to number
+ const maxLeverage = marketInfo?.maxLeverage
+ ? parseInt(marketInfo.maxLeverage, 10)
+ : MARGIN_ADJUSTMENT_CONFIG.FALLBACK_MAX_LEVERAGE;
+
+ // Add performance measurement for this view
+ usePerpsMeasurement({
+ traceName: TraceName.PerpsAdjustMarginView,
+ conditions: [!isAdjusting, !!position],
+ debugContext: { mode },
+ });
+
+ // Get live prices for liquidation distance calculation
+ const livePrices = usePerpsLivePrices({
+ symbols: position?.coin ? [position.coin] : [],
+ throttleMs: 1000,
+ });
+ const currentPrice = useMemo(
+ () => parseFloat(livePrices?.[position?.coin]?.price || '0'),
+ [livePrices, position?.coin],
+ );
+
+ // Current position data
+ const currentMargin = useMemo(
+ () => parseFloat(position?.marginUsed || '0'),
+ [position],
+ );
+
+ const currentLiquidationPrice = useMemo(
+ () => parseFloat(position?.liquidationPrice || '0'),
+ [position],
+ );
+
+ const positionSize = useMemo(
+ () => Math.abs(parseFloat(position?.size || '0')),
+ [position],
+ );
+
+ const entryPrice = useMemo(
+ () => parseFloat(position?.entryPrice || '0'),
+ [position],
+ );
+
+ const isLong = useMemo(
+ () => parseFloat(position?.size || '0') > 0,
+ [position],
+ );
+
+ // Available balance for add mode
+ const availableBalance = useMemo(
+ () => parseFloat(account?.availableBalance || '0'),
+ [account],
+ );
+
+ // Calculate maximum amount based on mode
+ const maxAmount = useMemo(() => {
+ if (isAddMode) {
+ return Math.max(0, availableBalance);
+ }
+ return calculateMaxRemovableMargin({
+ currentMargin,
+ positionSize,
+ entryPrice,
+ currentPrice,
+ maxLeverage,
+ });
+ }, [
+ isAddMode,
+ availableBalance,
+ currentMargin,
+ positionSize,
+ entryPrice,
+ currentPrice,
+ maxLeverage,
+ ]);
+
+ // Calculate new values after adjustment
+ const newMargin = useMemo(() => {
+ if (isAddMode) {
+ return currentMargin + marginAmount;
+ }
+ return Math.max(0, currentMargin - marginAmount);
+ }, [isAddMode, currentMargin, marginAmount]);
+
+ // Calculate new liquidation price
+ const newLiquidationPrice = useMemo(() => {
+ if (newMargin === 0 || positionSize === 0) return currentLiquidationPrice;
+
+ // For add mode, use simplified calculation
+ if (isAddMode) {
+ const marginPerUnit = newMargin / positionSize;
+ if (isLong) {
+ return Math.max(0, entryPrice - marginPerUnit);
+ }
+ return entryPrice + marginPerUnit;
+ }
+
+ // For remove mode, use utility function
+ return calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+ }, [
+ isAddMode,
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ ]);
+
+ // Calculate liquidation distance percentage
+ const calculateLiquidationDistance = useCallback(
+ (liquidationPrice: number) => {
+ if (currentPrice === 0 || !currentPrice || liquidationPrice === 0) {
+ return 0;
+ }
+ return (Math.abs(currentPrice - liquidationPrice) / currentPrice) * 100;
+ },
+ [currentPrice],
+ );
+
+ const currentLiquidationDistance = useMemo(
+ () => calculateLiquidationDistance(currentLiquidationPrice),
+ [calculateLiquidationDistance, currentLiquidationPrice],
+ );
+
+ const newLiquidationDistance = useMemo(
+ () => calculateLiquidationDistance(newLiquidationPrice),
+ [calculateLiquidationDistance, newLiquidationPrice],
+ );
+
+ const handleSliderChange = useCallback((value: number) => {
+ setMarginAmountString(Math.floor(value).toString());
+ }, []);
+
+ const handleMaxPress = useCallback(() => {
+ setMarginAmountString(Math.floor(maxAmount).toString());
+ }, [maxAmount]);
+
+ // Keypad handlers
+ const handleAmountPress = useCallback(() => {
+ setIsInputFocused(true);
+ }, []);
+
+ const handleKeypadChange = useCallback(
+ ({ value }: { value: string }) => {
+ const numValue = parseFloat(value) || 0;
+ // Clamp to maxAmount for remove mode to prevent invalid submissions
+ if (!isAddMode && numValue > maxAmount) {
+ setMarginAmountString(Math.floor(maxAmount).toString());
+ } else {
+ setMarginAmountString(value || '0');
+ }
+ },
+ [isAddMode, maxAmount],
+ );
+
+ const handleDonePress = useCallback(() => {
+ setIsInputFocused(false);
+ }, []);
+
+ const handlePercentagePress = useCallback(
+ (percentage: number) => {
+ const amount = Math.floor(maxAmount * percentage);
+ setMarginAmountString(amount.toString());
+ },
+ [maxAmount],
+ );
+
+ // Tooltip handlers
+ const handleTooltipPress = useCallback(
+ (contentKey: PerpsTooltipContentKey) => {
+ setSelectedTooltip(contentKey);
+ },
+ [],
+ );
+
+ const handleTooltipClose = useCallback(() => {
+ setSelectedTooltip(null);
+ }, []);
+
+ const handleConfirm = useCallback(async () => {
+ if (marginAmount <= 0 || !position) return;
+
+ // Prevent submission if amount exceeds max removable (extra safety for remove mode)
+ if (!isAddMode && marginAmount > maxAmount) {
+ return;
+ }
+
+ try {
+ if (isAddMode) {
+ await handleAddMargin(position.coin, marginAmount);
+ } else {
+ await handleRemoveMargin(position.coin, marginAmount);
+ }
+ } catch (error) {
+ Logger.error(
+ ensureError(error),
+ `Failed to ${isAddMode ? 'add' : 'remove'} margin for ${position.coin}`,
+ );
+ // Note: Toast notification is handled by usePerpsMarginAdjustment hook
+ }
+ }, [
+ marginAmount,
+ position,
+ isAddMode,
+ maxAmount,
+ handleAddMargin,
+ handleRemoveMargin,
+ ]);
+
+ if (!position || !mode) {
+ return (
+
+
+
+ {strings('perps.errors.position_not_found')}
+
+
+
+ );
+ }
+
+ const title = isAddMode
+ ? strings('perps.adjust_margin.add_title')
+ : strings('perps.adjust_margin.remove_title');
+
+ const buttonLabel = isAddMode
+ ? strings('perps.adjust_margin.add_margin')
+ : strings('perps.adjust_margin.reduce_margin');
+
+ return (
+
+
+ navigation.goBack()}
+ iconColor={IconColor.Default}
+ size={ButtonIconSizes.Md}
+ />
+
+ {title}
+
+
+
+
+
+ {/* Amount Display */}
+
+
+
+
+ {/* Slider - Hide when keypad is active */}
+ {!isInputFocused && (
+
+
+
+ )}
+
+ {/* Info Section - Always visible */}
+
+ {/* First row: Perps balance or Margin in position */}
+
+
+ {isAddMode
+ ? strings('perps.adjust_margin.perps_balance')
+ : strings('perps.adjust_margin.margin_in_position')}
+
+
+ {formatPerpsFiat(isAddMode ? availableBalance : currentMargin, {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ })}
+
+
+
+ {/* Second row: Liquidation price with transition */}
+
+
+
+ {strings('perps.adjust_margin.liquidation_price')}
+
+ handleTooltipPress('liquidation_price')}
+ style={styles.infoIcon}
+ >
+
+
+
+ {marginAmount > 0 ? (
+
+
+ {formatPerpsFiat(currentLiquidationPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })}
+
+
+
+ {formatPerpsFiat(newLiquidationPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })}
+
+
+ ) : (
+
+ {formatPerpsFiat(currentLiquidationPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })}
+
+ )}
+
+
+ {/* Third row: Liquidation distance with transition */}
+
+
+
+ {strings('perps.adjust_margin.liquidation_distance')}
+
+ handleTooltipPress('liquidation_distance')}
+ style={styles.infoIcon}
+ >
+
+
+
+ {marginAmount > 0 ? (
+
+
+ {currentLiquidationDistance.toFixed(0)}%
+
+
+
+ {newLiquidationDistance.toFixed(0)}%
+
+
+ ) : (
+
+ {currentLiquidationDistance.toFixed(0)}%
+
+ )}
+
+
+
+
+ {/* Footer - Shows either Add Margin button or Keypad */}
+ {!isInputFocused ? (
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+ {/* Tooltip Bottom Sheet */}
+ {selectedTooltip && (
+
+ )}
+
+ );
+};
+
+export default PerpsAdjustMarginView;
diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts b/app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts
new file mode 100644
index 000000000000..9d524418a932
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsAdjustMarginView';
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts
index aade887f696e..94dadfc65209 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts
@@ -34,9 +34,12 @@ export const createStyles = ({ theme }: { theme: Theme }) =>
backgroundColor: theme.colors.background.default,
},
section: {
- paddingVertical: 8,
+ paddingVertical: 16,
paddingHorizontal: 16,
},
+ sectionTitle: {
+ marginBottom: 12,
+ },
chartSection: {
paddingTop: 0,
marginTop: 16,
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
index a7c0ccee3c8e..9c4153ecc54d 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
@@ -319,6 +319,37 @@ jest.mock('../../hooks', () => ({
navigateBack: mockNavigateBack,
canGoBack: mockCanGoBack(),
})),
+ usePositionManagement: jest.fn(() => ({
+ showModifyActionSheet: false,
+ showAdjustMarginActionSheet: false,
+ showReversePositionSheet: false,
+ modifyActionSheetRef: { current: null },
+ adjustMarginActionSheetRef: { current: null },
+ reversePositionSheetRef: { current: null },
+ openModifySheet: jest.fn(),
+ closeModifySheet: jest.fn(),
+ openAdjustMarginSheet: jest.fn(),
+ closeAdjustMarginSheet: jest.fn(),
+ openReversePositionSheet: jest.fn(),
+ closeReversePositionSheet: jest.fn(),
+ handleReversePosition: jest.fn(),
+ })),
+}));
+
+// Mock usePerpsABTest to return default variant
+jest.mock('../../utils/abTesting/usePerpsABTest', () => ({
+ usePerpsABTest: () => ({
+ variantName: 'semantic',
+ isEnabled: false,
+ }),
+}));
+
+// Mock usePerpsOICap to return not at cap by default
+jest.mock('../../hooks/usePerpsOICap', () => ({
+ usePerpsOICap: () => ({
+ isAtCap: false,
+ capPercentage: 50,
+ }),
}));
// Mock PerpsMarketStatisticsCard to simplify the test
@@ -686,7 +717,7 @@ describe('PerpsMarketDetailsView', () => {
).toBeNull();
});
- it('renders long/short buttons when user has balance and existing position', () => {
+ it('renders modify/close buttons when user has balance and existing position', () => {
// Override with non-zero balance and existing position
mockUsePerpsAccount.mockReturnValue({
account: {
@@ -723,7 +754,7 @@ describe('PerpsMarketDetailsView', () => {
refreshPosition: jest.fn(),
});
- const { getByTestId, queryByText } = renderWithProvider(
+ const { getByTestId, queryByText, queryByTestId } = renderWithProvider(
,
@@ -732,14 +763,22 @@ describe('PerpsMarketDetailsView', () => {
},
);
- // Shows long/short buttons even with existing position
+ // Shows modify/close buttons when existing position exists (not long/short buttons)
expect(
- getByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON),
+ getByTestId(PerpsMarketDetailsViewSelectorsIDs.MODIFY_BUTTON),
).toBeTruthy();
expect(
- getByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON),
+ getByTestId(PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON),
).toBeTruthy();
+ // Long/short buttons should NOT be shown when position exists
+ expect(
+ queryByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON),
+ ).toBeNull();
+ expect(
+ queryByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON),
+ ).toBeNull();
+
// Does not show add funds message
expect(queryByText('Add funds to start trading perps')).toBeNull();
});
@@ -829,7 +868,7 @@ describe('PerpsMarketDetailsView', () => {
expect(mockRefreshPosition).not.toHaveBeenCalled();
});
- it('refreshes statistics data when statistics tab is active', async () => {
+ it('refreshes statistics data via WebSocket', async () => {
// Arrange
const mockRefreshPosition = jest.fn();
mockUseHasExistingPosition.mockReturnValue({
@@ -856,7 +895,7 @@ describe('PerpsMarketDetailsView', () => {
refreshPosition: mockRefreshPosition,
});
- const { getByTestId, getByText } = renderWithProvider(
+ const { getByTestId } = renderWithProvider(
,
@@ -865,10 +904,6 @@ describe('PerpsMarketDetailsView', () => {
},
);
- // Act - Switch to statistics tab
- const statisticsTab = getByText('Overview');
- fireEvent.press(statisticsTab);
-
const scrollView = getByTestId(
PerpsMarketDetailsViewSelectorsIDs.SCROLL_VIEW,
);
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
index 2658544ec85a..e1238b13bcdb 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
@@ -31,12 +31,12 @@ import {
PerpsMarketDetailsViewSelectorsIDs,
PerpsOrderViewSelectorsIDs,
PerpsTutorialSelectorsIDs,
- PerpsMarketTabsSelectorsIDs,
} from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import PerpsMarketHeader from '../../components/PerpsMarketHeader';
import type {
PerpsMarketData,
PerpsNavigationParamList,
+ TPSLTrackingData,
} from '../../controllers/types';
import { usePerpsLiveCandles } from '../../hooks/stream/usePerpsLiveCandles';
import { usePerpsMarketStats } from '../../hooks/usePerpsMarketStats';
@@ -46,10 +46,7 @@ import {
TimeDuration,
PERPS_CHART_CONFIG,
} from '../../constants/chartConfig';
-import {
- PERFORMANCE_CONFIG,
- PERPS_CONSTANTS,
-} from '../../constants/perpsConfig';
+import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
import { createStyles } from './PerpsMarketDetailsView.styles';
import type { PerpsMarketDetailsViewProps } from './PerpsMarketDetailsView.types';
import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
@@ -64,6 +61,7 @@ import {
usePerpsTrading,
usePerpsNetworkManagement,
usePerpsNavigation,
+ usePositionManagement,
} from '../../hooks';
import { usePerpsOICap } from '../../hooks/usePerpsOICap';
import {
@@ -71,18 +69,25 @@ import {
type DataMonitorParams,
} from '../../hooks/usePerpsDataMonitor';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
-import { usePerpsLiveOrders, usePerpsLiveAccount } from '../../hooks/stream';
+import {
+ usePerpsLiveAccount,
+ usePerpsLivePrices,
+ usePerpsLiveOrders,
+} from '../../hooks/stream';
import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest';
import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests';
import { selectPerpsButtonColorTestVariant } from '../../selectors/featureFlags';
-import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs';
-import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types';
+import PerpsPositionCard from '../../components/PerpsPositionCard';
+import PerpsMarketStatisticsCard from '../../components/PerpsMarketStatisticsCard';
+import type { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import PerpsOICapWarning from '../../components/PerpsOICapWarning';
import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip';
import PerpsNavigationCard, {
type NavigationItem,
} from '../../components/PerpsNavigationCard/PerpsNavigationCard';
import PerpsMarketTradesList from '../../components/PerpsMarketTradesList';
+import PerpsCompactOrderRow from '../../components/PerpsCompactOrderRow';
+import PerpsFlipPositionConfirmSheet from '../../components/PerpsFlipPositionConfirmSheet';
import { isNotificationsFeatureEnabled } from '../../../../../util/notifications';
import Logger from '../../../../../util/Logger';
import { ensureError } from '../../utils/perpsErrorHandler';
@@ -111,10 +116,12 @@ import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useC
import Engine from '../../../../../core/Engine';
import { setPerpsChartPreferredCandlePeriod } from '../../../../../actions/settings';
import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences';
+import PerpsSelectAdjustMarginActionView from '../PerpsSelectAdjustMarginActionView';
+import PerpsSelectModifyActionView from '../PerpsSelectModifyActionView';
+import { usePerpsTPSLUpdate } from '../../hooks/usePerpsTPSLUpdate';
interface MarketDetailsRouteParams {
market: PerpsMarketData;
- initialTab?: PerpsTabId;
monitoringIntent?: Partial;
isNavigationFromOrderSuccess?: boolean;
source?: string;
@@ -124,18 +131,37 @@ const PerpsMarketDetailsView: React.FC = () => {
// Use centralized navigation hook for all Perps navigation
const {
navigateToHome,
- navigateToActivity,
navigateToOrder,
navigateToTutorial,
+ navigateToClosePosition,
navigateBack,
canGoBack,
} = usePerpsNavigation();
+ // Use position management hook for bottom sheet state and handlers
+ const {
+ showModifyActionSheet,
+ showAdjustMarginActionSheet,
+ showReversePositionSheet,
+ modifyActionSheetRef,
+ adjustMarginActionSheetRef,
+ reversePositionSheetRef,
+ openModifySheet,
+ openAdjustMarginSheet,
+ closeModifySheet,
+ closeAdjustMarginSheet,
+ closeReversePositionSheet,
+ handleReversePosition,
+ } = usePositionManagement();
+
+ // Hook for updating TP/SL on existing positions
+ const { handleUpdateTPSL } = usePerpsTPSLUpdate();
+
// Keep direct navigation for configuration methods (setOptions, setParams)
const navigation = useNavigation>();
const route =
useRoute>();
- const { market, initialTab, monitoringIntent, source } = route.params || {};
+ const { market, monitoringIntent, source } = route.params || {};
const { track } = usePerpsEventTracking();
const dispatch = useDispatch();
@@ -143,6 +169,8 @@ const PerpsMarketDetailsView: React.FC = () => {
useState(false);
const [isMarketHoursModalVisible, setIsMarketHoursModalVisible] =
useState(false);
+ const [selectedTooltip, setSelectedTooltip] =
+ useState(null);
const isEligible = useSelector(selectPerpsEligibility);
@@ -200,6 +228,48 @@ const PerpsMarketDetailsView: React.FC = () => {
const { account } = usePerpsLiveAccount();
+ // Get real-time open orders via WebSocket
+ const { orders: ordersData } = usePerpsLiveOrders({});
+
+ // Filter orders for the current market
+ const openOrders = useMemo(() => {
+ if (!ordersData?.length || !market?.symbol) return [];
+ return ordersData.filter((order) => order.symbol === market.symbol);
+ }, [ordersData, market?.symbol]);
+
+ // Sort orders by time
+ const sortedOrders = useMemo(
+ () =>
+ [...openOrders].sort((a, b) => {
+ const timeA = a.timestamp || 0;
+ const timeB = b.timestamp || 0;
+ return timeB - timeA;
+ }),
+ [openOrders],
+ );
+
+ // Filter out TP/SL (reduceOnly) orders
+ const nonTPSLOrders = useMemo(
+ () => sortedOrders.filter((order) => !order.reduceOnly),
+ [sortedOrders],
+ );
+
+ // Subscribe to live prices for current position price
+ const livePrices = usePerpsLivePrices({
+ symbols: market?.symbol ? [market.symbol] : [],
+ throttleMs: 1000,
+ });
+
+ // Get current price for the symbol
+ const currentPrice = useMemo(() => {
+ if (!market?.symbol) return 0;
+ const priceData = livePrices[market.symbol];
+ if (priceData?.price) {
+ return parseFloat(priceData.price);
+ }
+ return 0;
+ }, [livePrices, market?.symbol]);
+
// A/B Testing: Button color test (TAT-1937)
const {
variantName: buttonColorVariant,
@@ -209,10 +279,6 @@ const PerpsMarketDetailsView: React.FC = () => {
featureFlagSelector: selectPerpsButtonColorTestVariant,
});
- // TP/SL order selection state - track TP and SL separately
- const [activeTPOrderId, setActiveTPOrderId] = useState(null);
- const [activeSLOrderId, setActiveSLOrderId] = useState(null);
-
usePerpsConnection();
const { depositWithConfirmation } = usePerpsTrading();
const { ensureArbitrumNetworkExists } = usePerpsNetworkManagement();
@@ -220,35 +286,12 @@ const PerpsMarketDetailsView: React.FC = () => {
// Check if market is at open interest cap
const { isAtCap: isAtOICap } = usePerpsOICap(market?.symbol);
- // Programmatic tab control state for data-driven navigation
- const [programmaticActiveTab, setProgrammaticActiveTab] = useState<
- string | null
- >(null);
-
- // Callback to handle data detection from monitoring hook
- const handleDataDetected = useCallback(
- ({
- detectedData,
- }: {
- detectedData: 'positions' | 'orders';
- asset: string;
- reason: string;
- }) => {
- const targetTab = detectedData === 'positions' ? 'position' : 'orders';
- setProgrammaticActiveTab(targetTab);
-
- // Reset programmatic tab control after a brief delay to prevent render loops
- setTimeout(() => {
- setProgrammaticActiveTab(null);
- }, PERFORMANCE_CONFIG.TAB_CONTROL_RESET_DELAY_MS);
-
- // Clear monitoringIntent to allow fresh monitoring next time
- navigation.setParams({ monitoringIntent: undefined });
- },
- [navigation],
- );
+ // Handle data-driven monitoring when coming from order success
+ // Clear monitoringIntent after processing to allow fresh monitoring next time
+ const handleDataDetected = useCallback(() => {
+ navigation.setParams({ monitoringIntent: undefined });
+ }, [navigation]);
- // Handle data-driven monitoring when coming from order success (declarative API)
usePerpsDataMonitor({
asset: monitoringIntent?.asset,
monitorOrders: monitoringIntent?.monitorOrders,
@@ -257,94 +300,6 @@ const PerpsMarketDetailsView: React.FC = () => {
onDataDetected: handleDataDetected,
enabled: !!(monitoringIntent && market && monitoringIntent.asset),
});
- // Get real-time open orders via WebSocket
- const { orders: ordersData } = usePerpsLiveOrders({});
- // Filter orders for the current market
- const openOrders = useMemo(() => {
- if (!ordersData?.length || !market?.symbol) return [];
- return ordersData.filter((order) => order.symbol === market.symbol);
- }, [ordersData, market?.symbol]);
-
- // Filter orders that have TP/SL data for chart integration
- const ordersWithTPSL = useMemo(
- () =>
- openOrders.filter((order) => {
- // Check if order has TP/SL prices directly
- if (order.takeProfitPrice || order.stopLossPrice) return true;
-
- // Check if it's a trigger order (TP/SL orders are stored as trigger orders)
- if (order.isTrigger && order.detailedOrderType) {
- const orderType = order.detailedOrderType.toLowerCase();
- return (
- orderType.includes('take profit') || orderType.includes('stop')
- );
- }
-
- return false;
- }),
- [openOrders],
- );
-
- const orderChildOrderIds = useMemo(
- () =>
- openOrders
- .filter((order) => order.takeProfitOrderId || order.stopLossOrderId)
- .reduce((acc, order) => {
- if (order.takeProfitOrderId) {
- acc.push(order.takeProfitOrderId);
- }
- if (order.stopLossOrderId) {
- acc.push(order.stopLossOrderId);
- }
- return acc;
- }, [] as string[]),
- [openOrders],
- );
-
- // Determine which TP/SL lines to show on the chart
- const selectedOrderTPSL = useMemo(() => {
- // Find the active TP order
- let activeTPOrder = ordersWithTPSL.find(
- (order) => order.orderId === activeTPOrderId,
- );
- // Only use default TP if no TP has ever been explicitly selected
- if (!activeTPOrder && activeTPOrderId === null) {
- activeTPOrder = ordersWithTPSL.find((order) => {
- if (
- order.isTrigger &&
- order.detailedOrderType?.toLowerCase().includes('take profit') &&
- !orderChildOrderIds.includes(order.orderId)
- )
- return true;
- return false;
- });
- }
-
- // Find the active SL order
- let activeSLOrder = ordersWithTPSL.find(
- (order) => order.orderId === activeSLOrderId,
- );
- // Only use default SL if no SL has ever been explicitly selected
- if (!activeSLOrder && activeSLOrderId === null) {
- activeSLOrder = ordersWithTPSL.find((order) => {
- if (
- order.isTrigger &&
- order.detailedOrderType?.toLowerCase().includes('stop') &&
- !orderChildOrderIds.includes(order.orderId)
- )
- return true;
- return false;
- });
- }
-
- const result = {
- takeProfitPrice: activeTPOrder?.takeProfitPrice || activeTPOrder?.price,
- stopLossPrice: activeSLOrder?.stopLossPrice || activeSLOrder?.price,
- activeTPOrderId: activeTPOrder?.orderId,
- activeSLOrderId: activeSLOrder?.orderId,
- };
- return result;
- }, [ordersWithTPSL, activeTPOrderId, activeSLOrderId, orderChildOrderIds]);
const hasZeroBalance = useMemo(
() => parseFloat(account?.availableBalance || '0') === 0,
@@ -394,28 +349,19 @@ const PerpsMarketDetailsView: React.FC = () => {
loadOnMount: true,
});
- // Compute TP/SL lines for the chart based on existing position and selected orders
+ // Compute TP/SL lines for the chart based on existing position
const tpslLines = useMemo(() => {
if (existingPosition) {
return {
entryPrice: existingPosition.entryPrice,
- takeProfitPrice:
- selectedOrderTPSL.takeProfitPrice || existingPosition.takeProfitPrice,
- stopLossPrice:
- selectedOrderTPSL.stopLossPrice || existingPosition.stopLossPrice,
+ takeProfitPrice: existingPosition.takeProfitPrice,
+ stopLossPrice: existingPosition.stopLossPrice,
liquidationPrice: existingPosition.liquidationPrice || undefined,
};
}
- if (selectedOrderTPSL.takeProfitPrice || selectedOrderTPSL.stopLossPrice) {
- return {
- takeProfitPrice: selectedOrderTPSL.takeProfitPrice,
- stopLossPrice: selectedOrderTPSL.stopLossPrice,
- };
- }
-
return undefined;
- }, [existingPosition, selectedOrderTPSL]);
+ }, [existingPosition]);
// Track Perps asset screen load performance with simplified API
usePerpsMeasurement({
@@ -504,53 +450,6 @@ const PerpsMarketDetailsView: React.FC = () => {
}
}, []);
- // Handle order selection for chart integration
- const handleOrderSelect = useCallback(
- (orderId: string) => {
- const selectedOrder = ordersWithTPSL.find(
- (order) => order.orderId === orderId,
- );
-
- if (selectedOrder) {
- const hasBothTPSL =
- selectedOrder.takeProfitPrice && selectedOrder.stopLossPrice;
-
- if (hasBothTPSL) {
- setActiveTPOrderId(orderId);
- setActiveSLOrderId(orderId);
- } else if (selectedOrder.isTrigger && selectedOrder.detailedOrderType) {
- const orderType = selectedOrder.detailedOrderType.toLowerCase();
- if (orderType.includes('take profit')) {
- setActiveTPOrderId(orderId);
- } else if (orderType.includes('stop')) {
- setActiveSLOrderId(orderId);
- }
- } else if (selectedOrder.takeProfitPrice) {
- setActiveTPOrderId(orderId);
- } else if (selectedOrder.stopLossPrice) {
- setActiveSLOrderId(orderId);
- }
- }
- },
- [ordersWithTPSL],
- );
-
- // Handle order cancellation to update chart
- const handleOrderCancelled = useCallback(
- (cancelledOrderId: string) => {
- // If the cancelled order was the active TP order, clear it
- if (activeTPOrderId === cancelledOrderId) {
- setActiveTPOrderId(null);
- }
-
- // If the cancelled order was the active SL order, clear it
- if (activeSLOrderId === cancelledOrderId) {
- setActiveSLOrderId(null);
- }
- },
- [activeTPOrderId, activeSLOrderId],
- );
-
// Check if notifications feature is enabled once
const isNotificationsEnabled = isNotificationsFeatureEnabled();
@@ -693,6 +592,79 @@ const PerpsMarketDetailsView: React.FC = () => {
setIsMarketHoursModalVisible(true);
}, []);
+ // Position card handlers
+ const handleAutoClosePress = useCallback(() => {
+ if (!existingPosition) return;
+
+ navigation.navigate(Routes.PERPS.TPSL, {
+ asset: existingPosition.coin,
+ currentPrice,
+ position: existingPosition,
+ initialTakeProfitPrice: existingPosition.takeProfitPrice,
+ initialStopLossPrice: existingPosition.stopLossPrice,
+ onConfirm: async (
+ takeProfitPrice?: string,
+ stopLossPrice?: string,
+ trackingData?: TPSLTrackingData,
+ ) => {
+ await handleUpdateTPSL(
+ existingPosition,
+ takeProfitPrice,
+ stopLossPrice,
+ trackingData,
+ );
+ },
+ });
+ }, [existingPosition, currentPrice, navigation, handleUpdateTPSL]);
+
+ const handleMarginPress = useCallback(() => {
+ if (!existingPosition) return;
+ openAdjustMarginSheet();
+ }, [existingPosition, openAdjustMarginSheet]);
+
+ const handleSharePress = useCallback(() => {
+ if (!existingPosition) return;
+
+ navigation.navigate(Routes.PERPS.PNL_HERO_CARD, {
+ position: existingPosition,
+ marketPrice: currentPrice.toString(),
+ });
+ }, [existingPosition, currentPrice, navigation]);
+
+ // Stats card tooltip handler
+ const handleTooltipPress = useCallback(
+ (contentKey: PerpsTooltipContentKey) => {
+ setSelectedTooltip(contentKey);
+ },
+ [],
+ );
+
+ const handleTooltipClose = useCallback(() => {
+ setSelectedTooltip(null);
+ }, []);
+
+ // Close position handler
+ const handleClosePosition = useCallback(() => {
+ if (!existingPosition) return;
+ navigateToClosePosition(existingPosition);
+ }, [existingPosition, navigateToClosePosition]);
+
+ // Modify position handler - opens the modify action sheet
+ const handleModifyPress = useCallback(() => {
+ if (!existingPosition) return;
+ openModifySheet();
+ }, [existingPosition, openModifySheet]);
+
+ // Handler for order selection - navigates to order details
+ const handleOrderSelect = useCallback(
+ (order: (typeof nonTPSLOrders)[number]) => {
+ navigation.navigate(Routes.PERPS.ORDER_DETAILS, {
+ order,
+ });
+ },
+ [navigation],
+ );
+
const handleFullscreenChartOpen = useCallback(() => {
setIsFullscreenChartVisible(true);
}, []);
@@ -741,13 +713,8 @@ const PerpsMarketDetailsView: React.FC = () => {
onPress: () => navigateToTutorial(),
testID: PerpsTutorialSelectorsIDs.TUTORIAL_CARD,
},
- {
- label: strings('perps.market.go_to_activity'),
- onPress: () => navigateToActivity(),
- testID: PerpsMarketTabsSelectorsIDs.ACTIVITY_LINK,
- },
],
- [navigateToTutorial, navigateToActivity],
+ [navigateToTutorial],
);
// Simplified styles - no complex calculations needed
@@ -862,18 +829,45 @@ const PerpsMarketDetailsView: React.FC = () => {
/>
)}
- {/* Market Tabs Section */}
-
-
+
+
+ )}
+
+ {/* Orders Section - Compact view (TP/SL orders excluded) */}
+ {nonTPSLOrders.length > 0 && (
+
+
+ {strings('perps.market.orders')}
+
+ {nonTPSLOrders.map((order) => (
+ handleOrderSelect(order)}
+ testID={`compact-order-${order.orderId}`}
+ />
+ ))}
+
+ )}
+
+ {/* Statistics Section - Always shown */}
+
+
@@ -884,6 +878,11 @@ const PerpsMarketDetailsView: React.FC = () => {
)}
+ {/* Navigation Card Section */}
+
+
+
+
{/* Risk Disclaimer Section */}
= () => {
-
- {/* Navigation Card Section */}
-
-
-
{/* Fixed Actions Footer */}
- {(hasAddFundsButton || (hasLongShortButtons && !isAtOICap)) && (
+ {(hasAddFundsButton || hasLongShortButtons) && (
{hasAddFundsButton && (
@@ -928,7 +922,39 @@ const PerpsMarketDetailsView: React.FC = () => {
)}
- {hasLongShortButtons && !isAtOICap && (
+ {/* Show Modify/Close buttons when position exists */}
+ {hasLongShortButtons && existingPosition && (
+
+
+
+
+
+
+ = 0
+ ? strings('perps.market.close_long')
+ : strings('perps.market.close_short')
+ }
+ onPress={handleClosePosition}
+ testID={PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON}
+ />
+
+
+ )}
+
+ {/* Show Long/Short buttons when no position exists */}
+ {hasLongShortButtons && !existingPosition && !isAtOICap && (
{buttonColorVariant === 'monochrome' ? (
@@ -1019,6 +1045,16 @@ const PerpsMarketDetailsView: React.FC = () => {
/>
)}
+ {/* Statistics Tooltip Bottom Sheet */}
+ {selectedTooltip && (
+
+ )}
+
{/* Notification Tooltip - Shows after first successful order */}
{isNotificationsEnabled && !!monitoringIntent && (
= () => {
onClose={handleFullscreenChartClose}
onIntervalChange={handleCandlePeriodChange}
/>
+
+ {/* Modify Action Bottom Sheet - Rendered conditionally using PerpsHomeView pattern */}
+ {showModifyActionSheet && (
+
+ )}
+
+ {/* Adjust Margin Action Bottom Sheet - Rendered conditionally using PerpsHomeView pattern */}
+ {showAdjustMarginActionSheet && (
+
+ )}
+
+ {/* Flip Position Confirm Bottom Sheet - Rendered conditionally using PerpsHomeView pattern */}
+ {showReversePositionSheet && existingPosition && (
+
+ )}
);
};
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts
new file mode 100644
index 000000000000..f7b8e5d44946
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts
@@ -0,0 +1,107 @@
+import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background.default,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border.muted,
+ },
+ headerBackButton: {
+ marginRight: 12,
+ },
+ headerTitleContainer: {
+ flex: 1,
+ alignItems: 'center',
+ marginRight: 40, // Compensate for back button width to center title
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingBottom: 100,
+ },
+ headerSection: {
+ alignItems: 'center',
+ paddingVertical: 24,
+ paddingHorizontal: 16,
+ },
+ assetLogoContainer: {
+ marginBottom: 12,
+ },
+ assetName: {
+ marginBottom: 4,
+ },
+ orderTypeLabel: {
+ marginBottom: 8,
+ },
+ section: {
+ paddingHorizontal: 16,
+ marginBottom: 16,
+ },
+ detailsCard: {
+ borderRadius: 8,
+ padding: 16,
+ },
+ detailRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ detailLabel: {
+ flex: 1,
+ },
+ detailValue: {
+ flex: 1,
+ alignItems: 'flex-end',
+ },
+ separator: {
+ height: 1,
+ marginVertical: 4,
+ },
+ statusContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ statusFilled: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 4,
+ backgroundColor: colors.success.muted,
+ },
+ footer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ paddingHorizontal: 16,
+ paddingTop: 12,
+ paddingBottom: 24,
+ backgroundColor: colors.background.default,
+ borderTopWidth: 1,
+ borderTopColor: colors.border.muted,
+ gap: 8,
+ },
+ errorContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx
new file mode 100644
index 000000000000..ecddd218a3e3
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx
@@ -0,0 +1,320 @@
+import React from 'react';
+import {
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react-native';
+import PerpsOrderDetailsView from './PerpsOrderDetailsView';
+import type { Order } from '../../controllers/types';
+
+let mockRouteParams: { order?: Order } = {};
+const mockCancelOrder = jest.fn();
+const mockShowToast = jest.fn();
+const mockGetExplorerUrl = jest.fn();
+
+// Mock dependencies
+jest.mock('react-native-safe-area-context', () => {
+ const { View } = jest.requireActual('react-native');
+ const inset = { top: 0, right: 0, bottom: 0, left: 0 };
+ return {
+ SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children),
+ SafeAreaView: jest
+ .fn()
+ .mockImplementation(({ children, ...props }) => (
+ {children}
+ )),
+ useSafeAreaInsets: jest.fn().mockImplementation(() => inset),
+ };
+});
+
+const mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ setOptions: jest.fn(),
+ }),
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'PerpsOrderDetails',
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsTrading', () => ({
+ usePerpsTrading: () => ({
+ cancelOrder: mockCancelOrder,
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsMeasurement', () => ({
+ usePerpsMeasurement: jest.fn(),
+}));
+
+jest.mock('../../hooks/usePerpsOrderFees', () => ({
+ usePerpsOrderFees: () => ({
+ totalFee: 0.5,
+ makerFee: 0.2,
+ takerFee: 0.3,
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsBlockExplorerUrl', () => ({
+ usePerpsBlockExplorerUrl: () => ({
+ getExplorerUrl: mockGetExplorerUrl,
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsToasts', () => ({
+ __esModule: true,
+ default: () => ({
+ showToast: mockShowToast,
+ PerpsToastOptions: {
+ orderManagement: {
+ shared: {
+ cancellationInProgress: jest
+ .fn()
+ .mockReturnValue({ type: 'progress' }),
+ cancellationSuccess: jest.fn().mockReturnValue({ type: 'success' }),
+ cancellationFailed: { type: 'error' },
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: () => ({ address: '0x1234' }),
+}));
+
+jest.mock('../../../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ success: { default: '#00FF00' },
+ error: { default: '#FF0000' },
+ border: { muted: '#CCCCCC' },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+jest.mock('../../components/PerpsTokenLogo', () => 'PerpsTokenLogo');
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`),
+ formatPositionSize: jest.fn((value) => value.toFixed(4)),
+ formatOrderCardDate: jest.fn(() => 'Nov 25, 2025'),
+}));
+
+// Mock component-library Button to be testable
+jest.mock('../../../../../component-library/components/Buttons/Button', () => {
+ const ReactModule = jest.requireActual('react');
+ const { TouchableOpacity, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockButton({
+ label,
+ onPress,
+ testID,
+ }: {
+ label: string;
+ onPress?: () => void;
+ testID?: string;
+ }) {
+ return ReactModule.createElement(
+ TouchableOpacity,
+ { onPress, testID },
+ ReactModule.createElement(Text, null, label),
+ );
+ },
+ ButtonVariants: { Primary: 'Primary', Secondary: 'Secondary' },
+ ButtonWidthTypes: { Full: 'Full' },
+ ButtonSize: { Lg: 'Lg' },
+ };
+});
+
+// Mock ButtonIcon for back button
+jest.mock(
+ '../../../../../component-library/components/Buttons/ButtonIcon',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { TouchableOpacity, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockButtonIcon({
+ onPress,
+ testID,
+ }: {
+ onPress?: () => void;
+ testID?: string;
+ }) {
+ return ReactModule.createElement(
+ TouchableOpacity,
+ { onPress, testID: testID || 'back-button' },
+ ReactModule.createElement(Text, null, 'Back'),
+ );
+ },
+ ButtonIconSizes: { Md: 'Md' },
+ };
+ },
+);
+
+describe('PerpsOrderDetailsView', () => {
+ const mockOrder: Order = {
+ orderId: 'order-123',
+ symbol: 'BTC',
+ size: '0.5',
+ originalSize: '0.5',
+ filledSize: '0',
+ remainingSize: '0.5',
+ price: '50000',
+ side: 'buy',
+ orderType: 'limit',
+ timestamp: Date.now(),
+ status: 'open',
+ reduceOnly: false,
+ };
+
+ const mockPartiallyFilledOrder: Order = {
+ ...mockOrder,
+ orderId: 'order-456',
+ filledSize: '0.25',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouteParams = { order: mockOrder };
+ mockCancelOrder.mockResolvedValue({ success: true });
+ mockGetExplorerUrl.mockReturnValue('https://explorer.test.com');
+ });
+
+ afterEach(() => {
+ mockRouteParams = {};
+ });
+
+ it('renders order details view with order data', () => {
+ render();
+
+ expect(screen.getByText('BTC')).toBeOnTheScreen();
+ });
+
+ it('renders error state when no order is provided', () => {
+ mockRouteParams = {};
+
+ render();
+
+ expect(screen.getByText('perps.errors.order_not_found')).toBeOnTheScreen();
+ });
+
+ it('renders cancel order button', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.order_details.cancel_order'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders cancel order button label', () => {
+ render();
+
+ // Verify cancel button is rendered (this is one of the key actions)
+ expect(
+ screen.getByText('perps.order_details.cancel_order'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders order date', () => {
+ render();
+
+ expect(screen.getByText('Nov 25, 2025')).toBeOnTheScreen();
+ });
+
+ it('renders limit price label', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.order_details.limit_price'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls goBack when back button is pressed', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('back-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('cancels order successfully when cancel button is pressed', async () => {
+ render();
+
+ fireEvent.press(screen.getByText('perps.order_details.cancel_order'));
+
+ await waitFor(() => {
+ expect(mockCancelOrder).toHaveBeenCalledWith({
+ orderId: 'order-123',
+ coin: 'BTC',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+ });
+
+ it('shows error toast when cancel order fails', async () => {
+ mockCancelOrder.mockResolvedValue({ success: false });
+ render();
+
+ fireEvent.press(screen.getByText('perps.order_details.cancel_order'));
+
+ await waitFor(() => {
+ expect(mockCancelOrder).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+ });
+
+ it('shows error toast when cancel order throws exception', async () => {
+ mockCancelOrder.mockRejectedValue(new Error('Network error'));
+ render();
+
+ fireEvent.press(screen.getByText('perps.order_details.cancel_order'));
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+ });
+
+ it('shows fill percentage for partially filled orders', () => {
+ mockRouteParams = { order: mockPartiallyFilledOrder };
+ render();
+
+ expect(screen.getByText('50% filled')).toBeOnTheScreen();
+ });
+
+ it('shows open status for unfilled orders', () => {
+ render();
+
+ expect(screen.getByText('perps.order_details.open')).toBeOnTheScreen();
+ });
+
+ it('renders short direction for sell orders', () => {
+ const sellOrder = { ...mockOrder, side: 'sell' as const };
+ mockRouteParams = { order: sellOrder };
+ render();
+
+ expect(screen.getByText('BTC')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx
new file mode 100644
index 000000000000..bc163e669d9f
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx
@@ -0,0 +1,348 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { View, ScrollView } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
+import { useStyles } from '../../../../../component-library/hooks';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import Button, {
+ ButtonVariants,
+ ButtonWidthTypes,
+ ButtonSize,
+} from '../../../../../component-library/components/Buttons/Button';
+import ButtonIcon, {
+ ButtonIconSizes,
+} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import {
+ IconColor,
+ IconName,
+} from '../../../../../component-library/components/Icons/Icon';
+import { strings } from '../../../../../../locales/i18n';
+import { usePerpsTrading } from '../../hooks/usePerpsTrading';
+import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
+import { usePerpsOrderFees } from '../../hooks/usePerpsOrderFees';
+import usePerpsToasts from '../../hooks/usePerpsToasts';
+import { TraceName } from '../../../../../util/trace';
+import type { Order } from '../../controllers/types';
+import styleSheet from './PerpsOrderDetailsView.styles';
+import PerpsTokenLogo from '../../components/PerpsTokenLogo';
+import {
+ formatPerpsFiat,
+ formatPositionSize,
+ formatOrderCardDate,
+} from '../../utils/formatUtils';
+import { useTheme } from '../../../../../util/theme';
+
+interface OrderDetailsRouteParams {
+ order: Order;
+}
+
+const PerpsOrderDetailsView: React.FC = () => {
+ const navigation = useNavigation();
+ const route =
+ useRoute>();
+ const { order } = route.params || {};
+ const { styles } = useStyles(styleSheet, {});
+ const { colors } = useTheme();
+ const { cancelOrder } = usePerpsTrading();
+ const { showToast, PerpsToastOptions } = usePerpsToasts();
+
+ const [isCanceling, setIsCanceling] = useState(false);
+
+ // Calculate size in USD for fee calculation
+ const sizeInUSD = useMemo(() => {
+ if (!order) return '0';
+ return (parseFloat(order.size) * parseFloat(order.price)).toString();
+ }, [order]);
+
+ // Get order fees
+ const { totalFee } = usePerpsOrderFees({
+ orderType: order?.orderType ?? 'market',
+ amount: sizeInUSD,
+ });
+
+ // Add performance measurement
+ usePerpsMeasurement({
+ traceName: TraceName.PerpsOrderDetailsView,
+ conditions: [!!order],
+ });
+
+ // Handle back button press
+ const handleBack = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ // Calculate order details
+ const orderDetails = useMemo(() => {
+ if (!order) return null;
+
+ const isLong = order.side === 'buy';
+ const directionLabel = isLong
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label');
+ const orderTypeLabel = strings(
+ `perps.order_details.${order.orderType}_${order.side}`,
+ );
+
+ // Calculate fill percentage
+ const fillPercentage =
+ parseFloat(order.originalSize) > 0
+ ? (parseFloat(order.filledSize) / parseFloat(order.originalSize)) * 100
+ : 0;
+
+ // Calculate size in USD (size * price)
+ const orderSizeUSD = parseFloat(order.size) * parseFloat(order.price);
+
+ // Format date using formatOrderCardDate
+ const dateString = formatOrderCardDate(order.timestamp);
+
+ return {
+ isLong,
+ directionLabel,
+ orderTypeLabel,
+ fillPercentage,
+ sizeInUSD: orderSizeUSD,
+ dateString,
+ directionColor: isLong ? colors.success.default : colors.error.default,
+ };
+ }, [order, colors]);
+
+ const handleCancelOrder = useCallback(async () => {
+ if (!order) return;
+
+ setIsCanceling(true);
+
+ // Show in-progress toast
+ showToast(
+ PerpsToastOptions.orderManagement.shared.cancellationInProgress(
+ order.side === 'buy' ? 'long' : 'short',
+ order.size,
+ order.symbol,
+ order.orderType,
+ ),
+ );
+
+ try {
+ const result = await cancelOrder({
+ orderId: order.orderId,
+ coin: order.symbol,
+ });
+
+ // Show success/failure toast
+ if (result.success) {
+ showToast(
+ PerpsToastOptions.orderManagement.shared.cancellationSuccess(
+ order.reduceOnly,
+ order.orderType,
+ order.side === 'buy' ? 'long' : 'short',
+ order.size,
+ order.symbol,
+ ),
+ );
+ navigation.goBack();
+ } else {
+ showToast(PerpsToastOptions.orderManagement.shared.cancellationFailed);
+ }
+ } catch (error) {
+ showToast(PerpsToastOptions.orderManagement.shared.cancellationFailed);
+ } finally {
+ setIsCanceling(false);
+ }
+ }, [order, cancelOrder, navigation, showToast, PerpsToastOptions]);
+
+ if (!order) {
+ return (
+
+
+
+ {strings('perps.errors.order_not_found')}
+
+
+
+ );
+ }
+
+ if (!orderDetails) {
+ return null;
+ }
+
+ return (
+
+ {/* Header with back button */}
+
+
+
+
+
+
+ {orderDetails.orderTypeLabel}
+
+
+
+
+ {/* Header Section */}
+
+
+
+
+
+ {order.symbol}
+
+
+
+ {/* Order Details Card */}
+
+
+ {/* Date */}
+
+
+ {strings('perps.order_details.date')}
+
+
+
+ {orderDetails.dateString}
+
+
+
+
+
+
+ {/* Limit Price */}
+
+
+ {strings('perps.order_details.limit_price')}
+
+
+
+ {formatPerpsFiat(parseFloat(order.price))}
+
+
+
+
+
+
+ {/* Size */}
+
+
+ {strings('perps.order_details.size')}
+
+
+
+ {formatPositionSize(parseFloat(order.size))} {order.symbol} •{' '}
+ {formatPerpsFiat(orderDetails.sizeInUSD)}
+
+
+
+
+
+
+ {/* Fee */}
+
+
+ {strings('perps.order_details.fee')}
+
+
+
+ {formatPerpsFiat(totalFee)}
+
+
+
+
+
+
+ {/* Status */}
+
+
+ {strings('perps.order_details.status')}
+
+
+
+ {orderDetails.fillPercentage > 0 && (
+
+
+ {Math.round(orderDetails.fillPercentage)}% filled
+
+
+ )}
+ {orderDetails.fillPercentage === 0 && (
+
+ {strings('perps.order_details.open')}
+
+ )}
+
+
+
+
+
+
+
+ {/* Footer Actions */}
+
+
+
+
+ );
+};
+
+export default PerpsOrderDetailsView;
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts b/app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts
new file mode 100644
index 000000000000..d123b1134d9a
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsOrderDetailsView';
diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
index 1ebb9c4c6610..050f4eaf1138 100644
--- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
@@ -79,6 +79,7 @@ import type {
OrderParams,
OrderType,
PerpsNavigationParamList,
+ Position,
} from '../../controllers/types';
import {
useHasExistingPosition,
@@ -124,6 +125,8 @@ interface OrderRouteParams {
asset?: string;
amount?: string;
leverage?: number;
+ // Existing position param
+ existingPosition?: Position;
// Modal return values
leverageUpdate?: number;
orderTypeUpdate?: OrderType;
@@ -132,6 +135,12 @@ interface OrderRouteParams {
stopLossPrice?: string;
};
limitPriceUpdate?: string;
+ // Hide TP/SL when modifying existing position
+ hideTPSL?: boolean;
+}
+
+interface PerpsOrderViewContentProps {
+ hideTPSL?: boolean;
}
/**
@@ -145,7 +154,9 @@ interface OrderRouteParams {
* - Auto-opening limit price modal when switching order types
* - Comprehensive order validation
*/
-const PerpsOrderViewContentBase: React.FC = () => {
+const PerpsOrderViewContentBase: React.FC = ({
+ hideTPSL = false,
+}) => {
const navigation = useNavigation>();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
@@ -203,6 +214,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
handlePercentageAmount,
handleMaxAmount,
maxPossibleAmount,
+ // existingPosition is available in context but not used in this component
} = usePerpsOrderContext();
/**
@@ -233,7 +245,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
});
// Check if user has an existing position for this market
- const { existingPosition } = useHasExistingPosition({
+ const { existingPosition: currentMarketPosition } = useHasExistingPosition({
asset: orderForm.asset || '',
loadOnMount: true,
});
@@ -554,7 +566,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
// Get existing position leverage for validation (protocol constraint)
// Note: This is the same value used for initial form state, but needed here for validation
const existingPositionLeverageForValidation =
- existingPosition?.leverage?.value;
+ currentMarketPosition?.leverage?.value;
// Order validation using new hook
const orderValidation = usePerpsOrderValidation({
@@ -706,7 +718,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
}
// Check for cross-margin position (MetaMask only supports isolated margin)
- if (existingPosition?.leverage?.type === 'cross') {
+ if (currentMarketPosition?.leverage?.type === 'cross') {
navigation.navigate(Routes.PERPS.MODALS.ROOT, {
screen: Routes.PERPS.MODALS.CROSS_MARGIN_WARNING,
});
@@ -795,9 +807,9 @@ const PerpsOrderViewContentBase: React.FC = () => {
// Check if TP/SL should be handled separately (for new positions or position flips)
const shouldHandleTPSLSeparately =
(orderForm.takeProfitPrice || orderForm.stopLossPrice) &&
- ((!existingPosition && orderForm.type === 'market') ||
- (existingPosition &&
- willFlipPosition(existingPosition, orderParams)));
+ ((!currentMarketPosition && orderForm.type === 'market') ||
+ (currentMarketPosition &&
+ willFlipPosition(currentMarketPosition, orderParams)));
if (shouldHandleTPSLSeparately) {
// Execute order without TP/SL first, then update position TP/SL
@@ -834,7 +846,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
assetData.price,
navigation,
navigationMarketData,
- existingPosition,
+ currentMarketPosition,
executeOrder,
showToast,
PerpsToastOptions.formValidation.orderForm,
@@ -1011,46 +1023,48 @@ const PerpsOrderViewContentBase: React.FC = () => {
)}
- {/* Combined TP/SL row */}
-
-
-
-
-
+ {/* Combined TP/SL row - Hidden when modifying existing position */}
+ {!hideTPSL && (
+
+
+
+
+
+
+ {strings('perps.order.tp_sl')}
+
+ handleTooltipPress('tp_sl')}
+ style={styles.infoIcon}
+ >
+
+
+
+
+
- {strings('perps.order.tp_sl')}
+ {tpSlDisplayText}
- handleTooltipPress('tp_sl')}
- style={styles.infoIcon}
- >
-
-
-
-
-
-
- {tpSlDisplayText}
-
-
-
-
-
- {doesStopLossRiskLiquidation && (
+
+
+
+
+ )}
+ {!hideTPSL && doesStopLossRiskLiquidation && (
{strings('perps.tpsl.stop_loss_order_view_warning', {
@@ -1444,6 +1458,8 @@ const PerpsOrderView: React.FC = () => {
asset = 'BTC',
amount: paramAmount,
leverage: paramLeverage,
+ existingPosition,
+ hideTPSL = false,
} = route.params || {};
return (
@@ -1452,8 +1468,9 @@ const PerpsOrderView: React.FC = () => {
initialDirection={direction}
initialAmount={paramAmount}
initialLeverage={paramLeverage}
+ existingPosition={existingPosition}
>
-
+
);
};
diff --git a/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx
new file mode 100644
index 000000000000..6824a4fc1ae3
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsSelectAdjustMarginActionView from './PerpsSelectAdjustMarginActionView';
+import type { Position } from '../../controllers/types';
+
+let mockRouteParams: { position?: Position } = {};
+const mockGoBack = jest.fn();
+const mockNavigateToAdjustMargin = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.());
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ }),
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'SELECT_ADJUST_MARGIN_ACTION',
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsNavigation', () => ({
+ usePerpsNavigation: () => ({
+ navigateToAdjustMargin: mockNavigateToAdjustMargin,
+ }),
+}));
+
+// Mock the PerpsAdjustMarginActionSheet component
+jest.mock(
+ '../../components/PerpsAdjustMarginActionSheet',
+ () =>
+ function MockPerpsAdjustMarginActionSheet({
+ onClose,
+ onSelectAction,
+ }: {
+ onClose: () => void;
+ onSelectAction: (action: string) => void;
+ }) {
+ const ReactModule = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } =
+ jest.requireActual('react-native');
+ return ReactModule.createElement(
+ View,
+ { testID: 'adjust-margin-action-sheet' },
+ ReactModule.createElement(Text, null, 'Adjust Margin Action Sheet'),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: () => onSelectAction('add_margin'), testID: 'add-margin' },
+ ReactModule.createElement(Text, null, 'Add Margin'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onSelectAction('reduce_margin'),
+ testID: 'reduce-margin',
+ },
+ ReactModule.createElement(Text, null, 'Reduce Margin'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: onClose, testID: 'close-button' },
+ ReactModule.createElement(Text, null, 'Close'),
+ ),
+ );
+ },
+);
+
+describe('PerpsSelectAdjustMarginActionView', () => {
+ const mockPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouteParams = { position: mockPosition };
+ });
+
+ afterEach(() => {
+ mockRouteParams = {};
+ });
+
+ it('renders the adjust margin action sheet', () => {
+ render();
+
+ expect(screen.getByTestId('adjust-margin-action-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders add margin option', () => {
+ render();
+
+ expect(screen.getByText('Add Margin')).toBeOnTheScreen();
+ });
+
+ it('renders reduce margin option', () => {
+ render();
+
+ expect(screen.getByText('Reduce Margin')).toBeOnTheScreen();
+ });
+
+ it('renders with position from props', () => {
+ render();
+
+ expect(screen.getByTestId('adjust-margin-action-sheet')).toBeOnTheScreen();
+ });
+
+ it('navigates to add margin when add_margin action is selected', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('add-margin'));
+
+ expect(mockNavigateToAdjustMargin).toHaveBeenCalledWith(
+ mockPosition,
+ 'add',
+ );
+ });
+
+ it('navigates to reduce margin when reduce_margin action is selected', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('reduce-margin'));
+
+ expect(mockNavigateToAdjustMargin).toHaveBeenCalledWith(
+ mockPosition,
+ 'remove',
+ );
+ });
+
+ it('does not navigate when position is not available', () => {
+ mockRouteParams = {};
+ render();
+
+ fireEvent.press(screen.getByTestId('add-margin'));
+
+ expect(mockNavigateToAdjustMargin).not.toHaveBeenCalled();
+ });
+
+ it('calls goBack when close button is pressed without external sheetRef', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('calls onClose callback when close button is pressed with external sheetRef', () => {
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('uses position from route params when not provided via props', () => {
+ mockRouteParams = { position: mockPosition };
+ render();
+
+ fireEvent.press(screen.getByTestId('add-margin'));
+
+ expect(mockNavigateToAdjustMargin).toHaveBeenCalledWith(
+ mockPosition,
+ 'add',
+ );
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx
new file mode 100644
index 000000000000..beebb578673b
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx
@@ -0,0 +1,82 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ useNavigation,
+ useRoute,
+ type NavigationProp,
+ type RouteProp,
+} from '@react-navigation/native';
+import type { Position } from '../../controllers/types';
+import type { PerpsNavigationParamList } from '../../types/navigation';
+import PerpsAdjustMarginActionSheet, {
+ type AdjustMarginAction,
+} from '../../components/PerpsAdjustMarginActionSheet';
+import { usePerpsNavigation } from '../../hooks/usePerpsNavigation';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+interface PerpsSelectAdjustMarginActionViewProps {
+ sheetRef?: React.RefObject;
+ position?: Position;
+ onClose?: () => void;
+}
+
+const PerpsSelectAdjustMarginActionView: React.FC<
+ PerpsSelectAdjustMarginActionViewProps
+> = ({
+ sheetRef: externalSheetRef,
+ position: positionProp,
+ onClose: onExternalClose,
+}) => {
+ const navigation = useNavigation>();
+ const route =
+ useRoute<
+ RouteProp
+ >();
+
+ // Support both props and route params
+ const position = positionProp || route.params?.position;
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+ const { navigateToAdjustMargin } = usePerpsNavigation();
+
+ const handleActionSelect = useCallback(
+ (action: AdjustMarginAction) => {
+ if (!position) return;
+
+ // Navigate BEFORE closing (prevents navigation loss from component unmounting)
+ switch (action) {
+ case 'add_margin':
+ navigateToAdjustMargin(position, 'add');
+ break;
+ case 'reduce_margin':
+ navigateToAdjustMargin(position, 'remove');
+ break;
+ }
+
+ // Close bottom sheet AFTER navigation is triggered
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ },
+ [position, sheetRef, onExternalClose, navigateToAdjustMargin],
+ );
+
+ const handleClose = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ } else {
+ navigation.goBack();
+ }
+ }, [navigation, externalSheetRef, sheetRef, onExternalClose]);
+
+ return (
+
+ );
+};
+
+export default PerpsSelectAdjustMarginActionView;
diff --git a/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts
new file mode 100644
index 000000000000..65e68435f802
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsSelectAdjustMarginActionView';
diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx
new file mode 100644
index 000000000000..ee8cd3b1ecbc
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx
@@ -0,0 +1,276 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsSelectModifyActionView from './PerpsSelectModifyActionView';
+import type { Position } from '../../controllers/types';
+
+let mockRouteParams: { position?: Position } = {};
+const mockGoBack = jest.fn();
+const mockNavigateToOrder = jest.fn();
+const mockNavigateToClosePosition = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.());
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ }),
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'SELECT_MODIFY_ACTION',
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsNavigation', () => ({
+ usePerpsNavigation: () => ({
+ navigateToOrder: mockNavigateToOrder,
+ navigateToClosePosition: mockNavigateToClosePosition,
+ }),
+}));
+
+// Mock the PerpsModifyActionSheet component
+jest.mock(
+ '../../components/PerpsModifyActionSheet',
+ () =>
+ function MockPerpsModifyActionSheet({
+ onClose,
+ onActionSelect,
+ position,
+ }: {
+ onClose: () => void;
+ onActionSelect: (action: string) => void;
+ position?: Position;
+ }) {
+ const ReactModule = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } =
+ jest.requireActual('react-native');
+ return ReactModule.createElement(
+ View,
+ { testID: 'modify-action-sheet' },
+ ReactModule.createElement(Text, null, 'Modify Position'),
+ position &&
+ ReactModule.createElement(
+ Text,
+ { testID: 'position-coin' },
+ position.coin,
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onActionSelect('add_to_position'),
+ testID: 'add-to-position',
+ },
+ ReactModule.createElement(Text, null, 'Add to Position'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onActionSelect('reduce_position'),
+ testID: 'reduce-position',
+ },
+ ReactModule.createElement(Text, null, 'Reduce Position'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onActionSelect('flip_position'),
+ testID: 'flip-position',
+ },
+ ReactModule.createElement(Text, null, 'Flip Position'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: onClose, testID: 'close-button' },
+ ReactModule.createElement(Text, null, 'Close'),
+ ),
+ );
+ },
+);
+
+describe('PerpsSelectModifyActionView', () => {
+ const mockLongPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockShortPosition: Position = {
+ ...mockLongPosition,
+ size: '-2.5',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouteParams = { position: mockLongPosition };
+ });
+
+ afterEach(() => {
+ mockRouteParams = {};
+ });
+
+ it('renders the modify action sheet', () => {
+ render();
+
+ expect(screen.getByTestId('modify-action-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders add to position option', () => {
+ render();
+
+ expect(screen.getByText('Add to Position')).toBeOnTheScreen();
+ });
+
+ it('renders reduce position option', () => {
+ render();
+
+ expect(screen.getByText('Reduce Position')).toBeOnTheScreen();
+ });
+
+ it('renders flip position option', () => {
+ render();
+
+ expect(screen.getByText('Flip Position')).toBeOnTheScreen();
+ });
+
+ it('renders with position from props', () => {
+ render();
+
+ expect(screen.getByTestId('position-coin')).toBeOnTheScreen();
+ expect(screen.getByText('ETH')).toBeOnTheScreen();
+ });
+
+ it('navigates to order with long direction when add_to_position is selected for long position', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'long',
+ asset: 'ETH',
+ hideTPSL: true,
+ });
+ });
+
+ it('navigates to order with short direction when add_to_position is selected for short position', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'short',
+ asset: 'ETH',
+ hideTPSL: true,
+ });
+ });
+
+ it('navigates to close position when reduce_position is selected', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('reduce-position'));
+
+ expect(mockNavigateToClosePosition).toHaveBeenCalledWith(mockLongPosition);
+ });
+
+ it('calls onReversePosition when flip_position is selected with callback', () => {
+ const mockOnReversePosition = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('flip-position'));
+
+ expect(mockOnReversePosition).toHaveBeenCalledWith(mockLongPosition);
+ });
+
+ it('navigates to order with opposite direction when flip_position is selected without callback (long)', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('flip-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'short',
+ asset: 'ETH',
+ size: '2.5',
+ leverage: 10,
+ });
+ });
+
+ it('navigates to order with opposite direction when flip_position is selected without callback (short)', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('flip-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'long',
+ asset: 'ETH',
+ size: '2.5',
+ leverage: 10,
+ });
+ });
+
+ it('does not navigate when position is not available', () => {
+ mockRouteParams = {};
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).not.toHaveBeenCalled();
+ });
+
+ it('calls goBack when close button is pressed without external sheetRef', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('calls onClose callback when close button is pressed with external sheetRef', () => {
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('uses position from route params when not provided via props', () => {
+ mockRouteParams = { position: mockLongPosition };
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'long',
+ asset: 'ETH',
+ hideTPSL: true,
+ });
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx
new file mode 100644
index 000000000000..2659691349da
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx
@@ -0,0 +1,120 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ useNavigation,
+ useRoute,
+ type NavigationProp,
+ type RouteProp,
+} from '@react-navigation/native';
+import type { Position } from '../../controllers/types';
+import type { PerpsNavigationParamList } from '../../types/navigation';
+import PerpsModifyActionSheet, {
+ type ModifyAction,
+} from '../../components/PerpsModifyActionSheet';
+import { usePerpsNavigation } from '../../hooks/usePerpsNavigation';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+interface PerpsSelectModifyActionViewProps {
+ sheetRef?: React.RefObject;
+ position?: Position;
+ onClose?: () => void;
+ onReversePosition?: (position: Position) => void;
+}
+
+const PerpsSelectModifyActionView: React.FC<
+ PerpsSelectModifyActionViewProps
+> = ({
+ sheetRef: externalSheetRef,
+ position: positionProp,
+ onClose: onExternalClose,
+ onReversePosition,
+}) => {
+ const navigation = useNavigation>();
+ const route =
+ useRoute>();
+
+ // Support both props and route params
+ const position = positionProp || route.params?.position;
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+ const { navigateToOrder, navigateToClosePosition } = usePerpsNavigation();
+
+ const handleActionSelect = useCallback(
+ (action: ModifyAction) => {
+ if (!position) return;
+
+ // Navigate BEFORE closing (prevents navigation loss from component unmounting)
+ switch (action) {
+ case 'add_to_position':
+ // Open trade screen in same direction
+ {
+ const direction = parseFloat(position.size) > 0 ? 'long' : 'short';
+ navigateToOrder({
+ direction,
+ asset: position.coin,
+ hideTPSL: true, // Hide TP/SL when adding to existing position
+ });
+ }
+ break;
+
+ case 'reduce_position':
+ // Open close position screen
+ navigateToClosePosition(position);
+ break;
+
+ case 'flip_position':
+ // If parent provides onReversePosition callback, use it (shows confirmation sheet)
+ // Otherwise, navigate directly to order screen (legacy behavior)
+ if (onReversePosition) {
+ onReversePosition(position);
+ } else {
+ const oppositeDirection =
+ parseFloat(position.size) > 0 ? 'short' : 'long';
+ const positionSize = Math.abs(parseFloat(position.size));
+ const positionLeverage = position.leverage?.value;
+
+ navigateToOrder({
+ direction: oppositeDirection,
+ asset: position.coin,
+ size: positionSize.toString(),
+ leverage: positionLeverage,
+ });
+ }
+ break;
+ }
+
+ // Close bottom sheet AFTER navigation is triggered
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ },
+ [
+ position,
+ navigateToOrder,
+ navigateToClosePosition,
+ onReversePosition,
+ sheetRef,
+ onExternalClose,
+ ],
+ );
+
+ const handleClose = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ } else {
+ navigation.goBack();
+ }
+ }, [navigation, externalSheetRef, sheetRef, onExternalClose]);
+
+ return (
+
+ );
+};
+
+export default PerpsSelectModifyActionView;
diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts
new file mode 100644
index 000000000000..a84ae2cd3fe8
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsSelectModifyActionView';
diff --git a/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx
new file mode 100644
index 000000000000..bc563597d219
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx
@@ -0,0 +1,193 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsSelectOrderTypeView from './PerpsSelectOrderTypeView';
+
+const mockGoBack = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.());
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ }),
+}));
+
+// Mock the PerpsOrderTypeBottomSheet component
+jest.mock(
+ '../../components/PerpsOrderTypeBottomSheet',
+ () =>
+ function MockPerpsOrderTypeBottomSheet({
+ onClose,
+ onSelect,
+ currentOrderType,
+ asset,
+ direction,
+ }: {
+ onClose: () => void;
+ onSelect: (type: string) => void;
+ currentOrderType: string;
+ asset?: string;
+ direction?: string;
+ }) {
+ const ReactModule = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } =
+ jest.requireActual('react-native');
+ return ReactModule.createElement(
+ View,
+ { testID: 'order-type-bottom-sheet' },
+ ReactModule.createElement(Text, null, 'Select Order Type'),
+ ReactModule.createElement(
+ Text,
+ { testID: 'current-type' },
+ `Current: ${currentOrderType}`,
+ ),
+ asset &&
+ ReactModule.createElement(
+ Text,
+ { testID: 'asset' },
+ `Asset: ${asset}`,
+ ),
+ direction &&
+ ReactModule.createElement(
+ Text,
+ { testID: 'direction' },
+ `Direction: ${direction}`,
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: () => onSelect('market'), testID: 'select-market' },
+ ReactModule.createElement(Text, null, 'Market'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: () => onSelect('limit'), testID: 'select-limit' },
+ ReactModule.createElement(Text, null, 'Limit'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: onClose, testID: 'close-button' },
+ ReactModule.createElement(Text, null, 'Close'),
+ ),
+ );
+ },
+);
+
+describe('PerpsSelectOrderTypeView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the order type bottom sheet', () => {
+ render();
+
+ expect(screen.getByTestId('order-type-bottom-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders with default market order type', () => {
+ render();
+
+ expect(screen.getByText('Current: market')).toBeOnTheScreen();
+ });
+
+ it('renders with custom order type', () => {
+ render();
+
+ expect(screen.getByText('Current: limit')).toBeOnTheScreen();
+ });
+
+ it('renders market option', () => {
+ render();
+
+ expect(screen.getByText('Market')).toBeOnTheScreen();
+ });
+
+ it('renders limit option', () => {
+ render();
+
+ expect(screen.getByText('Limit')).toBeOnTheScreen();
+ });
+
+ it('displays asset when provided', () => {
+ render();
+
+ expect(screen.getByText('Asset: BTC')).toBeOnTheScreen();
+ });
+
+ it('displays direction when provided', () => {
+ render();
+
+ expect(screen.getByText('Direction: long')).toBeOnTheScreen();
+ });
+
+ it('calls onSelect callback when order type is selected', () => {
+ const mockOnSelect = jest.fn();
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('select-market'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockOnSelect).toHaveBeenCalledWith('market');
+ });
+
+ it('calls onSelect with limit when limit is selected', () => {
+ const mockOnSelect = jest.fn();
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('select-limit'));
+
+ expect(mockOnSelect).toHaveBeenCalledWith('limit');
+ });
+
+ it('calls goBack when close button is pressed without external sheetRef', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('calls onClose callback when close button is pressed with external sheetRef', () => {
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx
new file mode 100644
index 000000000000..a76bbe878cb0
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx
@@ -0,0 +1,61 @@
+import React, { useCallback, useRef } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import type { OrderType } from '../../controllers/types';
+import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+interface PerpsSelectOrderTypeViewProps {
+ sheetRef?: React.RefObject;
+ currentOrderType?: OrderType;
+ asset?: string;
+ direction?: 'long' | 'short';
+ onSelect?: (type: OrderType) => void;
+ onClose?: () => void;
+}
+
+const PerpsSelectOrderTypeView: React.FC = ({
+ sheetRef: externalSheetRef,
+ currentOrderType,
+ asset,
+ direction,
+ onSelect,
+ onClose: onExternalClose,
+}) => {
+ const navigation = useNavigation();
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ const handleSelect = useCallback(
+ (type: OrderType) => {
+ // Close bottom sheet first, then call onSelect callback
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ onSelect?.(type);
+ });
+ },
+ [sheetRef, onExternalClose, onSelect],
+ );
+
+ const handleClose = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ } else {
+ navigation.goBack();
+ }
+ }, [navigation, externalSheetRef, sheetRef, onExternalClose]);
+
+ return (
+
+ );
+};
+
+export default PerpsSelectOrderTypeView;
diff --git a/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts
new file mode 100644
index 000000000000..97de65f85882
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsSelectOrderTypeView';
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts
new file mode 100644
index 000000000000..3fd8ebaf4adf
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts
@@ -0,0 +1,25 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ container: {
+ paddingBottom: 16,
+ },
+ actionItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 16,
+ paddingHorizontal: 16,
+ gap: 12,
+ },
+ actionContent: {
+ flex: 1,
+ gap: 4,
+ },
+ separator: {
+ height: 1,
+ marginHorizontal: 16,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx
new file mode 100644
index 000000000000..50d3bc30fbf5
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsAdjustMarginActionSheet from './PerpsAdjustMarginActionSheet';
+
+// Mock dependencies
+jest.mock('../../../../../component-library/hooks', () => ({
+ useStyles: () => ({
+ styles: {
+ container: {},
+ actionItem: {},
+ actionContent: {},
+ separator: {},
+ },
+ theme: {
+ colors: {
+ border: { muted: '#CCCCCC' },
+ },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => {
+ const translations: Record = {
+ 'perps.adjust_margin.title': 'Adjust Margin',
+ 'perps.adjust_margin.add_margin': 'Add Margin',
+ 'perps.adjust_margin.add_margin_description':
+ 'Increase margin to reduce liquidation risk',
+ 'perps.adjust_margin.reduce_margin': 'Reduce Margin',
+ 'perps.adjust_margin.reduce_margin_description':
+ 'Withdraw excess margin from position',
+ };
+ return translations[key] || key;
+ }),
+}));
+
+// Mock BottomSheet and BottomSheetHeader
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ReactModule.forwardRef(
+ (
+ { children, testID }: { children: React.ReactNode; testID?: string },
+ _ref: unknown,
+ ) => ReactModule.createElement(View, { testID }, children),
+ ),
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockBottomSheetHeader({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ );
+ };
+ },
+);
+
+// Mock Icon component
+jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
+ __esModule: true,
+ default: function MockIcon() {
+ return null;
+ },
+ IconName: {
+ Add: 'Add',
+ Minus: 'Minus',
+ Arrow2Right: 'Arrow2Right',
+ },
+ IconSize: {
+ Lg: 'Lg',
+ Md: 'Md',
+ },
+ IconColor: {
+ Primary: 'Primary',
+ Alternative: 'Alternative',
+ },
+}));
+
+describe('PerpsAdjustMarginActionSheet', () => {
+ const mockOnClose = jest.fn();
+ const mockOnSelectAction = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the adjust margin title', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Adjust Margin')).toBeOnTheScreen();
+ });
+
+ it('renders add margin option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Add Margin')).toBeOnTheScreen();
+ expect(
+ screen.getByText('Increase margin to reduce liquidation risk'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders reduce margin option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Reduce Margin')).toBeOnTheScreen();
+ expect(
+ screen.getByText('Withdraw excess margin from position'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onSelectAction with add_margin when add margin is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add Margin'));
+
+ expect(mockOnSelectAction).toHaveBeenCalledWith('add_margin');
+ });
+
+ it('calls onSelectAction with reduce_margin when reduce margin is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Reduce Margin'));
+
+ expect(mockOnSelectAction).toHaveBeenCalledWith('reduce_margin');
+ });
+
+ it('calls onClose when action is selected', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add Margin'));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('renders with testID when provided', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('test-adjust-margin-sheet')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
new file mode 100644
index 000000000000..30260e0aff7e
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
@@ -0,0 +1,129 @@
+import React, { useMemo, useCallback, useRef, useEffect } from 'react';
+import { View, TouchableOpacity } from 'react-native';
+import { useStyles } from '../../../../../component-library/hooks';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../../../component-library/components/Icons/Icon';
+import { strings } from '../../../../../../locales/i18n';
+import styleSheet from './PerpsAdjustMarginActionSheet.styles';
+import type {
+ PerpsAdjustMarginActionSheetProps,
+ AdjustMarginAction,
+} from './PerpsAdjustMarginActionSheet.types';
+
+interface ActionOption {
+ action: AdjustMarginAction;
+ label: string;
+ description: string;
+ iconName: IconName;
+}
+
+const PerpsAdjustMarginActionSheet: React.FC<
+ PerpsAdjustMarginActionSheetProps
+> = ({
+ isVisible = true,
+ onClose,
+ onSelectAction,
+ sheetRef: externalSheetRef,
+ testID,
+}) => {
+ const { styles, theme } = useStyles(styleSheet, {});
+ const { colors } = theme;
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ useEffect(() => {
+ if (isVisible && !externalSheetRef) {
+ sheetRef.current?.onOpenBottomSheet();
+ }
+ }, [isVisible, externalSheetRef, sheetRef]);
+
+ const actionOptions: ActionOption[] = useMemo(
+ () => [
+ {
+ action: 'add_margin',
+ label: strings('perps.adjust_margin.add_margin'),
+ description: strings('perps.adjust_margin.add_margin_description'),
+ iconName: IconName.Add,
+ },
+ {
+ action: 'reduce_margin',
+ label: strings('perps.adjust_margin.reduce_margin'),
+ description: strings('perps.adjust_margin.reduce_margin_description'),
+ iconName: IconName.Minus,
+ },
+ ],
+ [],
+ );
+
+ const handleActionPress = useCallback(
+ (action: AdjustMarginAction) => {
+ onSelectAction(action);
+ onClose();
+ },
+ [onSelectAction, onClose],
+ );
+
+ return (
+
+
+
+ {strings('perps.adjust_margin.title')}
+
+
+
+ {actionOptions.map((option, index) => (
+
+ handleActionPress(option.action)}
+ activeOpacity={0.7}
+ >
+
+
+ {option.label}
+
+ {option.description}
+
+
+
+ {index < actionOptions.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+PerpsAdjustMarginActionSheet.displayName = 'PerpsAdjustMarginActionSheet';
+
+export default PerpsAdjustMarginActionSheet;
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts
new file mode 100644
index 000000000000..1a708b660097
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts
@@ -0,0 +1,11 @@
+import type { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+export type AdjustMarginAction = 'add_margin' | 'reduce_margin';
+
+export interface PerpsAdjustMarginActionSheetProps {
+ isVisible?: boolean;
+ onClose: () => void;
+ onSelectAction: (action: AdjustMarginAction) => void;
+ sheetRef?: React.RefObject;
+ testID?: string;
+}
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts
new file mode 100644
index 000000000000..f73701840873
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts
@@ -0,0 +1,5 @@
+export { default } from './PerpsAdjustMarginActionSheet';
+export type {
+ PerpsAdjustMarginActionSheetProps,
+ AdjustMarginAction,
+} from './PerpsAdjustMarginActionSheet.types';
diff --git a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts
index a094e6553aff..5037fa59f58c 100644
--- a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts
+++ b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts
@@ -15,11 +15,19 @@ export const styleSheet = (params: {
// Background uses .muted variant, text uses .default variant from same color family
// This ensures proper contrast while maintaining semantic color consistency
// Pattern inspired by AvatarIcon component (primary.muted background + primary.default icon)
- const badgeStyles: Record = {
+ const badgeStyles: Record<
+ BadgeType,
+ { background: string; text: string; border?: string }
+ > = {
experimental: {
background: theme.colors.primary.muted,
text: theme.colors.primary.default,
},
+ dex: {
+ background: theme.colors.background.default,
+ text: theme.colors.text.alternative,
+ border: theme.colors.border.default,
+ },
equity: {
background: theme.colors.info.muted,
text: theme.colors.info.default,
@@ -51,6 +59,10 @@ export const styleSheet = (params: {
borderRadius: 4,
backgroundColor: style.background,
alignSelf: 'flex-start',
+ ...(style.border && {
+ borderWidth: 1,
+ borderColor: style.border,
+ }),
},
badgeText: {
fontSize: 10,
diff --git a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts
index 8e4a357aa7ab..f345ff32423e 100644
--- a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts
+++ b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts
@@ -1,6 +1,6 @@
import type { MarketType } from '../../controllers/types';
-export type BadgeType = MarketType | 'experimental';
+export type BadgeType = MarketType | 'experimental' | 'dex';
export interface PerpsBadgeProps {
/**
diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
index abdfa05a6b0a..bdb0d40e032b 100644
--- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
+++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
@@ -36,6 +36,7 @@ export interface PerpsBottomSheetTooltipProps {
export type PerpsTooltipContentKey =
| 'leverage'
| 'liquidation_price'
+ | 'liquidation_distance'
| 'margin'
| 'fees'
| 'closing_fees'
diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
index 002a5e669bcf..0c7fe2975e38 100644
--- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
+++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
@@ -21,6 +21,7 @@ export const tooltipContentRegistry: ContentRegistry = {
receive: undefined,
leverage: undefined,
liquidation_price: undefined,
+ liquidation_distance: undefined,
margin: undefined,
open_interest: undefined,
funding_rate: undefined,
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts
new file mode 100644
index 000000000000..dafcab74d9dc
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts
@@ -0,0 +1,39 @@
+import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
+
+const createStyles = (params: { theme: Theme }) =>
+ StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ backgroundColor: params.theme.colors.background.section,
+ borderRadius: 8,
+ marginBottom: 8,
+ },
+ leftSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ marginRight: 16,
+ gap: 12,
+ },
+ infoContainer: {
+ flex: 1,
+ gap: 4,
+ },
+ rightSection: {
+ alignItems: 'flex-end',
+ gap: 4,
+ },
+ priceText: {
+ textAlign: 'right',
+ },
+ labelText: {
+ textAlign: 'right',
+ },
+ });
+
+export default createStyles;
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx
new file mode 100644
index 000000000000..8ca916d1836a
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx
@@ -0,0 +1,189 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsCompactOrderRow from './PerpsCompactOrderRow';
+import type { Order } from '../../controllers/types';
+
+// Mock dependencies
+jest.mock('../../../../../component-library/hooks', () => ({
+ useStyles: () => ({
+ styles: {
+ container: {},
+ leftSection: {},
+ infoContainer: {},
+ rightSection: {},
+ priceText: {},
+ labelText: {},
+ },
+ theme: {
+ colors: {
+ success: { default: '#00FF00' },
+ error: { default: '#FF0000' },
+ },
+ },
+ }),
+}));
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPositionSize: jest.fn((value) => value),
+ formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`),
+ PRICE_RANGES_MINIMAL_VIEW: {},
+}));
+
+jest.mock('../../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+jest.mock('../PerpsTokenLogo', () => 'PerpsTokenLogo');
+
+describe('PerpsCompactOrderRow', () => {
+ const mockLimitBuyOrder: Order = {
+ orderId: 'order-123',
+ symbol: 'BTC',
+ size: '0.5',
+ originalSize: '0.5',
+ filledSize: '0',
+ remainingSize: '0.5',
+ price: '50000',
+ side: 'buy',
+ orderType: 'limit',
+ timestamp: Date.now(),
+ status: 'open',
+ reduceOnly: false,
+ detailedOrderType: 'Limit',
+ };
+
+ const mockLimitSellOrder: Order = {
+ ...mockLimitBuyOrder,
+ orderId: 'order-456',
+ side: 'sell',
+ };
+
+ const mockMarketOrder: Order = {
+ ...mockLimitBuyOrder,
+ orderId: 'order-789',
+ orderType: 'market',
+ detailedOrderType: 'Market',
+ };
+
+ const mockTriggerOrder: Order = {
+ ...mockLimitBuyOrder,
+ orderId: 'order-trigger',
+ orderType: 'limit',
+ isTrigger: true,
+ price: '48000',
+ detailedOrderType: 'Stop Market',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders order info for limit buy order', () => {
+ render();
+
+ expect(screen.getByText('Limit long')).toBeOnTheScreen();
+ expect(screen.getByText('0.5 BTC')).toBeOnTheScreen();
+ expect(screen.getByText('Limit price')).toBeOnTheScreen();
+ });
+
+ it('renders order info for limit sell order (short direction)', () => {
+ render();
+
+ expect(screen.getByText('Limit short')).toBeOnTheScreen();
+ });
+
+ it('renders market price label for market orders', () => {
+ render();
+
+ expect(screen.getByText('Market price')).toBeOnTheScreen();
+ expect(screen.getByText('Market long')).toBeOnTheScreen();
+ });
+
+ it('renders Stop Market order type for trigger orders', () => {
+ render();
+
+ expect(screen.getByText('Stop Market long')).toBeOnTheScreen();
+ });
+
+ it('uses price for trigger orders', () => {
+ const { formatPerpsFiat } = jest.requireMock('../../utils/formatUtils');
+ render();
+
+ // Should have called formatPerpsFiat with the order price value (48000)
+ // Note: The adapter maps triggerPx to price, so trigger orders use price field
+ expect(formatPerpsFiat).toHaveBeenCalledWith(48000, expect.any(Object));
+ });
+
+ it('uses order price for limit orders', () => {
+ const { formatPerpsFiat } = jest.requireMock('../../utils/formatUtils');
+ render();
+
+ // Should have called formatPerpsFiat with the order price value (50000)
+ expect(formatPerpsFiat).toHaveBeenCalledWith(50000, expect.any(Object));
+ });
+
+ it('calls onPress when tapped', () => {
+ const mockOnPress = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('compact-order-row'));
+
+ expect(mockOnPress).toHaveBeenCalled();
+ });
+
+ it('is disabled when no onPress handler is provided', () => {
+ render(
+ ,
+ );
+
+ // TouchableOpacity should be disabled - we verify by checking it renders
+ expect(screen.getByTestId('compact-order-row')).toBeOnTheScreen();
+ });
+
+ it('renders with custom testID', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('custom-test-id')).toBeOnTheScreen();
+ });
+
+ it('formats position size correctly', () => {
+ const { formatPositionSize } = jest.requireMock('../../utils/formatUtils');
+ render();
+
+ expect(formatPositionSize).toHaveBeenCalledWith('0.5');
+ });
+
+ it('gets display symbol for the order', () => {
+ const { getPerpsDisplaySymbol } = jest.requireMock(
+ '../../utils/marketUtils',
+ );
+ render();
+
+ expect(getPerpsDisplaySymbol).toHaveBeenCalledWith('BTC');
+ });
+
+ it('handles order without detailedOrderType', () => {
+ const orderWithoutDetailedType: Order = {
+ ...mockLimitBuyOrder,
+ detailedOrderType: undefined,
+ };
+ render();
+
+ // Falls back to 'Limit' for order type display
+ expect(screen.getByText('Limit long')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx
new file mode 100644
index 000000000000..554fde36f442
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx
@@ -0,0 +1,129 @@
+import React, { useMemo } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import { useStyles } from '../../../../../component-library/hooks';
+import {
+ formatPositionSize,
+ formatPerpsFiat,
+ PRICE_RANGES_MINIMAL_VIEW,
+} from '../../utils/formatUtils';
+import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
+import styleSheet from './PerpsCompactOrderRow.styles';
+import type { Order } from '../../controllers/types';
+import PerpsTokenLogo from '../PerpsTokenLogo';
+
+interface PerpsCompactOrderRowProps {
+ order: Order;
+ onPress?: () => void;
+ testID?: string;
+}
+
+/**
+ * PerpsCompactOrderRow Component
+ *
+ * A compact, single-line representation of an open order.
+ * Designed for displaying non-TP/SL orders in a simplified list format.
+ *
+ * Shows:
+ * - Order direction indicator (circle)
+ * - Order type and direction (e.g., "Limit long")
+ * - Price
+ * - Size in asset units
+ * - Order type label
+ */
+const PerpsCompactOrderRow: React.FC = ({
+ order,
+ onPress,
+ testID,
+}) => {
+ const { styles, theme } = useStyles(styleSheet, {});
+
+ const orderInfo = useMemo(() => {
+ // Determine direction and color
+ const isLong = order.side === 'buy';
+ const direction = isLong ? 'long' : 'short';
+ const directionColor = isLong
+ ? theme.colors.success.default
+ : theme.colors.error.default;
+
+ // Format order type
+ let orderTypeLabel = 'Limit price';
+ if (order.detailedOrderType?.includes('Market')) {
+ orderTypeLabel = 'Market price';
+ } else if (order.isTrigger) {
+ orderTypeLabel = 'Trigger price';
+ }
+
+ // Format price - trigger orders already have triggerPx mapped to price by adapter
+ const priceValue = parseFloat(order.price || '0');
+ const formattedPrice = formatPerpsFiat(priceValue, {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ });
+
+ // Format size
+ const size = Math.abs(parseFloat(order.size));
+ const formattedSize = formatPositionSize(size.toString());
+ const symbol = getPerpsDisplaySymbol(order.symbol);
+
+ // Order type display (e.g., "Limit long", "Stop Market")
+ const orderTypeDisplay = order.detailedOrderType || 'Limit';
+
+ return {
+ direction,
+ directionColor,
+ orderTypeLabel,
+ formattedPrice,
+ formattedSize,
+ symbol,
+ orderTypeDisplay,
+ isLong,
+ };
+ }, [order, theme]);
+
+ return (
+
+
+ {/* Token icon */}
+
+
+ {/* Order info */}
+
+
+ {orderInfo.orderTypeDisplay} {orderInfo.direction}
+
+
+ {orderInfo.formattedSize} {orderInfo.symbol}
+
+
+
+
+
+
+ {orderInfo.formattedPrice}
+
+
+ {orderInfo.orderTypeLabel}
+
+
+
+ );
+};
+
+export default PerpsCompactOrderRow;
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts b/app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts
new file mode 100644
index 000000000000..d652190d2005
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsCompactOrderRow';
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts
new file mode 100644
index 000000000000..410fbc2f4eb5
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts
@@ -0,0 +1,64 @@
+import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ contentContainer: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ },
+ description: {
+ marginBottom: 24,
+ },
+ detailsWrapper: {
+ gap: 1,
+ marginBottom: 16,
+ },
+ detailItem: {
+ backgroundColor: theme.colors.background.alternative,
+ overflow: 'hidden',
+ },
+ detailItemFirst: {
+ borderTopLeftRadius: 12,
+ borderTopRightRadius: 12,
+ },
+ detailItemLast: {
+ borderBottomLeftRadius: 12,
+ borderBottomRightRadius: 12,
+ },
+ detailItemWrapper: {
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ },
+ directionContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ directionText: {
+ marginHorizontal: 8,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ infoLabel: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ loadingContainer: {
+ paddingVertical: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ loadingText: {
+ marginTop: 16,
+ },
+ footerContainer: {
+ paddingTop: 16,
+ },
+ });
+
+export default createStyles;
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx
new file mode 100644
index 000000000000..a8a620b70f62
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx
@@ -0,0 +1,374 @@
+import React from 'react';
+import {
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react-native';
+import PerpsFlipPositionConfirmSheet from './PerpsFlipPositionConfirmSheet';
+import type { Position } from '../../controllers/types';
+
+const mockHandleFlipPosition = jest.fn();
+let mockIsFlipping = false;
+
+// Mock dependencies
+jest.mock('../../../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ primary: { default: '#0376C9' },
+ success: { default: '#00FF00' },
+ error: { default: '#FF0000' },
+ border: { muted: '#CCCCCC' },
+ background: { alternative: '#F5F5F5' },
+ },
+ }),
+}));
+
+jest.mock('./PerpsFlipPositionConfirmSheet.styles', () => () => ({
+ contentContainer: {},
+ loadingContainer: {},
+ loadingText: {},
+ detailsWrapper: {},
+ detailItem: {},
+ detailItemFirst: {},
+ detailItemLast: {},
+ detailItemWrapper: {},
+ infoRow: {},
+ directionContainer: {},
+ footerContainer: {},
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => {
+ const translations: Record = {
+ 'perps.flip_position.title': 'Flip Position',
+ 'perps.flip_position.direction': 'Direction',
+ 'perps.flip_position.est_size': 'Est. Size',
+ 'perps.flip_position.cancel': 'Cancel',
+ 'perps.flip_position.flip': 'Flip',
+ 'perps.flip_position.flipping': 'Flipping...',
+ 'perps.order.long_label': 'Long',
+ 'perps.order.short_label': 'Short',
+ 'perps.order.fees': 'Fees',
+ 'perps.estimated_points': 'Est. Points',
+ };
+ return translations[key] || key;
+ }),
+}));
+
+jest.mock('../../hooks', () => ({
+ usePerpsOrderFees: () => ({
+ totalFee: 0.5,
+ makerFee: 0.2,
+ takerFee: 0.3,
+ isLoadingMetamaskFee: false,
+ }),
+ usePerpsRewards: () => ({
+ shouldShowRewardsRow: false,
+ estimatedPoints: undefined,
+ accountOptedIn: false,
+ feeDiscountPercentage: 0,
+ hasError: false,
+ bonusBips: 0,
+ }),
+ usePerpsMeasurement: jest.fn(),
+}));
+
+jest.mock('../../hooks/usePerpsFlipPosition', () => ({
+ usePerpsFlipPosition: ({ onSuccess }: { onSuccess?: () => void }) => ({
+ handleFlipPosition: mockHandleFlipPosition.mockImplementation(async () => {
+ onSuccess?.();
+ }),
+ isFlipping: mockIsFlipping,
+ }),
+}));
+
+jest.mock('../../hooks/stream', () => ({
+ usePerpsLivePrices: () => ({
+ ETH: { price: '2500', markPrice: '2502' },
+ BTC: { price: '50000', markPrice: '50010' },
+ }),
+ usePerpsTopOfBook: () => ({
+ bestAsk: '2501',
+ bestBid: '2499',
+ }),
+}));
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`),
+ PRICE_RANGES_MINIMAL_VIEW: {},
+}));
+
+jest.mock('../../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+jest.mock('../PerpsFeesDisplay', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return function MockPerpsFeesDisplay({
+ formatFeeText,
+ }: {
+ formatFeeText: string;
+ }) {
+ return ReactModule.createElement(Text, null, formatFeeText);
+ };
+});
+
+jest.mock('../../../Rewards/components/RewardPointsAnimation', () => ({
+ __esModule: true,
+ default: () => null,
+ RewardAnimationState: {
+ Idle: 'Idle',
+ Loading: 'Loading',
+ ErrorState: 'ErrorState',
+ },
+}));
+
+jest.mock('../../../../../util/trace', () => ({
+ TraceName: {
+ PerpsFlipPositionSheet: 'PerpsFlipPositionSheet',
+ },
+}));
+
+// Mock BottomSheet components
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ReactModule.forwardRef(
+ ({ children }: { children: React.ReactNode }, _ref: unknown) =>
+ ReactModule.createElement(View, { testID: 'bottom-sheet' }, children),
+ ),
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockBottomSheetHeader({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ );
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetFooter',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View, TouchableOpacity, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ ButtonsAlignment: { Horizontal: 'Horizontal' },
+ default: function MockBottomSheetFooter({
+ buttonPropsArray,
+ }: {
+ buttonPropsArray: {
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ }[];
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-footer' },
+ buttonPropsArray.map(
+ (
+ button: {
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ },
+ index: number,
+ ) =>
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ key: index,
+ onPress: button.onPress,
+ disabled: button.disabled,
+ testID: `footer-button-${index}`,
+ },
+ ReactModule.createElement(Text, null, button.label),
+ ),
+ ),
+ );
+ },
+ };
+ },
+);
+
+jest.mock('../../../../../component-library/components/Texts/Text', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockText({
+ children,
+ testID,
+ }: {
+ children: React.ReactNode;
+ testID?: string;
+ }) {
+ return ReactModule.createElement(Text, { testID }, children);
+ },
+ TextVariant: {
+ HeadingMD: 'HeadingMD',
+ BodyMD: 'BodyMD',
+ },
+ TextColor: {
+ Default: 'Default',
+ Alternative: 'Alternative',
+ },
+ };
+});
+
+jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
+ __esModule: true,
+ default: () => null,
+ IconName: { Arrow2Right: 'Arrow2Right' },
+ IconSize: { Md: 'Md' },
+ IconColor: { Default: 'Default' },
+}));
+
+jest.mock('../../../../../component-library/components/Buttons/Button', () => ({
+ ButtonSize: { Lg: 'Lg' },
+ ButtonVariants: { Primary: 'Primary', Secondary: 'Secondary' },
+}));
+
+describe('PerpsFlipPositionConfirmSheet', () => {
+ const mockLongPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockShortPosition: Position = {
+ ...mockLongPosition,
+ size: '-2.5',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsFlipping = false;
+ });
+
+ it('renders the flip position title', () => {
+ render();
+
+ expect(screen.getByText('Flip Position')).toBeOnTheScreen();
+ });
+
+ it('renders direction labels', () => {
+ render();
+
+ expect(screen.getByText('Direction')).toBeOnTheScreen();
+ expect(screen.getByText('Long')).toBeOnTheScreen();
+ expect(screen.getByText('Short')).toBeOnTheScreen();
+ });
+
+ it('renders direction for short position', () => {
+ render();
+
+ expect(screen.getByText('Short')).toBeOnTheScreen();
+ expect(screen.getByText('Long')).toBeOnTheScreen();
+ });
+
+ it('renders estimated size', () => {
+ render();
+
+ expect(screen.getByText('Est. Size')).toBeOnTheScreen();
+ expect(screen.getByText('2.5 ETH')).toBeOnTheScreen();
+ });
+
+ it('renders fees label', () => {
+ render();
+
+ expect(screen.getByText('Fees')).toBeOnTheScreen();
+ });
+
+ it('renders cancel button', () => {
+ render();
+
+ expect(screen.getByText('Cancel')).toBeOnTheScreen();
+ });
+
+ it('renders flip button', () => {
+ render();
+
+ expect(screen.getByText('Flip')).toBeOnTheScreen();
+ });
+
+ it('calls handleFlipPosition when flip button is pressed', async () => {
+ render();
+
+ fireEvent.press(screen.getByText('Flip'));
+
+ await waitFor(() => {
+ expect(mockHandleFlipPosition).toHaveBeenCalledWith(mockLongPosition);
+ });
+ });
+
+ it('calls onClose when cancel button is pressed', () => {
+ const mockOnClose = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Cancel'));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onConfirm after successful flip', async () => {
+ const mockOnConfirm = jest.fn();
+ const mockOnClose = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Flip'));
+
+ await waitFor(() => {
+ expect(mockOnConfirm).toHaveBeenCalled();
+ });
+ });
+
+ it('displays the position size correctly for short position', () => {
+ render();
+
+ // Math.abs(-2.5) = 2.5
+ expect(screen.getByText('2.5 ETH')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx
new file mode 100644
index 000000000000..6db237c6e636
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx
@@ -0,0 +1,284 @@
+import React, { useCallback, useMemo, useRef } from 'react';
+import { View, ActivityIndicator } from 'react-native';
+import { strings } from '../../../../../../locales/i18n';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import BottomSheetFooter, {
+ ButtonsAlignment,
+} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter';
+import Text, {
+ TextColor,
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../../../component-library/components/Icons/Icon';
+import {
+ ButtonSize,
+ ButtonVariants,
+} from '../../../../../component-library/components/Buttons/Button';
+import type { PerpsFlipPositionConfirmSheetProps } from './PerpsFlipPositionConfirmSheet.types';
+import createStyles from './PerpsFlipPositionConfirmSheet.styles';
+import { useTheme } from '../../../../../util/theme';
+import { TraceName } from '../../../../../util/trace';
+import {
+ usePerpsOrderFees,
+ usePerpsRewards,
+ usePerpsMeasurement,
+} from '../../hooks';
+import { usePerpsFlipPosition } from '../../hooks/usePerpsFlipPosition';
+import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream';
+import {
+ formatPerpsFiat,
+ PRICE_RANGES_MINIMAL_VIEW,
+} from '../../utils/formatUtils';
+import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
+import PerpsFeesDisplay from '../PerpsFeesDisplay';
+import RewardsAnimations, {
+ RewardAnimationState,
+} from '../../../Rewards/components/RewardPointsAnimation';
+
+const PerpsFlipPositionConfirmSheet: React.FC<
+ PerpsFlipPositionConfirmSheetProps
+> = ({ position, sheetRef: externalSheetRef, onClose, onConfirm }) => {
+ const theme = useTheme();
+ const styles = createStyles(theme);
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ // Measure bottom sheet display
+ usePerpsMeasurement({ traceName: TraceName.PerpsFlipPositionSheet });
+
+ // Determine current and opposite direction
+ const currentDirection = parseFloat(position.size) > 0 ? 'long' : 'short';
+ const oppositeDirection = currentDirection === 'long' ? 'short' : 'long';
+ const positionSize = Math.abs(parseFloat(position.size));
+
+ // Get current price
+ const prices = usePerpsLivePrices({
+ symbols: [position.coin],
+ throttleMs: 1000,
+ });
+ const currentPrice = prices[position.coin];
+ const price = parseFloat(currentPrice?.price || '0');
+ const markPrice = parseFloat(currentPrice?.markPrice || '0');
+
+ // Calculate USD amount for fee estimation
+ const usdAmount = useMemo(
+ () => (positionSize * (markPrice || price)).toString(),
+ [positionSize, markPrice, price],
+ );
+
+ // Get top of book for maker/taker fee determination
+ const topOfBook = usePerpsTopOfBook({ symbol: position.coin });
+
+ // Calculate estimated fees
+ const feeResults = usePerpsOrderFees({
+ orderType: 'market',
+ amount: usdAmount,
+ coin: position.coin,
+ isClosing: false,
+ direction: oppositeDirection,
+ currentAskPrice: topOfBook?.bestAsk
+ ? Number.parseFloat(topOfBook.bestAsk)
+ : undefined,
+ currentBidPrice: topOfBook?.bestBid
+ ? Number.parseFloat(topOfBook.bestBid)
+ : undefined,
+ });
+
+ const hasValidAmount = parseFloat(usdAmount) > 0;
+
+ // Get rewards state
+ const rewardsState = usePerpsRewards({
+ feeResults,
+ hasValidAmount,
+ isFeesLoading: feeResults.isLoadingMetamaskFee,
+ orderAmount: usdAmount,
+ });
+
+ // Determine reward animation state
+ let rewardAnimationState = RewardAnimationState.Idle;
+ if (feeResults.isLoadingMetamaskFee) {
+ rewardAnimationState = RewardAnimationState.Loading;
+ } else if (rewardsState.hasError) {
+ rewardAnimationState = RewardAnimationState.ErrorState;
+ }
+
+ // Define close handler first to avoid hoisting issues
+ const handleCloseInternal = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onClose?.();
+ });
+ } else {
+ onClose?.();
+ }
+ }, [externalSheetRef, sheetRef, onClose]);
+
+ // Use flip position hook for handling position reversal
+ const { handleFlipPosition, isFlipping } = usePerpsFlipPosition({
+ onSuccess: () => {
+ handleCloseInternal();
+ onConfirm?.();
+ },
+ });
+
+ const handleReverse = useCallback(async () => {
+ await handleFlipPosition(position);
+ }, [position, handleFlipPosition]);
+
+ const footerButtons = useMemo(
+ () => [
+ {
+ label: strings('perps.flip_position.cancel'),
+ onPress: handleCloseInternal,
+ variant: ButtonVariants.Secondary,
+ size: ButtonSize.Lg,
+ disabled: isFlipping,
+ },
+ {
+ label: isFlipping
+ ? strings('perps.flip_position.flipping')
+ : strings('perps.flip_position.flip'),
+ onPress: handleReverse,
+ variant: ButtonVariants.Primary,
+ size: ButtonSize.Lg,
+ disabled: isFlipping || !hasValidAmount,
+ danger: true,
+ },
+ ],
+ [handleCloseInternal, handleReverse, isFlipping, hasValidAmount],
+ );
+
+ return (
+
+
+
+ {strings('perps.flip_position.title')}
+
+
+
+
+ {isFlipping ? (
+
+
+
+ {strings('perps.flip_position.flipping')}
+
+
+ ) : (
+ <>
+ {/* Grouped Details: Direction and Est. Size */}
+
+ {/* Direction Display */}
+
+
+
+ {strings('perps.flip_position.direction')}
+
+
+
+ {currentDirection === 'long'
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label')}
+
+
+
+ {oppositeDirection === 'long'
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label')}
+
+
+
+
+
+ {/* Est. Size */}
+
+
+
+ {strings('perps.flip_position.est_size')}
+
+
+ {positionSize} {getPerpsDisplaySymbol(position.coin)}
+
+
+
+
+
+ {/* Fees */}
+
+
+ {strings('perps.order.fees')}
+
+
+
+
+ {/* Est. Points */}
+ {rewardsState.shouldShowRewardsRow &&
+ rewardsState.estimatedPoints !== undefined &&
+ rewardsState.accountOptedIn && (
+
+
+ {strings('perps.estimated_points')}
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default PerpsFlipPositionConfirmSheet;
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts
new file mode 100644
index 000000000000..4258b23124aa
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts
@@ -0,0 +1,9 @@
+import type { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import type { Position } from '../../controllers/types';
+
+export interface PerpsFlipPositionConfirmSheetProps {
+ position: Position;
+ sheetRef?: React.RefObject;
+ onClose?: () => void;
+ onConfirm?: () => void;
+}
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts
new file mode 100644
index 000000000000..242b97ef28e2
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts
@@ -0,0 +1,2 @@
+export { default } from './PerpsFlipPositionConfirmSheet';
+export * from './PerpsFlipPositionConfirmSheet.types';
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts
index 79891385bac3..916c39ed6fce 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts
@@ -1,22 +1,32 @@
import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
-const styleSheet = () =>
+const styleSheet = (params: { theme: Theme }) =>
StyleSheet.create({
- statisticsGrid: {
- gap: 24,
+ container: {
+ gap: 16,
},
- statisticsRow: {
+ header: {
flexDirection: 'row',
- justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: 8,
},
- statisticsItem: {
- flex: 1,
- borderRadius: 8,
+ statsRowsContainer: {
+ gap: 1,
},
- statisticsLabelContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
+ statsRow: {
+ padding: 12,
+ backgroundColor: params.theme.colors.background.section,
+ },
+ statsRowFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ },
+ statsRowLast: {
+ padding: 12,
+ backgroundColor: params.theme.colors.background.section,
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
fundingRateContainer: {
flexDirection: 'row',
@@ -26,6 +36,16 @@ const styleSheet = () =>
fundingCountdown: {
marginLeft: 2,
},
+ labelWithIcon: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ dexTag: {
+ backgroundColor: params.theme.colors.background.default,
+ borderWidth: 1,
+ borderColor: params.theme.colors.border.default,
+ },
});
export default styleSheet;
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx
index 5d39a414dbd2..e3ccfab3b9df 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx
@@ -97,11 +97,8 @@ describe('PerpsMarketStatisticsCard', () => {
,
);
- // Check 24h high/low row
- expect(getByText('perps.market.24h_high')).toBeOnTheScreen();
- expect(getByText('perps.market.24h_low')).toBeOnTheScreen();
- expect(getByText('$50,000.00')).toBeOnTheScreen();
- expect(getByText('$45,000.00')).toBeOnTheScreen();
+ // Check stats title
+ expect(getByText('perps.market.stats')).toBeOnTheScreen();
// Check volume and open interest row
expect(getByText('perps.market.24h_volume')).toBeOnTheScreen();
@@ -112,6 +109,9 @@ describe('PerpsMarketStatisticsCard', () => {
// Check funding rate row
expect(getByText('perps.market.funding_rate')).toBeOnTheScreen();
expect(getByText('0.0125%')).toBeOnTheScreen();
+
+ // Check oracle price label
+ expect(getByText('perps.market.oracle_price')).toBeOnTheScreen();
});
it('displays positive funding rate in success color', () => {
@@ -191,19 +191,6 @@ describe('PerpsMarketStatisticsCard', () => {
expect(mockOnTooltipPress).toHaveBeenCalledWith('funding_rate');
});
- it('renders with correct test IDs for all statistics', () => {
- const { getByTestId } = render(
- ,
- );
-
- // Check all test IDs are present
- expect(getByTestId('perps-statistics-high-24h')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-low-24h')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-volume-24h')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-open-interest')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-funding-rate')).toBeOnTheScreen();
- });
-
it('handles edge case with very small funding rate values', () => {
const smallFundingStats = {
...mockMarketStats,
@@ -241,9 +228,7 @@ describe('PerpsMarketStatisticsCard', () => {
,
);
- // Verify all values are displayed
- expect(getByText('$50,000.00')).toBeOnTheScreen(); // high24h
- expect(getByText('$45,000.00')).toBeOnTheScreen(); // low24h
+ // Verify all values are displayed (component now shows volume, open interest, funding rate, oracle price)
expect(getByText('$1,234,567.89')).toBeOnTheScreen(); // volume24h
expect(getByText('$987,654.32')).toBeOnTheScreen(); // openInterest
expect(getByText('0.0125%')).toBeOnTheScreen(); // fundingRate
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx
index 445173a3a94d..106627da95a4 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx
@@ -10,14 +10,20 @@ import Text, {
TextColor,
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
+import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow';
import { useStyles } from '../../../../hooks/useStyles';
import styleSheet from './PerpsMarketStatisticsCard.styles';
import type { PerpsMarketStatisticsCardProps } from './PerpsMarketStatisticsCard.types';
import { PerpsMarketDetailsViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import FundingCountdown from '../FundingCountdown';
import { usePerpsLivePrices } from '../../hooks/stream';
-import { formatFundingRate } from '../../utils/formatUtils';
+import {
+ formatFundingRate,
+ formatPerpsFiat,
+ PRICE_RANGES_UNIVERSAL,
+} from '../../utils/formatUtils';
import { FUNDING_RATE_CONFIG } from '../../constants/perpsConfig';
+import Tag from '../../../../../component-library/components/Tags/Tag';
const PerpsMarketStatisticsCard: React.FC = ({
symbol,
@@ -25,6 +31,7 @@ const PerpsMarketStatisticsCard: React.FC = ({
onTooltipPress,
nextFundingTime,
fundingIntervalHours,
+ dexName,
}) => {
const { styles } = useStyles(styleSheet, {});
@@ -34,8 +41,10 @@ const PerpsMarketStatisticsCard: React.FC = ({
throttleMs: 2000, // Update every 2 seconds for funding rate
});
- // Get live funding rate from WebSocket subscription
+ // Get live funding rate and oracle price from WebSocket subscription
const liveFunding = symbol ? livePrices[symbol]?.funding : undefined;
+ // Use markPrice (oracle/mark price) for oracle price display, not price (mid price)
+ const liveOraclePrice = symbol ? livePrices[symbol]?.markPrice : undefined;
// Compute funding rate value and display once
const fundingRateData = useMemo(() => {
@@ -72,112 +81,151 @@ const PerpsMarketStatisticsCard: React.FC = ({
};
}, [liveFunding, marketStats.fundingRate]);
- return (
-
- {/* Row 1: 24h High/Low */}
-
-
-
- {strings('perps.market.24h_low')}
-
-
- {marketStats.low24h}
-
-
-
-
- {strings('perps.market.24h_high')}
-
-
- {marketStats.high24h}
-
-
+ // Render funding rate value with countdown
+ const fundingValueContent = useMemo(
+ () => (
+
+
+ {fundingRateData.displayText}
+
+
+ ),
+ [
+ fundingRateData,
+ nextFundingTime,
+ fundingIntervalHours,
+ styles.fundingRateContainer,
+ styles.fundingCountdown,
+ ],
+ );
- {/* Row 2: Volume and Open Interest */}
-
-
-
- {strings('perps.market.24h_volume')}
-
-
- {marketStats.volume24h}
-
-
-
-
-
- {strings('perps.market.open_interest')}
-
- onTooltipPress('open_interest')}>
-
-
-
-
- {marketStats.openInterest}
-
-
+ return (
+
+ {/* Header with title and DEX badge */}
+
+
+ {strings('perps.market.stats')}
+
+ {dexName && }
- {/* Row 3: Funding Rate */}
-
-
-
-
- {strings('perps.market.funding_rate')}
-
- onTooltipPress('funding_rate')}>
-
-
-
-
-
- {fundingRateData.displayText}
-
-
-
-
+ {/* Stats rows with card background */}
+
+ {/* 24h volume */}
+
+
+ {/* Open interest with tooltip */}
+
+
+ {strings('perps.market.open_interest')}
+
+ onTooltipPress('open_interest')}
+ testID="perps-market-details-open-interest-info-icon"
+ >
+
+
+
+ ),
+ }}
+ value={{
+ label: {
+ text: marketStats.openInterest,
+ variant: TextVariant.BodyMD,
+ color: TextColor.Default,
+ },
+ }}
+ style={styles.statsRow}
+ />
+
+ {/* Funding rate with tooltip and countdown */}
+
+
+ {strings('perps.market.funding_rate')}
+
+ onTooltipPress('funding_rate')}
+ testID="perps-market-details-funding-rate-info-icon"
+ >
+
+
+
+ ),
+ }}
+ value={{
+ label: fundingValueContent,
+ }}
+ style={styles.statsRow}
+ />
+
+ {/* Oracle price (markPrice) - last row without bottom border */}
+
);
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts
index 3bc02b12ff5f..17d4c81b4ea9 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts
@@ -16,4 +16,8 @@ export interface PerpsMarketStatisticsCardProps {
* Funding interval in hours (optional, market-specific)
*/
fundingIntervalHours?: number;
+ /**
+ * DEX name for HIP-3 markets (e.g., "XYZ", "Hyperliquid")
+ */
+ dexName?: string;
}
diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx
index 536bcdd17edb..5e6d1b4d3ae3 100644
--- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx
@@ -5,6 +5,7 @@ import React, {
useRef,
useMemo,
} from 'react';
+import { useNavigation, NavigationProp } from '@react-navigation/native';
import Text, {
TextVariant,
TextColor,
@@ -25,9 +26,17 @@ import PerpsMarketStatisticsCard from '../PerpsMarketStatisticsCard';
import PerpsPositionCard from '../PerpsPositionCard';
import { PerpsMarketTabsProps, PerpsTabId } from './PerpsMarketTabs.types';
import styleSheet from './PerpsMarketTabs.styles';
-import type { Position, Order } from '../../controllers/types';
+import type {
+ Position,
+ Order,
+ PerpsNavigationParamList,
+} from '../../controllers/types';
import { usePerpsMarketStats } from '../../hooks/usePerpsMarketStats';
-import { usePerpsLivePositions, usePerpsLiveOrders } from '../../hooks/stream';
+import {
+ usePerpsLivePositions,
+ usePerpsLiveOrders,
+ usePerpsLivePrices,
+} from '../../hooks/stream';
import type { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip';
import {
@@ -40,23 +49,24 @@ import Engine from '../../../../../core/Engine';
import { getOrderDirection } from '../../utils/orderUtils';
import usePerpsToasts from '../../hooks/usePerpsToasts';
import { OrderDirection } from '../../types/perps-types';
+import Routes from '../../../../../constants/navigation/Routes';
// Tab content component for Position tab
interface PositionTabContentProps {
tabLabel: string;
position: Position | null;
- expanded: boolean;
showIcon: boolean;
- onTooltipPress: (contentKey: PerpsTooltipContentKey) => void;
- onTpslCountPress: (tabId: string) => void;
+ onAutoClosePress?: () => void;
+ onMarginPress?: () => void;
+ onSharePress?: () => void;
}
const PositionTabContent: React.FC = ({
position,
- expanded,
showIcon,
- onTooltipPress,
- onTpslCountPress,
+ onAutoClosePress,
+ onMarginPress,
+ onSharePress,
}) => {
const { styles } = useStyles(styleSheet, {});
@@ -70,10 +80,10 @@ const PositionTabContent: React.FC = ({
);
@@ -210,6 +220,7 @@ const PerpsMarketTabs: React.FC = ({
activeSLOrderId,
}) => {
const { styles } = useStyles(styleSheet, {});
+ const navigation = useNavigation>();
const hasUserInteracted = useRef(false);
const hasSetInitialTab = useRef(false);
const tabsListRef = useRef(null);
@@ -224,6 +235,12 @@ const PerpsMarketTabs: React.FC = ({
});
const { orders: allOrders } = usePerpsLiveOrders({ throttleMs: 0 });
+ // Subscribe to live prices for current position price
+ const livePrices = usePerpsLivePrices({
+ symbols: symbol ? [symbol] : [],
+ throttleMs: 1000,
+ });
+
const position = useMemo(
() => positions.find((p) => p.coin === symbol) || null,
[positions, symbol],
@@ -233,6 +250,19 @@ const PerpsMarketTabs: React.FC = ({
[allOrders, symbol],
);
+ // Get current price for the symbol
+ const currentPrice = useMemo(() => {
+ const priceData = livePrices[symbol];
+ if (priceData?.price) {
+ return parseFloat(priceData.price);
+ }
+ // Fallback to position entry price if available
+ if (position?.entryPrice) {
+ return parseFloat(position.entryPrice);
+ }
+ return 0;
+ }, [livePrices, symbol, position]);
+
// State to track which orders are being cancelled for UI display
const [cancellingOrderIds, setCancellingOrderIds] = useState>(
new Set(),
@@ -288,6 +318,40 @@ const PerpsMarketTabs: React.FC = ({
}
}, [unfilledOrders, successfullyCancelledOrderIds]);
+ // Position management callbacks
+ const handleAutoClosePress = useCallback(() => {
+ if (!position) return;
+
+ navigation.navigate(Routes.PERPS.TPSL, {
+ asset: position.coin,
+ currentPrice,
+ position,
+ initialTakeProfitPrice: position.takeProfitPrice,
+ initialStopLossPrice: position.stopLossPrice,
+ onConfirm: async () => {
+ // TP/SL is set directly on the position, no need to handle here
+ // The position will update via WebSocket
+ },
+ });
+ }, [position, currentPrice, navigation]);
+
+ const handleMarginPress = useCallback(() => {
+ if (!position) return;
+
+ navigation.navigate(Routes.PERPS.SELECT_ADJUST_MARGIN_ACTION, {
+ position,
+ });
+ }, [position, navigation]);
+
+ const handleSharePress = useCallback(() => {
+ if (!position) return;
+
+ navigation.navigate(Routes.PERPS.PNL_HERO_CARD, {
+ position,
+ marketPrice: currentPrice.toString(),
+ });
+ }, [position, currentPrice, navigation]);
+
const tabs = React.useMemo(() => {
const dynamicTabs = [];
@@ -552,35 +616,18 @@ const PerpsMarketTabs: React.FC = ({
],
);
- // Helper to switch tabs programmatically by tabId (for backwards compatibility)
- const handleTabSwitchByTabId = useCallback(
- (tabId: string) => {
- // Build current available tabs in same order as tabsToRender
- const availableTabIds: PerpsTabId[] = [];
- if (position) availableTabIds.push('position');
- if (sortedUnfilledOrders.length > 0) availableTabIds.push('orders');
- availableTabIds.push('statistics'); // Always available
-
- const targetIndex = availableTabIds.indexOf(tabId as PerpsTabId);
- if (targetIndex >= 0 && tabsListRef.current) {
- tabsListRef.current.goToTabIndex(targetIndex);
- }
- },
- [position, sortedUnfilledOrders.length],
- );
-
// Define tab props objects (similar to wallet's tokensTabProps, perpsTabProps pattern)
const positionTabProps = useMemo(
() => ({
key: 'position-tab',
tabLabel: strings('perps.market.position'),
position,
- expanded: true,
showIcon: true,
- onTooltipPress: handleTooltipPress,
- onTpslCountPress: handleTabSwitchByTabId,
+ onAutoClosePress: handleAutoClosePress,
+ onMarginPress: handleMarginPress,
+ onSharePress: handleSharePress,
}),
- [position, handleTooltipPress, handleTabSwitchByTabId],
+ [position, handleAutoClosePress, handleMarginPress, handleSharePress],
);
const ordersTabProps = useMemo(
diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts
index 02e3a86ddfbe..f5bbe0b6e961 100644
--- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts
+++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts
@@ -13,17 +13,24 @@ const styleSheet = (params: { theme: Theme }) => {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- marginBottom: 12,
+ marginBottom: 16,
+ },
+ listContainer: {
+ gap: 1,
},
tradeItem: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: 12,
- borderBottomWidth: 1,
- borderBottomColor: colors.border.muted,
+ padding: 12,
+ backgroundColor: colors.background.section,
+ },
+ tradeItemFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
},
- lastTradeItem: {
- borderBottomWidth: 0,
+ tradeItemLast: {
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
leftSection: {
flex: 1,
diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
index 6d126ff8ce3e..0594fe731208 100644
--- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
@@ -88,11 +88,16 @@ const PerpsMarketTradesList: React.FC = ({
const renderItem = useCallback(
(props: { item: PerpsTransaction; index: number }) => {
const { item, index } = props;
+ const isFirstItem = index === 0;
const isLastItem = index === trades.length - 1;
return (
handleTradePress(item)}
activeOpacity={0.7}
>
@@ -132,7 +137,7 @@ const PerpsMarketTradesList: React.FC = ({
// Render header section
const renderHeader = () => (
-
+
{strings('perps.market.recent_trades')}
{!isLoading && trades.length > 0 && (
@@ -164,12 +169,14 @@ const PerpsMarketTradesList: React.FC = ({
}
return (
- `${item.id || index}`}
- scrollEnabled={false}
- />
+
+ `${item.id || index}`}
+ scrollEnabled={false}
+ />
+
);
};
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts
new file mode 100644
index 000000000000..6edc4683cb7f
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts
@@ -0,0 +1,38 @@
+import { StyleSheet } from 'react-native';
+import { Theme } from '../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ contentContainer: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ },
+ actionItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 16,
+ },
+ actionItemBorder: {
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border.muted,
+ },
+ actionIconContainer: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: 12,
+ },
+ iconColor: {
+ color: colors.icon.default,
+ },
+ actionTextContainer: {
+ flex: 1,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx
new file mode 100644
index 000000000000..0bab0b9bb286
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx
@@ -0,0 +1,329 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsModifyActionSheet from './PerpsModifyActionSheet';
+import type { Position } from '../../controllers/types';
+
+// Mock dependencies
+jest.mock('../../../../../component-library/hooks', () => ({
+ useStyles: () => ({
+ styles: {
+ contentContainer: {},
+ actionItem: {},
+ actionItemBorder: {},
+ actionIconContainer: {},
+ actionTextContainer: {},
+ iconColor: { color: '#000000' },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => {
+ const translations: Record = {
+ 'perps.modify.title': 'Modify Position',
+ 'perps.modify.add_to_position': 'Add to Position',
+ 'perps.modify.add_to_position_description': 'Increase your position size',
+ 'perps.modify.reduce_position': 'Reduce Position',
+ 'perps.modify.reduce_position_description': 'Decrease your position size',
+ 'perps.modify.flip_position': 'Flip Position',
+ 'perps.modify.flip_position_description':
+ 'Reverse your position direction',
+ };
+ return translations[key] || key;
+ }),
+}));
+
+// 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(
+ (
+ { children, testID }: { children: React.ReactNode; testID?: string },
+ _ref: unknown,
+ ) => ReactModule.createElement(View, { testID }, children),
+ ),
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockBottomSheetHeader({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ );
+ };
+ },
+);
+
+// Mock Icon component
+jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
+ __esModule: true,
+ default: function MockIcon() {
+ return null;
+ },
+ IconName: {
+ Add: 'Add',
+ Minus: 'Minus',
+ SwapHorizontal: 'SwapHorizontal',
+ },
+ IconSize: {
+ Md: 'Md',
+ },
+}));
+
+// Mock Text component
+jest.mock('../../../../../component-library/components/Texts/Text', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockText({ children }: { children: React.ReactNode }) {
+ return ReactModule.createElement(Text, null, children);
+ },
+ TextVariant: {
+ HeadingMD: 'HeadingMD',
+ BodyMDBold: 'BodyMDBold',
+ BodySM: 'BodySM',
+ },
+ TextColor: {
+ Alternative: 'Alternative',
+ },
+ };
+});
+
+// Mock Box component
+jest.mock('@metamask/design-system-react-native', () => ({
+ Box: function MockBox({ children }: { children: React.ReactNode }) {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return ReactModule.createElement(View, null, children);
+ },
+}));
+
+describe('PerpsModifyActionSheet', () => {
+ const mockOnClose = jest.fn();
+ const mockOnActionSelect = jest.fn();
+
+ const mockPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the modify position title', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Modify Position')).toBeOnTheScreen();
+ });
+
+ it('renders add to position option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Add to Position')).toBeOnTheScreen();
+ expect(screen.getByText('Increase your position size')).toBeOnTheScreen();
+ });
+
+ it('renders reduce position option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Reduce Position')).toBeOnTheScreen();
+ expect(screen.getByText('Decrease your position size')).toBeOnTheScreen();
+ });
+
+ it('renders flip position option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Flip Position')).toBeOnTheScreen();
+ expect(
+ screen.getByText('Reverse your position direction'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onActionSelect with add_to_position when add option is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add to Position'));
+
+ expect(mockOnActionSelect).toHaveBeenCalledWith('add_to_position');
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onActionSelect with reduce_position when reduce option is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Reduce Position'));
+
+ expect(mockOnActionSelect).toHaveBeenCalledWith('reduce_position');
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onActionSelect with flip_position when flip option is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Flip Position'));
+
+ expect(mockOnActionSelect).toHaveBeenCalledWith('flip_position');
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onClose when action is selected', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add to Position'));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('renders with testID when provided', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('test-modify-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders action items with correct testIDs', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId('modify-sheet-add_to_position'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId('modify-sheet-reduce_position'),
+ ).toBeOnTheScreen();
+ expect(screen.getByTestId('modify-sheet-flip_position')).toBeOnTheScreen();
+ });
+
+ it('works with null position', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Modify Position')).toBeOnTheScreen();
+ });
+
+ it('renders with isVisible true by default', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('modify-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders with external sheetRef', () => {
+ const mockSheetRef = { current: null };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('modify-sheet')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx
new file mode 100644
index 000000000000..c6b2460961d7
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx
@@ -0,0 +1,145 @@
+import React, { useRef, useEffect, useCallback } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import { useStyles } from '../../../../../component-library/hooks';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import Icon, {
+ IconSize,
+ IconName,
+} from '../../../../../component-library/components/Icons/Icon';
+import { Box } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../locales/i18n';
+import styleSheet from './PerpsModifyActionSheet.styles';
+import type { ModifyAction } from './PerpsModifyActionSheet.types';
+import type { Position } from '../../controllers/types';
+
+interface ActionOption {
+ action: ModifyAction;
+ label: string;
+ description: string;
+ iconName: IconName;
+}
+
+interface PerpsModifyActionSheetProps {
+ isVisible?: boolean;
+ onClose: () => void;
+ position: Position | null;
+ onActionSelect: (action: ModifyAction) => void;
+ sheetRef?: React.RefObject;
+ testID?: string;
+}
+
+const PerpsModifyActionSheet: React.FC = ({
+ isVisible = true,
+ onClose,
+ position,
+ onActionSelect,
+ sheetRef: externalSheetRef,
+ testID,
+}) => {
+ const { styles } = useStyles(styleSheet, {});
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ // Get direction labels for the position
+ const isLong = position?.size ? parseFloat(position.size) > 0 : true;
+ const direction = isLong
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label');
+ const fromDirection = direction.toLowerCase();
+ const toDirection = isLong
+ ? strings('perps.order.short_label').toLowerCase()
+ : strings('perps.order.long_label').toLowerCase();
+
+ useEffect(() => {
+ if (isVisible && !externalSheetRef) {
+ sheetRef.current?.onOpenBottomSheet();
+ }
+ }, [isVisible, externalSheetRef, sheetRef]);
+
+ const actionOptions: ActionOption[] = [
+ {
+ action: 'add_to_position',
+ label: strings('perps.modify.add_to_position'),
+ description: strings('perps.modify.add_to_position_description', {
+ direction: fromDirection,
+ }),
+ iconName: IconName.Add,
+ },
+ {
+ action: 'reduce_position',
+ label: strings('perps.modify.reduce_position'),
+ description: strings('perps.modify.reduce_position_description', {
+ direction: fromDirection,
+ }),
+ iconName: IconName.Minus,
+ },
+ {
+ action: 'flip_position',
+ label: strings('perps.modify.flip_position'),
+ description: strings('perps.modify.flip_position_description', {
+ fromDirection,
+ toDirection,
+ }),
+ iconName: IconName.SwapHorizontal,
+ },
+ ];
+
+ const handleActionPress = useCallback(
+ (action: ModifyAction) => {
+ onActionSelect(action);
+ onClose();
+ },
+ [onActionSelect, onClose],
+ );
+
+ return (
+
+
+
+ {strings('perps.modify.title')}
+
+
+
+ {actionOptions.map((option, index) => (
+ handleActionPress(option.action)}
+ testID={`${testID}-${option.action}`}
+ >
+
+
+
+
+ {option.label}
+
+ {option.description}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default PerpsModifyActionSheet;
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts
new file mode 100644
index 000000000000..84f086526836
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts
@@ -0,0 +1,4 @@
+export type ModifyAction =
+ | 'add_to_position'
+ | 'reduce_position'
+ | 'flip_position';
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts b/app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts
new file mode 100644
index 000000000000..402c1b69efbe
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts
@@ -0,0 +1,2 @@
+export { default } from './PerpsModifyActionSheet';
+export * from './PerpsModifyActionSheet.types';
diff --git a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
index 78a5c1d2b72d..e1afe76b2eec 100644
--- a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
@@ -20,32 +20,35 @@ import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
import type { OrderType } from '../../controllers/types';
interface PerpsOrderTypeBottomSheetProps {
- isVisible: boolean;
+ isVisible?: boolean;
onClose: () => void;
onSelect: (orderType: OrderType) => void;
- currentOrderType: OrderType;
+ currentOrderType?: OrderType;
asset?: string;
direction?: 'long' | 'short';
+ sheetRef?: React.RefObject;
}
const PerpsOrderTypeBottomSheet: React.FC = ({
- isVisible,
+ isVisible = true,
onClose,
onSelect,
currentOrderType,
asset = 'BTC',
direction = 'long',
+ sheetRef: externalSheetRef,
}) => {
const { colors } = useTheme();
const styles = createStyles(colors);
- const bottomSheetRef = useRef(null);
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
const { track } = usePerpsEventTracking();
useEffect(() => {
- if (isVisible) {
- bottomSheetRef.current?.onOpenBottomSheet();
+ if (isVisible && !externalSheetRef) {
+ sheetRef.current?.onOpenBottomSheet();
}
- }, [isVisible]);
+ }, [isVisible, externalSheetRef, sheetRef]);
const orderTypes = [
{
@@ -86,9 +89,9 @@ const PerpsOrderTypeBottomSheet: React.FC = ({
return (
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts
index 58496e378f4d..984da75000f5 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts
@@ -6,107 +6,153 @@ const styleSheet = (params: { theme: Theme }) => {
const { colors } = theme;
return StyleSheet.create({
- // Legacy container for backward compatibility
container: {
- backgroundColor: colors.background.section,
- borderRadius: 12,
- marginVertical: 6,
- },
- // Container styles for different states
- expandedContainer: {
- backgroundColor: colors.background.section,
+ backgroundColor: colors.background.default,
borderRadius: 12,
- padding: 16,
- marginVertical: 8,
- },
- collapsedContainer: {
- borderRadius: 8,
- paddingVertical: 12,
- marginVertical: 2, // Reduced spacing between cards
},
header: {
flexDirection: 'row',
- justifyContent: 'space-between',
alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 16,
},
- headerExpanded: {
- marginBottom: 16, // Extra spacing for expanded cards before the divider
+ pnlSection: {
+ flexDirection: 'row',
+ gap: 8,
+ marginBottom: 8,
},
- // Icon section styles
- perpIcon: {
- width: 40,
- height: 40,
- borderRadius: 20,
- marginRight: 12,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
+ pnlCard: {
+ flex: 1,
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
+ gap: 4,
+ },
+ pnlCardLeft: {
+ // Left card styling if different
},
- tpslCountPress: {
- textDecorationLine: 'underline',
- textDecorationStyle: 'dotted',
+ pnlCardRight: {
+ // Right card styling if different
},
- headerLeft: {
+ sizeMarginRow: {
+ flexDirection: 'row',
+ gap: 8,
+ marginBottom: 8,
+ },
+ sizeContainer: {
flex: 1,
- alignItems: 'flex-start',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
},
- headerRight: {
+ sizeLeftContent: {
flex: 1,
- alignItems: 'flex-end',
+ gap: 4,
},
- headerRow: {
+ sizeLabelRow: {
flexDirection: 'row',
alignItems: 'center',
+ gap: 4,
},
- // Right accessory styles
- rightAccessory: {
- marginLeft: 12,
+ marginContainer: {
+ flex: 1,
+ flexDirection: 'row',
alignItems: 'center',
- justifyContent: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
},
- body: {
- borderTopWidth: 1,
- borderTopColor: colors.border.muted,
- paddingVertical: 16,
- marginBottom: 4,
+ marginLeftContent: {
+ flex: 1,
+ gap: 4,
+ },
+ marginLabelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
},
- bodyRow: {
+ autoCloseSection: {
flexDirection: 'row',
+ alignItems: 'center',
justifyContent: 'space-between',
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
marginBottom: 12,
},
- bodyRowLast: {
- marginBottom: 0,
- },
- bodyItem: {
+ autoCloseTextContainer: {
flex: 1,
- alignItems: 'flex-start',
+ gap: 4,
+ },
+ autoCloseButton: {
+ borderRadius: 8,
+ },
+ iconButton: {
+ backgroundColor: colors.background.muted,
+ borderRadius: 8,
+ },
+ iconButtonContainer: {
+ height: '100%',
+ alignItems: 'flex-end',
+ },
+ toggleContainer: {
+ marginLeft: 16,
+ },
+ toggle: {
+ width: 48,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: colors.background.alternative,
+ padding: 2,
+ justifyContent: 'center',
+ },
+ toggleEnabled: {
+ backgroundColor: colors.primary.default,
+ },
+ toggleThumb: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: colors.background.default,
+ shadowColor: colors.shadow.default,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 2,
+ elevation: 2,
},
- bodyItemLabel: {
- marginBottom: 4,
+ toggleThumbEnabled: {
+ alignSelf: 'flex-end',
},
- footer: {
+ detailsSection: {
+ gap: 1,
+ marginTop: 20,
+ },
+ detailsTitle: {
+ marginBottom: 16,
+ },
+ detailRow: {
+ padding: 12,
flexDirection: 'row',
justifyContent: 'space-between',
- gap: 12,
+ alignItems: 'center',
+ backgroundColor: colors.background.section,
},
- footerButton: {
- flex: 1,
+ detailRowFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
},
- fundingCostLabelRightMargin: {
- marginRight: 4,
+ detailRowLast: {
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
- fundingCostLabelFlex: {
- display: 'flex',
+ liquidationPriceValue: {
flexDirection: 'row',
alignItems: 'center',
},
- shareButton: {
- alignSelf: 'center',
- backgroundColor: colors.background.muted,
- height: 40,
- width: 40,
- },
});
};
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx
index cfff1a1496a9..53b09c500600 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx
@@ -1,14 +1,11 @@
import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react-native';
-import { useNavigation } from '@react-navigation/native';
-import Routes from '../../../../../constants/navigation/Routes';
+import { render, screen } from '@testing-library/react-native';
import { PerpsPositionCardSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
import PerpsPositionCard from './PerpsPositionCard';
import type { Position } from '../../controllers/types';
jest.mock('@react-navigation/native', () => ({
- useNavigation: jest.fn(),
useFocusEffect: jest.fn(),
}));
@@ -32,10 +29,13 @@ jest.mock('../../../../../../locales/i18n', () => ({
if (key === 'perps.position.card.tpsl_count_single' && params?.count) {
return `${params.count} order`;
}
- if (key === 'perps.market.long_lowercase') {
+ if (key === 'perps.market.long_lowercase' || key === 'perps.market.long') {
return 'long';
}
- if (key === 'perps.market.short_lowercase') {
+ if (
+ key === 'perps.market.short_lowercase' ||
+ key === 'perps.market.short'
+ ) {
return 'short';
}
return key;
@@ -172,10 +172,6 @@ jest.mock('../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip', () => ({
}));
describe('PerpsPositionCard', () => {
- const mockNavigation = {
- navigate: jest.fn(),
- };
-
const mockPosition: Position = {
coin: 'ETH',
size: '2.5',
@@ -211,7 +207,6 @@ describe('PerpsPositionCard', () => {
beforeEach(() => {
jest.clearAllMocks();
- (useNavigation as jest.Mock).mockReturnValue(mockNavigation);
mockUseTheme.mockReturnValue(mockTheme);
// Reset the PnL calculation mock to default value
const { calculatePnLPercentageFromUnrealized } = jest.requireMock(
@@ -264,44 +259,62 @@ describe('PerpsPositionCard', () => {
describe('Component Rendering', () => {
it('renders position card with all sections', () => {
- // Act - Render expanded to show all sections
- render();
+ // Arrange
+ const currentPrice = 2000;
+
+ // Act
+ render(
+ ,
+ );
// Assert - Header section
- expect(screen.getByText(/10x\s+long/)).toBeOnTheScreen();
- expect(screen.getByText('2.5 ETH')).toBeOnTheScreen(); // Trailing zero removed
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$5,000')).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.HEADER),
+ ).toBeOnTheScreen();
- // Assert - Body section - using string keys since strings() mock returns keys
+ // Assert - P&L section
expect(
- screen.getByText('perps.position.card.entry_price'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.PNL_CARD),
).toBeOnTheScreen();
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$2,000')).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.funding_cost'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.PNL_VALUE),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.RETURN_CARD),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.RETURN_VALUE),
+ ).toBeOnTheScreen();
+
+ // Assert - Size/Margin row
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.SIZE_CONTAINER),
).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.liquidation_price'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.SIZE_VALUE),
).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.take_profit'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.MARGIN_CONTAINER),
).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.stop_loss'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.MARGIN_VALUE),
).toBeOnTheScreen();
- expect(screen.getByText('perps.position.card.margin')).toBeOnTheScreen();
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, max 2 decimals for $100-$1k, trailing zeros removed
- expect(screen.getByText('$500')).toBeOnTheScreen();
- // Assert - Footer section
+ // Assert - Auto-close section
expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.AUTO_CLOSE_TOGGLE),
).toBeOnTheScreen();
+
+ // Assert - Details section
expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.DETAILS_SECTION),
).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.DIRECTION_VALUE),
+ ).toHaveTextContent(/long\s+10x/);
});
it('renders SHORT position correctly', () => {
@@ -315,16 +328,29 @@ describe('PerpsPositionCard', () => {
render();
// Assert
- expect(screen.getByText('short')).toBeOnTheScreen();
- expect(screen.getByText(/2\.5.*ETH/)).toBeOnTheScreen(); // Should show absolute value (trailing zero removed)
+ const directionValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.DIRECTION_VALUE,
+ );
+ expect(directionValue).toHaveTextContent(/short\s+10x/);
+ const sizeValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.SIZE_VALUE,
+ );
+ expect(sizeValue).toHaveTextContent(/2\.5.*ETH/); // Should show absolute value
});
it('renders with PnL data', () => {
// Act
render();
- // Assert - ROE is 12.5 * 100 = 1250%
- expect(screen.getByText(/\+\$250\.00.*\+1250\.0%/)).toBeOnTheScreen();
+ // Assert
+ const pnlValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.PNL_VALUE,
+ );
+ expect(pnlValue).toHaveTextContent(/\+\$250\.00/);
+ const returnValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.RETURN_VALUE,
+ );
+ expect(returnValue).toHaveTextContent(/\+1250\.00%/); // ROE is 12.5 * 100 = 1250%
});
it('handles missing PnL percentage data', () => {
@@ -338,7 +364,10 @@ describe('PerpsPositionCard', () => {
render();
// Assert - Should show 0% when ROE is missing
- expect(screen.getByText(/\+\$250\.00.*\+0\.0%/)).toBeOnTheScreen();
+ const returnValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.RETURN_VALUE,
+ );
+ expect(returnValue).toHaveTextContent(/\+0\.00%/);
});
it('handles missing liquidation price', () => {
@@ -356,134 +385,9 @@ describe('PerpsPositionCard', () => {
screen.getByText(PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY),
).toBeOnTheScreen();
});
-
- it('renders with icon when showIcon is true and not expanded', () => {
- // Act - Render collapsed with showIcon
- render(
- ,
- );
-
- // Assert - PerpsTokenLogo should be rendered
- expect(screen.getByTestId('perps-token-logo')).toBeOnTheScreen();
- });
-
- it('does not render icon when showIcon is false', () => {
- // Act - Render collapsed without showIcon
- render(
- ,
- );
-
- // Assert - PerpsTokenLogo should not be rendered
- expect(screen.queryByTestId('perps-token-logo')).not.toBeOnTheScreen();
- });
-
- it('does not render icon when expanded even if showIcon is true', () => {
- // Act - Render expanded with showIcon (should not show icon)
- render();
-
- // Assert - PerpsTokenLogo should not be rendered in expanded mode
- expect(screen.queryByTestId('perps-token-logo')).not.toBeOnTheScreen();
- });
- });
-
- describe('User Interactions', () => {
- it('navigates to market details when card is pressed', () => {
- // Act - render with expanded=false to make card clickable
- render();
- fireEvent.press(screen.getByTestId('PerpsPositionCard'));
-
- // Assert
- expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
- screen: Routes.PERPS.MARKET_DETAILS,
- params: {
- market: expect.any(Object),
- initialTab: 'position',
- },
- });
- });
-
- it('opens close position bottom sheet when close button is pressed and user is eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return true;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Verify bottom sheet is not visible initially
- expect(screen.queryByText('perps.close_position.title')).toBeNull();
-
- // Press close button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- );
-
- // Assert - The bottom sheet should be rendered
- // Note: The actual bottom sheet content might be mocked, so we check for its presence
- expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- ).toBeDefined();
- });
-
- it('opens TP/SL bottom sheet when edit button is pressed and user is eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return true;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Verify bottom sheet is not visible initially
- expect(screen.queryByText('perps.tpsl.title')).toBeNull();
-
- // Press edit button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Assert - The TP/SL bottom sheet should be opened
- // Note: The actual bottom sheet content might be mocked, so we check for its presence
- expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- ).toBeDefined();
- });
});
describe('Data Formatting', () => {
- it('formats leverage correctly', () => {
- // Arrange
- const highLeveragePosition = {
- ...mockPosition,
- leverage: { type: 'cross' as const, value: 100 },
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText(/100x\s+long/)).toBeOnTheScreen();
- });
-
it('formats position size correctly for different coin', () => {
// Arrange
const btcPosition = {
@@ -514,70 +418,7 @@ describe('PerpsPositionCard', () => {
});
});
- describe('Position Direction Logic', () => {
- it('correctly identifies LONG position', () => {
- // Arrange
- const longPosition = {
- ...mockPosition,
- size: '1.5',
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText('long')).toBeOnTheScreen();
- });
-
- it('correctly identifies SHORT position', () => {
- // Arrange
- const shortPosition = {
- ...mockPosition,
- size: '-1.5',
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText('short')).toBeOnTheScreen();
- });
-
- it('handles zero position size as LONG', () => {
- // Arrange
- const zeroPosition = {
- ...mockPosition,
- size: '0',
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText('long')).toBeOnTheScreen(); // Zero is >= 0, so it's long
- });
- });
-
describe('Edge Cases', () => {
- it('handles missing price change data gracefully', () => {
- // Arrange
- const positionWithZeroPnl = {
- ...mockPosition,
- unrealizedPnl: '0.00',
- returnOnEquity: '0',
- };
- const { calculatePnLPercentageFromUnrealized } = jest.requireMock(
- '../../utils/pnlCalculations',
- );
- calculatePnLPercentageFromUnrealized.mockReturnValueOnce(0);
-
- // Act
- render();
-
- // Assert - ROE is shown as 0.0% (not 0.00%)
- expect(screen.getByText(/\$0\.00.*\+0\.0%/)).toBeOnTheScreen();
- });
-
it('handles position with empty liquidation price', () => {
// Arrange
const positionWithEmptyLiquidation = {
@@ -593,221 +434,6 @@ describe('PerpsPositionCard', () => {
screen.getByText(PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY),
).toBeOnTheScreen();
});
-
- it('renders all body items in correct order', () => {
- // Act - Render with expanded=true to show body items
- render();
-
- // Assert - Check that all 6 body items are present
- const bodyLabels = [
- 'perps.position.card.entry_price',
- 'perps.position.card.funding_cost',
- 'perps.position.card.liquidation_price',
- 'perps.position.card.take_profit',
- 'perps.position.card.stop_loss',
- 'perps.position.card.margin',
- ];
-
- bodyLabels.forEach((label) => {
- expect(screen.getByText(label)).toBeOnTheScreen();
- });
- });
- });
-
- describe('Hook Integration', () => {
- // Tests removed - loadPositions no longer exists with WebSocket streaming
- // Positions update automatically via WebSocket subscriptions
-
- it('returns early from handleCardPress when isLoading is true', () => {
- // Arrange
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [
- {
- name: 'ETH',
- symbol: 'ETH',
- priceDecimals: 2,
- sizeDecimals: 4,
- maxLeverage: 50,
- minSize: 0.01,
- sizeIncrement: 0.01,
- },
- ],
- error: null,
- isLoading: true, // Set loading to true
- });
-
- // Act
- render();
- fireEvent.press(screen.getByTestId('PerpsPositionCard'));
-
- // Assert - navigation should not be called
- expect(mockNavigation.navigate).not.toHaveBeenCalled();
- });
-
- it('returns early from handleCardPress when error exists', () => {
- // Arrange
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [
- {
- name: 'ETH',
- symbol: 'ETH',
- priceDecimals: 2,
- sizeDecimals: 4,
- maxLeverage: 50,
- minSize: 0.01,
- sizeIncrement: 0.01,
- },
- ],
- error: 'Failed to fetch markets',
- isLoading: false,
- });
-
- // Act
- render();
- fireEvent.press(screen.getByTestId('PerpsPositionCard'));
-
- // Assert - navigation should not be called
- expect(mockNavigation.navigate).not.toHaveBeenCalled();
- });
- });
-
- describe('Bottom Sheet Interactions', () => {
- it('navigates to TP/SL screen when edit button is pressed', () => {
- // Act
- render();
-
- // Open the TP/SL screen via navigation
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Assert - Should navigate to TP/SL screen
- expect(mockNavigation.navigate).toHaveBeenCalledWith(
- expect.stringContaining('TPSL'),
- expect.objectContaining({
- position: mockPosition,
- }),
- );
- });
-
- it('navigates to close position screen when close button is pressed', () => {
- // Act
- render();
-
- // Press the close button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- );
-
- // Assert - should navigate to close position screen
- expect(mockNavigation.navigate).toHaveBeenCalledWith(
- Routes.PERPS.CLOSE_POSITION,
- { position: mockPosition },
- );
- });
-
- it('does not show close button when card is collapsed', () => {
- // Act
- render(
- ,
- );
-
- // Assert - close button should not be visible in collapsed view
- expect(
- screen.queryByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- ).toBeNull();
- });
-
- it('shows geo block modal when edit TP/SL button is pressed and user is not eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return false;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Press edit button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Assert - Geo block tooltip should be shown
- expect(screen.getByText('Geo Block Tooltip')).toBeOnTheScreen();
- // Assert - Navigation should not be called
- expect(mockNavigation.navigate).not.toHaveBeenCalled();
- });
-
- it('shows geo block modal when close position button is pressed and user is not eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return false;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Press close button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- );
-
- // Assert - Geo block tooltip should be shown
- expect(screen.getByText('Geo Block Tooltip')).toBeOnTheScreen();
- // Assert - Close position bottom sheet should not be shown
- expect(
- screen.queryByTestId('perps-close-position-bottomsheet'),
- ).toBeNull();
- });
-
- it('closes geo block modal when onClose is called', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return false;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Press edit button to show geo block modal
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Verify modal is shown
- expect(screen.getByText('Geo Block Tooltip')).toBeOnTheScreen();
-
- // Press the geo block tooltip to close it
- fireEvent.press(
- screen.getByTestId('perps-position-card-geo-block-tooltip'),
- );
-
- // Assert - Geo block tooltip should be closed
- expect(screen.queryByText('Geo Block Tooltip')).not.toBeOnTheScreen();
- });
});
describe('Cumulative Funding Display', () => {
@@ -942,256 +568,16 @@ describe('PerpsPositionCard', () => {
});
});
- describe('TP/SL Count Functionality', () => {
- it('displays take profit count when multiple TP orders exist', () => {
- const positionWithMultipleTP = {
- ...mockPosition,
- takeProfitCount: 3,
- takeProfitPrice: undefined, // No single price when multiple orders
- stopLossCount: 0,
- };
-
- render();
-
- expect(screen.getByText('3 orders')).toBeOnTheScreen();
- });
-
- it('displays stop loss count when multiple SL orders exist', () => {
- const positionWithMultipleSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 2,
- stopLossPrice: undefined, // No single price when multiple orders
- };
-
- render();
-
- expect(screen.getByText('2 orders')).toBeOnTheScreen();
- });
-
- it('displays single TP price when only one TP order exists with price', () => {
- const positionWithSingleTP = {
- ...mockPosition,
- takeProfitCount: 1,
- takeProfitPrice: '2500.00',
- stopLossCount: 0,
- };
-
- render();
-
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$2,500')).toBeOnTheScreen();
- expect(screen.queryByText(/1.*orders/)).not.toBeOnTheScreen();
- });
-
- it('displays single SL price when only one SL order exists with price', () => {
- const positionWithSingleSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 1,
- stopLossPrice: '1500.00',
- };
-
- render();
-
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$1,500')).toBeOnTheScreen();
- expect(screen.queryByText(/1.*orders/)).not.toBeOnTheScreen();
- });
-
- it('displays single TP count when only one TP order exists without price', () => {
- const positionWithSingleTPNoPrice = {
- ...mockPosition,
- takeProfitCount: 1,
- takeProfitPrice: undefined, // No price available
- stopLossCount: 0,
- stopLossPrice: undefined,
- };
-
- render(
- ,
- );
-
- expect(screen.getByText('1 order')).toBeOnTheScreen();
- // Should still show "Not Set" for stop loss since stopLossCount is 0
- const notSetTexts = screen.getAllByText('perps.position.card.not_set');
- expect(notSetTexts).toHaveLength(1); // Only for SL
- });
-
- it('displays single SL count when only one SL order exists without price', () => {
- const positionWithSingleSLNoPrice = {
- ...mockPosition,
- takeProfitCount: 0,
- takeProfitPrice: undefined,
- stopLossCount: 1,
- stopLossPrice: undefined, // No price available
- };
-
- render(
- ,
- );
-
- expect(screen.getByText('1 order')).toBeOnTheScreen();
- // Note: There should still be one "Not Set" for the TP field
- const notSetTexts = screen.getAllByText('perps.position.card.not_set');
- expect(notSetTexts).toHaveLength(1); // Only for TP
- });
-
- it('displays "Not Set" when no TP/SL orders exist', () => {
- const positionWithoutTPSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 0,
- takeProfitPrice: undefined,
- stopLossPrice: undefined,
- };
-
- render();
-
- // Should show "Not Set" for both TP and SL
- const notSetTexts = screen.getAllByText('perps.position.card.not_set');
- expect(notSetTexts).toHaveLength(2); // One for TP, one for SL
- });
- });
-
- describe('TP/SL Configuration Behavior', () => {
- it('renders count as clickable when position bound TP/SL is enabled and count > 1', () => {
- // Position bound TP/SL is always enabled in production (TP_SL_CONFIG.USE_POSITION_BOUND_TPSL = true)
-
- const positionWithMultipleTP = {
- ...mockPosition,
- takeProfitCount: 3,
- takeProfitPrice: undefined,
- stopLossCount: 0,
- };
-
- render();
-
- const tpCountText = screen.getByText('3 orders');
- // TouchableOpacity should be pressable
- expect(tpCountText).toBeTruthy();
- });
-
- it('handles navigation error gracefully when pressing TP/SL count', () => {
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [],
- error: 'Network error',
- isLoading: false,
- });
-
- const mockOnTpslCountPress = jest.fn();
- const positionWithMultipleTP = {
- ...mockPosition,
- takeProfitCount: 3,
- takeProfitPrice: undefined,
- stopLossCount: 0,
- };
-
- render(
- ,
- );
-
- const tpCountText = screen.getByText('3 orders');
- fireEvent.press(tpCountText);
-
- // Should not call onTpslCountPress due to error
- expect(mockOnTpslCountPress).not.toHaveBeenCalled();
- });
-
- it('handles missing market data gracefully when pressing TP/SL count', () => {
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [], // Empty markets array
- error: null,
- isLoading: false,
- });
-
- const mockOnTpslCountPress = jest.fn();
- const positionWithMultipleSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 2,
- stopLossPrice: undefined,
- };
-
- render(
- ,
- );
-
- const slCountText = screen.getByText('2 orders');
- fireEvent.press(slCountText);
-
- // Should not call onTpslCountPress due to missing market data
- expect(mockOnTpslCountPress).not.toHaveBeenCalled();
- });
- });
-
- describe('share button functionality', () => {
- it('renders share button when expanded is true', () => {
- render();
-
- const shareButton = screen.getByTestId(
- PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
- );
-
- expect(shareButton).toBeOnTheScreen();
- });
-
- it('does not render share button when expanded is false', () => {
- render();
+ describe('Share Button Functionality', () => {
+ it('does not render share button when onSharePress not provided', () => {
+ // Arrange & Act
+ render();
+ // Assert
const shareButton = screen.queryByTestId(
PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
);
-
expect(shareButton).toBeNull();
});
-
- it('navigates to PNL_HERO_CARD route when share button pressed', () => {
- const mockNavigate = jest.fn();
- (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
-
- render();
-
- const shareButton = screen.getByTestId(
- PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
- );
- fireEvent.press(shareButton);
-
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PERPS.PNL_HERO_CARD,
- expect.objectContaining({
- position: mockPosition,
- marketPrice: '2100.50',
- }),
- );
- });
-
- it('passes position and marketPrice to route params', () => {
- const mockNavigate = jest.fn();
- (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
-
- render();
-
- const shareButton = screen.getByTestId(
- PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
- );
- fireEvent.press(shareButton);
-
- expect(mockNavigate).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- position: mockPosition,
- marketPrice: '2100.50', // Live price from usePerpsLivePrices, not market data
- }),
- );
- });
});
});
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
index 33c96bbc9320..ad8455d62838 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
@@ -1,20 +1,14 @@
-import { useNavigation, type NavigationProp } from '@react-navigation/native';
-import React, { useCallback, useMemo, useState } from 'react';
-import { Modal, TouchableOpacity, View } from 'react-native';
-import { useSelector } from 'react-redux';
-import {
- PerpsMarketDetailsViewSelectorsIDs,
- PerpsPositionCardSelectorsIDs,
-} from '../../../../../../e2e/selectors/Perps/Perps.selectors';
+import React, { useState, useMemo } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import { PerpsPositionCardSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import { strings } from '../../../../../../locales/i18n';
-import Button, {
- ButtonSize,
- ButtonVariants,
- ButtonWidthTypes,
-} from '../../../../../component-library/components/Buttons/Button';
import ButtonIcon, {
ButtonIconSizes,
} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import Button, {
+ ButtonVariants,
+ ButtonSize,
+} from '../../../../../component-library/components/Buttons/Button';
import Icon, {
IconColor,
IconName,
@@ -25,20 +19,8 @@ import Text, {
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
import { useStyles } from '../../../../../component-library/hooks';
-import Routes from '../../../../../constants/navigation/Routes';
-import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger';
-import { PERPS_CONSTANTS, TP_SL_CONFIG } from '../../constants/perpsConfig';
-import type {
- PerpsNavigationParamList,
- Position,
- TPSLTrackingData,
-} from '../../controllers/types';
-import {
- usePerpsLivePrices,
- usePerpsMarkets,
- usePerpsTPSLUpdate,
-} from '../../hooks';
-import { selectPerpsEligibility } from '../../selectors/perpsController';
+import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
+import type { Position, Order } from '../../controllers/types';
import {
formatPerpsFiat,
formatPnl,
@@ -47,93 +29,70 @@ import {
PRICE_RANGES_UNIVERSAL,
} from '../../utils/formatUtils';
import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
-import { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip';
-import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip';
-import PerpsTokenLogo from '../PerpsTokenLogo';
import styleSheet from './PerpsPositionCard.styles';
+/**
+ * PerpsPositionCard Component
+ *
+ * Displays open position details with interactive controls for position management.
+ *
+ * @component
+ *
+ * @remarks
+ * **Callback Requirements by Context:**
+ * - **View-Only Mode** (no callbacks): Shows position data only, no interactive elements
+ * - **Interactive Mode** (with callbacks): Enables position management actions
+ *
+ * **Interactive Callbacks:**
+ * - `onAutoClosePress`: Required for TP/SL configuration - opens auto-close settings
+ * - `onMarginPress`: Required for margin adjustment - opens add/remove margin flow
+ * - `onSharePress`: Optional - enables sharing position P&L card
+ * - `onFlipPress`: Not currently used (flip handled via modify action sheet)
+ *
+ * @example
+ * // View-only mode
+ *
+ *
+ * @example
+ * // Interactive mode
+ * navigateToTPSL(position)}
+ * onMarginPress={() => setShowAdjustMarginSheet(true)}
+ * onSharePress={() => navigateToPnlShare(position)}
+ * />
+ */
interface PerpsPositionCardProps {
position: Position;
- expanded?: boolean;
+ orders?: Order[];
showIcon?: boolean;
- rightAccessory?: React.ReactNode;
- onPositionUpdate?: () => Promise;
- onTooltipPress?: (contentKey: PerpsTooltipContentKey) => void;
- onTpslCountPress?: (tabId: string) => void;
+ currentPrice?: number;
+ autoCloseEnabled?: boolean;
+ onAutoClosePress?: () => void;
+ onFlipPress?: () => void;
+ onMarginPress?: () => void;
+ onSharePress?: () => void;
}
const PerpsPositionCard: React.FC = ({
position,
- expanded = true, // Default to expanded for backward compatibility
- showIcon = false, // Default to not showing icon
- rightAccessory,
- onPositionUpdate,
- onTooltipPress,
- onTpslCountPress,
+ orders,
+ currentPrice,
+ autoCloseEnabled: _autoCloseEnabled = false,
+ onAutoClosePress,
+ onFlipPress: _onFlipPress,
+ onMarginPress,
+ onSharePress,
}) => {
const { styles } = useStyles(styleSheet, {});
- const navigation = useNavigation>();
-
- const [isEligibilityModalVisible, setIsEligibilityModalVisible] =
- useState(false);
-
- const [isTPSLCountWarningVisible, setIsTPSLCountWarningVisible] =
- useState(false);
-
- const isEligible = useSelector(selectPerpsEligibility);
-
- const { handleUpdateTPSL } = usePerpsTPSLUpdate({
- onSuccess: () => {
- // Positions update automatically via WebSocket
- // Call parent's position update callback if provided
- if (onPositionUpdate) {
- onPositionUpdate();
- }
- },
- });
+ const [showSizeInUSD, setShowSizeInUSD] = useState(false);
// Determine if position is long or short based on size
const isLong = parseFloat(position.size) >= 0;
const direction = isLong ? 'long' : 'short';
const absoluteSize = Math.abs(parseFloat(position.size));
- const { markets, error, isLoading } = usePerpsMarkets();
-
- const livePrices = usePerpsLivePrices({ symbols: [position.coin] });
-
- const marketData = useMemo(
- () => markets.find((market) => market.symbol === position.coin),
- [markets, position.coin],
- );
-
- const handleCardPress = async () => {
- if (isLoading || error) {
- DevLogger.log(
- 'Failed to redirect to market details. Error fetching market data: ',
- error,
- );
- return;
- }
-
- navigation.navigate(Routes.PERPS.ROOT, {
- screen: Routes.PERPS.MARKET_DETAILS,
- params: {
- market: marketData,
- initialTab: 'position',
- },
- });
- };
-
- const handleClosePress = () => {
- if (!isEligible) {
- setIsEligibilityModalVisible(true);
- return;
- }
-
- DevLogger.log('PerpsPositionCard: Navigating to close position screen');
- navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position });
- };
-
const pnlNum = parseFloat(position.unrealizedPnl);
// ROE is always stored as a decimal (e.g., 0.171 for 17.1%)
@@ -141,77 +100,6 @@ const PerpsPositionCard: React.FC = ({
const roeValue = parseFloat(position.returnOnEquity || '0');
const roe = isNaN(roeValue) ? 0 : roeValue * 100;
- const handleEditTPSL = useCallback(() => {
- if (!isEligible) {
- setIsEligibilityModalVisible(true);
- return;
- }
-
- if (!TP_SL_CONFIG.USE_POSITION_BOUND_TPSL) {
- if (
- (position.takeProfitCount > 0 || position.stopLossCount > 0) &&
- (!position.takeProfitPrice || !position.stopLossPrice)
- ) {
- setIsTPSLCountWarningVisible(true);
- return;
- }
- }
-
- DevLogger.log('PerpsPositionCard: Editing TPSL', { position });
-
- navigation.navigate(Routes.PERPS.TPSL, {
- asset: position.coin,
- position,
- initialTakeProfitPrice: position.takeProfitPrice,
- initialStopLossPrice: position.stopLossPrice,
- onConfirm: async (
- takeProfitPrice?: string,
- stopLossPrice?: string,
- trackingData?: TPSLTrackingData,
- ) => {
- await handleUpdateTPSL(
- position,
- takeProfitPrice,
- stopLossPrice,
- trackingData,
- );
- },
- });
- }, [
- isEligible,
- position,
- navigation,
- handleUpdateTPSL,
- setIsEligibilityModalVisible,
- setIsTPSLCountWarningVisible,
- ]);
-
- const handleSharePress = () => {
- navigation.navigate(Routes.PERPS.PNL_HERO_CARD, {
- position,
- marketPrice: livePrices[position.coin]?.price,
- });
- };
-
- const handleTpslCountPress = useCallback(async () => {
- if (isLoading || error) {
- DevLogger.log(
- 'Failed to redirect to orders tab. Error fetching market data: ',
- error,
- );
- return;
- }
-
- if (!marketData) {
- DevLogger.log('No market data available for navigation');
- return;
- }
-
- if (onTpslCountPress) {
- onTpslCountPress('orders');
- }
- }, [isLoading, error, marketData, onTpslCountPress]);
-
// Funding cost (cumulative since open) formatting logic
const fundingSinceOpenRaw = position.cumulativeFunding?.sinceOpen ?? '0';
const fundingSinceOpen = parseFloat(fundingSinceOpenRaw);
@@ -235,358 +123,359 @@ const PerpsPositionCard: React.FC = ({
ranges: PRICE_RANGES_MINIMAL_VIEW,
})}`;
- const positionTakeProfitCount = position.takeProfitCount || 0;
- const positionStopLossCount = position.stopLossCount || 0;
-
- // Shared helper function for rendering TP/SL text
- const renderTPSLText = useCallback(
- (
- _type: 'takeProfit' | 'stopLoss',
- count: number,
- price: string | undefined,
- ) => {
- if (TP_SL_CONFIG.USE_POSITION_BOUND_TPSL) {
- // Multiple orders - show count as clickable
- if (count > 1) {
- return (
-
-
- {strings('perps.position.card.tpsl_count_multiple', {
- count,
- })}
-
-
- );
- }
+ const handleSizeToggle = () => {
+ setShowSizeInUSD(!showSizeInUSD);
+ };
- // Single order with price - show price
- if (count === 1 && price) {
- return (
-
- {formatPerpsFiat(price, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })}
-
- );
- }
-
- // Single order without price - show count as clickable
- if (count === 1 && !price) {
- return (
-
-
- {strings('perps.position.card.tpsl_count_single', {
- count,
- })}
-
-
- );
- }
+ // Calculate liquidation distance percentage
+ const liquidationDistance = useMemo(() => {
+ if (!currentPrice || !position.liquidationPrice) return null;
+ const liqPrice = parseFloat(String(position.liquidationPrice));
+ if (liqPrice <= 0 || currentPrice <= 0) return null;
+ return (Math.abs(currentPrice - liqPrice) / currentPrice) * 100;
+ }, [currentPrice, position.liquidationPrice]);
+
+ // Compute whether TPSL is configured (for button label)
+ const hasTPSLConfigured = useMemo(() => {
+ // First, check position-level TP/SL (from separate trigger orders)
+ let takeProfitPrice = position.takeProfitPrice;
+ let stopLossPrice = position.stopLossPrice;
+
+ // If position-level TP/SL is undefined, check order-level TP/SL (from child orders)
+ if ((!takeProfitPrice || !stopLossPrice) && orders && orders.length > 0) {
+ const parentOrder = orders.find(
+ (order) =>
+ order.symbol === position.coin &&
+ !order.isTrigger &&
+ (order.takeProfitPrice || order.stopLossPrice),
+ );
- // No orders - show "Not Set"
- return (
-
- {strings('perps.position.card.not_set')}
-
- );
+ if (parentOrder) {
+ takeProfitPrice = takeProfitPrice || parentOrder.takeProfitPrice;
+ stopLossPrice = stopLossPrice || parentOrder.stopLossPrice;
}
+ }
- // if position bound TP/SL is disabled
- if (count > 0 && !price) {
- return (
-
-
- {count === 1
- ? strings('perps.position.card.tpsl_count_single', {
- count,
- })
- : strings('perps.position.card.tpsl_count_multiple', {
- count,
- })}
-
-
- );
- }
+ const hasTakeProfit = takeProfitPrice && parseFloat(takeProfitPrice) > 0;
+ const hasStopLoss = stopLossPrice && parseFloat(stopLossPrice) > 0;
+ return Boolean(hasTakeProfit || hasStopLoss);
+ }, [position.takeProfitPrice, position.stopLossPrice, position.coin, orders]);
- return (
-
- {price !== undefined && price !== null
- ? formatPerpsFiat(price, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })
- : strings('perps.position.card.not_set')}
-
- );
- },
- [handleTpslCountPress, styles.tpslCountPress],
- );
+ const handleAutoCloseButtonPress = () => {
+ if (onAutoClosePress) {
+ onAutoClosePress();
+ }
+ };
- const renderStopLossText = useMemo(
- () =>
- renderTPSLText('stopLoss', positionStopLossCount, position.stopLossPrice),
- [renderTPSLText, positionStopLossCount, position.stopLossPrice],
- );
+ return (
+
+ {/* Header Section */}
+
+
+ {strings('perps.position.card.position_title')}
+
+ {onSharePress && (
+
+ )}
+
+
+ {/* P&L Section - Two cards side by side */}
+
+
+
+ {strings('perps.position.card.pnl_label')}
+
+ = 0 ? TextColor.Success : TextColor.Error}
+ testID={PerpsPositionCardSelectorsIDs.PNL_VALUE}
+ >
+ {formatPnl(pnlNum)}
+
+
- const renderTakeProfitText = useMemo(
- () =>
- renderTPSLText(
- 'takeProfit',
- positionTakeProfitCount,
- position.takeProfitPrice,
- ),
- [renderTPSLText, positionTakeProfitCount, position.takeProfitPrice],
- );
+
+
+ {strings('perps.position.card.return_label')}
+
+ = 0 ? TextColor.Success : TextColor.Error}
+ testID={PerpsPositionCardSelectorsIDs.RETURN_VALUE}
+ >
+ {roe >= 0 ? '+' : ''}
+ {roe.toFixed(2)}%
+
+
+
+
+ {/* Size/Margin Row */}
+
+
+
+
+ {strings('perps.position.card.size_label')}
+
+
+ {showSizeInUSD && currentPrice
+ ? formatPerpsFiat(absoluteSize * currentPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })
+ : `${formatPositionSize(absoluteSize.toString())} ${getPerpsDisplaySymbol(position.coin)}`}
+
+
+
+
+
+
+
+
+
+
+ {strings('perps.position.card.margin_label')}
+
+
+ {formatPerpsFiat(position.marginUsed, {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ })}
+
+
+ {onMarginPress && (
+
+
+
+ )}
+
+
- return (
- <>
+ {/* Auto Close Section */}
- {/* Header - Always shown */}
-
- {/* Icon Section - Conditionally shown (only in collapsed mode) */}
- {showIcon && !expanded && (
-
-
-
- )}
+
+
+ {strings('perps.auto_close.title')}
+
+ {(() => {
+ // First, check position-level TP/SL (from separate trigger orders)
+ let takeProfitPrice = position.takeProfitPrice;
+ let stopLossPrice = position.stopLossPrice;
+
+ // If position-level TP/SL is undefined, check order-level TP/SL (from child orders)
+ if (
+ (!takeProfitPrice || !stopLossPrice) &&
+ orders &&
+ orders.length > 0
+ ) {
+ // Find the parent order for this position
+ // Parent orders: same symbol, not trigger orders, have TP/SL children
+ const parentOrder = orders.find(
+ (order) =>
+ order.symbol === position.coin &&
+ !order.isTrigger &&
+ (order.takeProfitPrice || order.stopLossPrice),
+ );
+
+ if (parentOrder) {
+ takeProfitPrice =
+ takeProfitPrice || parentOrder.takeProfitPrice;
+ stopLossPrice = stopLossPrice || parentOrder.stopLossPrice;
+ }
+ }
-
-
-
- {getPerpsDisplaySymbol(position.coin)} {position.leverage.value}
- x{' '}
-
- {direction === 'long'
- ? strings('perps.market.long_lowercase')
- : strings('perps.market.short_lowercase')}
-
-
-
-
-
- {formatPositionSize(absoluteSize.toString())}{' '}
- {getPerpsDisplaySymbol(position.coin)}
-
-
-
+ const hasTakeProfit =
+ takeProfitPrice && parseFloat(takeProfitPrice) > 0;
+ const hasStopLoss = stopLossPrice && parseFloat(stopLossPrice) > 0;
-
-
-
- {formatPerpsFiat(position.positionValue, {
- ranges: PRICE_RANGES_MINIMAL_VIEW,
- })}
-
-
-
- = 0 ? TextColor.Success : TextColor.Error}
- >
- {formatPnl(pnlNum)} ({roe >= 0 ? '+' : ''}
- {roe.toFixed(1)}%)
-
-
-
+ if (hasTakeProfit || hasStopLoss) {
+ const parts: string[] = [];
- {/* Right Accessory - Conditionally shown */}
- {rightAccessory && (
- {rightAccessory}
- )}
-
+ if (hasTakeProfit && takeProfitPrice) {
+ const tpPrice = formatPerpsFiat(parseFloat(takeProfitPrice), {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ });
+ parts.push(`${strings('perps.order.tp')} ${tpPrice}`);
+ }
- {/* Body - Only shown when expanded */}
- {expanded && (
-
-
-
-
- {strings('perps.position.card.entry_price')}
-
-
- {formatPerpsFiat(position.entryPrice, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })}
-
-
-
-
- {strings('perps.position.card.liquidation_price')}
-
-
- {position.liquidationPrice !== undefined &&
- position.liquidationPrice !== null
- ? formatPerpsFiat(position.liquidationPrice, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })
- : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY}
-
-
-
-
- {strings('perps.position.card.margin')}
-
-
- {formatPerpsFiat(position.marginUsed, {
- ranges: PRICE_RANGES_MINIMAL_VIEW,
- })}
-
-
-
+ if (hasStopLoss && stopLossPrice) {
+ const slPrice = formatPerpsFiat(parseFloat(stopLossPrice), {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ });
+ parts.push(`${strings('perps.order.sl')} ${slPrice}`);
+ }
-
-
-
- {strings('perps.position.card.take_profit')}
+ return (
+
+ {parts.join(', ')}
- <>{renderTakeProfitText}>
-
-
-
- {strings('perps.position.card.stop_loss')}
-
- <>{renderStopLossText}>
-
-
-
-
- {strings('perps.position.card.funding_cost')}
-
- {onTooltipPress && (
- onTooltipPress('funding_payments')}
- >
-
-
- )}
-
-
- {fundingDisplay}
-
-
-
-
- )}
+ );
+ }
- {/* Footer - Only shown when expanded */}
- {expanded && (
-
+ return (
+
+ {strings('perps.auto_close.description')}
+
+ );
+ })()}
+
+
+ {onAutoClosePress && (
+ )}
+
+
+
+ {/* Details Section - Always expanded */}
+
+
+ {strings('perps.position.card.details_title')}
+
+
+
+
+ {strings('perps.position.card.direction_label')}
+
+
+ {direction === 'long'
+ ? strings('perps.market.long')
+ : strings('perps.market.short')}{' '}
+ {position.leverage.value}x
+
+
+
+
+
+ {strings('perps.position.card.entry_label')}
+
+
+ {formatPerpsFiat(position.entryPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })}
+
+
+
+
+
+ {strings('perps.position.card.liquidation_price_label')}
+
+
+
+ {position.liquidationPrice !== undefined &&
+ position.liquidationPrice !== null
+ ? formatPerpsFiat(position.liquidationPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })
+ : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY}
+
+ {liquidationDistance !== null && (
+ <>
- {strings('perps.position.card.close_position')}
+ {' '}
+ {Math.round(liquidationDistance)}%
- }
- onPress={handleClosePress}
- style={styles.footerButton}
- testID={PerpsPositionCardSelectorsIDs.CLOSE_BUTTON}
- />
-
-
+
+ >
+ )}
- )}
-
- {isTPSLCountWarningVisible && (
- // Android Compatibility: Wrap the in a plain component to prevent rendering issues and freezing.
-
-
- setIsTPSLCountWarningVisible(false)}
- contentKey={'tpsl_count_warning'}
- buttonConfig={[
- {
- label: strings(
- 'perps.tooltips.tpsl_count_warning.got_it_button',
- ),
- onPress: () => setIsTPSLCountWarningVisible(false),
- variant: ButtonVariants.Secondary,
- size: ButtonSize.Lg,
- testID:
- PerpsPositionCardSelectorsIDs.TPSL_COUNT_WARNING_TOOLTIP_GOT_IT_BUTTON,
- },
- {
- label: strings(
- 'perps.tooltips.tpsl_count_warning.view_orders_button',
- ),
- onPress: () => handleTpslCountPress(),
- variant: ButtonVariants.Primary,
- size: ButtonSize.Lg,
- testID:
- PerpsPositionCardSelectorsIDs.TPSL_COUNT_WARNING_TOOLTIP_VIEW_ORDERS_BUTTON,
- },
- ]}
- />
-
- )}
- {isEligibilityModalVisible && (
- // Android Compatibility: Wrap the in a plain component to prevent rendering issues and freezing.
-
-
- setIsEligibilityModalVisible(false)}
- contentKey={'geo_block'}
- />
-
+
+
+
+ {strings('perps.position.card.funding_payments_label')}
+
+
+ {fundingDisplay}
+
- )}
- >
+
+
);
};
diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts
index 8220d30d9a3e..348952634b37 100644
--- a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts
+++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts
@@ -14,14 +14,26 @@ const styleSheet = (params: { theme: Theme }) => {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- marginBottom: 12,
+ marginBottom: 16,
+ paddingHorizontal: 16,
+ },
+ listContainer: {
+ gap: 1,
paddingHorizontal: 16,
},
activityItem: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: 12,
- paddingHorizontal: 16,
+ padding: 12,
+ backgroundColor: colors.background.section,
+ },
+ activityItemFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ },
+ activityItemLast: {
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
leftSection: {
flexDirection: 'row',
diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx
index 5b9852c644d1..90d8684156e3 100644
--- a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx
+++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx
@@ -67,11 +67,18 @@ const PerpsRecentActivityList: React.FC = ({
}, []);
const renderItem = useCallback(
- (props: { item: PerpsTransaction }) => {
- const { item } = props;
+ (props: { item: PerpsTransaction; index: number }) => {
+ const { item, index } = props;
+ const isFirstItem = index === 0;
+ const isLastItem = index === transactions.length - 1;
+
return (
handleTransactionPress(item)}
activeOpacity={0.7}
>
@@ -105,14 +112,20 @@ const PerpsRecentActivityList: React.FC = ({
);
},
- [styles, handleTransactionPress, iconSize, renderRightContent],
+ [
+ styles,
+ handleTransactionPress,
+ iconSize,
+ renderRightContent,
+ transactions.length,
+ ],
);
if (isLoading) {
return (
-
+
{strings('perps.home.recent_activity')}
@@ -125,7 +138,7 @@ const PerpsRecentActivityList: React.FC = ({
return (
-
+
{strings('perps.home.recent_activity')}
@@ -143,7 +156,7 @@ const PerpsRecentActivityList: React.FC = ({
return (
-
+
{strings('perps.home.recent_activity')}
@@ -153,12 +166,14 @@ const PerpsRecentActivityList: React.FC = ({
- `${item.id || index}`}
- scrollEnabled={false}
- />
+
+ `${item.id || index}`}
+ scrollEnabled={false}
+ />
+
);
};
diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx
index f600abf2af43..9af99bfad9d5 100644
--- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx
+++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx
@@ -173,7 +173,7 @@ export const PerpsTabControlBar: React.FC = ({
]}
>
diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts
index 5d1932614078..6c9dd36b07fb 100644
--- a/app/components/UI/Perps/constants/perpsConfig.ts
+++ b/app/components/UI/Perps/constants/perpsConfig.ts
@@ -268,6 +268,36 @@ export const CLOSE_POSITION_CONFIG = {
FALLBACK_TOKEN_DECIMALS: 18,
} as const;
+/**
+ * Margin adjustment configuration
+ * Controls behavior for adding/removing margin from positions
+ */
+export const MARGIN_ADJUSTMENT_CONFIG = {
+ // Risk thresholds for margin removal warnings
+ // Threshold values represent ratio of (price distance to liquidation) / (liquidation price)
+ // Values < 1.0 mean price is dangerously close to liquidation
+ LIQUIDATION_RISK_THRESHOLD: 1.2, // 20% buffer before liquidation - triggers danger state
+ LIQUIDATION_WARNING_THRESHOLD: 1.5, // 50% buffer before liquidation - triggers warning state
+
+ // Minimum margin adjustment amount (USD)
+ // Prevents dust adjustments and ensures meaningful position changes
+ MIN_ADJUSTMENT_AMOUNT: 1,
+
+ // Precision for margin calculations
+ // Ensures accurate decimal handling in margin/leverage calculations
+ CALCULATION_PRECISION: 6,
+
+ // Safety buffer for margin removal to account for HyperLiquid's transfer margin requirement
+ // HyperLiquid enforces: transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
+ // See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl
+ MARGIN_REMOVAL_SAFETY_BUFFER: 0.1,
+
+ // Fallback max leverage when market data is unavailable
+ // Conservative value to prevent over-removal of margin
+ // Most HyperLiquid assets support at least 50x leverage
+ FALLBACK_MAX_LEVERAGE: 50,
+} as const;
+
/**
* Data Lake API configuration
* Endpoints for reporting perps trading activity for notifications
diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx
index f03fb4fa4805..8688a24c6aa0 100644
--- a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx
+++ b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx
@@ -3,9 +3,11 @@ import {
usePerpsOrderForm,
UsePerpsOrderFormReturn,
} from '../hooks/usePerpsOrderForm';
-import { OrderType } from '../controllers/types';
+import { OrderType, Position } from '../controllers/types';
-interface PerpsOrderContextType extends UsePerpsOrderFormReturn {}
+interface PerpsOrderContextType extends UsePerpsOrderFormReturn {
+ existingPosition?: Position;
+}
const PerpsOrderContext = createContext(null);
@@ -16,6 +18,7 @@ interface PerpsOrderProviderProps {
initialAmount?: string;
initialLeverage?: number;
initialType?: OrderType;
+ existingPosition?: Position;
}
export const PerpsOrderProvider = ({
@@ -25,6 +28,7 @@ export const PerpsOrderProvider = ({
initialAmount,
initialLeverage,
initialType,
+ existingPosition,
}: PerpsOrderProviderProps) => {
const orderFormState = usePerpsOrderForm({
initialAsset,
@@ -35,7 +39,12 @@ export const PerpsOrderProvider = ({
});
return (
-
+
{children}
);
diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts
index 9d80caa108ce..d810e51351ad 100644
--- a/app/components/UI/Perps/controllers/PerpsController.test.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.test.ts
@@ -170,6 +170,8 @@ jest.mock('./services/TradingService', () => ({
closePosition: jest.fn(),
closePositions: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
},
}));
@@ -1945,6 +1947,138 @@ describe('PerpsController', () => {
},
);
});
+
+ it('updates margin successfully', async () => {
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ const mockUpdateResult = {
+ success: true,
+ };
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'updateMargin')
+ .mockResolvedValue(mockUpdateResult);
+
+ const result = await controller.updateMargin(updateMarginParams);
+
+ expect(result).toEqual(mockUpdateResult);
+ expect(TradingService.updateMargin).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: mockProvider,
+ coin: updateMarginParams.coin,
+ amount: '100',
+ context: expect.any(Object),
+ }),
+ );
+ });
+
+ it('handles updateMargin error', async () => {
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ const errorMessage = 'Insufficient balance';
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'updateMargin')
+ .mockRejectedValue(new Error(errorMessage));
+
+ await expect(controller.updateMargin(updateMarginParams)).rejects.toThrow(
+ errorMessage,
+ );
+ expect(TradingService.updateMargin).toHaveBeenCalled();
+ });
+
+ it('flips position successfully', async () => {
+ const mockPosition = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const flipPositionParams = {
+ coin: 'BTC',
+ position: mockPosition,
+ };
+
+ const mockFlipResult = {
+ success: true,
+ orderId: 'flip-123',
+ filledSize: '1.0',
+ averagePrice: '50000',
+ };
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'flipPosition')
+ .mockResolvedValue(mockFlipResult);
+
+ const result = await controller.flipPosition(flipPositionParams);
+
+ expect(result).toEqual(mockFlipResult);
+ expect(TradingService.flipPosition).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: mockProvider,
+ position: mockPosition,
+ context: expect.any(Object),
+ }),
+ );
+ });
+
+ it('handles flipPosition error', async () => {
+ const mockPosition = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const flipPositionParams = {
+ coin: 'BTC',
+ position: mockPosition,
+ };
+
+ const errorMessage = 'Insufficient balance for flip fees';
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'flipPosition')
+ .mockRejectedValue(new Error(errorMessage));
+
+ await expect(controller.flipPosition(flipPositionParams)).rejects.toThrow(
+ errorMessage,
+ );
+ expect(TradingService.flipPosition).toHaveBeenCalled();
+ });
});
describe('fee calculations', () => {
diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts
index 0bbf5f1c6f08..73047b8fe3b2 100644
--- a/app/components/UI/Perps/controllers/PerpsController.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.ts
@@ -57,6 +57,7 @@ import type {
EditOrderParams,
FeeCalculationParams,
FeeCalculationResult,
+ FlipPositionParams,
Funding,
GetAccountStateParams,
GetAvailableDexsParams,
@@ -68,6 +69,7 @@ import type {
LiquidationPriceParams,
LiveDataConfig,
MaintenanceMarginParams,
+ MarginResult,
MarketInfo,
Order,
OrderFill,
@@ -84,6 +86,7 @@ import type {
SubscribePricesParams,
SwitchProviderResult,
ToggleTestnetResult,
+ UpdateMarginParams,
UpdatePositionTPSLParams,
WithdrawParams,
WithdrawResult,
@@ -1234,6 +1237,43 @@ export class PerpsController extends BaseController<
});
}
+ /**
+ * Update margin for an existing position (add or remove)
+ */
+ async updateMargin(params: UpdateMarginParams): Promise {
+ const provider = this.getActiveProvider();
+ const { RewardsController, NetworkController } = Engine.context;
+
+ return TradingService.updateMargin({
+ provider,
+ coin: params.coin,
+ amount: params.amount,
+ context: this.createServiceContext('updateMargin', {
+ rewardsController: RewardsController,
+ networkController: NetworkController,
+ messenger: this.messenger,
+ }),
+ });
+ }
+
+ /**
+ * Flip position (reverse direction while keeping size and leverage)
+ */
+ async flipPosition(params: FlipPositionParams): Promise {
+ const provider = this.getActiveProvider();
+ const { RewardsController, NetworkController } = Engine.context;
+
+ return TradingService.flipPosition({
+ provider,
+ position: params.position,
+ context: this.createServiceContext('flipPosition', {
+ rewardsController: RewardsController,
+ networkController: NetworkController,
+ messenger: this.messenger,
+ }),
+ });
+ }
+
/**
* Simplified deposit method that prepares transaction for confirmation screen
* No complex state tracking - just sets a loading flag
diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
index af0f17adfb77..a57b7f05e9b9 100644
--- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
+++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
@@ -98,6 +98,7 @@ import type {
LiquidationPriceParams,
LiveDataConfig,
MaintenanceMarginParams,
+ MarginResult,
MarketInfo,
Order,
OrderFill,
@@ -2983,6 +2984,94 @@ export class HyperLiquidProvider implements IPerpsProvider {
}
}
+ /**
+ * Update margin for an existing position (add or remove)
+ *
+ * @param params - Margin adjustment parameters
+ * @param params.coin - Asset symbol (e.g., 'BTC', 'ETH')
+ * @param params.amount - Amount to adjust as string (positive = add, negative = remove)
+ * @returns Promise resolving to margin adjustment result
+ *
+ * Note: HyperLiquid uses micro-units (multiply by 1e6) for the ntli parameter.
+ * The SDK's updateIsolatedMargin requires:
+ * - asset: Asset ID (number)
+ * - isBuy: Position direction (true for long, false for short)
+ * - ntli: Amount in micro-units (amount * 1e6)
+ */
+ async updateMargin(params: {
+ coin: string;
+ amount: string;
+ }): Promise {
+ try {
+ DevLogger.log('Updating position margin:', params);
+
+ const { coin, amount } = params;
+
+ // Ensure provider is ready
+ await this.ensureReady();
+
+ // Get current position to determine direction
+ // Force fresh API data since we're about to mutate the position
+ const positions = await this.getPositions({ skipCache: true });
+ const position = positions.find((p) => p.coin === coin);
+
+ if (!position) {
+ throw new Error(`No position found for ${coin}`);
+ }
+
+ // Determine position direction
+ const isBuy = parseFloat(position.size) > 0; // true for long, false for short
+
+ // Get asset ID for the coin
+ const assetId = this.coinToAssetId.get(coin);
+ if (assetId === undefined) {
+ throw new Error(`Asset ID not found for ${coin}`);
+ }
+
+ // Convert amount to micro-units (HyperLiquid SDK requirement)
+ const amountFloat = parseFloat(amount);
+ const ntli = Math.floor(amountFloat * 1e6);
+
+ DevLogger.log('Margin adjustment details', {
+ coin,
+ assetId,
+ isBuy,
+ amount: amountFloat,
+ ntli,
+ });
+
+ // Call SDK to update isolated margin
+ const exchangeClient = this.clientService.getExchangeClient();
+ const result = await exchangeClient.updateIsolatedMargin({
+ asset: assetId,
+ isBuy,
+ ntli,
+ });
+
+ DevLogger.log('Margin update result:', result);
+
+ if (result.status !== 'ok') {
+ throw new Error(`Margin adjustment failed: ${JSON.stringify(result)}`);
+ }
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ Logger.error(
+ ensureError(error),
+ this.getErrorContext('updateMargin', {
+ coin: params.coin,
+ amount: params.amount,
+ }),
+ );
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
/**
* Get current positions with TP/SL prices
*
diff --git a/app/components/UI/Perps/controllers/services/TradingService.test.ts b/app/components/UI/Perps/controllers/services/TradingService.test.ts
index 0d85e3b7476f..84c3d2c72325 100644
--- a/app/components/UI/Perps/controllers/services/TradingService.test.ts
+++ b/app/components/UI/Perps/controllers/services/TradingService.test.ts
@@ -1645,4 +1645,365 @@ describe('TradingService', () => {
);
});
});
+
+ describe('updateMargin', () => {
+ it('updates margin successfully when adding margin', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ const result = await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(result).toEqual(mockResult);
+ expect(mockProvider.updateMargin).toHaveBeenCalledWith({
+ coin: 'BTC',
+ amount: '100',
+ });
+ });
+
+ it('updates margin successfully when removing margin', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ const result = await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '-50',
+ context: mockContext,
+ });
+
+ expect(result).toEqual(mockResult);
+ expect(mockProvider.updateMargin).toHaveBeenCalledWith({
+ coin: 'BTC',
+ amount: '-50',
+ });
+ });
+
+ it('throws error when provider does not support margin adjustment', async () => {
+ mockProvider.updateMargin = undefined as never;
+
+ await expect(
+ TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Provider does not support margin adjustment');
+ });
+
+ it('returns error when margin update fails', async () => {
+ const mockResult = { success: false, error: 'Insufficient balance' };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ const result = await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Insufficient balance');
+ });
+
+ it('tracks analytics on success', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('tracks analytics on failure with error message', async () => {
+ mockProvider.updateMargin = jest
+ .fn()
+ .mockRejectedValue(new Error('Network error'));
+
+ await expect(
+ TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Network error');
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('updates state on success', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(mockContext.stateManager?.update).toHaveBeenCalled();
+ });
+
+ it('creates trace for margin update', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(trace).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'PerpsUpdateMargin',
+ id: 'mock-trace-id',
+ }),
+ );
+ expect(endTrace).toHaveBeenCalled();
+ });
+ });
+
+ describe('flipPosition', () => {
+ const mockPosition: Position = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ liquidationPrice: '45000',
+ leverage: { type: 'cross', value: 10 },
+ marginUsed: '2500',
+ maxLeverage: 20,
+ positionValue: '25000',
+ returnOnEquity: '0.2',
+ unrealizedPnl: '5000',
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockAccountState = {
+ availableBalance: '10000',
+ equity: '15000',
+ marginUsed: '5000',
+ };
+
+ beforeEach(() => {
+ mockProvider.getAccountState = jest
+ .fn()
+ .mockResolvedValue(mockAccountState);
+ });
+
+ it('places order with 2x position size to flip position', async () => {
+ const mockResult: OrderResult = {
+ success: true,
+ orderId: 'flip-123',
+ filledSize: '1.0',
+ averagePrice: '50000',
+ };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ // Verify order placed with 2x position size (0.5 * 2 = 1.0)
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ coin: 'BTC',
+ size: '1',
+ }),
+ );
+ });
+
+ it('flips long position to short (isBuy=false)', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ // Long position (positive size)
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: { ...mockPosition, size: '0.5' },
+ context: mockContext,
+ });
+
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isBuy: false,
+ }),
+ );
+ });
+
+ it('flips short position to long (isBuy=true)', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ // Short position (negative size)
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: { ...mockPosition, size: '-0.5' },
+ context: mockContext,
+ });
+
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isBuy: true,
+ }),
+ );
+ });
+
+ it('returns error when insufficient balance for fees', async () => {
+ // Set very low available balance
+ mockProvider.getAccountState = jest.fn().mockResolvedValue({
+ ...mockAccountState,
+ availableBalance: '1', // $1 balance, insufficient for fees
+ });
+
+ await expect(
+ TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ }),
+ ).rejects.toThrow(/Insufficient balance for flip fees/);
+ });
+
+ it('throws error when account state cannot be retrieved', async () => {
+ mockProvider.getAccountState = jest.fn().mockResolvedValue(null);
+
+ await expect(
+ TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Failed to get account state');
+ });
+
+ it('returns error when order placement fails', async () => {
+ const mockResult: OrderResult = {
+ success: false,
+ error: 'Order rejected',
+ };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ const result = await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Order rejected');
+ });
+
+ it('tracks analytics on success', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('tracks analytics on failure', async () => {
+ mockProvider.placeOrder.mockRejectedValue(new Error('Network error'));
+
+ await expect(
+ TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Network error');
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('updates state on success', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(mockContext.stateManager?.update).toHaveBeenCalled();
+ });
+
+ it('creates trace for flip position', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(trace).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'PerpsFlipPosition',
+ id: 'mock-trace-id',
+ }),
+ );
+ expect(endTrace).toHaveBeenCalled();
+ });
+
+ it('uses correct order params including leverage', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith({
+ coin: 'BTC',
+ isBuy: false,
+ size: '1',
+ orderType: 'market',
+ leverage: 10,
+ currentPrice: 50000,
+ });
+ });
+ });
});
diff --git a/app/components/UI/Perps/controllers/services/TradingService.ts b/app/components/UI/Perps/controllers/services/TradingService.ts
index 466ef1057f4e..6fb25ca9d630 100644
--- a/app/components/UI/Perps/controllers/services/TradingService.ts
+++ b/app/components/UI/Perps/controllers/services/TradingService.ts
@@ -1548,4 +1548,233 @@ export class TradingService {
});
}
}
+
+ /**
+ * Update margin for an existing position (add or remove)
+ */
+ static async updateMargin(options: {
+ provider: IPerpsProvider;
+ coin: string;
+ amount: string;
+ context: ServiceContext;
+ }): Promise<{ success: boolean; error?: string }> {
+ const { provider, coin, amount, context } = options;
+ const traceId = uuidv4();
+ const startTime = performance.now();
+
+ try {
+ trace({
+ name: 'PerpsUpdateMargin' as TraceName,
+ id: traceId,
+ op: TraceOperation.PerpsPositionManagement,
+ tags: {
+ provider: context.tracingContext.provider,
+ coin,
+ isAdd: parseFloat(amount) > 0,
+ isTestnet: context.tracingContext.isTestnet,
+ },
+ });
+
+ // Call provider method
+ const result = await provider.updateMargin?.({ coin, amount });
+
+ if (!result) {
+ throw new Error('Provider does not support margin adjustment');
+ }
+
+ const completionDuration = performance.now() - startTime;
+
+ if (result.success) {
+ // Update state on success
+ if (context.stateManager) {
+ context.stateManager.update((state) => {
+ state.lastUpdateTimestamp = Date.now();
+ });
+ }
+
+ // Track success analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_RISK_MANAGEMENT,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED,
+ [PerpsEventProperties.ASSET]: coin,
+ [PerpsEventProperties.ACTION]:
+ parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin',
+ [PerpsEventProperties.MARGIN_USED]: Math.abs(parseFloat(amount)),
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+ }
+
+ endTrace({
+ name: 'PerpsUpdateMargin' as TraceName,
+ id: traceId,
+ data: { success: result.success, error: result.error || '' },
+ });
+
+ return result;
+ } catch (error) {
+ const completionDuration = performance.now() - startTime;
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+
+ Logger.error(
+ ensureError(error),
+ this.getErrorContext('updateMargin', { coin, amount }),
+ );
+
+ // Track failure analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_RISK_MANAGEMENT,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED,
+ [PerpsEventProperties.ASSET]: coin,
+ [PerpsEventProperties.ACTION]:
+ parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin',
+ [PerpsEventProperties.MARGIN_USED]: Math.abs(parseFloat(amount)),
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ [PerpsEventProperties.ERROR_MESSAGE]: errorMessage,
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+
+ endTrace({
+ name: 'PerpsUpdateMargin' as TraceName,
+ id: traceId,
+ data: { success: false, error: errorMessage },
+ });
+
+ throw error;
+ }
+ }
+
+ /**
+ * Flip position (reverse direction while keeping size and leverage)
+ */
+ static async flipPosition(options: {
+ provider: IPerpsProvider;
+ position: Position;
+ context: ServiceContext;
+ }): Promise {
+ const { provider, position, context } = options;
+ const traceId = uuidv4();
+ const startTime = performance.now();
+
+ try {
+ trace({
+ name: 'PerpsFlipPosition' as TraceName,
+ id: traceId,
+ op: TraceOperation.PerpsPositionManagement,
+ tags: {
+ provider: context.tracingContext.provider,
+ coin: position.coin,
+ isTestnet: context.tracingContext.isTestnet,
+ },
+ });
+
+ // Calculate flip parameters
+ const positionSize = Math.abs(parseFloat(position.size));
+ const isCurrentlyLong = parseFloat(position.size) > 0;
+ const oppositeDirection = !isCurrentlyLong;
+
+ // Validate available balance for fees
+ const accountState = await provider.getAccountState?.();
+ if (!accountState) {
+ throw new Error('Failed to get account state');
+ }
+
+ const availableBalance = parseFloat(accountState.availableBalance);
+
+ // Estimate fees (close + open, approximately 0.09% of notional)
+ // Flip requires 2x position size (1x to close, 1x to open opposite)
+ const entryPrice = parseFloat(position.entryPrice);
+ const flipSize = positionSize * 2;
+ const notionalValue = flipSize * entryPrice;
+ const estimatedFees = notionalValue * 0.0009;
+
+ if (estimatedFees > availableBalance) {
+ throw new Error(
+ `Insufficient balance for flip fees. Need $${estimatedFees.toFixed(2)}, have $${availableBalance.toFixed(2)}`,
+ );
+ }
+
+ // Create order params for flip
+ // Use 2x position size: 1x to close current position + 1x to open opposite position
+ const orderParams: OrderParams = {
+ coin: position.coin,
+ isBuy: oppositeDirection,
+ size: flipSize.toString(),
+ orderType: 'market',
+ leverage: position.leverage?.value,
+ currentPrice: entryPrice,
+ };
+
+ // Place flip order (HyperLiquid handles margin transfer automatically)
+ const result = await provider.placeOrder(orderParams);
+
+ const completionDuration = performance.now() - startTime;
+
+ if (result.success) {
+ // Update state on success
+ if (context.stateManager) {
+ context.stateManager.update((state) => {
+ state.lastUpdateTimestamp = Date.now();
+ });
+ }
+
+ // Track success analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED,
+ [PerpsEventProperties.ASSET]: position.coin,
+ [PerpsEventProperties.DIRECTION]: oppositeDirection
+ ? PerpsEventValues.DIRECTION.LONG
+ : PerpsEventValues.DIRECTION.SHORT,
+ [PerpsEventProperties.ORDER_TYPE]: 'market',
+ [PerpsEventProperties.LEVERAGE]: position.leverage?.value || 1,
+ [PerpsEventProperties.ORDER_SIZE]: positionSize,
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ [PerpsEventProperties.ACTION]: 'flip_position',
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+ }
+
+ endTrace({
+ name: 'PerpsFlipPosition' as TraceName,
+ id: traceId,
+ data: { success: result.success ?? false, error: result.error || '' },
+ });
+
+ return result;
+ } catch (error) {
+ const completionDuration = performance.now() - startTime;
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+
+ Logger.error(
+ ensureError(error),
+ this.getErrorContext('flipPosition', { coin: position.coin }),
+ );
+
+ // Track failure analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED,
+ [PerpsEventProperties.ASSET]: position.coin,
+ [PerpsEventProperties.ACTION]: 'flip_position',
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ [PerpsEventProperties.ERROR_MESSAGE]: errorMessage,
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+
+ endTrace({
+ name: 'PerpsFlipPosition' as TraceName,
+ id: traceId,
+ data: { success: false, error: errorMessage },
+ });
+
+ throw error;
+ }
+ }
}
diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts
index 334ac0ab26d3..ba69dc436940 100644
--- a/app/components/UI/Perps/controllers/types/index.ts
+++ b/app/components/UI/Perps/controllers/types/index.ts
@@ -216,6 +216,21 @@ export type ClosePositionsResult = {
}[];
};
+export type UpdateMarginParams = {
+ coin: string; // Asset symbol (e.g., 'BTC', 'ETH')
+ amount: string; // Amount to adjust as string (positive = add, negative = remove)
+};
+
+export type MarginResult = {
+ success: boolean;
+ error?: string;
+};
+
+export type FlipPositionParams = {
+ coin: string; // Asset symbol to flip
+ position: Position; // Current position to flip
+};
+
export interface InitializeResult {
success: boolean;
error?: string;
@@ -719,6 +734,7 @@ export interface IPerpsProvider {
closePosition(params: ClosePositionParams): Promise;
closePositions?(params: ClosePositionsParams): Promise; // Optional: batch close for protocols that support it
updatePositionTPSL(params: UpdatePositionTPSLParams): Promise;
+ updateMargin(params: UpdateMarginParams): Promise;
getPositions(params?: GetPositionsParams): Promise;
getAccountState(params?: GetAccountStateParams): Promise;
getMarkets(params?: GetMarketsParams): Promise;
diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts
index 7893243f03ec..3fb62cc13cf7 100644
--- a/app/components/UI/Perps/hooks/index.ts
+++ b/app/components/UI/Perps/hooks/index.ts
@@ -54,6 +54,7 @@ export { usePerpsRewardAccountOptedIn } from './usePerpsRewardAccountOptedIn';
export { usePerpsCloseAllCalculations } from './usePerpsCloseAllCalculations';
export { usePerpsCancelAllOrders } from './usePerpsCancelAllOrders';
export { usePerpsCloseAllPositions } from './usePerpsCloseAllPositions';
+export { usePositionManagement } from './usePositionManagement';
// Removed from barrel: usePerpsHomeActions imports Engine-dependent hooks
// Import directly: import { usePerpsHomeActions } from './hooks/usePerpsHomeActions';
export { useHasExistingPosition } from './useHasExistingPosition';
diff --git a/app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts b/app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts
new file mode 100644
index 000000000000..3023fc8f9bac
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts
@@ -0,0 +1,319 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePerpsFlipPosition } from './usePerpsFlipPosition';
+import type { Position } from '../controllers/types';
+
+const mockFlipPosition = jest.fn();
+const mockShowToast = jest.fn();
+const mockCaptureException = jest.fn();
+
+jest.mock('./usePerpsTrading', () => ({
+ usePerpsTrading: () => ({
+ flipPosition: mockFlipPosition,
+ }),
+}));
+
+jest.mock('./usePerpsToasts', () => ({
+ __esModule: true,
+ default: () => ({
+ showToast: mockShowToast,
+ PerpsToastOptions: {
+ orderManagement: {
+ market: {
+ confirmed: jest.fn((direction, amount, symbol) => ({
+ type: 'success',
+ direction,
+ amount,
+ symbol,
+ })),
+ creationFailed: jest.fn((error) => ({
+ type: 'error',
+ error,
+ })),
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('@sentry/react-native', () => ({
+ captureException: (error: Error, context: unknown) =>
+ mockCaptureException(error, context),
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({
+ DevLogger: {
+ log: jest.fn(),
+ },
+}));
+
+jest.mock('../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+describe('usePerpsFlipPosition', () => {
+ const mockLongPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockShortPosition: Position = {
+ ...mockLongPosition,
+ size: '-2.5',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns handleFlipPosition function and isFlipping state', () => {
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ expect(result.current.handleFlipPosition).toBeDefined();
+ expect(typeof result.current.handleFlipPosition).toBe('function');
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('sets isFlipping to true while flipping', async () => {
+ let resolveFlip: (value: { success: boolean }) => void;
+ mockFlipPosition.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveFlip = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ // Start the flip operation
+ act(() => {
+ result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ // Check that isFlipping is true during the operation
+ expect(result.current.isFlipping).toBe(true);
+
+ // Resolve the promise
+ await act(async () => {
+ resolveFlip({ success: true });
+ });
+
+ // Check that isFlipping is false after completion
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('calls flipPosition with correct parameters for long position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockFlipPosition).toHaveBeenCalledWith({
+ coin: 'ETH',
+ position: mockLongPosition,
+ });
+ });
+
+ it('calls flipPosition with correct parameters for short position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockShortPosition);
+ });
+
+ expect(mockFlipPosition).toHaveBeenCalledWith({
+ coin: 'ETH',
+ position: mockShortPosition,
+ });
+ });
+
+ it('shows success toast and calls onSuccess callback on successful flip', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+ const mockOnSuccess = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onSuccess: mockOnSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it('shows error toast and calls onError callback on failed flip', async () => {
+ mockFlipPosition.mockResolvedValue({
+ success: false,
+ error: 'Insufficient margin',
+ });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockOnError).toHaveBeenCalledWith('Insufficient margin');
+ });
+
+ it('shows default error message when no error provided', async () => {
+ mockFlipPosition.mockResolvedValue({ success: false });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+
+ it('handles exceptions and captures to Sentry', async () => {
+ const testError = new Error('Network error');
+ mockFlipPosition.mockRejectedValue(testError);
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ component: 'usePerpsFlipPosition',
+ action: 'flip_position',
+ }),
+ extra: expect.objectContaining({
+ positionContext: expect.objectContaining({
+ coin: 'ETH',
+ size: '2.5',
+ }),
+ }),
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Network error');
+ });
+
+ it('handles non-Error exceptions', async () => {
+ mockFlipPosition.mockRejectedValue('String error');
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.anything(),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+
+ it('resets isFlipping to false after completion', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('resets isFlipping to false after error', async () => {
+ mockFlipPosition.mockRejectedValue(new Error('Test error'));
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('works without options provided', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockFlipPosition).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+
+ it('determines correct opposite direction for long position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ // The toast should be called with 'short' as the opposite direction
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ direction: 'short',
+ }),
+ );
+ });
+
+ it('determines correct opposite direction for short position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockShortPosition);
+ });
+
+ // The toast should be called with 'long' as the opposite direction
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ direction: 'long',
+ }),
+ );
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePerpsFlipPosition.ts b/app/components/UI/Perps/hooks/usePerpsFlipPosition.ts
new file mode 100644
index 000000000000..46f616a142af
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsFlipPosition.ts
@@ -0,0 +1,129 @@
+import { useCallback, useState } from 'react';
+import { strings } from '../../../../../locales/i18n';
+import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
+import { usePerpsTrading } from './usePerpsTrading';
+import type { Position } from '../controllers/types';
+import { captureException } from '@sentry/react-native';
+import usePerpsToasts from './usePerpsToasts';
+import { getPerpsDisplaySymbol } from '../utils/marketUtils';
+import type { OrderDirection } from '../types/perps-types';
+
+export interface UsePerpsFlipPositionOptions {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+}
+
+/**
+ * Hook for flipping (reversing) position direction
+ * Converts long positions to short and vice versa
+ * Provides consistent error handling, toast notifications, and Sentry tracking
+ * @param options Optional callbacks for success and error cases
+ * @returns handleFlipPosition function and loading state
+ */
+export function usePerpsFlipPosition(options?: UsePerpsFlipPositionOptions) {
+ const { flipPosition } = usePerpsTrading();
+ const [isFlipping, setIsFlipping] = useState(false);
+
+ const { showToast, PerpsToastOptions } = usePerpsToasts();
+
+ const handleFlipPosition = useCallback(
+ async (position: Position) => {
+ setIsFlipping(true);
+ DevLogger.log('usePerpsFlipPosition: Setting isFlipping to true');
+
+ // Determine current and opposite direction
+ const currentDirection: OrderDirection =
+ parseFloat(position.size) > 0 ? 'long' : 'short';
+ const oppositeDirection: OrderDirection =
+ currentDirection === 'long' ? 'short' : 'long';
+ const positionSize = Math.abs(parseFloat(position.size));
+
+ try {
+ const result = await flipPosition({
+ coin: position.coin,
+ position,
+ });
+
+ if (result.success) {
+ DevLogger.log('Position flipped successfully:', result);
+
+ // Show success toast using existing market order confirmation toast
+ // (flip is implemented as a market order in opposite direction)
+ const displaySymbol = getPerpsDisplaySymbol(position.coin);
+ showToast(
+ PerpsToastOptions.orderManagement.market.confirmed(
+ oppositeDirection,
+ positionSize.toString(),
+ displaySymbol,
+ ),
+ );
+
+ // Call success callback if provided
+ options?.onSuccess?.();
+ } else {
+ DevLogger.log('Failed to flip position:', result.error);
+
+ const errorMessage = result.error || strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.orderManagement.market.creationFailed(
+ errorMessage,
+ ),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ }
+ } catch (error) {
+ DevLogger.log('Error flipping position:', error);
+
+ // Capture exception with position context
+ captureException(
+ error instanceof Error ? error : new Error(String(error)),
+ {
+ tags: {
+ component: 'usePerpsFlipPosition',
+ action: 'flip_position',
+ operation: 'position_management',
+ },
+ extra: {
+ positionContext: {
+ coin: position.coin,
+ size: position.size,
+ currentDirection,
+ targetDirection: oppositeDirection,
+ positionSize,
+ entryPrice: position.entryPrice,
+ unrealizedPnl: position.unrealizedPnl,
+ leverage: position.leverage,
+ },
+ },
+ },
+ );
+
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.orderManagement.market.creationFailed(errorMessage),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ } finally {
+ DevLogger.log('usePerpsFlipPosition: Setting isFlipping to false');
+ setIsFlipping(false);
+ }
+ },
+ [
+ flipPosition,
+ showToast,
+ PerpsToastOptions.orderManagement.market,
+ options,
+ ],
+ );
+
+ return { handleFlipPosition, isFlipping };
+}
diff --git a/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts
new file mode 100644
index 000000000000..7c521b68be13
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts
@@ -0,0 +1,367 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePerpsMarginAdjustment } from './usePerpsMarginAdjustment';
+
+const mockUpdateMargin = jest.fn();
+const mockShowToast = jest.fn();
+const mockCaptureException = jest.fn();
+
+jest.mock('./usePerpsTrading', () => ({
+ usePerpsTrading: () => ({
+ updateMargin: mockUpdateMargin,
+ }),
+}));
+
+jest.mock('./usePerpsToasts', () => ({
+ __esModule: true,
+ default: () => ({
+ showToast: mockShowToast,
+ PerpsToastOptions: {
+ positionManagement: {
+ margin: {
+ addSuccess: jest.fn((symbol, amount) => ({
+ type: 'add_success',
+ symbol,
+ amount,
+ })),
+ removeSuccess: jest.fn((symbol, amount) => ({
+ type: 'remove_success',
+ symbol,
+ amount,
+ })),
+ adjustmentFailed: jest.fn((error) => ({
+ type: 'error',
+ error,
+ })),
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('@sentry/react-native', () => ({
+ captureException: (error: Error, context: unknown) =>
+ mockCaptureException(error, context),
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({
+ DevLogger: {
+ log: jest.fn(),
+ },
+}));
+
+jest.mock('../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+describe('usePerpsMarginAdjustment', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns handleAddMargin, handleRemoveMargin functions and isAdjusting state', () => {
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ expect(result.current.handleAddMargin).toBeDefined();
+ expect(typeof result.current.handleAddMargin).toBe('function');
+ expect(result.current.handleRemoveMargin).toBeDefined();
+ expect(typeof result.current.handleRemoveMargin).toBe('function');
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ describe('handleAddMargin', () => {
+ it('sets isAdjusting to true while adding margin', async () => {
+ let resolveMargin: (value: { success: boolean }) => void;
+ mockUpdateMargin.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveMargin = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ // Start the margin operation
+ act(() => {
+ result.current.handleAddMargin('ETH', 100);
+ });
+
+ // Check that isAdjusting is true during the operation
+ expect(result.current.isAdjusting).toBe(true);
+
+ // Resolve the promise
+ await act(async () => {
+ resolveMargin({ success: true });
+ });
+
+ // Check that isAdjusting is false after completion
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('calls updateMargin with positive amount for add', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockUpdateMargin).toHaveBeenCalledWith({
+ coin: 'ETH',
+ amount: '100',
+ });
+ });
+
+ it('shows success toast on successful add margin', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+ const mockOnSuccess = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onSuccess: mockOnSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('BTC', 50);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'add_success',
+ }),
+ );
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it('shows error toast on failed add margin', async () => {
+ mockUpdateMargin.mockResolvedValue({
+ success: false,
+ error: 'Insufficient funds',
+ });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 1000);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Insufficient funds');
+ });
+ });
+
+ describe('handleRemoveMargin', () => {
+ it('calls updateMargin with negative amount for remove', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('ETH', 100);
+ });
+
+ expect(mockUpdateMargin).toHaveBeenCalledWith({
+ coin: 'ETH',
+ amount: '-100',
+ });
+ });
+
+ it('shows success toast on successful remove margin', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+ const mockOnSuccess = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onSuccess: mockOnSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('BTC', 25);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'remove_success',
+ }),
+ );
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it('shows error toast on failed remove margin', async () => {
+ mockUpdateMargin.mockResolvedValue({
+ success: false,
+ error: 'Cannot reduce below minimum',
+ });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('ETH', 500);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Cannot reduce below minimum');
+ });
+ });
+
+ describe('error handling', () => {
+ it('shows default error message when no error provided', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: false });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+
+ it('handles exceptions and captures to Sentry', async () => {
+ const testError = new Error('Network error');
+ mockUpdateMargin.mockRejectedValue(testError);
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ component: 'usePerpsMarginAdjustment',
+ action: 'margin_add',
+ }),
+ extra: expect.objectContaining({
+ marginContext: expect.objectContaining({
+ coin: 'ETH',
+ amount: 100,
+ action: 'add',
+ }),
+ }),
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Network error');
+ });
+
+ it('captures remove action in Sentry context', async () => {
+ const testError = new Error('API error');
+ mockUpdateMargin.mockRejectedValue(testError);
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('BTC', 50);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ action: 'margin_remove',
+ }),
+ extra: expect.objectContaining({
+ marginContext: expect.objectContaining({
+ action: 'remove',
+ adjustmentAmount: -50,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('handles non-Error exceptions', async () => {
+ mockUpdateMargin.mockRejectedValue('String error');
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.anything(),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+ });
+
+ describe('state management', () => {
+ it('resets isAdjusting to false after successful operation', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('resets isAdjusting to false after failed operation', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: false, error: 'Failed' });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('resets isAdjusting to false after exception', async () => {
+ mockUpdateMargin.mockRejectedValue(new Error('Test error'));
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('works without options provided', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockUpdateMargin).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts
new file mode 100644
index 000000000000..5a6902d45b3c
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts
@@ -0,0 +1,139 @@
+import { useCallback, useState } from 'react';
+import { strings } from '../../../../../locales/i18n';
+import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
+import { usePerpsTrading } from './usePerpsTrading';
+import { captureException } from '@sentry/react-native';
+import usePerpsToasts from './usePerpsToasts';
+import { getPerpsDisplaySymbol } from '../utils/marketUtils';
+
+export interface UsePerpsMarginAdjustmentOptions {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+}
+
+/**
+ * Hook for handling margin adjustment operations (add/remove margin from positions)
+ * Provides consistent error handling, toast notifications, and Sentry tracking
+ * @param options Optional callbacks for success and error cases
+ * @returns handleAddMargin, handleRemoveMargin functions and loading state
+ */
+export function usePerpsMarginAdjustment(
+ options?: UsePerpsMarginAdjustmentOptions,
+) {
+ const { updateMargin } = usePerpsTrading();
+ const [isAdjusting, setIsAdjusting] = useState(false);
+
+ const { showToast, PerpsToastOptions } = usePerpsToasts();
+
+ const handleMarginUpdate = useCallback(
+ async (coin: string, amount: number, action: 'add' | 'remove') => {
+ setIsAdjusting(true);
+ DevLogger.log(
+ `usePerpsMarginAdjustment: Setting isAdjusting to true (action: ${action})`,
+ );
+
+ try {
+ // Convert amount to string with proper sign
+ // Positive for add, negative for remove
+ const adjustmentAmount = action === 'remove' ? -amount : amount;
+
+ const result = await updateMargin({
+ coin,
+ amount: adjustmentAmount.toString(),
+ });
+
+ if (result.success) {
+ DevLogger.log('Margin adjusted successfully:', result);
+
+ // Show success toast
+ const displaySymbol = getPerpsDisplaySymbol(coin);
+ showToast(
+ action === 'add'
+ ? PerpsToastOptions.positionManagement.margin.addSuccess(
+ displaySymbol,
+ amount.toString(),
+ )
+ : PerpsToastOptions.positionManagement.margin.removeSuccess(
+ displaySymbol,
+ amount.toString(),
+ ),
+ );
+
+ // Call success callback if provided
+ options?.onSuccess?.();
+ } else {
+ DevLogger.log('Failed to adjust margin:', result.error);
+
+ const errorMessage = result.error || strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.positionManagement.margin.adjustmentFailed(
+ errorMessage,
+ ),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ }
+ } catch (error) {
+ DevLogger.log('Error adjusting margin:', error);
+
+ // Capture exception with margin context
+ captureException(
+ error instanceof Error ? error : new Error(String(error)),
+ {
+ tags: {
+ component: 'usePerpsMarginAdjustment',
+ action: `margin_${action}`,
+ operation: 'position_management',
+ },
+ extra: {
+ marginContext: {
+ coin,
+ amount,
+ action,
+ adjustmentAmount: action === 'remove' ? -amount : amount,
+ },
+ },
+ },
+ );
+
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.positionManagement.margin.adjustmentFailed(
+ errorMessage,
+ ),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ } finally {
+ DevLogger.log('usePerpsMarginAdjustment: Setting isAdjusting to false');
+ setIsAdjusting(false);
+ }
+ },
+ [
+ updateMargin,
+ showToast,
+ PerpsToastOptions.positionManagement.margin,
+ options,
+ ],
+ );
+
+ const handleAddMargin = useCallback(
+ (coin: string, amount: number) => handleMarginUpdate(coin, amount, 'add'),
+ [handleMarginUpdate],
+ );
+
+ const handleRemoveMargin = useCallback(
+ (coin: string, amount: number) =>
+ handleMarginUpdate(coin, amount, 'remove'),
+ [handleMarginUpdate],
+ );
+
+ return { handleAddMargin, handleRemoveMargin, isAdjusting };
+}
diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts
index 8c5d9c9252ee..d9f3bf481137 100644
--- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts
+++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import Routes from '../../../../constants/navigation/Routes';
import type { PerpsNavigationParamList } from '../types/navigation';
-import type { PerpsMarketData } from '../controllers/types';
+import type { PerpsMarketData, Position, Order } from '../controllers/types';
/**
* Navigation handler result interface
@@ -25,6 +25,9 @@ export interface PerpsNavigationHandlers {
navigateToTutorial: (
params?: PerpsNavigationParamList['PerpsTutorial'],
) => void;
+ navigateToAdjustMargin: (position: Position, mode: 'add' | 'remove') => void;
+ navigateToClosePosition: (position: Position) => void;
+ navigateToOrderDetails: (order: Order) => void;
// Utility navigation
navigateBack: () => void;
@@ -135,6 +138,27 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => {
[navigation],
);
+ const navigateToAdjustMargin = useCallback(
+ (position: Position, mode: 'add' | 'remove') => {
+ navigation.navigate(Routes.PERPS.ADJUST_MARGIN, { position, mode });
+ },
+ [navigation],
+ );
+
+ const navigateToClosePosition = useCallback(
+ (position: Position) => {
+ navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position });
+ },
+ [navigation],
+ );
+
+ const navigateToOrderDetails = useCallback(
+ (order: Order) => {
+ navigation.navigate(Routes.PERPS.ORDER_DETAILS, { order });
+ },
+ [navigation],
+ );
+
// Utility navigation handlers
const navigateBack = useCallback(() => {
if (navigation.canGoBack()) {
@@ -158,6 +182,9 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => {
navigateToMarketList,
navigateToOrder,
navigateToTutorial,
+ navigateToAdjustMargin,
+ navigateToClosePosition,
+ navigateToOrderDetails,
// Utility navigation
navigateBack,
diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
index 4f2f9c891ee3..9819d3d7316a 100644
--- a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
@@ -149,6 +149,8 @@ describe('usePerpsOrderFees', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
@@ -781,6 +783,8 @@ describe('usePerpsOrderFees - Maker/Taker Determination', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
@@ -1488,6 +1492,8 @@ describe('usePerpsOrderFees - Enhanced Error Handling', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
diff --git a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts
index 71a7866bb93f..21409ce7bb8b 100644
--- a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts
@@ -100,6 +100,8 @@ describe('usePerpsPositions', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
calculateFees: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.test.ts b/app/components/UI/Perps/hooks/usePerpsToasts.test.ts
index 62ae416f9475..77985dab3fa3 100644
--- a/app/components/UI/Perps/hooks/usePerpsToasts.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsToasts.test.ts
@@ -814,6 +814,93 @@ describe('usePerpsToasts', () => {
});
});
+ describe('positionManagement.margin', () => {
+ it('returns add margin success configuration', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.addSuccess(
+ 'ETH',
+ '100',
+ );
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ hapticsType: NotificationFeedbackType.Success,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(1);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ });
+
+ it('returns remove margin success configuration', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.removeSuccess(
+ 'BTC',
+ '50',
+ );
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ hapticsType: NotificationFeedbackType.Success,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(1);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ });
+
+ it('returns adjustment failed configuration with custom error', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const customError = 'Insufficient funds';
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.adjustmentFailed(
+ customError,
+ );
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Warning,
+ hapticsType: NotificationFeedbackType.Error,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(3);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ expect(config.labelOptions?.[2]).toMatchObject({
+ label: customError,
+ isBold: false,
+ });
+ });
+
+ it('returns adjustment failed configuration with default error', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.adjustmentFailed();
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Warning,
+ hapticsType: NotificationFeedbackType.Error,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(3);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ // Default error uses perps.errors.unknown key
+ expect(config.labelOptions?.[2]).toMatchObject({
+ isBold: false,
+ });
+ });
+ });
+
describe('positionManagement.tpsl', () => {
it('returns update TPSL success configuration', () => {
const { result } = renderHook(() => usePerpsToasts());
diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx
index 52bd79cb8f03..9968b392256a 100644
--- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx
+++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx
@@ -149,6 +149,11 @@ export interface PerpsToastOptionsConfig {
updateTPSLSuccess: PerpsToastOptions;
updateTPSLError: (error?: string) => PerpsToastOptions;
};
+ margin: {
+ addSuccess: (assetSymbol: string, amount: string) => PerpsToastOptions;
+ removeSuccess: (assetSymbol: string, amount: string) => PerpsToastOptions;
+ adjustmentFailed: (error?: string) => PerpsToastOptions;
+ };
};
formValidation: {
orderForm: {
@@ -818,6 +823,37 @@ const usePerpsToasts = (): {
};
},
},
+ margin: {
+ addSuccess: (assetSymbol: string, amount: string) => ({
+ ...perpsBaseToastOptions.success,
+ labelOptions: getPerpsToastLabels(
+ strings('perps.position.margin.add_success', {
+ amount,
+ asset: assetSymbol,
+ }),
+ ),
+ }),
+ removeSuccess: (assetSymbol: string, amount: string) => ({
+ ...perpsBaseToastOptions.success,
+ labelOptions: getPerpsToastLabels(
+ strings('perps.position.margin.remove_success', {
+ amount,
+ asset: assetSymbol,
+ }),
+ ),
+ }),
+ adjustmentFailed: (error?: string) => {
+ const errorMessage = error || strings('perps.errors.unknown');
+
+ return {
+ ...perpsBaseToastOptions.error,
+ labelOptions: getPerpsToastLabels(
+ strings('perps.position.margin.adjustment_failed'),
+ errorMessage,
+ ),
+ };
+ },
+ },
},
formValidation: {
orderForm: {
diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts
index 40c721f48b2c..175e01baa399 100644
--- a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts
@@ -44,6 +44,8 @@ jest.mock('../../../../core/Engine', () => ({
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
},
},
}));
@@ -767,6 +769,121 @@ describe('usePerpsTrading', () => {
});
});
+ describe('updateMargin', () => {
+ it('should call PerpsController.updateMargin with correct parameters', async () => {
+ const mockResult = { success: true };
+ (
+ Engine.context.PerpsController.updateMargin as jest.Mock
+ ).mockResolvedValue(mockResult);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ const response = await result.current.updateMargin(updateMarginParams);
+
+ expect(Engine.context.PerpsController.updateMargin).toHaveBeenCalledWith(
+ updateMarginParams,
+ );
+ expect(response).toEqual(mockResult);
+ });
+
+ it('should handle updateMargin errors', async () => {
+ const mockError = new Error('Insufficient balance');
+ (
+ Engine.context.PerpsController.updateMargin as jest.Mock
+ ).mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ await expect(
+ result.current.updateMargin(updateMarginParams),
+ ).rejects.toThrow('Insufficient balance');
+ });
+ });
+
+ describe('flipPosition', () => {
+ it('should call PerpsController.flipPosition with correct parameters', async () => {
+ const mockResult: OrderResult = {
+ success: true,
+ orderId: 'flip-123',
+ filledSize: '1.0',
+ averagePrice: '50000',
+ };
+ (
+ Engine.context.PerpsController.flipPosition as jest.Mock
+ ).mockResolvedValue(mockResult);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const flipParams = {
+ coin: 'BTC',
+ position: {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ },
+ };
+
+ const response = await result.current.flipPosition(flipParams);
+
+ expect(Engine.context.PerpsController.flipPosition).toHaveBeenCalledWith(
+ flipParams,
+ );
+ expect(response).toEqual(mockResult);
+ });
+
+ it('should handle flipPosition errors', async () => {
+ const mockError = new Error('Insufficient balance for flip fees');
+ (
+ Engine.context.PerpsController.flipPosition as jest.Mock
+ ).mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const flipParams = {
+ coin: 'BTC',
+ position: {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ },
+ };
+
+ await expect(result.current.flipPosition(flipParams)).rejects.toThrow(
+ 'Insufficient balance for flip fees',
+ );
+ });
+ });
+
describe('hook stability', () => {
it('should return stable function references', () => {
const { result, rerender } = renderHook(() => usePerpsTrading());
@@ -828,6 +945,8 @@ describe('usePerpsTrading', () => {
expect(initialFunctions.validateWithdrawal).toBe(
updatedFunctions.validateWithdrawal,
);
+ expect(initialFunctions.updateMargin).toBe(updatedFunctions.updateMargin);
+ expect(initialFunctions.flipPosition).toBe(updatedFunctions.flipPosition);
});
});
});
diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.ts b/app/components/UI/Perps/hooks/usePerpsTrading.ts
index 198703072a8a..2ca537d704b8 100644
--- a/app/components/UI/Perps/hooks/usePerpsTrading.ts
+++ b/app/components/UI/Perps/hooks/usePerpsTrading.ts
@@ -7,6 +7,7 @@ import type {
ClosePositionParams,
FeeCalculationParams,
FeeCalculationResult,
+ FlipPositionParams,
GetAccountStateParams,
GetOrderFillsParams,
GetOrdersParams,
@@ -16,6 +17,7 @@ import type {
Funding,
LiquidationPriceParams,
MaintenanceMarginParams,
+ MarginResult,
MarketInfo,
OrderParams,
OrderResult,
@@ -23,6 +25,7 @@ import type {
SubscribeOrderFillsParams,
SubscribePricesParams,
SubscribePositionsParams,
+ UpdateMarginParams,
UpdatePositionTPSLParams,
WithdrawParams,
WithdrawResult,
@@ -151,6 +154,22 @@ export function usePerpsTrading() {
[],
);
+ const updateMargin = useCallback(
+ async (params: UpdateMarginParams): Promise => {
+ const controller = Engine.context.PerpsController;
+ return controller.updateMargin(params);
+ },
+ [],
+ );
+
+ const flipPosition = useCallback(
+ async (params: FlipPositionParams): Promise => {
+ const controller = Engine.context.PerpsController;
+ return controller.flipPosition(params);
+ },
+ [],
+ );
+
const calculateFees = useCallback(
async (params: FeeCalculationParams): Promise => {
const controller = Engine.context.PerpsController;
@@ -230,6 +249,8 @@ export function usePerpsTrading() {
calculateMaintenanceMargin,
getMaxLeverage,
updatePositionTPSL,
+ updateMargin,
+ flipPosition,
calculateFees,
validateOrder,
validateClosePosition,
diff --git a/app/components/UI/Perps/hooks/usePositionManagement.test.ts b/app/components/UI/Perps/hooks/usePositionManagement.test.ts
new file mode 100644
index 000000000000..041b5068e428
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePositionManagement.test.ts
@@ -0,0 +1,181 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePositionManagement } from './usePositionManagement';
+import type { Position } from '../controllers/types';
+
+describe('usePositionManagement', () => {
+ const mockPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ it('returns initial state with all sheets hidden', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ expect(result.current.showAdjustMarginActionSheet).toBe(false);
+ expect(result.current.showReversePositionSheet).toBe(false);
+ });
+
+ it('returns refs for all bottom sheets', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ expect(result.current.modifyActionSheetRef).toBeDefined();
+ expect(result.current.modifyActionSheetRef.current).toBeNull();
+ expect(result.current.adjustMarginActionSheetRef).toBeDefined();
+ expect(result.current.adjustMarginActionSheetRef.current).toBeNull();
+ expect(result.current.reversePositionSheetRef).toBeDefined();
+ expect(result.current.reversePositionSheetRef.current).toBeNull();
+ });
+
+ describe('modify action sheet', () => {
+ it('opens modify sheet when openModifySheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openModifySheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(true);
+ });
+
+ it('closes modify sheet when closeModifySheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openModifySheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(true);
+
+ act(() => {
+ result.current.closeModifySheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ });
+ });
+
+ describe('adjust margin action sheet', () => {
+ it('opens adjust margin sheet when openAdjustMarginSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openAdjustMarginSheet();
+ });
+
+ expect(result.current.showAdjustMarginActionSheet).toBe(true);
+ });
+
+ it('closes adjust margin sheet when closeAdjustMarginSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openAdjustMarginSheet();
+ });
+
+ expect(result.current.showAdjustMarginActionSheet).toBe(true);
+
+ act(() => {
+ result.current.closeAdjustMarginSheet();
+ });
+
+ expect(result.current.showAdjustMarginActionSheet).toBe(false);
+ });
+ });
+
+ describe('reverse position sheet', () => {
+ it('opens reverse position sheet when openReversePositionSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openReversePositionSheet();
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(true);
+ });
+
+ it('closes reverse position sheet when closeReversePositionSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openReversePositionSheet();
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(true);
+
+ act(() => {
+ result.current.closeReversePositionSheet();
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(false);
+ });
+
+ it('opens reverse position sheet when handleReversePosition is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.handleReversePosition(mockPosition);
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(true);
+ });
+ });
+
+ describe('multiple sheets interaction', () => {
+ it('can have only one sheet open at a time (conceptual - sheets are independent)', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ // Open modify sheet
+ act(() => {
+ result.current.openModifySheet();
+ });
+ expect(result.current.showModifyActionSheet).toBe(true);
+
+ // Open adjust margin sheet (in real usage, you'd close modify first)
+ act(() => {
+ result.current.closeModifySheet();
+ result.current.openAdjustMarginSheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ expect(result.current.showAdjustMarginActionSheet).toBe(true);
+ });
+ });
+
+ describe('with options', () => {
+ it('accepts options without errors', () => {
+ const mockOnNavigateToTPSL = jest.fn();
+ const mockOnNavigateToAdjustMargin = jest.fn();
+ const mockOnNavigateToClosePosition = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePositionManagement({
+ position: mockPosition,
+ onNavigateToTPSL: mockOnNavigateToTPSL,
+ onNavigateToAdjustMargin: mockOnNavigateToAdjustMargin,
+ onNavigateToClosePosition: mockOnNavigateToClosePosition,
+ }),
+ );
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ });
+
+ it('works with empty options', () => {
+ const { result } = renderHook(() => usePositionManagement({}));
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ expect(result.current.openModifySheet).toBeDefined();
+ });
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePositionManagement.ts b/app/components/UI/Perps/hooks/usePositionManagement.ts
new file mode 100644
index 000000000000..2cb497c3387c
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePositionManagement.ts
@@ -0,0 +1,141 @@
+import { useCallback, useRef, useState } from 'react';
+import type { BottomSheetRef } from '../../../../component-library/components/BottomSheets/BottomSheet';
+import type { Position } from '../controllers/types';
+
+/**
+ * Options for position management hook
+ */
+export interface UsePositionManagementOptions {
+ position?: Position;
+ onNavigateToTPSL?: (position: Position) => void;
+ onNavigateToAdjustMargin?: (
+ position: Position,
+ mode: 'add' | 'remove',
+ ) => void;
+ onNavigateToClosePosition?: (position: Position) => void;
+}
+
+/**
+ * Return type for position management hook
+ */
+export interface UsePositionManagementReturn {
+ // Bottom sheet state
+ showModifyActionSheet: boolean;
+ showAdjustMarginActionSheet: boolean;
+ showReversePositionSheet: boolean;
+
+ // Bottom sheet refs
+ modifyActionSheetRef: React.RefObject;
+ adjustMarginActionSheetRef: React.RefObject;
+ reversePositionSheetRef: React.RefObject;
+
+ // Action handlers
+ openModifySheet: () => void;
+ closeModifySheet: () => void;
+ openAdjustMarginSheet: () => void;
+ closeAdjustMarginSheet: () => void;
+ openReversePositionSheet: () => void;
+ closeReversePositionSheet: () => void;
+ handleReversePosition: (position: Position) => void;
+}
+
+/**
+ * usePositionManagement Hook
+ *
+ * Centralizes position management UI state and handlers for bottom sheets.
+ * Extracted from PerpsMarketDetailsView to reduce component complexity.
+ *
+ * This hook manages the state and refs for three bottom sheets:
+ * 1. Modify Action Sheet - Shows position modification options (increase, reduce, flip, TP/SL, margin)
+ * 2. Adjust Margin Action Sheet - Specific sheet for margin adjustment mode selection
+ * 3. Reverse Position Sheet - Confirmation sheet for flipping position direction
+ *
+ * @param options - Configuration for callbacks (currently unused but available for future extension)
+ * @returns State, refs, and handlers for position management bottom sheets
+ *
+ * @example
+ * ```tsx
+ * const {
+ * showModifyActionSheet,
+ * modifyActionSheetRef,
+ * openModifySheet,
+ * closeModifySheet,
+ * // ... other returns
+ * } = usePositionManagement();
+ *
+ * // Use in JSX
+ *
+ *
+ * ```
+ */
+export const usePositionManagement = (
+ _options: UsePositionManagementOptions = {},
+): UsePositionManagementReturn => {
+ // Bottom sheet state
+ const [showModifyActionSheet, setShowModifyActionSheet] = useState(false);
+ const [showAdjustMarginActionSheet, setShowAdjustMarginActionSheet] =
+ useState(false);
+ const [showReversePositionSheet, setShowReversePositionSheet] =
+ useState(false);
+
+ // Bottom sheet refs
+ const modifyActionSheetRef = useRef(null);
+ const adjustMarginActionSheetRef = useRef(null);
+ const reversePositionSheetRef = useRef(null);
+
+ // Modify action sheet handlers
+ const openModifySheet = useCallback(() => {
+ setShowModifyActionSheet(true);
+ }, []);
+
+ const closeModifySheet = useCallback(() => {
+ setShowModifyActionSheet(false);
+ }, []);
+
+ // Adjust margin action sheet handlers
+ const openAdjustMarginSheet = useCallback(() => {
+ setShowAdjustMarginActionSheet(true);
+ }, []);
+
+ const closeAdjustMarginSheet = useCallback(() => {
+ setShowAdjustMarginActionSheet(false);
+ }, []);
+
+ // Reverse position sheet handlers
+ const openReversePositionSheet = useCallback(() => {
+ setShowReversePositionSheet(true);
+ }, []);
+
+ const closeReversePositionSheet = useCallback(() => {
+ setShowReversePositionSheet(false);
+ }, []);
+
+ const handleReversePosition = useCallback((_position: Position) => {
+ setShowReversePositionSheet(true);
+ }, []);
+
+ return {
+ // State
+ showModifyActionSheet,
+ showAdjustMarginActionSheet,
+ showReversePositionSheet,
+
+ // Refs
+ modifyActionSheetRef,
+ adjustMarginActionSheetRef,
+ reversePositionSheetRef,
+
+ // Handlers
+ openModifySheet,
+ closeModifySheet,
+ openAdjustMarginSheet,
+ closeAdjustMarginSheet,
+ openReversePositionSheet,
+ closeReversePositionSheet,
+ handleReversePosition,
+ };
+};
diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx
index fdd3ca977349..31293bffd1b3 100644
--- a/app/components/UI/Perps/routes/index.tsx
+++ b/app/components/UI/Perps/routes/index.tsx
@@ -19,6 +19,11 @@ import { Confirm } from '../../../Views/confirmations/components/confirm';
import PerpsGTMModal from '../components/PerpsGTMModal';
import PerpsTooltipView from '../Views/PerpsTooltipView/PerpsTooltipView';
import PerpsTPSLView from '../Views/PerpsTPSLView/PerpsTPSLView';
+import PerpsAdjustMarginView from '../Views/PerpsAdjustMarginView/PerpsAdjustMarginView';
+import PerpsSelectModifyActionView from '../Views/PerpsSelectModifyActionView';
+import PerpsSelectAdjustMarginActionView from '../Views/PerpsSelectAdjustMarginActionView';
+import PerpsSelectOrderTypeView from '../Views/PerpsSelectOrderTypeView';
+import PerpsOrderDetailsView from '../Views/PerpsOrderDetailsView';
import PerpsHeroCardView from '../Views/PerpsHeroCardView';
import ActivityView from '../../../Views/ActivityView';
import PerpsStreamBridge from '../components/PerpsStreamBridge';
@@ -74,6 +79,28 @@ const PerpsModalStack = () => (
title: strings('perps.crossMargin.title'),
}}
/>
+ {/* Action Selection Modals */}
+
+
+
@@ -212,6 +239,26 @@ const PerpsScreenStack = () => (
}}
/>
+ {/* Adjust Margin View */}
+
+
+ {/* Order Details View */}
+
+
{
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('calculateMaxRemovableMargin', () => {
+ it('uses 10% minimum when it exceeds leverage-based minimum (high leverage)', () => {
+ // For 50x leverage: initial margin = 2%, but 10% is higher
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 1000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ // notionalValue = 10 * 2000 = 20000
+ // initialMarginRequired = 20000 / 50 = 400 (2%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // baseMinimumRequired = max(400, 2000) = 2000
+ // minimumMarginRequired = 2000 * 3 = 6000 (with 3x safety buffer)
+ // maxRemovable = 1000 - 6000 = -5000 -> 0 (capped)
+ expect(result).toBe(0);
+ });
+
+ it('uses leverage-based minimum when it exceeds 10% (low leverage)', () => {
+ // For 5x leverage: initial margin = 20%, which is > 10%
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 15000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 5,
+ });
+
+ // notionalValue = 10 * 2000 = 20000
+ // initialMarginRequired = 20000 / 5 = 4000 (20%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // baseMinimumRequired = max(4000, 2000) = 4000
+ // minimumMarginRequired = 4000 * 3 = 12000 (with 3x safety buffer)
+ // maxRemovable = 15000 - 12000 = 3000
+ expect(result).toBe(3000);
+ });
+
+ it('uses higher of entry and current price for conservative calculation', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 20000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2500, // Higher than entry
+ maxLeverage: 5,
+ });
+
+ // Uses currentPrice (2500) since it's higher
+ // notionalValue = 10 * 2500 = 25000
+ // initialMarginRequired = 25000 / 5 = 5000 (20%)
+ // tenPercentMargin = 25000 * 0.1 = 2500 (10%)
+ // baseMinimumRequired = max(5000, 2500) = 5000
+ // minimumMarginRequired = 5000 * 3 = 15000 (with 3x safety buffer)
+ // maxRemovable = 20000 - 15000 = 5000
+ expect(result).toBe(5000);
+ });
+
+ it('allows margin removal when current margin exceeds minimum with safety buffer', () => {
+ // User has 8000 margin for a position requiring 6000 minimum (with buffer)
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 8000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ // notionalValue = 10 * 2000 = 20000
+ // initialMarginRequired = 20000 / 50 = 400 (2%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // baseMinimumRequired = max(400, 2000) = 2000
+ // minimumMarginRequired = 2000 * 3 = 6000 (with 3x safety buffer)
+ // maxRemovable = 8000 - 6000 = 2000
+ expect(result).toBe(2000);
+ });
+
+ it('correctly limits small positions (real-world scenario)', () => {
+ // Real scenario: ~$10.39 notional, $3.50 margin, 3x leverage
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 3.5,
+ positionSize: 0.1,
+ entryPrice: 103.9,
+ currentPrice: 103.9,
+ maxLeverage: 3,
+ });
+
+ // notionalValue = 0.1 * 103.9 = 10.39
+ // initialMarginRequired = 10.39 / 3 = 3.46 (33%)
+ // tenPercentMargin = 10.39 * 0.1 = 1.04 (10%)
+ // baseMinimumRequired = max(3.46, 1.04) = 3.46
+ // minimumMarginRequired = 3.46 * 3 = 10.38 (with 3x safety buffer)
+ // maxRemovable = 3.5 - 10.38 = -6.88 -> 0 (capped)
+ // With only 3.5 margin at 3x leverage, no margin can be removed!
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 for negative margin values', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: -100,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when position size is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 0,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when max leverage is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 0,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when entry price is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 10,
+ entryPrice: 0,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when current price is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 0,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('calculateNewLiquidationPrice', () => {
+ it('calculates liquidation price below entry for long position', () => {
+ const newMargin = 200;
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+ const expectedMarginPerUnit = newMargin / positionSize; // 20
+ const expectedLiquidation = entryPrice - expectedMarginPerUnit; // 1980
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(expectedLiquidation);
+ });
+
+ it('calculates liquidation price above entry for short position', () => {
+ const newMargin = 200;
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = false;
+ const currentLiquidationPrice = 2100;
+ const expectedMarginPerUnit = newMargin / positionSize; // 20
+ const expectedLiquidation = entryPrice + expectedMarginPerUnit; // 2020
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(expectedLiquidation);
+ });
+
+ it('returns current liquidation price when new margin is 0', () => {
+ const newMargin = 0;
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(currentLiquidationPrice);
+ });
+
+ it('returns current liquidation price when position size is 0', () => {
+ const newMargin = 200;
+ const positionSize = 0;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(currentLiquidationPrice);
+ });
+
+ it('returns 0 for long position when liquidation would be negative', () => {
+ const newMargin = 25000; // Very high margin
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('assessMarginRemovalRisk', () => {
+ it('returns danger risk level when buffer is below 20%', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 1900;
+ const isLong = true;
+ const priceDiff = currentPrice - newLiquidationPrice; // 100
+ const riskRatio = priceDiff / newLiquidationPrice; // 0.0526 (5.26%)
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('danger');
+ expect(result.priceDiff).toBe(priceDiff);
+ expect(result.riskRatio).toBeCloseTo(riskRatio, 4);
+ });
+
+ it('returns warning risk level when buffer is between 20% and 50%', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 1600;
+ const isLong = true;
+ const priceDiff = currentPrice - newLiquidationPrice; // 400
+ const riskRatio = priceDiff / newLiquidationPrice; // 0.25 (25%)
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('warning');
+ expect(result.priceDiff).toBe(priceDiff);
+ expect(result.riskRatio).toBe(riskRatio);
+ });
+
+ it('returns safe risk level when buffer is above 50%', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 1200;
+ const isLong = true;
+ const priceDiff = currentPrice - newLiquidationPrice; // 800
+ const riskRatio = priceDiff / newLiquidationPrice; // 0.6667 (66.67%)
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('safe');
+ expect(result.priceDiff).toBe(priceDiff);
+ expect(result.riskRatio).toBeCloseTo(riskRatio, 4);
+ });
+
+ it('returns danger when liquidation price equals current price', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 2000;
+ const isLong = true;
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('danger');
+ expect(result.priceDiff).toBe(0);
+ expect(result.riskRatio).toBe(0);
+ });
+
+ it('calculates correct price difference for short position', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 2600;
+ const isLong = false;
+ const expectedPriceDiff = newLiquidationPrice - currentPrice; // 600
+ const expectedRiskRatio = expectedPriceDiff / newLiquidationPrice; // 0.2308
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.priceDiff).toBe(expectedPriceDiff);
+ expect(result.riskRatio).toBeCloseTo(expectedRiskRatio, 4);
+ });
+
+ it('returns safe risk assessment when liquidation price is NaN', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = NaN;
+ const isLong = true;
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('safe');
+ expect(result.priceDiff).toBe(0);
+ expect(result.riskRatio).toBe(0);
+ });
+
+ it('returns safe risk assessment when current price is 0', () => {
+ const currentPrice = 0;
+ const newLiquidationPrice = 1900;
+ const isLong = true;
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('safe');
+ expect(result.priceDiff).toBe(0);
+ expect(result.riskRatio).toBe(0);
+ });
+ });
+});
diff --git a/app/components/UI/Perps/utils/marginUtils.ts b/app/components/UI/Perps/utils/marginUtils.ts
new file mode 100644
index 000000000000..d468b1e0a76e
--- /dev/null
+++ b/app/components/UI/Perps/utils/marginUtils.ts
@@ -0,0 +1,191 @@
+/**
+ * Margin adjustment calculation utilities
+ * Provides risk assessment and margin calculation functions for position management
+ */
+import { MARGIN_ADJUSTMENT_CONFIG } from '../constants/perpsConfig';
+
+export type RiskLevel = 'safe' | 'warning' | 'danger';
+
+export interface MarginRiskAssessment {
+ riskLevel: RiskLevel;
+ priceDiff: number;
+ riskRatio: number;
+}
+
+export interface AssessMarginRemovalRiskParams {
+ newLiquidationPrice: number;
+ currentPrice: number;
+ isLong: boolean;
+}
+
+export interface CalculateMaxRemovableMarginParams {
+ currentMargin: number;
+ positionSize: number;
+ entryPrice: number;
+ currentPrice: number;
+ maxLeverage: number;
+}
+
+export interface CalculateNewLiquidationPriceParams {
+ newMargin: number;
+ positionSize: number;
+ entryPrice: number;
+ isLong: boolean;
+ currentLiquidationPrice: number;
+}
+
+/**
+ * Assess liquidation risk after margin removal
+ * Compares new liquidation price against current market price to determine risk level
+ * @param params - New liquidation price, current market price, and position direction
+ * @returns Risk assessment with level (safe/warning/danger), price difference, and risk ratio
+ */
+export function assessMarginRemovalRisk(
+ params: AssessMarginRemovalRiskParams,
+): MarginRiskAssessment {
+ const { newLiquidationPrice, currentPrice, isLong } = params;
+
+ if (
+ !newLiquidationPrice ||
+ !currentPrice ||
+ isNaN(newLiquidationPrice) ||
+ isNaN(currentPrice)
+ ) {
+ return { riskLevel: 'safe', priceDiff: 0, riskRatio: 0 };
+ }
+
+ // Calculate price difference based on position direction
+ // For long: current price should be above liquidation price
+ // For short: liquidation price should be above current price
+ const priceDiff = isLong
+ ? currentPrice - newLiquidationPrice
+ : newLiquidationPrice - currentPrice;
+
+ // Risk ratio: how far away is price from liquidation, relative to liquidation price
+ // Higher ratio = safer (price is far from liquidation)
+ // Lower ratio = riskier (price is close to liquidation)
+ const riskRatio = priceDiff / newLiquidationPrice;
+
+ let riskLevel: RiskLevel;
+ if (riskRatio < MARGIN_ADJUSTMENT_CONFIG.LIQUIDATION_RISK_THRESHOLD - 1) {
+ riskLevel = 'danger'; // <20% buffer - critical risk
+ } else if (
+ riskRatio <
+ MARGIN_ADJUSTMENT_CONFIG.LIQUIDATION_WARNING_THRESHOLD - 1
+ ) {
+ riskLevel = 'warning'; // <50% buffer - moderate risk
+ } else {
+ riskLevel = 'safe'; // >=50% buffer - safe
+ }
+
+ return { riskLevel, priceDiff, riskRatio };
+}
+
+/**
+ * Calculate maximum margin that can be safely removed from a position
+ *
+ * HyperLiquid enforces: transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
+ * See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl
+ *
+ * For high leverage assets (e.g., 50x where initial margin = 2%),
+ * the 10% requirement is the binding constraint.
+ *
+ * IMPORTANT: We apply an additional 50% safety buffer on top of the minimum required
+ * because HyperLiquid's actual margin requirements can vary based on market conditions,
+ * unrealized PnL, and other factors not captured in this simplified calculation.
+ *
+ * @param params - Current margin, position size, entry price, current price, and max leverage limit
+ * @returns Maximum removable margin amount in USD
+ */
+export function calculateMaxRemovableMargin(
+ params: CalculateMaxRemovableMarginParams,
+): number {
+ const { currentMargin, positionSize, entryPrice, currentPrice, maxLeverage } =
+ params;
+
+ // Validate inputs
+ if (
+ isNaN(currentMargin) ||
+ isNaN(positionSize) ||
+ isNaN(entryPrice) ||
+ isNaN(currentPrice) ||
+ isNaN(maxLeverage) ||
+ currentMargin <= 0 ||
+ positionSize <= 0 ||
+ entryPrice <= 0 ||
+ currentPrice <= 0 ||
+ maxLeverage <= 0
+ ) {
+ return 0;
+ }
+
+ // Use the higher price to be conservative (HyperLiquid uses current mark price)
+ const price = Math.max(entryPrice, currentPrice);
+
+ // Calculate notional value
+ const notionalValue = positionSize * price;
+
+ // HyperLiquid's transfer margin requirement:
+ // transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
+ const initialMarginRequired = notionalValue / maxLeverage;
+ const tenPercentMargin =
+ notionalValue * MARGIN_ADJUSTMENT_CONFIG.MARGIN_REMOVAL_SAFETY_BUFFER;
+
+ // Minimum margin is the MAX of these two constraints
+ const baseMinimumRequired = Math.max(initialMarginRequired, tenPercentMargin);
+
+ // Apply 3x safety buffer because HyperLiquid's actual requirements are significantly higher
+ // than the documented formula due to:
+ // - Maintenance margin requirements
+ // - Unrealized PnL impact
+ // - Market volatility adjustments
+ // - Funding rate considerations
+ // Testing showed 1.5x was insufficient - $2 removal rejected when calc showed $2.95 available
+ const minimumMarginRequired = baseMinimumRequired * 3;
+
+ // Maximum removable = current - minimum (must be non-negative)
+ return Math.max(0, currentMargin - minimumMarginRequired);
+}
+
+/**
+ * Calculate new liquidation price after margin adjustment
+ * Estimates where the liquidation price will move based on margin change
+ * Note: This is a simplified calculation; actual liquidation price may vary based on protocol
+ * @param params - New margin amount, position size, entry price, direction, and current liquidation price
+ * @returns Estimated new liquidation price
+ */
+export function calculateNewLiquidationPrice(
+ params: CalculateNewLiquidationPriceParams,
+): number {
+ const {
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ } = params;
+
+ // Validate inputs
+ if (
+ isNaN(newMargin) ||
+ isNaN(positionSize) ||
+ isNaN(entryPrice) ||
+ newMargin <= 0 ||
+ positionSize <= 0 ||
+ entryPrice <= 0
+ ) {
+ return currentLiquidationPrice; // Return current if invalid inputs
+ }
+
+ // Calculate margin per unit of position
+ const marginPerUnit = newMargin / positionSize;
+
+ // For long positions: liquidation price is below entry price
+ // liquidationPrice = entryPrice - marginPerUnit
+ // For short positions: liquidation price is above entry price
+ // liquidationPrice = entryPrice + marginPerUnit
+ if (isLong) {
+ return Math.max(0, entryPrice - marginPerUnit);
+ }
+ return entryPrice + marginPerUnit;
+}
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index dc3108646399..16a1203c0ca3 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -264,6 +264,11 @@ const Routes = {
CLOSE_POSITION: 'PerpsClosePosition',
HIP3_DEBUG: 'PerpsHIP3Debug',
TPSL: 'PerpsTPSL',
+ ADJUST_MARGIN: 'PerpsAdjustMargin',
+ SELECT_MODIFY_ACTION: 'PerpsSelectModifyAction',
+ SELECT_ADJUST_MARGIN_ACTION: 'PerpsSelectAdjustMarginAction',
+ SELECT_ORDER_TYPE: 'PerpsSelectOrderType',
+ ORDER_DETAILS: 'PerpsOrderDetailsView',
PNL_HERO_CARD: 'PerpsPnlHeroCard',
ACTIVITY: 'PerpsActivity', // Stack-based activity view for proper back navigation
MODALS: {
diff --git a/app/util/trace.ts b/app/util/trace.ts
index 3d7f5f3fba88..617be9ae5f1c 100644
--- a/app/util/trace.ts
+++ b/app/util/trace.ts
@@ -141,12 +141,17 @@ export enum TraceName {
PerpsEditOrder = 'Perps Edit Order',
PerpsCancelOrder = 'Perps Cancel Order',
PerpsUpdateTPSL = 'Perps Update TP/SL',
+ PerpsUpdateMargin = 'Perps Update Margin',
+ PerpsFlipPosition = 'Perps Flip Position',
PerpsOrderSubmissionToast = 'Perps Order Submission Toast',
PerpsMarketDataUpdate = 'Perps Market Data Update',
PerpsOrderView = 'Perps Order View',
PerpsTabView = 'Perps Tab View',
PerpsMarketListView = 'Perps Market List View',
PerpsPositionDetailsView = 'Perps Position Details View',
+ PerpsAdjustMarginView = 'Perps Adjust Margin View',
+ PerpsOrderDetailsView = 'Perps Order Details View',
+ PerpsFlipPositionSheet = 'Perps Flip Position Sheet',
PerpsTransactionsView = 'Perps Transactions View',
PerpsOrderFillsFetch = 'Perps Order Fills Fetch',
PerpsOrdersFetch = 'Perps Orders Fetch',
diff --git a/docs/perps/perps-screens.md b/docs/perps/perps-screens.md
index 5f08dfdd613e..339ec82228c6 100644
--- a/docs/perps/perps-screens.md
+++ b/docs/perps/perps-screens.md
@@ -1,6 +1,6 @@
# Perps Screens & Views Documentation
-Complete architectural reference for all 16 Perps screens in MetaMask Mobile.
+Complete architectural reference for all 17 Perps screens in MetaMask Mobile.
## Table of Contents
@@ -11,15 +11,16 @@ Complete architectural reference for all 16 Perps screens in MetaMask Mobile.
5. [PerpsOrderView](#perpsorderview) - Order entry
6. [PerpsPositionsView](#perpspositionsview) - Positions list
7. [PerpsClosePositionView](#perpsclosepositio nview) - Close position
-8. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all
-9. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all
-10. [PerpsTPSLView](#perpstpslview) - TP/SL management
-11. [PerpsTransactionsView](#perpstransactionsview) - Transaction history
-12. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal
-13. [PerpsHeroCardView](#perpsherocardview) - Hero cards
-14. [PerpsEmptyState](#perpsemptystate) - Empty states
-15. [PerpsRedirect](#perpsredirect) - Routing logic
-16. [HIP3DebugView](#hip3debugview) - Debug tools
+8. [PerpsAdjustMarginView](#perpsadjustmarginview) - Adjust margin
+9. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all
+10. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all
+11. [PerpsTPSLView](#perpstpslview) - TP/SL management
+12. [PerpsTransactionsView](#perpstransactionsview) - Transaction history
+13. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal
+14. [PerpsHeroCardView](#perpsherocardview) - Hero cards
+15. [PerpsEmptyState](#perpsemptystate) - Empty states
+16. [PerpsRedirect](#perpsredirect) - Routing logic
+17. [HIP3DebugView](#hip3debugview) - Debug tools
---
@@ -171,16 +172,17 @@ Detailed market view with TradingView chart, market stats, and trading interface
### Key Components Used
-| Component | Purpose |
-| --------------------------- | ---------------------------------- |
-| `PerpsMarketHeader` | Title, price, 24h change |
-| `TradingViewChart` | Chart with multiple timeframes |
-| `PerpsCandlePeriodSelector` | Candle period (1m, 5m, 1h, 4h, 1d) |
-| `PerpsMarketTabs` | Info/Orders/Positions tabs |
-| `PerpsNavigationCard` | Quick action buttons |
-| `PerpsOICapWarning` | OI capacity warning |
-| `PerpsMarketHoursBanner` | Trading hours status |
-| `PerpsMarketBalanceActions` | Balance info |
+| Component | Purpose |
+| ------------------------------- | ---------------------------------- |
+| `PerpsMarketHeader` | Title, price, 24h change |
+| `TradingViewChart` | Chart with multiple timeframes |
+| `PerpsCandlePeriodSelector` | Candle period (1m, 5m, 1h, 4h, 1d) |
+| `PerpsMarketTabs` | Info/Orders/Positions tabs |
+| `PerpsNavigationCard` | Quick action buttons |
+| `PerpsOICapWarning` | OI capacity warning |
+| `PerpsMarketHoursBanner` | Trading hours status |
+| `PerpsMarketBalanceActions` | Balance info |
+| `PerpsFlipPositionConfirmSheet` | Flip position confirmation modal |
### Hooks Consumed
@@ -547,6 +549,50 @@ User action: Confirm → onConfirm(tpPrice, slPrice, trackingData)
---
+## PerpsAdjustMarginView
+
+**Location:** `app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx`
+
+### Purpose & User Journey
+
+Unified view for adjusting position margin (add or remove). Mode parameter determines behavior: add mode increases margin to reduce leverage; remove mode decreases margin to free collateral. Slider-based selection with live impact preview and risk warnings for remove mode.
+
+### Key Components Used
+
+| Component | Purpose |
+| ------------------ | ------------------ |
+| `Slider` | Amount selector |
+| `PerpsOrderHeader` | Asset info & price |
+
+### Hooks Consumed
+
+| Hook | Purpose |
+| -------------------------- | ------------------------------------- |
+| `usePerpsMarginAdjustment` | Unified margin adjustment with toasts |
+| `usePerpsLiveAccount` | Available balance (add mode) |
+| `usePerpsMarkets` | Max leverage (remove mode) |
+| `usePerpsLivePrices` | Current market price |
+| `usePerpsMeasurement` | Performance tracking with mode tag |
+
+### Data Flow
+
+```
+Route params: { position, mode: 'add' | 'remove' }
+Add mode: availableBalance → maxAmount
+Remove mode: calculateMaxRemovableMargin() → maxAmount
+User slides → Preview new margin/leverage/liq price
+Remove mode: assessMarginRemovalRisk() → risk level (safe/warning/danger)
+Confirm → handleAddMargin() or handleRemoveMargin()
+```
+
+### Navigation
+
+- **From:** PerpsMarketDetailsView (position card → Adjust Margin action sheet → mode selection)
+- **To:** Navigates back on success
+- **Full screen:** SafeAreaView-based
+
+---
+
## PerpsTransactionsView
**Location:** `app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx`
diff --git a/docs/perps/perps-sentry-reference.md b/docs/perps/perps-sentry-reference.md
index f60fa9f05383..f8d2148b65ea 100644
--- a/docs/perps/perps-sentry-reference.md
+++ b/docs/perps/perps-sentry-reference.md
@@ -135,7 +135,7 @@ setMeasurement(
## Event Catalog
-### UI Screen Measurements (14 events)
+### UI Screen Measurements (16 events)
**Purpose:** Track screen load times and user-perceived performance.
@@ -146,6 +146,8 @@ setMeasurement(
| `PerpsPositionDetailsView` | Position data, market stats, history loaded | Position details |
| `PerpsOrderView` | Current price, market data, account available | Trade entry |
| `PerpsClosePositionView` | Position data, current price | Position exit |
+| `PerpsAdjustMarginView` | Position data, balance/max removable (mode) | Adjust margin (add/remove) - differentiated by mode tag |
+| `PerpsFlipPositionSheet` | Position data, fees, current price | Flip position confirmation bottom sheet |
| `PerpsWithdrawView` | Account balance, destination token | Withdrawal form |
| `PerpsTransactionsView` | Order fills loaded | History view |
| `PerpsOrderSubmissionToast` | Immediate (shows when toast appears) | Order feedback |
@@ -168,7 +170,7 @@ setMeasurement(
| `PERPS_CLOSE_ORDER_CONFIRMATION_TOAST_LOADED` | ms | Close confirmation |
| `PERPS_LEVERAGE_BOTTOM_SHEET_LOADED` | ms | Leverage picker |
-### Trading Operations (7 events)
+### Trading Operations (9 events)
**Purpose:** Track order execution, position management, and transaction completion.
@@ -179,6 +181,8 @@ setMeasurement(
| `PerpsCancelOrder` | `PerpsOrderSubmission` | provider, market, isTestnet, **isBatch** (batch ops only) | orderId, success, **coinCount** (batch), **successCount** (batch) |
| `PerpsClosePosition` | `PerpsPositionManagement` | provider, coin, closeSize, isTestnet, **isBatch** (batch) | success, filledSize, **closeAll** (batch), **coinCount** (batch) |
| `PerpsUpdateTPSL` | `PerpsPositionManagement` | provider, market, isTestnet | takeProfitPrice, stopLossPrice, success |
+| `PerpsUpdateMargin` | `PerpsPositionManagement` | provider, coin, action, isTestnet | amount, success |
+| `PerpsFlipPosition` | `PerpsPositionManagement` | provider, coin, fromDirection, toDirection, isTestnet | size, success |
| `PerpsWithdraw` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash, withdrawalId |
| `PerpsDeposit` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash |
diff --git a/e2e/pages/Perps/PerpsMarketDetailsView.ts b/e2e/pages/Perps/PerpsMarketDetailsView.ts
index 66a0e4b8322d..7da4009dc560 100644
--- a/e2e/pages/Perps/PerpsMarketDetailsView.ts
+++ b/e2e/pages/Perps/PerpsMarketDetailsView.ts
@@ -4,7 +4,6 @@ import {
PerpsCandlestickChartSelectorsIDs,
PerpsMarketTabsSelectorsIDs,
PerpsOpenOrderCardSelectorsIDs,
- PerpsPositionCardSelectorsIDs,
} from '../../selectors/Perps/Perps.selectors';
import Gestures from '../../framework/Gestures';
import Matchers from '../../framework/Matchers';
@@ -252,7 +251,7 @@ class PerpsMarketDetailsView {
// Ensure Close Position button is visible by performing best-effort scrolls, then assert
async expectClosePositionButtonVisible() {
const closeBtn = Matchers.getElementByID(
- PerpsPositionCardSelectorsIDs.CLOSE_BUTTON,
+ PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON,
) as DetoxElement;
for (let i = 0; i < 3; i++) {
diff --git a/e2e/pages/Perps/PerpsView.ts b/e2e/pages/Perps/PerpsView.ts
index 9acf0ecbd276..e2ac474c19be 100644
--- a/e2e/pages/Perps/PerpsView.ts
+++ b/e2e/pages/Perps/PerpsView.ts
@@ -1,10 +1,10 @@
import {
- PerpsPositionCardSelectorsIDs,
PerpsGeneralSelectorsIDs,
PerpsOrderViewSelectorsIDs,
PerpsMarketListViewSelectorsIDs,
PerpsClosePositionViewSelectorsIDs,
PerpsPositionDetailsViewSelectorsIDs,
+ PerpsMarketDetailsViewSelectorsIDs,
getPerpsTPSLViewSelector,
} from '../../selectors/Perps/Perps.selectors';
import Gestures from '../../framework/Gestures';
@@ -14,7 +14,9 @@ import Utilities from '../../framework/Utilities';
class PerpsView {
get closePositionButton() {
- return Matchers.getElementByID(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON);
+ return Matchers.getElementByID(
+ PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON,
+ );
}
getPositionItem(
diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts
index 82dd30d44d24..1711d4103080 100644
--- a/e2e/selectors/Perps/Perps.selectors.ts
+++ b/e2e/selectors/Perps/Perps.selectors.ts
@@ -68,17 +68,21 @@ export const PerpsChartFullscreenModalSelectorsIDs = {
export const PerpsPositionCardSelectorsIDs = {
CARD: 'PerpsPositionCard',
- // Test mock selectors (for component testing)
- COIN: 'position-card-coin',
- SIZE: 'position-card-size',
- PNL: 'position-card-pnl',
- CLOSE_BUTTON: 'position-card-close',
- EDIT_BUTTON: 'position-card-edit',
+ HEADER: 'position-card-header',
SHARE_BUTTON: 'position-card-share',
- TPSL_COUNT_WARNING_TOOLTIP_VIEW_ORDERS_BUTTON:
- 'position-card-tpsl-count-warning-tooltip-view-orders',
- TPSL_COUNT_WARNING_TOOLTIP_GOT_IT_BUTTON:
- 'position-card-tpsl-count-warning-tooltip-got-it',
+ PNL_CARD: 'position-card-pnl',
+ PNL_VALUE: 'position-card-pnl-value',
+ RETURN_CARD: 'position-card-return',
+ RETURN_VALUE: 'position-card-return-value',
+ SIZE_CONTAINER: 'position-card-size',
+ SIZE_VALUE: 'position-card-size-value',
+ FLIP_ICON: 'position-card-flip-icon',
+ MARGIN_CONTAINER: 'position-card-margin',
+ MARGIN_VALUE: 'position-card-margin-value',
+ MARGIN_CHEVRON: 'position-card-margin-chevron',
+ AUTO_CLOSE_TOGGLE: 'position-card-auto-close-toggle',
+ DETAILS_SECTION: 'position-card-details',
+ DIRECTION_VALUE: 'position-card-direction-value',
};
// ========================================
@@ -333,6 +337,12 @@ export const PerpsMarketDetailsViewSelectorsIDs = {
ADD_FUNDS_BUTTON: 'perps-market-details-add-funds-button',
LONG_BUTTON: 'perps-market-details-long-button',
SHORT_BUTTON: 'perps-market-details-short-button',
+ CLOSE_BUTTON: 'perps-market-details-close-button',
+ MODIFY_BUTTON: 'perps-market-details-modify-button',
+ SHARE_BUTTON: 'perps-market-details-share-button',
+ ADD_TPSL_BUTTON: 'perps-market-details-add-tpsl-button',
+ MODIFY_ACTION_SHEET: 'perps-market-details-modify-action-sheet',
+ ADJUST_MARGIN_ACTION_SHEET: 'perps-market-details-adjust-margin-action-sheet',
CANDLE_PERIOD_BOTTOM_SHEET: 'perps-market-candle-period-bottom-sheet',
OPEN_INTEREST_INFO_ICON: 'perps-market-details-open-interest-info-icon',
FUNDING_RATE_INFO_ICON: 'perps-market-details-funding-rate-info-icon',
diff --git a/locales/languages/en.json b/locales/languages/en.json
index f245f8bb0781..770bd551694b 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -960,6 +960,12 @@
"loading_positions": "Loading positions...",
"refreshing_positions": "Refreshing positions...",
"no_open_orders": "No open orders",
+ "auto_close": {
+ "title": "Auto close",
+ "description": "Protect your margin, lock in gains",
+ "set_button": "Set",
+ "edit_button": "Edit"
+ },
"deposit": {
"title": "Amount to deposit",
"get_usdc_hyperliquid": "Get USDC • Hyperliquid",
@@ -1148,6 +1154,8 @@
"tp_sl": "Auto close",
"tp": "TP",
"sl": "SL",
+ "long_label": "Long",
+ "short_label": "Short",
"button": {
"long": "Long {{asset}}",
"short": "Short {{asset}}"
@@ -1226,6 +1234,21 @@
"your_funds_have_been_returned_to_you": "Your funds have been returned to you",
"order_cancelled_success": "{{detailedOrderType}} order cancelled"
},
+ "order_details": {
+ "title": "Order Details",
+ "cancel_order": "Cancel Order",
+ "date": "Date",
+ "fee": "Fee",
+ "limit_buy": "Limit Long",
+ "limit_price": "Limit Price",
+ "limit_sell": "Limit Short",
+ "market_buy": "Market Long",
+ "market_sell": "Market Short",
+ "open": "Open",
+ "size": "Size",
+ "status": "Status",
+ "view_explorer": "View on Explorer"
+ },
"close_position": {
"title": "Close position",
"button": "Close position",
@@ -1265,6 +1288,36 @@
"you_need_set_price_limit_order": "You need to set a price for a limit order.",
"your_pnl_is": "Your P&L is"
},
+ "modify": {
+ "title": "Modify",
+ "add_to_position": "Increase exposure",
+ "add_to_position_description": "Increase the size of your {{direction}} position",
+ "reduce_position": "Reduce exposure",
+ "reduce_position_description": "Decrease the size of your {{direction}} position by closing it partially",
+ "flip_position": "Reverse position",
+ "flip_position_description": "Flip your {{fromDirection}} to a {{toDirection}}"
+ },
+ "flip_position": {
+ "title": "Flip Position",
+ "direction": "Direction",
+ "est_size": "Est. Size",
+ "flip": "Flip",
+ "flipping": "Flipping...",
+ "cancel": "Cancel"
+ },
+ "adjust_margin": {
+ "title": "Adjust Margin",
+ "add_margin": "Add Margin",
+ "add_margin_description": "Increase margin to reduce liquidation risk",
+ "reduce_margin": "Reduce Margin",
+ "reduce_margin_description": "Withdraw excess margin from position",
+ "add_title": "Add Margin",
+ "remove_title": "Reduce Margin",
+ "margin_in_position": "Margin in position",
+ "perps_balance": "Perps balance",
+ "liquidation_price": "Liquidation price",
+ "liquidation_distance": "Liquidation distance"
+ },
"tpsl": {
"title": "Auto close",
"description": "Pick a percentage gain or loss, or enter a custom trigger price to automatically close your position.",
@@ -1304,6 +1357,9 @@
"minimumDeposit": "Minimum deposit amount is {{amount}} USDC",
"tokenNotSupported": "Token {{token}} not supported for deposits",
"unknownError": "Unknown error occurred",
+ "unknown": "Unknown error occurred",
+ "position_not_found": "Position not found",
+ "order_not_found": "Order not found",
"clientNotInitialized": "HyperLiquid SDK clients not properly initialized",
"exchangeClientNotAvailable": "ExchangeClient not available after initialization",
"infoClientNotAvailable": "InfoClient not available after initialization",
@@ -1368,11 +1424,26 @@
"title": "Something Went Wrong",
"description": "An unexpected error occurred. Please try again later.",
"retry": "Retry"
+ },
+ "marginValidation": {
+ "exceedsMaxRemovable": "Amount exceeds maximum removable margin",
+ "insufficientMargin": "Position does not have sufficient margin for this reduction"
}
},
"position": {
"title": "Positions",
"card": {
+ "position_title": "Position",
+ "pnl_label": "P&L",
+ "return_label": "Return",
+ "size_label": "Size",
+ "margin_label": "Margin",
+ "direction_label": "Direction",
+ "entry_label": "Entry price",
+ "liquidation_price_label": "Liquidation price",
+ "funding_payments_label": "Funding payments",
+ "oracle_price_label": "Oracle price",
+ "details_title": "Details",
"entry_price": "Entry price",
"funding_cost": "Funding",
"liquidation_price": "Liq. Price",
@@ -1413,6 +1484,11 @@
"tpsl": {
"update_success": "TP/SL updated successfully",
"update_failed": "Failed to update TP/SL"
+ },
+ "margin": {
+ "add_success": "Added ${{amount}} margin to {{asset}} position",
+ "remove_success": "Removed ${{amount}} margin from {{asset}} position",
+ "adjustment_failed": "Margin adjustment failed"
}
},
"markets": {
@@ -1424,16 +1500,21 @@
"error_message": "Market data not found. Please go back and try again."
},
"statistics": "Overview",
+ "stats": "Stats",
"24h_high": "24h high",
"24h_low": "24h low",
"24h_volume": "24h volume",
"open_interest": "Open interest",
"funding_rate": "Funding rate",
+ "oracle_price": "Oracle price",
"countdown": "Countdown",
"long": "Long",
"short": "Short",
"long_lowercase": "long",
"short_lowercase": "short",
+ "modify": "Modify",
+ "close_long": "Close long",
+ "close_short": "Close short",
"add_funds": "Add funds",
"add_funds_to_start_trading_perps": "Add funds to start trading perps",
"position": "Position",
@@ -1470,6 +1551,10 @@
"title": "Liquidation price",
"content": "If the price hits this point, you'll be liquidated and lose your margin. Higher leverage makes this more likely."
},
+ "liquidation_distance": {
+ "title": "Liquidation distance",
+ "content": "The percentage the price needs to move against your position before liquidation. A higher percentage means more room before liquidation."
+ },
"margin": {
"title": "Margin",
"content": "Margin is the money you put in to open a trade. It acts as collateral, and it's the most you can lose on that trade."
From 380795594375b1d2160c66416134758d33e7f320 Mon Sep 17 00:00:00 2001
From: Amitabh Aggarwal
Date: Wed, 26 Nov 2025 11:11:11 -0600
Subject: [PATCH 13/16] fix(ramp): cp-7.60.0 fix phone already registered error
detection in BasicInfo form (#23299)
## **Description**
Fixed error handling in BasicInfo component to correctly access error
codes from Axios error responses. The code was previously trying to
access `error.error.errorCode` but Axios errors have the structure
`error.response.data.error.errorCode`, which prevented error code 2020
(phone already registered) from being detected correctly.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/23302
## **Manual testing steps**
```gherkin
Feature: BasicInfo form error handling
Scenario: user submits form with already registered phone number
Given I am on the BasicInfo screen with valid form data
When I submit the form with a phone number that is already registered
Then I should see an error message indicating the phone is already registered
And I should see a "Log in with email" button
And clicking the logout button should navigate to the email entry screen
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/e8acba92-1032-4f49-a5bd-84a605bda71f
## **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]
> Fixes BasicInfo to read Axios error response for code 2020, format the
email-based message, and expose a logout-to-email flow; updates tests to
use AxiosError and cover these behaviors.
>
> - **UI (Deposit BasicInfo)**:
> - Parse Axios errors via `response.data.error` to detect Transak
`errorCode` `2020` and set `isPhoneRegisteredError`.
> - Extract email from error message to show localized
`phone_already_registered` banner text.
> - Show "Log in with email" action on the error banner and navigate to
`Routes.DEPOSIT.ENTER_EMAIL`; gracefully handle logout failures.
> - Minor: add `AxiosError` typing and default fallback message.
> - **Tests** (`BasicInfo.test.tsx`):
> - Replace custom errors with `AxiosError` shaped responses; add/import
Axios types.
> - Verify formatted message, presence/absence of logout button by error
type, navigation on logout, and error handling when logout fails.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0961bb628abe43c89e8bacdda2e2b5297206dc52. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../Views/BasicInfo/BasicInfo.test.tsx | 103 +++++++++++++-----
.../Deposit/Views/BasicInfo/BasicInfo.tsx | 46 ++++----
2 files changed, 103 insertions(+), 46 deletions(-)
diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx
index 60739fda4805..c02658fcafb5 100644
--- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx
@@ -9,6 +9,7 @@ import { createSsnInfoModalNavigationDetails } from '../Modals/SsnInfoModal';
import { BuyQuote } from '@consensys/native-ramps-sdk';
import { endTrace } from '../../../../../../util/trace';
import Logger from '../../../../../../util/Logger';
+import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import {
MOCK_REGIONS,
MOCK_US_REGION,
@@ -392,14 +393,27 @@ describe('BasicInfo Component', () => {
});
it('displays logout button when error has errorCode 2020', async () => {
- // Mock Transak API error structure: { error: { errorCode: 2020, message: "..." } }
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.',
+ // Mock Transak API error structure: { response: { data: { error: { errorCode: 2020, message: "..." } } } }
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
@@ -435,13 +449,26 @@ describe('BasicInfo Component', () => {
it('displays formatted error message for errorCode 2020', async () => {
// Mock Transak API error structure with email in message
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.',
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
@@ -505,13 +532,26 @@ describe('BasicInfo Component', () => {
});
it('calls logoutFromProvider and navigates to EnterEmail on logout click', async () => {
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with test@gmail.com. Login with this email to continue.',
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with test@gmail.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
@@ -553,13 +593,26 @@ describe('BasicInfo Component', () => {
const logoutError = new Error('Logout failed');
mockLogoutFromProvider.mockRejectedValueOnce(logoutError);
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with d***@example.com. Login with this email to continue.',
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with d***@example.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx
index 54363ce480ae..531aabb92686 100644
--- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx
@@ -49,6 +49,7 @@ import Logger from '../../../../../../util/Logger';
import BannerAlert from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert';
import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types';
import { useRegions } from '../../hooks/useRegions';
+import type { AxiosError } from 'axios';
export interface BasicInfoParams {
quote: BuyQuote;
@@ -230,31 +231,34 @@ const BasicInfo = (): JSX.Element => {
}),
);
} catch (submissionError) {
- // Check for Transak error code 2020 (phone already registered)
- // API returns: { error: { errorCode: 2020, message: "..." } }
- const errorWithCode = submissionError as unknown as {
- error?: { errorCode?: number; message?: string };
- };
- const isPhoneError = errorWithCode?.error?.errorCode === 2020;
+ const axiosError = submissionError as AxiosError;
+ const apiError = (
+ axiosError?.response?.data as {
+ error?: { errorCode?: number; message?: string };
+ }
+ )?.error;
+ const isPhoneError = apiError?.errorCode === 2020;
setIsPhoneRegisteredError(isPhoneError);
- // For error code 2020, extract email from message and format it
- let errorMessage = '';
- if (isPhoneError && errorWithCode?.error?.message) {
- // Extract email from message like "...created with k****@pedalsup.com..."
- const emailMatch = errorWithCode.error.message.match(
- /[\w*]+@[\w*]+(?:\.[\w*]+)*/,
- );
+ const errorMessageText =
+ submissionError instanceof Error && submissionError.message
+ ? submissionError.message
+ : strings('deposit.basic_info.unexpected_error');
+
+ let errorMessage = errorMessageText;
+ if (isPhoneError && errorMessageText) {
+ // Extract email from message for error code 2020 (phone already registered)
+ const emailMatch = errorMessageText.match(/[\w*]+@[\w*]+(?:\.[\w*]+)*/);
const email = emailMatch ? emailMatch[0] : '';
- errorMessage = email
- ? strings('deposit.basic_info.phone_already_registered', { email })
- : errorWithCode.error.message;
- } else {
- errorMessage =
- submissionError instanceof Error && submissionError.message
- ? submissionError.message
- : strings('deposit.basic_info.unexpected_error');
+ if (email) {
+ errorMessage = strings(
+ 'deposit.basic_info.phone_already_registered',
+ {
+ email,
+ },
+ );
+ }
}
setError(errorMessage);
From 3cf4f741b431112f9f6a99b2e5d201097502552c Mon Sep 17 00:00:00 2001
From: SteP-n-s
Date: Wed, 26 Nov 2025 17:20:34 +0000
Subject: [PATCH 14/16] fix: update logic to support all non-evm chains
cp-7.60.0 (#23328)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Update logic to include all non-evm chains when parsing deep link urls.
## **Changelog**
CHANGELOG entry: Update logic for deeplink parsing to take into account
all non-EVM chains
## **Related issues**
Fixes: #23314
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ x ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ x ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ x ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Generalizes asset metadata handling from Solana-only to all non-EVM
chains using `isNonEvmChainId`.
>
> - **Bridge asset metadata utils
(`app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts`)**:
> - Replace Solana-specific check with generic `isNonEvmChainId` for
non-EVM asset handling in `fetchAssetMetadata`.
> - Import `isNonEvmChainId` from `@metamask/bridge-controller` to
support all non-EVM chains.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
20194118637c9cf9b136e28317673495dd896f2e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts b/app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts
index c978c4a8aa04..0530d1db6d91 100644
--- a/app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts
+++ b/app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts
@@ -16,6 +16,7 @@ import { handleFetch } from '@metamask/controller-utils';
import {
formatAddressToCaipReference,
formatChainIdToHex,
+ isNonEvmChainId,
} from '@metamask/bridge-controller';
const TOKEN_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3';
@@ -103,8 +104,8 @@ export const fetchAssetMetadata = async (
assetId,
};
- // Solana
- if (chainId === MultichainNetwork.Solana && assetId) {
+ // non-EVM
+ if (isNonEvmChainId(chainId) && assetId) {
const { assetReference } = parseCaipAssetType(assetId);
return {
...commonFields,
From 3f78b4c49e450e6ab6365ebc5a5afed90382d056 Mon Sep 17 00:00:00 2001
From: sophieqgu <37032128+sophieqgu@users.noreply.github.com>
Date: Wed, 26 Nov 2025 12:27:05 -0500
Subject: [PATCH 15/16] chore: add activity item for shield and holding m usd
(#23231)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
https://consensyssoftware.atlassian.net/browse/RWDS-844
Create a new way render unknown activity types based on data from the
backend.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [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]
> Adds support to render unknown activity events using backend-provided
season activity types and templated descriptions, wiring activityTypes
through controller, state, selectors, and UI.
>
> - **Rewards UI/UX**:
> - Activity rows and details now consume `activityTypes` from Redux
(`selectSeasonActivityTypes`) and pass them to
`getEventDetails`/`openActivityDetailsSheet` to render titles/icons and
a templated "Description" row.
> - `ActivityEventRow` improves network icon extraction (typed payloads,
safer parsing) and passes `confirmAction`.
> - **Utils**:
> - `getEventDetails(event, activityTypes, accountName)` supports custom
types using `getIconName` and `resolveTemplate` for descriptions; adds
per-event extra description in details sheet.
> - New `resolveTemplate` helper; locale adds
`rewards.events.description`.
> - **State/Selectors**:
> - Introduces `SeasonActivityTypeDto`; adds `seasonActivityTypes` to
`SeasonDto/SeasonDtoState/SeasonStatusDto`, Redux state, rehydration,
and selectors.
> - **Engine/Data**:
> - Rewards controller and data service fetch/persist `activityTypes`;
state conversion and caching updated; ensure array defaulting.
> - **Tests**:
> - Extensive updates/coverage across components, utils, reducers,
selectors, and controller to reflect new `activityTypes`, templating,
and network parsing behavior.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
3f6fadbd4cfb61d9dab4e9bc17a04b1a2218831a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../ActivityTab/ActivityEventRow.test.tsx | 168 +-
.../Tabs/ActivityTab/ActivityEventRow.tsx | 41 +-
.../Tabs/ActivityTab/ActivityTab.test.tsx | 1 +
.../ActivityDetailsSheet.test.tsx | 112 +-
.../EventDetails/ActivityDetailsSheet.tsx | 69 +-
.../hooks/useActivityDetailsConfirmAction.ts | 11 +-
.../UI/Rewards/hooks/useSeasonStatus.test.ts | 3 +
.../Rewards/utils/eventDetailsUtils.test.ts | 273 +-
.../UI/Rewards/utils/eventDetailsUtils.ts | 27 +-
.../UI/Rewards/utils/formatUtils.test.ts | 52 +
.../UI/Rewards/utils/formatUtils.ts | 16 +
.../RewardsController.test.ts | 372 +-
.../rewards-controller/RewardsController.ts | 5 +-
.../services/rewards-data-service.test.ts | 1 +
.../services/rewards-data-service.ts | 5 +
.../controllers/rewards-controller/types.ts | 35 +
app/reducers/rewards/index.test.ts | 4898 +++++++++--------
app/reducers/rewards/index.ts | 6 +
app/reducers/rewards/selectors.test.ts | 38 +
app/reducers/rewards/selectors.ts | 3 +
locales/languages/en.json | 3 +-
21 files changed, 3466 insertions(+), 2673 deletions(-)
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx
index cdb4b6ab1759..3dce7d3a3f5e 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx
@@ -1,14 +1,21 @@
import React from 'react';
-import { render } from '@testing-library/react-native';
+import { render, fireEvent } from '@testing-library/react-native';
import { useSelector } from 'react-redux';
import { ActivityEventRow } from './ActivityEventRow';
-import { PointsEventDto } from '../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ CardEventPayload,
+ PerpsEventPayload,
+ PointsEventDto,
+ SeasonActivityTypeDto,
+ SwapEventPayload,
+} from '../../../../../../core/Engine/controllers/rewards-controller/types';
import { formatRewardsDate } from '../../../utils/formatUtils';
import { getEventDetails } from '../../../utils/eventDetailsUtils';
import { IconName } from '@metamask/design-system-react-native';
import TEST_ADDRESS from '../../../../../../constants/address';
import { useActivityDetailsConfirmAction } from '../../../hooks/useActivityDetailsConfirmAction';
import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants';
+import { selectSeasonActivityTypes } from '../../../../../../reducers/rewards/selectors';
// Mock the utility functions
jest.mock('../../../utils/formatUtils', () => ({
@@ -76,6 +83,53 @@ const mockUseActivityDetailsConfirmAction =
useActivityDetailsConfirmAction as jest.MockedFunction<
typeof useActivityDetailsConfirmAction
>;
+jest.mock('../../../../../../util/networks', () => ({
+ getNetworkImageSource: jest.fn(),
+}));
+
+jest.mock('@metamask/utils', () => ({
+ parseCaipAssetType: jest.fn(),
+}));
+
+jest.mock('./EventDetails/ActivityDetailsSheet', () => ({
+ openActivityDetailsSheet: jest.fn(),
+}));
+
+jest.mock('../../../../../../util/Logger', () => ({
+ __esModule: true,
+ default: {
+ error: jest.fn(),
+ },
+}));
+import { getNetworkImageSource } from '../../../../../../util/networks';
+import { parseCaipAssetType } from '@metamask/utils';
+import { openActivityDetailsSheet } from './EventDetails/ActivityDetailsSheet';
+import { ModalAction } from '../../RewardsBottomSheetModal';
+jest.mock(
+ '../../../../../../component-library/components/Badges/Badge',
+ () => ({
+ __esModule: true,
+ default: ({ children }: { children?: React.ReactNode }) => children ?? null,
+ BadgeVariant: { Network: 'Network' },
+ }),
+);
+
+jest.mock(
+ '../../../../../../component-library/components/Badges/BadgeWrapper',
+ () => ({
+ __esModule: true,
+ default: ({ children }: { children?: React.ReactNode }) => children ?? null,
+ BadgePosition: { BottomRight: 'BottomRight' },
+ }),
+);
+
+jest.mock(
+ '../../../../../../component-library/components/Avatars/Avatar',
+ () => ({
+ __esModule: true,
+ AvatarSize: { Sm: 'Sm' },
+ }),
+);
describe('ActivityEventRow', () => {
// Helper to create a valid PointsEventDto for all event types
@@ -253,11 +307,31 @@ describe('ActivityEventRow', () => {
icon: IconName.Star,
};
+ const mockActivityTypes: SeasonActivityTypeDto[] = [
+ {
+ type: 'SWAP',
+ title: 'Swap',
+ description: 'Swap desc',
+ icon: 'SwapVertical',
+ },
+ {
+ type: 'CARD',
+ title: 'Card spend',
+ description: 'Spend',
+ icon: 'Card',
+ },
+ ];
+
beforeEach(() => {
jest.clearAllMocks();
mockGetEventDetails.mockReturnValue(defaultEventDetails);
mockFormatRewardsDate.mockReturnValue('Sep 9, 2025');
- mockUseSelector.mockReturnValue({});
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectSeasonActivityTypes) {
+ return mockActivityTypes;
+ }
+ return {} as unknown;
+ });
mockUseActivityDetailsConfirmAction.mockReturnValue(undefined);
});
@@ -459,7 +533,11 @@ describe('ActivityEventRow', () => {
expect(getByText('Opened position')).toBeOnTheScreen();
expect(getByText('Opened SHORT BIO position')).toBeOnTheScreen();
expect(getByText('+1')).toBeOnTheScreen();
- expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS);
+ expect(mockGetEventDetails).toHaveBeenCalledWith(
+ event,
+ mockActivityTypes,
+ TEST_ADDRESS,
+ );
});
it('should render SIGN_UP_BONUS event correctly', () => {
@@ -562,7 +640,11 @@ describe('ActivityEventRow', () => {
expect(getByText('43.25 USDC')).toBeOnTheScreen();
expect(getByText('+15')).toBeOnTheScreen();
expect(getByText('+50%')).toBeOnTheScreen();
- expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS);
+ expect(mockGetEventDetails).toHaveBeenCalledWith(
+ event,
+ mockActivityTypes,
+ TEST_ADDRESS,
+ );
});
it('renders PREDICT event without description', () => {
@@ -734,7 +816,11 @@ describe('ActivityEventRow', () => {
// Assert
expect(getByText('Test Event')).toBeOnTheScreen();
- expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS);
+ expect(mockGetEventDetails).toHaveBeenCalledWith(
+ event,
+ mockActivityTypes,
+ TEST_ADDRESS,
+ );
});
it('should handle formatRewardsDate returning different date formats', () => {
@@ -762,6 +848,8 @@ describe('ActivityEventRow', () => {
details: 'Swapped USDC for ETH',
icon: IconName.SwapHorizontal,
});
+ (parseCaipAssetType as jest.Mock).mockReturnValue({ chainId: '59144' });
+ (getNetworkImageSource as jest.Mock).mockReturnValue({ uri: 'net.png' });
// Act
const { getByText } = render(
@@ -771,6 +859,10 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Swap')).toBeOnTheScreen();
expect(getByText('Swapped USDC for ETH')).toBeOnTheScreen();
+ expect(parseCaipAssetType).toHaveBeenCalledWith(
+ (event.payload as unknown as SwapEventPayload).srcAsset.type,
+ );
+ expect(getNetworkImageSource).toHaveBeenCalledWith({ chainId: '59144' });
});
it('should extract chainId from PERPS event asset type', () => {
@@ -781,6 +873,8 @@ describe('ActivityEventRow', () => {
details: 'Opened SHORT BIO position',
icon: IconName.Candlestick,
});
+ (parseCaipAssetType as jest.Mock).mockReturnValue({ chainId: '999' });
+ (getNetworkImageSource as jest.Mock).mockReturnValue({ uri: 'p.png' });
// Act
const { getByText } = render(
@@ -790,6 +884,10 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Opened position')).toBeOnTheScreen();
expect(getByText('Opened SHORT BIO position')).toBeOnTheScreen();
+ expect(parseCaipAssetType).toHaveBeenCalledWith(
+ (event.payload as unknown as PerpsEventPayload).asset.type,
+ );
+ expect(getNetworkImageSource).toHaveBeenCalledWith({ chainId: '999' });
});
it('should extract chainId from CARD event asset type', () => {
@@ -800,6 +898,8 @@ describe('ActivityEventRow', () => {
details: '43.25 USDC',
icon: IconName.Card,
});
+ (parseCaipAssetType as jest.Mock).mockReturnValue({ chainId: '1' });
+ (getNetworkImageSource as jest.Mock).mockReturnValue({ uri: 'c.png' });
// Act
const { getByText } = render(
@@ -809,6 +909,10 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Card spend')).toBeOnTheScreen();
expect(getByText('43.25 USDC')).toBeOnTheScreen();
+ expect(parseCaipAssetType).toHaveBeenCalledWith(
+ (event.payload as unknown as CardEventPayload).asset.type,
+ );
+ expect(getNetworkImageSource).toHaveBeenCalledWith({ chainId: '1' });
});
it('should handle CARD event without asset type gracefully', () => {
@@ -840,6 +944,8 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Card spend')).toBeOnTheScreen();
expect(getByText('50 USDC')).toBeOnTheScreen();
+ expect(parseCaipAssetType).not.toHaveBeenCalled();
+ expect(getNetworkImageSource).not.toHaveBeenCalled();
});
it('should handle events without payload gracefully', () => {
@@ -859,6 +965,8 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Sign up bonus')).toBeOnTheScreen();
expect(getByText('Welcome bonus')).toBeOnTheScreen();
+ expect(parseCaipAssetType).not.toHaveBeenCalled();
+ expect(getNetworkImageSource).not.toHaveBeenCalled();
});
it('should handle CARD event with missing payload fields', () => {
@@ -880,6 +988,54 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Card spend')).toBeOnTheScreen();
+ expect(parseCaipAssetType).not.toHaveBeenCalled();
+ expect(getNetworkImageSource).not.toHaveBeenCalled();
+ });
+
+ it('handles error when asset parsing throws without crashing', () => {
+ // Arrange
+ const event = createMockEvent({ type: 'SWAP' });
+ (parseCaipAssetType as jest.Mock).mockImplementation(() => {
+ throw new Error('bad parse');
+ });
+
+ // Act
+ const { getByText } = render(
+ ,
+ );
+
+ // Assert
+ expect(getByText('Swap Event')).toBeOnTheScreen();
+ });
+ });
+
+ describe('openActivityDetailsSheet', () => {
+ it('opens details sheet with activityTypes and confirmAction on press', () => {
+ // Arrange
+ const event = createMockEvent({ type: 'CARD' });
+ const confirmAction = jest.fn();
+ mockUseActivityDetailsConfirmAction.mockReturnValue(
+ confirmAction as unknown as ModalAction,
+ );
+
+ // Act
+ const { getByTestId } = render(
+ ,
+ );
+ const row = getByTestId('row-1');
+ fireEvent.press(row);
+
+ // Assert
+ expect(openActivityDetailsSheet).toHaveBeenCalledWith(expect.anything(), {
+ event,
+ accountName: TEST_ADDRESS,
+ activityTypes: mockActivityTypes,
+ confirmAction,
+ });
});
});
});
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx
index 2274138595cd..1bc9a521cc50 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx
@@ -12,7 +12,12 @@ import {
} from '@metamask/design-system-react-native';
import { useNavigation } from '@react-navigation/native';
import { CaipAssetType, parseCaipAssetType } from '@metamask/utils';
-import { PointsEventDto } from '../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ CardEventPayload,
+ PerpsEventPayload,
+ PointsEventDto,
+ SwapEventPayload,
+} from '../../../../../../core/Engine/controllers/rewards-controller/types';
import { formatRewardsDate, formatNumber } from '../../../utils/formatUtils';
import { getEventDetails } from '../../../utils/eventDetailsUtils';
import { getNetworkImageSource } from '../../../../../../util/networks';
@@ -28,6 +33,8 @@ import { openActivityDetailsSheet } from './EventDetails/ActivityDetailsSheet';
import { TouchableOpacity } from 'react-native';
import { useActivityDetailsConfirmAction } from '../../../hooks/useActivityDetailsConfirmAction';
import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants';
+import { useSelector } from 'react-redux';
+import { selectSeasonActivityTypes } from '../../../../../../reducers/rewards/selectors';
export const ActivityEventRow: React.FC<{
event: PointsEventDto;
@@ -35,9 +42,12 @@ export const ActivityEventRow: React.FC<{
testID?: string;
}> = ({ event, accountName, testID }) => {
const navigation = useNavigation();
+ const activityTypes = useSelector(selectSeasonActivityTypes);
+
const eventDetails = React.useMemo(
- () => (event ? getEventDetails(event, accountName) : undefined),
- [event, accountName],
+ () =>
+ event ? getEventDetails(event, activityTypes, accountName) : undefined,
+ [event, accountName, activityTypes],
);
const confirmAction = useActivityDetailsConfirmAction(event);
@@ -50,14 +60,26 @@ export const ActivityEventRow: React.FC<{
let assetType: CaipAssetType | undefined;
let chainId: string | undefined;
- if (event.type === 'SWAP' && event.payload.srcAsset?.type) {
- assetType = event.payload.srcAsset.type as CaipAssetType;
+ if (
+ event.type === 'SWAP' &&
+ (event.payload as SwapEventPayload).srcAsset?.type
+ ) {
+ assetType = (event.payload as SwapEventPayload).srcAsset
+ .type as CaipAssetType;
chainId = parseCaipAssetType(assetType).chainId;
- } else if (event.type === 'PERPS' && event.payload.asset?.type) {
- assetType = event.payload.asset.type as CaipAssetType;
+ } else if (
+ event.type === 'PERPS' &&
+ (event.payload as PerpsEventPayload).asset?.type
+ ) {
+ assetType = (event.payload as PerpsEventPayload).asset
+ .type as CaipAssetType;
chainId = parseCaipAssetType(assetType).chainId;
- } else if (event.type === 'CARD' && event.payload.asset?.type) {
- assetType = event.payload.asset.type as CaipAssetType;
+ } else if (
+ event.type === 'CARD' &&
+ (event.payload as CardEventPayload).asset?.type
+ ) {
+ assetType = (event.payload as CardEventPayload).asset
+ .type as CaipAssetType;
chainId = parseCaipAssetType(assetType).chainId;
} else {
return;
@@ -81,6 +103,7 @@ export const ActivityEventRow: React.FC<{
openActivityDetailsSheet(navigation, {
event,
accountName,
+ activityTypes,
confirmAction,
});
};
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx
index b69873edc066..9e3cf6ee4e75 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx
@@ -253,6 +253,7 @@ describe('ActivityTab', () => {
startDate: Date.now(),
endDate: Date.now() + 1000,
tiers: [],
+ activityTypes: [],
},
balance: {
total: 0,
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx
index c1c2f3b28dc4..075751b9c63c 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx
@@ -10,7 +10,10 @@ import { ButtonVariant } from '@metamask/design-system-react-native';
import Routes from '../../../../../../../constants/navigation/Routes';
import { ModalType } from '../../../RewardsBottomSheetModal';
import TEST_ADDRESS from '../../../../../../../constants/address';
-import { PointsEventDto } from '../../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ PointsEventDto,
+ SeasonActivityTypeDto,
+} from '../../../../../../../core/Engine/controllers/rewards-controller/types';
import { AvatarAccountType } from '../../../../../../../component-library/components/Avatars/Avatar';
// Mock navigation
@@ -37,6 +40,7 @@ jest.mock('../../../../../../../../locales/i18n', () => ({
'rewards.events.points_base': 'Base',
'rewards.events.points_boost': 'Boost',
'rewards.events.points_total': 'Total',
+ 'rewards.events.description': 'Description',
'rewards.events.for_deposit_period': 'For deposit period',
};
return t[key] || key;
@@ -66,6 +70,12 @@ jest.mock('../../../../utils/formatUtils', () => ({
}).format(date);
},
),
+ resolveTemplate: jest.fn((template: string, values: Record) =>
+ template.replace(/\$\{(\w+)\}/g, (match, placeholder) => {
+ const value = values[placeholder as keyof typeof values];
+ return value !== undefined ? String(value) : match;
+ }),
+ ),
}));
// Mock eventDetailsUtils
@@ -94,6 +104,27 @@ describe('ActivityDetailsSheet', () => {
mockUseSelector.mockReturnValue(AvatarAccountType.JazzIcon);
});
+ const mockActivityTypes: SeasonActivityTypeDto[] = [
+ {
+ type: 'SWAP',
+ title: 'Swap',
+ description: 'Swap desc',
+ icon: 'SwapVertical',
+ },
+ {
+ type: 'CARD',
+ title: 'Card spend',
+ description: 'Spend',
+ icon: 'Card',
+ },
+ {
+ type: 'BRIDGE',
+ title: 'Bridge',
+ description: 'Bridge details',
+ icon: 'ArrowRight',
+ },
+ ];
+
const baseEvent: PointsEventDto = {
id: 'test-id',
timestamp: new Date('2025-09-09T09:09:33.000Z'),
@@ -126,7 +157,13 @@ describe('ActivityDetailsSheet', () => {
},
};
- render();
+ render(
+ ,
+ );
// Verify GenericEventDetails content is rendered (base component)
expect(screen.getByText('Details')).toBeTruthy();
@@ -151,7 +188,13 @@ describe('ActivityDetailsSheet', () => {
},
};
- render();
+ render(
+ ,
+ );
// Verify GenericEventDetails content is rendered (base component)
expect(screen.getByText('Details')).toBeTruthy();
@@ -173,7 +216,11 @@ describe('ActivityDetailsSheet', () => {
};
render(
- ,
+ ,
);
// Verify GenericEventDetails content is rendered (base component)
@@ -183,21 +230,70 @@ describe('ActivityDetailsSheet', () => {
expect(screen.getByText('Nov 11, 2025')).toBeTruthy();
});
- it('renders GenericEventDetails for other event types', () => {
+ it('renders GenericEventDetails with extra description for unspecified type when payload exists', () => {
const genericEvent: PointsEventDto = {
+ ...baseEvent,
+ type: 'BRIDGE' as never,
+ payload: { txHash: '0xabc123' } as unknown as PointsEventDto['payload'],
+ };
+
+ const activityTypesWithTemplate: SeasonActivityTypeDto[] = [
+ {
+ type: 'BRIDGE',
+ title: 'Bridge',
+ description: 'Tx: ${txHash}',
+ icon: 'ArrowRight',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Verify GenericEventDetails content is rendered
+ expect(screen.getByText('Details')).toBeTruthy();
+ expect(screen.getByText('Points')).toBeTruthy();
+ expect(screen.getByText('Date')).toBeTruthy();
+ // Description row label and resolved template
+ expect(screen.getByText('Description')).toBeTruthy();
+ expect(screen.getByText('Tx: 0xabc123')).toBeTruthy();
+ });
+
+ it('renders GenericEventDetails without extra description when payload is null', () => {
+ const genericEventNoPayload: PointsEventDto = {
...baseEvent,
type: 'BRIDGE' as never,
payload: null,
};
+ const activityTypesTemplate: SeasonActivityTypeDto[] = [
+ {
+ type: 'BRIDGE',
+ title: 'Bridge',
+ description: 'Tx: ${txHash}',
+ icon: 'ArrowRight',
+ },
+ ];
+
render(
- ,
+ ,
);
// Verify GenericEventDetails content is rendered
expect(screen.getByText('Details')).toBeTruthy();
expect(screen.getByText('Points')).toBeTruthy();
expect(screen.getByText('Date')).toBeTruthy();
+ // No Description row since payload is null
+ expect(screen.queryByText('Description')).toBeNull();
+ expect(screen.queryByText('Tx: 0xabc123')).toBeNull();
});
});
@@ -225,6 +321,7 @@ describe('ActivityDetailsSheet', () => {
openActivityDetailsSheet(mockNavigation, {
event: testEvent,
+ activityTypes: mockActivityTypes,
accountName: 'Test Account',
});
@@ -270,6 +367,7 @@ describe('ActivityDetailsSheet', () => {
event: testEvent,
accountName: 'Test Account',
confirmAction: customAction,
+ activityTypes: mockActivityTypes,
});
// Verify custom action is used
@@ -305,6 +403,7 @@ describe('ActivityDetailsSheet', () => {
openActivityDetailsSheet(mockNavigation, {
event: testEvent,
accountName: 'My Custom Account',
+ activityTypes: mockActivityTypes,
});
// Get the description prop which is the ActivityDetailsSheet component
@@ -342,6 +441,7 @@ describe('ActivityDetailsSheet', () => {
expect(() => {
openActivityDetailsSheet(mockNavigation, {
event: testEvent,
+ activityTypes: mockActivityTypes,
});
}).not.toThrow();
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx
index 0cc13a2e70bc..d326b88efdb5 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx
@@ -1,18 +1,28 @@
import React from 'react';
-import { ButtonVariant } from '@metamask/design-system-react-native';
+import {
+ Text,
+ TextVariant,
+ TextColor,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../../../../constants/navigation/Routes';
import { ModalAction, ModalType } from '../../../RewardsBottomSheetModal';
import { strings } from '../../../../../../../../locales/i18n';
import { getEventDetails } from '../../../../utils/eventDetailsUtils';
-import { GenericEventDetails } from './GenericEventDetails';
+import { DetailsRow, GenericEventDetails } from './GenericEventDetails';
import { SwapEventDetails } from './SwapEventDetails';
import { CardEventDetails } from './CardEventDetails';
import { MusdDepositEventDetails } from './MusdDepositEventDetails';
-import { PointsEventDto } from '../../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ PointsEventDto,
+ SeasonActivityTypeDto,
+} from '../../../../../../../core/Engine/controllers/rewards-controller/types';
+import { resolveTemplate } from '../../../../utils/formatUtils';
interface ActivityDetailsSheetProps {
event: PointsEventDto;
+ activityTypes: SeasonActivityTypeDto[];
accountName?: string;
confirmAction?: ModalAction;
}
@@ -21,18 +31,54 @@ interface ActivityDetailsSheetProps {
export const ActivityDetailsSheet: React.FC = ({
event,
accountName,
+ activityTypes,
}) => {
+ const matchingActivityType = activityTypes.find(
+ (activity) => activity.type === event.type,
+ );
+
+ const extraDetails =
+ matchingActivityType && event.payload ? (
+
+
+ {resolveTemplate(
+ matchingActivityType.description,
+ (event.payload ?? {}) as Record,
+ )}
+
+
+ ) : null;
+
switch (event.type) {
case 'SWAP':
- return ;
+ return (
+ }
+ accountName={accountName}
+ />
+ );
case 'CARD':
- return ;
+ return (
+ }
+ accountName={accountName}
+ />
+ );
case 'MUSD_DEPOSIT':
return (
-
+ }
+ accountName={accountName}
+ />
);
default:
- return ;
+ return (
+
+ );
}
};
@@ -43,6 +89,7 @@ export const openActivityDetailsSheet = (
) => {
const {
event,
+ activityTypes,
accountName,
confirmAction = {
label: strings('navigation.close'),
@@ -50,12 +97,16 @@ export const openActivityDetailsSheet = (
variant: ButtonVariant.Secondary,
},
} = props;
- const eventDetails = getEventDetails(event, accountName);
+ const eventDetails = getEventDetails(event, activityTypes, accountName);
navigation.navigate(Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL, {
title: eventDetails.title,
description: (
-
+
),
type: ModalType.Confirmation,
showIcon: false,
diff --git a/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts b/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts
index 405bd58264ce..65e297362031 100644
--- a/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts
+++ b/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts
@@ -2,7 +2,10 @@ import { useMemo } from 'react';
import { Linking } from 'react-native';
import { ButtonVariant } from '@metamask/design-system-react-native';
import { CaipAssetType } from '@metamask/utils';
-import { PointsEventDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ PointsEventDto,
+ SwapEventPayload,
+} from '../../../../core/Engine/controllers/rewards-controller/types';
import { useTransactionExplorer } from './useTransactionExplorer';
import { strings } from '../../../../../locales/i18n';
import { ModalAction } from '../components/RewardsBottomSheetModal';
@@ -19,9 +22,11 @@ export const useActivityDetailsConfirmAction = (
const explorerInfo = useTransactionExplorer(
isSwap
- ? (event.payload?.srcAsset?.type as CaipAssetType | undefined)
+ ? ((event.payload as SwapEventPayload)?.srcAsset?.type as
+ | CaipAssetType
+ | undefined)
: undefined,
- isSwap ? event.payload?.txHash : undefined,
+ isSwap ? (event.payload as SwapEventPayload)?.txHash : undefined,
);
return useMemo(() => {
diff --git a/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts b/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts
index 4e07ec3eb91c..1200513d33b3 100644
--- a/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts
+++ b/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts
@@ -185,6 +185,7 @@ describe('useSeasonStatus', () => {
startDate: 1640995200000,
endDate: 1672531200000,
tiers: [],
+ activityTypes: [],
};
const mockStatusData = {
@@ -516,6 +517,7 @@ describe('useSeasonStatus', () => {
startDate: 1640995200000,
endDate: 1672531200000,
tiers: [],
+ activityTypes: [],
};
const mockStatusData = {
@@ -623,6 +625,7 @@ describe('useSeasonStatus', () => {
startDate: 1640995200000,
endDate: 1672531200000,
tiers: [],
+ activityTypes: [],
};
const mockStatusData = {
diff --git a/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts b/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts
index 3b76d5f29b3c..8c3539e36dd6 100644
--- a/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts
+++ b/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts
@@ -4,6 +4,7 @@ import {
PointsEventDto,
PointsEventEarnType,
SwapEventPayload,
+ SeasonActivityTypeDto,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import { PerpsEventType } from './eventConstants';
import {
@@ -48,29 +49,58 @@ jest.mock('../../../../../locales/i18n', () => ({
}));
// Mock formatUtils
-jest.mock('./formatUtils', () => ({
- formatNumber: jest.fn((value: number) => value.toString()),
- formatRewardsMusdDepositPayloadDate: jest.fn(
- (isoDate: string | undefined) => {
- // Mock implementation that matches the real implementation behavior
- if (
- !isoDate ||
- typeof isoDate !== 'string' ||
- !/^\d{4}-\d{2}-\d{2}$/.test(isoDate)
- ) {
- return null;
- }
- // Mock implementation that formats the date
- const date = new Date(`${isoDate}T00:00:00Z`);
- return new Intl.DateTimeFormat('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- timeZone: 'UTC',
- }).format(date);
- },
- ),
-}));
+jest.mock('./formatUtils', () => {
+ const { IconName: IconEnum } = jest.requireActual(
+ '@metamask/design-system-react-native',
+ );
+ return {
+ formatNumber: jest.fn((value: number) => value.toString()),
+ formatRewardsMusdDepositPayloadDate: jest.fn(
+ (isoDate: string | undefined) => {
+ if (
+ !isoDate ||
+ typeof isoDate !== 'string' ||
+ !/^\d{4}-\d{2}-\d{2}$/.test(isoDate)
+ ) {
+ return null;
+ }
+ const date = new Date(`${isoDate}T00:00:00Z`);
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+ }).format(date);
+ },
+ ),
+ resolveTemplate: jest.fn(
+ (template: string, values: Record) =>
+ template.replace(/\$\{(\w+)\}/g, (match, placeholder) => {
+ const value = values[placeholder as keyof typeof values];
+ return value !== undefined ? String(value) : match;
+ }),
+ ),
+ getIconName: jest.fn((name: string) => {
+ const map: Record = {
+ Star: IconEnum.Star,
+ ArrowDown: IconEnum.ArrowDown,
+ ArrowUp: IconEnum.ArrowUp,
+ ArrowRight: IconEnum.ArrowRight,
+ Lock: IconEnum.Lock,
+ Gift: IconEnum.Gift,
+ Edit: IconEnum.Edit,
+ ThumbUp: IconEnum.ThumbUp,
+ Speedometer: IconEnum.Speedometer,
+ Coin: IconEnum.Coin,
+ Card: IconEnum.Card,
+ Candlestick: IconEnum.Candlestick,
+ SwapVertical: IconEnum.SwapVertical,
+ UserCircleAdd: IconEnum.UserCircleAdd,
+ };
+ return map[name] ?? IconEnum.Star;
+ }),
+ };
+});
describe('eventDetailsUtils', () => {
beforeEach(() => {
@@ -548,7 +578,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return swap details
expect(result).toEqual({
@@ -574,7 +604,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -598,7 +628,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -622,7 +652,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -646,7 +676,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -670,7 +700,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -694,7 +724,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -717,7 +747,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return undefined details
expect(result).toEqual({
@@ -739,7 +769,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -764,7 +794,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return card spend details
expect(result).toEqual({
@@ -788,7 +818,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return card spend details with decimals
expect(result).toEqual({
@@ -803,7 +833,7 @@ describe('eventDetailsUtils', () => {
const event = createMockEvent('CARD', null);
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return card spend title with undefined details
expect(result).toEqual({
@@ -818,7 +848,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for REFERRAL event', () => {
const event = createMockEvent('REFERRAL');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Referral action',
@@ -832,7 +862,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for SIGN_UP_BONUS event', () => {
const event = createMockEvent('SIGN_UP_BONUS');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Sign up bonus',
@@ -844,7 +874,7 @@ describe('eventDetailsUtils', () => {
it('returns empty details when account name is not provided', () => {
const event = createMockEvent('SIGN_UP_BONUS');
- const result = getEventDetails(event, undefined);
+ const result = getEventDetails(event, [], undefined);
expect(result).toEqual({
title: 'Sign up bonus',
@@ -858,7 +888,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for LOYALTY_BONUS event', () => {
const event = createMockEvent('LOYALTY_BONUS');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Loyalty bonus',
@@ -872,7 +902,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for ONE_TIME_BONUS event', () => {
const event = createMockEvent('ONE_TIME_BONUS');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'One-time bonus',
@@ -886,7 +916,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for PREDICT event', () => {
const event = createMockEvent('PREDICT');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Prediction',
@@ -904,7 +934,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit details with formatted date
expect(result).toEqual({
@@ -921,7 +951,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit details with formatted date
expect(result).toEqual({
@@ -936,7 +966,7 @@ describe('eventDetailsUtils', () => {
const event = createMockEvent('MUSD_DEPOSIT', null);
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -954,7 +984,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -972,7 +1002,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -989,7 +1019,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1006,7 +1036,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1023,7 +1053,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1040,7 +1070,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1055,7 +1085,7 @@ describe('eventDetailsUtils', () => {
it('returns uncategorized event details for unknown type', () => {
const event = createMockEvent('UNKNOWN_TYPE' as PointsEventDto['type']);
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Uncategorized event',
@@ -1078,7 +1108,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1099,7 +1129,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1120,7 +1150,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1141,7 +1171,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1162,7 +1192,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1187,7 +1217,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Swap',
@@ -1212,7 +1242,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Swap',
@@ -1222,4 +1252,131 @@ describe('eventDetailsUtils', () => {
});
});
});
+
+ describe('Custom activity types', () => {
+ const CUSTOM_TYPE = 'CUSTOM_ACTION' as PointsEventDto['type'];
+
+ const makeCustomActivity = (
+ icon: string,
+ description: string = 'Custom description',
+ ): SeasonActivityTypeDto => ({
+ type: CUSTOM_TYPE as unknown as PointsEventEarnType,
+ title: 'Custom Title',
+ description,
+ icon,
+ });
+
+ const makeEvent = (): PointsEventDto => ({
+ id: 'custom-id',
+ timestamp: new Date('2024-02-01T00:00:00Z'),
+ value: 5,
+ bonus: null,
+ accountAddress: TEST_ADDRESS,
+ updatedAt: new Date('2024-02-01T00:00:00Z'),
+ type: CUSTOM_TYPE as PointsEventEarnType,
+ payload: null,
+ });
+
+ it('uses custom title, description, and icon when activityTypes provides a match', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('Lock'),
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Custom description',
+ icon: IconName.Lock,
+ });
+ });
+
+ it('falls back to Star icon when provided invalid icon name', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('NotARealIcon'),
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Custom description',
+ icon: IconName.Star,
+ });
+ });
+
+ it('returns uncategorized event when no matching activity type is found', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ // Different type that should not match
+ {
+ type: 'OTHER_ACTION' as unknown as PointsEventEarnType,
+ title: 'Other',
+ description: 'Other desc',
+ icon: 'Gift',
+ },
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Uncategorized event',
+ details: undefined,
+ icon: IconName.Star,
+ });
+ });
+
+ it('preserves empty description value when provided by activityTypes', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('ArrowDown', ''),
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: '',
+ icon: IconName.ArrowDown,
+ });
+ });
+
+ it('resolves ${...} tokens in description using payload values', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('Lock', 'Tx: ${txHash}'),
+ ];
+ const event: PointsEventDto = {
+ ...makeEvent(),
+ payload: { txHash: '0xabc123' } as unknown as Record,
+ };
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Tx: 0xabc123',
+ icon: IconName.Lock,
+ });
+ });
+
+ it('leaves ${...} tokens intact when payload is null', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('ArrowRight', 'Tx: ${txHash}'),
+ ];
+ const event: PointsEventDto = {
+ ...makeEvent(),
+ payload: null,
+ };
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Tx: ${txHash}',
+ icon: IconName.ArrowRight,
+ });
+ });
+ });
});
diff --git a/app/components/UI/Rewards/utils/eventDetailsUtils.ts b/app/components/UI/Rewards/utils/eventDetailsUtils.ts
index 8e9e5c27b492..ca21b3a81bf6 100644
--- a/app/components/UI/Rewards/utils/eventDetailsUtils.ts
+++ b/app/components/UI/Rewards/utils/eventDetailsUtils.ts
@@ -6,12 +6,17 @@ import {
PerpsEventPayload,
CardEventPayload,
EventAssetDto,
+ SeasonActivityTypeDto,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import { isNullOrUndefined } from '@metamask/utils';
import { formatUnits } from 'viem';
import { formatWithThreshold } from '../../../../util/assets';
import { PerpsEventType } from './eventConstants';
-import { formatRewardsMusdDepositPayloadDate } from './formatUtils';
+import {
+ formatRewardsMusdDepositPayloadDate,
+ getIconName,
+ resolveTemplate,
+} from './formatUtils';
/**
* Formats an asset amount with proper decimals
@@ -159,17 +164,23 @@ export const getCardEventDetails = (
/**
* Formats an event details
* @param event - The event
+ * @param activityTypes - The activity types
* @param accountName - Optional account name to display for bonus events
* @returns The event details
*/
export const getEventDetails = (
event: PointsEventDto,
+ activityTypes: SeasonActivityTypeDto[],
accountName: string | undefined,
): {
title: string;
details: string | undefined;
icon: IconName;
} => {
+ const matchingActivityType = activityTypes.find(
+ (activity) => activity.type === event.type,
+ );
+
switch (event.type) {
case 'SWAP':
return {
@@ -233,11 +244,23 @@ export const getEventDetails = (
icon: IconName.Coin,
};
}
- default:
+ default: {
+ if (matchingActivityType) {
+ return {
+ title: matchingActivityType.title,
+ details: resolveTemplate(
+ matchingActivityType.description,
+ (event.payload ?? {}) as Record,
+ ),
+ icon: getIconName(matchingActivityType.icon),
+ };
+ }
+
return {
title: strings('rewards.events.type.uncategorized_event'),
details: undefined,
icon: IconName.Star,
};
+ }
}
};
diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts
index 69249bb33a96..b7c3d090a59e 100644
--- a/app/components/UI/Rewards/utils/formatUtils.test.ts
+++ b/app/components/UI/Rewards/utils/formatUtils.test.ts
@@ -10,6 +10,7 @@ import {
formatUrl,
formatUTCDate,
formatRewardsMusdDepositPayloadDate,
+ resolveTemplate,
} from './formatUtils';
import { IconName } from '@metamask/design-system-react-native';
import { getTimeDifferenceFromNow } from '../../../../util/date';
@@ -1128,4 +1129,55 @@ describe('formatUtils', () => {
expect(jaResult).toMatch(/11/);
});
});
+
+ describe('resolveTemplate', () => {
+ it('replaces single placeholder with provided value', () => {
+ const template = 'Hello, ${name}!';
+ const values = { name: 'Alice' };
+ expect(resolveTemplate(template, values)).toBe('Hello, Alice!');
+ });
+
+ it('replaces multiple placeholders with provided values', () => {
+ const template = 'User: ${name}, Tier: ${tier}';
+ const values = { name: 'Bob', tier: 'Gold' };
+ expect(resolveTemplate(template, values)).toBe('User: Bob, Tier: Gold');
+ });
+
+ it('leaves placeholders intact when value is missing', () => {
+ const template = 'Hello, ${name}! Tier: ${tier}';
+ const values = { name: 'Charlie' };
+ expect(resolveTemplate(template, values)).toBe(
+ 'Hello, Charlie! Tier: ${tier}',
+ );
+ });
+
+ it('replaces repeated occurrences of the same placeholder', () => {
+ const template = '${name} is ${name}';
+ const values = { name: 'Dana' };
+ expect(resolveTemplate(template, values)).toBe('Dana is Dana');
+ });
+
+ it('does not replace when value is an empty string (fallback to original token)', () => {
+ const template = 'Optional: ${field}';
+ const values = { field: '' };
+ expect(resolveTemplate(template, values)).toBe('Optional: ${field}');
+ });
+
+ it('does not match non-word placeholders (e.g., dot paths)', () => {
+ const template = 'Tx: ${payload.txHash}';
+ const values = { 'payload.txHash': '0xabc' } as unknown as Record<
+ string,
+ string
+ >;
+ expect(resolveTemplate(template, values)).toBe('Tx: ${payload.txHash}');
+ });
+
+ it('returns the original string when no placeholders exist', () => {
+ const template = 'Static string with no tokens';
+ const values = { anything: 'value' };
+ expect(resolveTemplate(template, values)).toBe(
+ 'Static string with no tokens',
+ );
+ });
+ });
});
diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts
index 67483af465ab..1ec95eb263db 100644
--- a/app/components/UI/Rewards/utils/formatUtils.ts
+++ b/app/components/UI/Rewards/utils/formatUtils.ts
@@ -163,3 +163,19 @@ export const formatUrl = (url: string): string => {
return cleanedUrl;
}
};
+
+/**
+ * Resolves templated string in the format of ${placeholder}
+ * @param template - The templated string
+ * @param values - The values to replace the placeholders with
+ * @returns The resolved string
+ */
+
+export const resolveTemplate = (
+ template: string,
+ values: Record,
+): string =>
+ template.replace(
+ /\${(\w+)}/g,
+ (match, placeholder) => values[placeholder] || match,
+ );
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
index c8fb768f467d..4cd8a5cddf68 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
@@ -283,6 +283,7 @@ const createTestSeasonStatus = (
startDate: new Date(Date.now() - 86400000), // 1 day ago
endDate: new Date(Date.now() + 86400000), // 1 day from now
tiers: createTestTiers(),
+ activityTypes: [],
};
return {
@@ -311,6 +312,7 @@ const createTestSeasonStatusState = (
startDate: Date.now() - 86400000,
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: {
total: 100,
@@ -802,27 +804,25 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now - 86400000),
- endDate: new Date(now + 86400000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- if (method === 'RewardsDataService:estimatePoints') {
- return Promise.resolve(mockResponse);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now - 86400000),
+ endDate: new Date(now + 86400000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ if (method === 'RewardsDataService:estimatePoints') {
+ return Promise.resolve(mockResponse);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.estimatePoints(mockRequest);
@@ -909,27 +909,25 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now - 86400000),
- endDate: new Date(now + 86400000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- if (method === 'RewardsDataService:estimatePoints') {
- return Promise.resolve(mockResponse);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now - 86400000),
+ endDate: new Date(now + 86400000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ if (method === 'RewardsDataService:estimatePoints') {
+ return Promise.resolve(mockResponse);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.estimatePoints(mockRequest);
@@ -949,17 +947,15 @@ describe('RewardsController', () => {
// Mock getSeasonMetadata to return null (no active season)
// This simulates getSeasonMetadata('current') returning null
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: null,
- next: null,
- });
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: null,
+ next: null,
+ });
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.estimatePoints(mockRequest);
@@ -994,27 +990,25 @@ describe('RewardsController', () => {
// Mock getSeasonMetadata to return valid season metadata
// This simulates getSeasonMetadata('current') returning a valid season
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now - 86400000),
- endDate: new Date(now + 86400000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- if (method === 'RewardsDataService:estimatePoints') {
- return Promise.resolve(mockResponse);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now - 86400000),
+ endDate: new Date(now + 86400000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ if (method === 'RewardsDataService:estimatePoints') {
+ return Promise.resolve(mockResponse);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.estimatePoints(mockRequest);
@@ -1043,17 +1037,15 @@ describe('RewardsController', () => {
it('should return false when getSeasonMetadata returns null', async () => {
// Mock getSeasonMetadata to return null by having getDiscoverSeasons return null for current
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: null,
- next: null,
- });
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: null,
+ next: null,
+ });
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.hasActiveSeason();
@@ -1071,24 +1063,22 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now + 86400000),
- endDate: new Date(now + 172800000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now + 86400000),
+ endDate: new Date(now + 172800000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.hasActiveSeason();
@@ -1106,24 +1096,22 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now - 172800000),
- endDate: new Date(now - 86400000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now - 172800000),
+ endDate: new Date(now - 86400000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.hasActiveSeason();
@@ -1141,24 +1129,22 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now - 86400000),
- endDate: new Date(now + 86400000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now - 86400000),
+ endDate: new Date(now + 86400000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.hasActiveSeason();
@@ -1176,24 +1162,22 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now),
- endDate: new Date(now + 86400000),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now),
+ endDate: new Date(now + 86400000),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.hasActiveSeason();
@@ -1211,24 +1195,22 @@ describe('RewardsController', () => {
tiers: createTestTiers(),
};
- mockMessenger.call.mockImplementation(
- (method: string, ..._: unknown[]) => {
- if (method === 'RewardsDataService:getDiscoverSeasons') {
- return Promise.resolve({
- current: {
- id: mockSeasonId,
- startDate: new Date(now - 86400000),
- endDate: new Date(now),
- },
- next: null,
- });
- }
- if (method === 'RewardsDataService:getSeasonMetadata') {
- return Promise.resolve(mockSeasonMetadata);
- }
- return Promise.resolve(null);
- },
- );
+ mockMessenger.call.mockImplementation((method, ..._args): any => {
+ if (method === 'RewardsDataService:getDiscoverSeasons') {
+ return Promise.resolve({
+ current: {
+ id: mockSeasonId,
+ startDate: new Date(now - 86400000),
+ endDate: new Date(now),
+ },
+ next: null,
+ });
+ }
+ if (method === 'RewardsDataService:getSeasonMetadata') {
+ return Promise.resolve(mockSeasonMetadata);
+ }
+ return Promise.resolve(null);
+ });
const result = await controller.hasActiveSeason();
@@ -3121,6 +3103,7 @@ describe('RewardsController', () => {
startDate: Date.now() - 86400000, // 1 day ago
endDate: Date.now() + 86400000, // 1 day from now
tiers: createTestTiers(),
+ activityTypes: [],
};
const mockSeasonStatus: SeasonStatusState = {
@@ -3240,6 +3223,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z'),
endDate: new Date('2024-12-31T23:59:59Z'),
tiers: createTestTiers(),
+ activityTypes: [],
};
const mockApiResponse = createTestSeasonStatus({
season: mockSeasonMetadata,
@@ -3264,6 +3248,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now() - 7200000, // 2 hours ago (stale)
},
},
@@ -3313,6 +3298,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z'),
endDate: new Date('2024-12-31T23:59:59Z'),
tiers: createTestTiers(),
+ activityTypes: [],
};
const mockApiResponse = createTestSeasonStatus({
season: mockSeasonMetadata,
@@ -3339,6 +3325,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
},
@@ -3430,6 +3417,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
},
@@ -3481,6 +3469,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -3527,6 +3516,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z'),
endDate: new Date('2024-12-31T23:59:59Z'),
tiers: createTestTiers(),
+ activityTypes: [],
};
const mockSeasonStatus = createTestSeasonStatus({
season: mockSeasonMetadata,
@@ -3571,6 +3561,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -3691,6 +3682,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -3783,6 +3775,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z'),
endDate: new Date('2024-12-31T23:59:59Z'),
tiers: createTestTiers(),
+ activityTypes: [],
};
const mockSeasonStatus = createTestSeasonStatus({
season: mockSeasonMetadata,
@@ -3836,6 +3829,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -3930,6 +3924,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z'),
endDate: new Date('2024-12-31T23:59:59Z'),
tiers: createTestTiers(),
+ activityTypes: [],
};
const mockSeasonStatus = createTestSeasonStatus({
season: mockSeasonMetadata,
@@ -3991,6 +3986,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4111,6 +4107,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4207,6 +4204,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4292,6 +4290,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4368,6 +4367,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4440,6 +4440,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4498,6 +4499,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4555,6 +4557,7 @@ describe('RewardsController', () => {
startDate: new Date('2024-01-01T00:00:00Z').getTime(),
endDate: new Date('2024-12-31T23:59:59Z').getTime(),
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: Date.now(),
},
};
@@ -4610,6 +4613,7 @@ describe('RewardsController', () => {
startDate: Date.now() - 86400000,
endDate: Date.now() + 86400000,
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: recentTime,
};
@@ -4645,6 +4649,7 @@ describe('RewardsController', () => {
startDate: Date.now() + 86400000,
endDate: Date.now() + 172800000,
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: recentTime,
};
@@ -4680,6 +4685,7 @@ describe('RewardsController', () => {
startDate: Date.now() - 86400000,
endDate: Date.now() + 86400000,
tiers: createTestTiers(),
+ activityTypes: [],
lastFetched: staleTime,
};
@@ -6790,6 +6796,7 @@ describe('RewardsController', () => {
startDate: 1609459200000, // 2021-01-01
endDate: 1640995200000, // 2022-01-01
tiers,
+ activityTypes: [],
};
const seasonState: SeasonStateDto = {
@@ -6827,6 +6834,7 @@ describe('RewardsController', () => {
startDate: startTimestamp,
endDate: endTimestamp,
tiers: createTestTiers(),
+ activityTypes: [],
};
const seasonState: SeasonStateDto = {
@@ -6856,6 +6864,7 @@ describe('RewardsController', () => {
startDate: Date.now() - 86400000,
endDate: Date.now() + 86400000,
tiers: createTestTiers(),
+ activityTypes: [],
};
const updatedAtDate = new Date('2025-10-20T10:30:00.000Z');
@@ -6909,6 +6918,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 1000000,
tiers: customTiers,
+ activityTypes: [],
};
const seasonState: SeasonStateDto = {
@@ -6940,6 +6950,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: createTestTiers(),
+ activityTypes: [],
};
const seasonState: SeasonStateDto = {
@@ -6967,6 +6978,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: createTestTiers(),
+ activityTypes: [],
};
const largeBalance = 999999999;
@@ -7582,6 +7594,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 1000,
tiers: [],
+ activityTypes: [],
},
},
subscriptionReferralDetails: {
@@ -7600,6 +7613,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now(),
tiers: [],
+ activityTypes: [],
},
balance: { total: 100 },
tier: {
@@ -10847,6 +10861,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 1000 },
tier: {
@@ -11000,6 +11015,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 1000 },
tier: {
@@ -11110,6 +11126,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 500 },
tier: {
@@ -11133,6 +11150,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 1000 },
tier: {
@@ -11156,6 +11174,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 1500 },
tier: {
@@ -11179,6 +11198,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 2000 },
tier: {
@@ -13239,6 +13259,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 1000 },
tier: {
@@ -13441,6 +13462,7 @@ describe('RewardsController', () => {
startDate: Date.now(),
endDate: Date.now() + 86400000,
tiers: [],
+ activityTypes: [],
},
balance: { total: 500 },
tier: {
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts
index 72cfe14da882..1815d75a89ee 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts
@@ -305,6 +305,7 @@ export class RewardsController extends BaseController<
startDate: season.startDate.getTime(),
endDate: season.endDate.getTime(),
tiers: season.tiers,
+ activityTypes: season.activityTypes,
};
}
@@ -322,6 +323,7 @@ export class RewardsController extends BaseController<
startDate: new Date(seasonMetadata.startDate),
endDate: new Date(seasonMetadata.endDate),
tiers: seasonMetadata.tiers,
+ activityTypes: seasonMetadata.activityTypes,
},
balance: {
total: seasonState.balance,
@@ -1655,7 +1657,7 @@ export class RewardsController extends BaseController<
/**
* Get season metadata with caching. This fetches and caches the season metadata
- * including id, name, dates, and tiers.
+ * including id, name, dates, tiers, and activity types.
* @param type - The type of season to get
* @returns Promise - The season metadata
*/
@@ -1714,6 +1716,7 @@ export class RewardsController extends BaseController<
startDate: seasonMetadata.startDate,
endDate: seasonMetadata.endDate,
tiers: seasonMetadata.tiers,
+ activityTypes: seasonMetadata.activityTypes,
});
// Add lastFetched timestamp
diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts
index a9ccfa988573..9a7fdebf20ef 100644
--- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts
+++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts
@@ -1298,6 +1298,7 @@ describe('RewardsDataService', () => {
rewards: [],
},
],
+ activityTypes: [],
};
beforeEach(() => {
diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts
index ef84531a7bbb..2e6ae4d026c3 100644
--- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts
+++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts
@@ -972,6 +972,11 @@ export class RewardsDataService {
data.endDate = new Date(data.endDate);
}
+ // Ensure activityTypes is always an array per SeasonMetadataDto
+ if (!Array.isArray(data.activityTypes)) {
+ data.activityTypes = [];
+ }
+
return data as SeasonMetadataDto;
}
}
diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts
index 55ec72447704..c870b175389b 100644
--- a/app/core/Engine/controllers/rewards-controller/types.ts
+++ b/app/core/Engine/controllers/rewards-controller/types.ts
@@ -384,6 +384,7 @@ export type PointsEventDto = BasePointsEventDto &
type: 'REFERRAL' | 'SIGN_UP_BONUS' | 'LOYALTY_BONUS' | 'ONE_TIME_BONUS';
payload: null;
}
+ | { type: string; payload: Record | null }
);
export interface EstimatePointsDto {
@@ -454,6 +455,7 @@ export interface SeasonDto {
startDate: Date;
endDate: Date;
tiers: SeasonTierDto[];
+ activityTypes: SeasonActivityTypeDto[];
}
export interface SeasonStatusBalanceDto {
@@ -576,6 +578,7 @@ export type SeasonDtoState = {
startDate: number; // timestamp
endDate: number; // timestamp
tiers: SeasonTierDtoState[];
+ activityTypes: SeasonActivityTypeDto[];
lastFetched?: number;
};
@@ -1150,6 +1153,11 @@ export interface SeasonMetadataDto {
* The tiers for the season
*/
tiers: SeasonTierDto[];
+
+ /**
+ * Activity types for the season
+ */
+ activityTypes: SeasonActivityTypeDto[];
}
/**
@@ -1174,3 +1182,30 @@ export interface SeasonStateDto {
*/
updatedAt: Date;
}
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+export type SeasonActivityTypeDto = {
+ /**
+ * The activity type
+ * @example 'SWAP'
+ */
+ type: string;
+
+ /**
+ * The name of the activity type
+ * @example 'Swap'
+ */
+ title: string;
+
+ /**
+ * The description of the activity type
+ * @example 'Stake your M$D to earn points'
+ */
+ description: string;
+
+ /**
+ * The icon for the activity type
+ * @example 'Rocket'
+ */
+ icon: string;
+};
diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts
index 3a184f5b36ae..b46de9303187 100644
--- a/app/reducers/rewards/index.test.ts
+++ b/app/reducers/rewards/index.test.ts
@@ -34,6 +34,10 @@ import {
} from '../../core/Engine/controllers/rewards-controller/types';
import { AccountGroupId } from '@metamask/account-api';
+const initialState: RewardsState = rewardsReducer(undefined, {
+ type: 'unknown',
+} as Action);
+
describe('rewardsReducer', () => {
const initialState: RewardsState = {
activeTab: 'overview',
@@ -45,6 +49,7 @@ describe('rewardsReducer', () => {
seasonStartDate: null,
seasonEndDate: null,
seasonTiers: [],
+ seasonActivityTypes: [],
referralDetailsLoading: false,
referralDetailsError: false,
@@ -309,6 +314,7 @@ describe('rewardsReducer', () => {
rewards: [],
},
],
+ activityTypes: [],
},
balance: {
total: 500,
@@ -370,1633 +376,943 @@ describe('rewardsReducer', () => {
expect(state.balanceUpdatedAt).toBe(null);
});
- describe('setReferralDetails', () => {
- it('should update referral code when provided', () => {
- // Arrange
- const action = setReferralDetails({ referralCode: 'NEW123' });
-
- // Act
- const state = rewardsReducer(initialState, action);
-
- // Assert
- expect(state.referralCode).toBe('NEW123');
- expect(state.refereeCount).toBe(0); // Should remain unchanged
- });
+ it('should set seasonActivityTypes from season data', () => {
+ const mockSeasonStatus = {
+ season: {
+ id: 'season-activity',
+ name: 'Season Activity',
+ startDate: new Date('2024-02-01').getTime(),
+ endDate: new Date('2024-03-01').getTime(),
+ tiers: [],
+ activityTypes: [
+ {
+ type: 'SWAP',
+ title: 'Swap',
+ description: 'Swap desc',
+ icon: 'SwapVertical',
+ },
+ {
+ type: 'CARD',
+ title: 'Card spend',
+ description: 'Spend',
+ icon: 'Card',
+ },
+ ],
+ },
+ } as unknown as SeasonStatusState;
+ const action = setSeasonStatus(mockSeasonStatus);
- it('should update referee count when provided', () => {
- // Arrange
- const action = setReferralDetails({ refereeCount: 5 });
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ expect(state.seasonActivityTypes).toEqual(
+ mockSeasonStatus.season.activityTypes,
+ );
+ });
- // Assert
- expect(state.refereeCount).toBe(5);
- expect(state.referralCode).toBe(null); // Should remain unchanged
- });
+ it('should clear seasonActivityTypes when season status is null', () => {
+ const stateWithActivities = {
+ ...initialState,
+ seasonActivityTypes: [
+ {
+ type: 'REFERRAL',
+ title: 'Referral',
+ description: 'Refer a friend',
+ icon: 'UserCircleAdd',
+ },
+ ],
+ };
+ const action = setSeasonStatus(null);
- it('should update multiple referral fields when provided', () => {
- // Arrange
- const action = setReferralDetails({
- referralCode: 'MULTI123',
- refereeCount: 10,
- });
+ const state = rewardsReducer(stateWithActivities, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ expect(state.seasonActivityTypes).toEqual([]);
+ });
+ });
- // Assert
- expect(state.referralCode).toBe('MULTI123');
- expect(state.refereeCount).toBe(10);
- });
+ describe('setReferralDetails', () => {
+ it('should update referral code when provided', () => {
+ // Arrange
+ const action = setReferralDetails({ referralCode: 'NEW123' });
- it('should handle empty payload without updating any fields', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- referralCode: 'EXISTING',
- refereeCount: 3,
- };
- const action = setReferralDetails({});
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Assert
+ expect(state.referralCode).toBe('NEW123');
+ expect(state.refereeCount).toBe(0); // Should remain unchanged
+ });
- // Assert
- expect(state.referralCode).toBe('EXISTING');
- expect(state.refereeCount).toBe(3);
- });
+ it('should update referee count when provided', () => {
+ // Arrange
+ const action = setReferralDetails({ refereeCount: 5 });
- it('should handle zero referee count', () => {
- // Arrange
- const stateWithReferees = { ...initialState, refereeCount: 5 };
- const action = setReferralDetails({ refereeCount: 0 });
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(stateWithReferees, action);
+ // Assert
+ expect(state.refereeCount).toBe(5);
+ expect(state.referralCode).toBe(null); // Should remain unchanged
+ });
- // Assert
- expect(state.refereeCount).toBe(0);
+ it('should update multiple referral fields when provided', () => {
+ // Arrange
+ const action = setReferralDetails({
+ referralCode: 'MULTI123',
+ refereeCount: 10,
});
- it('should set referralDetailsLoading to false', () => {
- // Arrange
- const stateWithLoading = {
- ...initialState,
- referralDetailsLoading: true,
- };
- const action = setReferralDetails({ referralCode: 'TEST123' });
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ // Assert
+ expect(state.referralCode).toBe('MULTI123');
+ expect(state.refereeCount).toBe(10);
+ });
- // Assert
- expect(state.referralDetailsLoading).toBe(false);
- expect(state.referralCode).toBe('TEST123');
- });
+ it('should handle empty payload without updating any fields', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ referralCode: 'EXISTING',
+ refereeCount: 3,
+ };
+ const action = setReferralDetails({});
- it('should handle null referralCode explicitly', () => {
- // Arrange
- const stateWithCode = { ...initialState, referralCode: 'EXISTING' };
- const action = setReferralDetails({
- referralCode: null as unknown as string,
- });
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Act
- const state = rewardsReducer(stateWithCode, action);
+ // Assert
+ expect(state.referralCode).toBe('EXISTING');
+ expect(state.refereeCount).toBe(3);
+ });
- // Assert
- expect(state.referralCode).toBe(null);
- expect(state.referralDetailsLoading).toBe(false);
- });
+ it('should handle zero referee count', () => {
+ // Arrange
+ const stateWithReferees = { ...initialState, refereeCount: 5 };
+ const action = setReferralDetails({ refereeCount: 0 });
- it('should handle undefined referralCode in payload', () => {
- // Arrange
- const stateWithCode = { ...initialState, referralCode: 'EXISTING' };
- const action = setReferralDetails({
- referralCode: undefined,
- refereeCount: 5,
- });
+ // Act
+ const state = rewardsReducer(stateWithReferees, action);
- // Act
- const state = rewardsReducer(stateWithCode, action);
+ // Assert
+ expect(state.refereeCount).toBe(0);
+ });
- // Assert
- expect(state.referralCode).toBe('EXISTING'); // Should remain unchanged
- expect(state.refereeCount).toBe(5);
- expect(state.referralDetailsLoading).toBe(false);
- });
+ it('should set referralDetailsLoading to false', () => {
+ // Arrange
+ const stateWithLoading = {
+ ...initialState,
+ referralDetailsLoading: true,
+ };
+ const action = setReferralDetails({ referralCode: 'TEST123' });
- it('should handle negative referee count', () => {
- // Arrange
- const action = setReferralDetails({ refereeCount: -1 });
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.referralDetailsLoading).toBe(false);
+ expect(state.referralCode).toBe('TEST123');
+ });
- // Assert
- expect(state.refereeCount).toBe(-1); // Should accept negative values
- expect(state.referralDetailsLoading).toBe(false);
+ it('should handle null referralCode explicitly', () => {
+ // Arrange
+ const stateWithCode = { ...initialState, referralCode: 'EXISTING' };
+ const action = setReferralDetails({
+ referralCode: null as unknown as string,
});
- it('updates balanceRefereePortion when referralPoints is provided', () => {
- // Arrange
- const action = setReferralDetails({ referralPoints: 500 });
+ // Act
+ const state = rewardsReducer(stateWithCode, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.referralCode).toBe(null);
+ expect(state.referralDetailsLoading).toBe(false);
+ });
- // Assert
- expect(state.balanceRefereePortion).toBe(500);
- expect(state.referralDetailsLoading).toBe(false);
+ it('should handle undefined referralCode in payload', () => {
+ // Arrange
+ const stateWithCode = { ...initialState, referralCode: 'EXISTING' };
+ const action = setReferralDetails({
+ referralCode: undefined,
+ refereeCount: 5,
});
- it('updates balanceRefereePortion with zero value', () => {
- // Arrange
- const stateWithPoints = {
- ...initialState,
- balanceRefereePortion: 300,
- };
- const action = setReferralDetails({ referralPoints: 0 });
+ // Act
+ const state = rewardsReducer(stateWithCode, action);
- // Act
- const state = rewardsReducer(stateWithPoints, action);
+ // Assert
+ expect(state.referralCode).toBe('EXISTING'); // Should remain unchanged
+ expect(state.refereeCount).toBe(5);
+ expect(state.referralDetailsLoading).toBe(false);
+ });
- // Assert
- expect(state.balanceRefereePortion).toBe(0);
- });
+ it('should handle negative referee count', () => {
+ // Arrange
+ const action = setReferralDetails({ refereeCount: -1 });
- it('updates all fields including referralPoints when provided together', () => {
- // Arrange
- const action = setReferralDetails({
- referralCode: 'COMBO123',
- refereeCount: 15,
- referralPoints: 750,
- });
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.refereeCount).toBe(-1); // Should accept negative values
+ expect(state.referralDetailsLoading).toBe(false);
+ });
- // Assert
- expect(state.referralCode).toBe('COMBO123');
- expect(state.refereeCount).toBe(15);
- expect(state.balanceRefereePortion).toBe(750);
- expect(state.referralDetailsLoading).toBe(false);
- });
+ it('updates balanceRefereePortion when referralPoints is provided', () => {
+ // Arrange
+ const action = setReferralDetails({ referralPoints: 500 });
- it('preserves balanceRefereePortion when referralPoints is not provided', () => {
- // Arrange
- const stateWithPoints = {
- ...initialState,
- balanceRefereePortion: 200,
- };
- const action = setReferralDetails({ referralCode: 'TEST456' });
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(stateWithPoints, action);
+ // Assert
+ expect(state.balanceRefereePortion).toBe(500);
+ expect(state.referralDetailsLoading).toBe(false);
+ });
- // Assert
- expect(state.balanceRefereePortion).toBe(200);
- expect(state.referralCode).toBe('TEST456');
- });
+ it('updates balanceRefereePortion with zero value', () => {
+ // Arrange
+ const stateWithPoints = {
+ ...initialState,
+ balanceRefereePortion: 300,
+ };
+ const action = setReferralDetails({ referralPoints: 0 });
- it('handles negative referralPoints value', () => {
- // Arrange
- const action = setReferralDetails({ referralPoints: -50 });
+ // Act
+ const state = rewardsReducer(stateWithPoints, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.balanceRefereePortion).toBe(0);
+ });
- // Assert
- expect(state.balanceRefereePortion).toBe(-50);
+ it('updates all fields including referralPoints when provided together', () => {
+ // Arrange
+ const action = setReferralDetails({
+ referralCode: 'COMBO123',
+ refereeCount: 15,
+ referralPoints: 750,
});
- it('handles large referralPoints value', () => {
- // Arrange
- const action = setReferralDetails({ referralPoints: 999999 });
-
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.balanceRefereePortion).toBe(999999);
- });
+ // Assert
+ expect(state.referralCode).toBe('COMBO123');
+ expect(state.refereeCount).toBe(15);
+ expect(state.balanceRefereePortion).toBe(750);
+ expect(state.referralDetailsLoading).toBe(false);
+ });
- it('handles decimal referralPoints value', () => {
- // Arrange
- const action = setReferralDetails({ referralPoints: 125.75 });
+ it('preserves balanceRefereePortion when referralPoints is not provided', () => {
+ // Arrange
+ const stateWithPoints = {
+ ...initialState,
+ balanceRefereePortion: 200,
+ };
+ const action = setReferralDetails({ referralCode: 'TEST456' });
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithPoints, action);
- // Assert
- expect(state.balanceRefereePortion).toBe(125.75);
- });
+ // Assert
+ expect(state.balanceRefereePortion).toBe(200);
+ expect(state.referralCode).toBe('TEST456');
});
- describe('setReferralDetailsError', () => {
- it('should set referral details error to true', () => {
- // Arrange
- const action = setReferralDetailsError(true);
+ it('handles negative referralPoints value', () => {
+ // Arrange
+ const action = setReferralDetails({ referralPoints: -50 });
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.referralDetailsError).toBe(true);
- });
+ // Assert
+ expect(state.balanceRefereePortion).toBe(-50);
+ });
- it('should set referral details error to false', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- referralDetailsError: true,
- };
- const action = setReferralDetailsError(false);
+ it('handles large referralPoints value', () => {
+ // Arrange
+ const action = setReferralDetails({ referralPoints: 999999 });
- // Act
- const state = rewardsReducer(stateWithError, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.referralDetailsError).toBe(false);
- });
+ // Assert
+ expect(state.balanceRefereePortion).toBe(999999);
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- referralCode: 'TEST123',
- refereeCount: 5,
- referralDetailsLoading: true,
- };
- const action = setReferralDetailsError(true);
+ it('handles decimal referralPoints value', () => {
+ // Arrange
+ const action = setReferralDetails({ referralPoints: 125.75 });
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.referralDetailsError).toBe(true);
- expect(state.referralCode).toBe('TEST123');
- expect(state.refereeCount).toBe(5);
- expect(state.referralDetailsLoading).toBe(true);
- });
+ // Assert
+ expect(state.balanceRefereePortion).toBe(125.75);
});
+ });
- describe('setSeasonStatusLoading', () => {
- it('should set season status loading to true when no season data exists', () => {
- // Arrange
- const action = setSeasonStatusLoading(true);
+ describe('setReferralDetailsError', () => {
+ it('should set referral details error to true', () => {
+ // Arrange
+ const action = setReferralDetailsError(true);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.seasonStatusLoading).toBe(true);
- });
+ // Assert
+ expect(state.referralDetailsError).toBe(true);
+ });
- it('should not set season status loading to true when season data already exists', () => {
- // Arrange
- const stateWithSeasonData = {
- ...initialState,
- seasonStartDate: new Date('2024-01-01'),
- seasonStatusLoading: false,
- };
- const action = setSeasonStatusLoading(true);
+ it('should set referral details error to false', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ referralDetailsError: true,
+ };
+ const action = setReferralDetailsError(false);
- // Act
- const state = rewardsReducer(stateWithSeasonData, action);
+ // Act
+ const state = rewardsReducer(stateWithError, action);
- // Assert
- expect(state.seasonStatusLoading).toBe(false); // Should remain false due to guard clause
- });
+ // Assert
+ expect(state.referralDetailsError).toBe(false);
+ });
- it('should set season status loading to false', () => {
- // Arrange
- const stateWithLoading = { ...initialState, seasonStatusLoading: true };
- const action = setSeasonStatusLoading(false);
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ referralCode: 'TEST123',
+ refereeCount: 5,
+ referralDetailsLoading: true,
+ };
+ const action = setReferralDetailsError(true);
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Assert
- expect(state.seasonStatusLoading).toBe(false);
- });
+ // Assert
+ expect(state.referralDetailsError).toBe(true);
+ expect(state.referralCode).toBe('TEST123');
+ expect(state.refereeCount).toBe(5);
+ expect(state.referralDetailsLoading).toBe(true);
+ });
+ });
- it('should set season status loading to false even when season data exists', () => {
- // Arrange
- const stateWithSeasonDataAndLoading = {
- ...initialState,
- seasonStartDate: new Date('2024-01-01'),
- seasonStatusLoading: true,
- };
- const action = setSeasonStatusLoading(false);
+ describe('setSeasonStatusLoading', () => {
+ it('should set season status loading to true when no season data exists', () => {
+ // Arrange
+ const action = setSeasonStatusLoading(true);
- // Act
- const state = rewardsReducer(stateWithSeasonDataAndLoading, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.seasonStatusLoading).toBe(false);
- });
+ // Assert
+ expect(state.seasonStatusLoading).toBe(true);
});
- describe('setSeasonStatusError', () => {
- it('should set season status error to a string message', () => {
- // Arrange
- const errorMessage = 'Failed to fetch season status';
- const action = setSeasonStatusError(errorMessage);
-
- // Act
- const state = rewardsReducer(initialState, action);
-
- // Assert
- expect(state.seasonStatusError).toBe(errorMessage);
- });
-
- it('should clear season status error when set to null', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- seasonStatusError: 'Previous error message',
- };
- const action = setSeasonStatusError(null);
-
- // Act
- const state = rewardsReducer(stateWithError, action);
-
- // Assert
- expect(state.seasonStatusError).toBe(null);
- });
-
- it('should replace existing error with new error message', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- seasonStatusError: 'Old error message',
- };
- const newErrorMessage = 'New error message';
- const action = setSeasonStatusError(newErrorMessage);
-
- // Act
- const state = rewardsReducer(stateWithError, action);
-
- // Assert
- expect(state.seasonStatusError).toBe(newErrorMessage);
- });
-
- it('should handle network timeout error message', () => {
- // Arrange
- const timeoutError = 'Request timed out while fetching season status';
- const action = setSeasonStatusError(timeoutError);
-
- // Act
- const state = rewardsReducer(initialState, action);
-
- // Assert
- expect(state.seasonStatusError).toBe(timeoutError);
- });
-
- it('should handle API error response message', () => {
- // Arrange
- const apiError = 'API returned 500: Internal server error';
- const action = setSeasonStatusError(apiError);
-
- // Act
- const state = rewardsReducer(initialState, action);
-
- // Assert
- expect(state.seasonStatusError).toBe(apiError);
- });
-
- it('should not affect other state properties when setting error', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- seasonName: 'Test Season',
- seasonId: 'season-123',
- balanceTotal: 1000,
- seasonStatusLoading: false,
- };
- const errorMessage = 'Something went wrong';
- const action = setSeasonStatusError(errorMessage);
+ it('should not set season status loading to true when season data already exists', () => {
+ // Arrange
+ const stateWithSeasonData = {
+ ...initialState,
+ seasonStartDate: new Date('2024-01-01'),
+ seasonStatusLoading: false,
+ };
+ const action = setSeasonStatusLoading(true);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithSeasonData, action);
- // Assert
- expect(state.seasonStatusError).toBe(errorMessage);
- expect(state.seasonName).toBe('Test Season');
- expect(state.seasonId).toBe('season-123');
- expect(state.balanceTotal).toBe(1000);
- expect(state.seasonStatusLoading).toBe(false);
- });
+ // Assert
+ expect(state.seasonStatusLoading).toBe(false); // Should remain false due to guard clause
});
- describe('setReferralDetailsLoading', () => {
- it('should set referral details loading to true when no referral code exists', () => {
- // Arrange
- const action = setReferralDetailsLoading(true);
-
- // Act
- const state = rewardsReducer(initialState, action);
-
- // Assert
- expect(state.referralDetailsLoading).toBe(true);
- });
-
- it('should not set referral details loading to true when referral code already exists', () => {
- // Arrange
- const stateWithReferralCode = {
- ...initialState,
- referralCode: 'EXISTING123',
- referralDetailsLoading: false,
- };
- const action = setReferralDetailsLoading(true);
+ it('should set season status loading to false', () => {
+ // Arrange
+ const stateWithLoading = { ...initialState, seasonStatusLoading: true };
+ const action = setSeasonStatusLoading(false);
- // Act
- const state = rewardsReducer(stateWithReferralCode, action);
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Assert
- expect(state.referralDetailsLoading).toBe(false); // Should remain false due to guard clause
- });
+ // Assert
+ expect(state.seasonStatusLoading).toBe(false);
+ });
- it('should set referral details loading to false', () => {
- // Arrange
- const stateWithLoading = {
- ...initialState,
- referralDetailsLoading: true,
- };
- const action = setReferralDetailsLoading(false);
+ it('should set season status loading to false even when season data exists', () => {
+ // Arrange
+ const stateWithSeasonDataAndLoading = {
+ ...initialState,
+ seasonStartDate: new Date('2024-01-01'),
+ seasonStatusLoading: true,
+ };
+ const action = setSeasonStatusLoading(false);
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ // Act
+ const state = rewardsReducer(stateWithSeasonDataAndLoading, action);
- // Assert
- expect(state.referralDetailsLoading).toBe(false);
- });
+ // Assert
+ expect(state.seasonStatusLoading).toBe(false);
+ });
+ });
- it('should set referral details loading to false even when referral code exists', () => {
- // Arrange
- const stateWithReferralCodeAndLoading = {
- ...initialState,
- referralCode: 'EXISTING123',
- referralDetailsLoading: true,
- };
- const action = setReferralDetailsLoading(false);
+ describe('setSeasonStatusError', () => {
+ it('should set season status error to a string message', () => {
+ // Arrange
+ const errorMessage = 'Failed to fetch season status';
+ const action = setSeasonStatusError(errorMessage);
- // Act
- const state = rewardsReducer(stateWithReferralCodeAndLoading, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.referralDetailsLoading).toBe(false);
- });
+ // Assert
+ expect(state.seasonStatusError).toBe(errorMessage);
});
- describe('setOnboardingActiveStep', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- it.each([
- OnboardingStep.INTRO,
- OnboardingStep.STEP_1,
- OnboardingStep.STEP_2,
- OnboardingStep.STEP_3,
- OnboardingStep.STEP_4,
- ])('should set onboarding active step to %s', (step) => {
- // Arrange
- const action = setOnboardingActiveStep(step);
+ it('should clear season status error when set to null', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ seasonStatusError: 'Previous error message',
+ };
+ const action = setSeasonStatusError(null);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithError, action);
- // Assert
- expect(state.onboardingActiveStep).toBe(step);
- });
+ // Assert
+ expect(state.seasonStatusError).toBe(null);
+ });
- it('should update from different onboarding step', () => {
- // Arrange
- const stateWithStep = {
- ...initialState,
- onboardingActiveStep: OnboardingStep.STEP_2,
- };
- const action = setOnboardingActiveStep(OnboardingStep.STEP_4);
+ it('should replace existing error with new error message', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ seasonStatusError: 'Old error message',
+ };
+ const newErrorMessage = 'New error message';
+ const action = setSeasonStatusError(newErrorMessage);
- // Act
- const state = rewardsReducer(stateWithStep, action);
+ // Act
+ const state = rewardsReducer(stateWithError, action);
- // Assert
- expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_4);
- });
+ // Assert
+ expect(state.seasonStatusError).toBe(newErrorMessage);
+ });
- it('should call logger even when step is the same', () => {
- // Arrange
- const stateWithStep = {
- ...initialState,
- onboardingActiveStep: OnboardingStep.STEP_1,
- };
- const action = setOnboardingActiveStep(OnboardingStep.STEP_1);
+ it('should handle network timeout error message', () => {
+ // Arrange
+ const timeoutError = 'Request timed out while fetching season status';
+ const action = setSeasonStatusError(timeoutError);
- // Act
- const state = rewardsReducer(stateWithStep, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_1);
- });
+ // Assert
+ expect(state.seasonStatusError).toBe(timeoutError);
});
- describe('resetOnboarding', () => {
- it('should reset onboarding to INTRO step and clear referral code', () => {
- // Arrange
- const stateWithStep = {
- ...initialState,
- onboardingActiveStep: OnboardingStep.STEP_3,
- onboardingReferralCode: 'REF123',
- };
- const action = resetOnboarding();
+ it('should handle API error response message', () => {
+ // Arrange
+ const apiError = 'API returned 500: Internal server error';
+ const action = setSeasonStatusError(apiError);
- // Act
- const state = rewardsReducer(stateWithStep, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO);
- expect(state.onboardingReferralCode).toBeNull();
- });
+ // Assert
+ expect(state.seasonStatusError).toBe(apiError);
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- onboardingActiveStep: OnboardingStep.STEP_4,
- onboardingReferralCode: 'REF456',
- referralCode: 'KEEP123',
- balanceTotal: 1500,
- };
- const action = resetOnboarding();
+ it('should not affect other state properties when setting error', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ seasonName: 'Test Season',
+ seasonId: 'season-123',
+ balanceTotal: 1000,
+ seasonStatusLoading: false,
+ };
+ const errorMessage = 'Something went wrong';
+ const action = setSeasonStatusError(errorMessage);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Assert
- expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO);
- expect(state.onboardingReferralCode).toBeNull();
- expect(state.referralCode).toBe('KEEP123');
- expect(state.balanceTotal).toBe(1500);
- });
+ // Assert
+ expect(state.seasonStatusError).toBe(errorMessage);
+ expect(state.seasonName).toBe('Test Season');
+ expect(state.seasonId).toBe('season-123');
+ expect(state.balanceTotal).toBe(1000);
+ expect(state.seasonStatusLoading).toBe(false);
});
+ });
- describe('setOnboardingReferralCode', () => {
- it('should set onboarding referral code', () => {
- // Arrange
- const action = setOnboardingReferralCode('REF123');
+ describe('setReferralDetailsLoading', () => {
+ it('should set referral details loading to true when no referral code exists', () => {
+ // Arrange
+ const action = setReferralDetailsLoading(true);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.onboardingReferralCode).toBe('REF123');
- });
+ // Assert
+ expect(state.referralDetailsLoading).toBe(true);
+ });
- it('should update existing onboarding referral code', () => {
- // Arrange
- const stateWithCode = {
- ...initialState,
- onboardingReferralCode: 'OLD_REF',
- };
- const action = setOnboardingReferralCode('NEW_REF');
+ it('should not set referral details loading to true when referral code already exists', () => {
+ // Arrange
+ const stateWithReferralCode = {
+ ...initialState,
+ referralCode: 'EXISTING123',
+ referralDetailsLoading: false,
+ };
+ const action = setReferralDetailsLoading(true);
- // Act
- const state = rewardsReducer(stateWithCode, action);
+ // Act
+ const state = rewardsReducer(stateWithReferralCode, action);
- // Assert
- expect(state.onboardingReferralCode).toBe('NEW_REF');
- });
+ // Assert
+ expect(state.referralDetailsLoading).toBe(false); // Should remain false due to guard clause
+ });
- it('should set onboarding referral code to null', () => {
- // Arrange
- const stateWithCode = {
- ...initialState,
- onboardingReferralCode: 'REF123',
- };
- const action = setOnboardingReferralCode(null);
+ it('should set referral details loading to false', () => {
+ // Arrange
+ const stateWithLoading = {
+ ...initialState,
+ referralDetailsLoading: true,
+ };
+ const action = setReferralDetailsLoading(false);
- // Act
- const state = rewardsReducer(stateWithCode, action);
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Assert
- expect(state.onboardingReferralCode).toBeNull();
- });
+ // Assert
+ expect(state.referralDetailsLoading).toBe(false);
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- onboardingActiveStep: OnboardingStep.STEP_2,
- referralCode: 'KEEP123',
- balanceTotal: 1500,
- };
- const action = setOnboardingReferralCode('REF789');
+ it('should set referral details loading to false even when referral code exists', () => {
+ // Arrange
+ const stateWithReferralCodeAndLoading = {
+ ...initialState,
+ referralCode: 'EXISTING123',
+ referralDetailsLoading: true,
+ };
+ const action = setReferralDetailsLoading(false);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithReferralCodeAndLoading, action);
- // Assert
- expect(state.onboardingReferralCode).toBe('REF789');
- expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2);
- expect(state.referralCode).toBe('KEEP123');
- expect(state.balanceTotal).toBe(1500);
- });
+ // Assert
+ expect(state.referralDetailsLoading).toBe(false);
});
+ });
- describe('setGeoRewardsMetadata', () => {
- it('should update geo metadata when payload is provided', () => {
- // Arrange
- const geoMetadata = {
- geoLocation: 'US',
- optinAllowedForGeo: true,
- };
- const action = setGeoRewardsMetadata(geoMetadata);
-
- // Act
- const state = rewardsReducer(initialState, action);
+ describe('setOnboardingActiveStep', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
- // Assert
- expect(state.geoLocation).toBe('US');
- expect(state.optinAllowedForGeo).toBe(true);
- expect(state.optinAllowedForGeoLoading).toBe(false);
- });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
- it('should update geo metadata with different location', () => {
- // Arrange
- const geoMetadata = {
- geoLocation: 'CA',
- optinAllowedForGeo: false,
- };
- const action = setGeoRewardsMetadata(geoMetadata);
+ it.each([
+ OnboardingStep.INTRO,
+ OnboardingStep.STEP_1,
+ OnboardingStep.STEP_2,
+ OnboardingStep.STEP_3,
+ OnboardingStep.STEP_4,
+ ])('should set onboarding active step to %s', (step) => {
+ // Arrange
+ const action = setOnboardingActiveStep(step);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.geoLocation).toBe('CA');
- expect(state.optinAllowedForGeo).toBe(false);
- expect(state.optinAllowedForGeoLoading).toBe(false);
- });
+ // Assert
+ expect(state.onboardingActiveStep).toBe(step);
+ });
- it('should clear geo metadata when payload is null', () => {
- // Arrange
- const stateWithGeoData = {
- ...initialState,
- geoLocation: 'EU',
- optinAllowedForGeo: true,
- optinAllowedForGeoLoading: true,
- };
- const action = setGeoRewardsMetadata(null);
+ it('should update from different onboarding step', () => {
+ // Arrange
+ const stateWithStep = {
+ ...initialState,
+ onboardingActiveStep: OnboardingStep.STEP_2,
+ };
+ const action = setOnboardingActiveStep(OnboardingStep.STEP_4);
- // Act
- const state = rewardsReducer(stateWithGeoData, action);
+ // Act
+ const state = rewardsReducer(stateWithStep, action);
- // Assert
- expect(state.geoLocation).toBe(null);
- expect(state.optinAllowedForGeo).toBe(null);
- expect(state.optinAllowedForGeoLoading).toBe(false);
- });
+ // Assert
+ expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_4);
+ });
- it('should reset loading state when metadata is set', () => {
- // Arrange
- const stateWithLoading = {
- ...initialState,
- optinAllowedForGeoLoading: true,
- };
- const geoMetadata = {
- geoLocation: 'UK',
- optinAllowedForGeo: true,
- };
- const action = setGeoRewardsMetadata(geoMetadata);
+ it('should call logger even when step is the same', () => {
+ // Arrange
+ const stateWithStep = {
+ ...initialState,
+ onboardingActiveStep: OnboardingStep.STEP_1,
+ };
+ const action = setOnboardingActiveStep(OnboardingStep.STEP_1);
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ // Act
+ const state = rewardsReducer(stateWithStep, action);
- // Assert
- expect(state.geoLocation).toBe('UK');
- expect(state.optinAllowedForGeo).toBe(true);
- expect(state.optinAllowedForGeoLoading).toBe(false);
- });
+ // Assert
+ expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_1);
});
+ });
- describe('setGeoRewardsMetadataLoading', () => {
- it('should set geo rewards metadata loading to true', () => {
- // Arrange
- const action = setGeoRewardsMetadataLoading(true);
+ describe('resetOnboarding', () => {
+ it('should reset onboarding to INTRO step and clear referral code', () => {
+ // Arrange
+ const stateWithStep = {
+ ...initialState,
+ onboardingActiveStep: OnboardingStep.STEP_3,
+ onboardingReferralCode: 'REF123',
+ };
+ const action = resetOnboarding();
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithStep, action);
- // Assert
- expect(state.optinAllowedForGeoLoading).toBe(true);
- });
+ // Assert
+ expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO);
+ expect(state.onboardingReferralCode).toBeNull();
+ });
- it('should set geo rewards metadata loading to false', () => {
- // Arrange
- const stateWithLoading = {
- ...initialState,
- optinAllowedForGeoLoading: true,
- };
- const action = setGeoRewardsMetadataLoading(false);
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ onboardingActiveStep: OnboardingStep.STEP_4,
+ onboardingReferralCode: 'REF456',
+ referralCode: 'KEEP123',
+ balanceTotal: 1500,
+ };
+ const action = resetOnboarding();
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Assert
- expect(state.optinAllowedForGeoLoading).toBe(false);
- });
+ // Assert
+ expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO);
+ expect(state.onboardingReferralCode).toBeNull();
+ expect(state.referralCode).toBe('KEEP123');
+ expect(state.balanceTotal).toBe(1500);
});
+ });
- describe('setGeoRewardsMetadataError', () => {
- it('should set geo rewards metadata error to true', () => {
- // Arrange
- const action = setGeoRewardsMetadataError(true);
+ describe('setOnboardingReferralCode', () => {
+ it('should set onboarding referral code', () => {
+ // Arrange
+ const action = setOnboardingReferralCode('REF123');
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.optinAllowedForGeoError).toBe(true);
- });
+ // Assert
+ expect(state.onboardingReferralCode).toBe('REF123');
+ });
- it('should set geo rewards metadata error to false', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- optinAllowedForGeoError: true,
- };
- const action = setGeoRewardsMetadataError(false);
+ it('should update existing onboarding referral code', () => {
+ // Arrange
+ const stateWithCode = {
+ ...initialState,
+ onboardingReferralCode: 'OLD_REF',
+ };
+ const action = setOnboardingReferralCode('NEW_REF');
- // Act
- const state = rewardsReducer(stateWithError, action);
+ // Act
+ const state = rewardsReducer(stateWithCode, action);
- // Assert
- expect(state.optinAllowedForGeoError).toBe(false);
- });
+ // Assert
+ expect(state.onboardingReferralCode).toBe('NEW_REF');
+ });
- it('should not affect other geo metadata properties', () => {
- // Arrange
- const stateWithGeoData = {
- ...initialState,
- geoLocation: 'US',
- optinAllowedForGeo: true,
- optinAllowedForGeoLoading: true,
- };
- const action = setGeoRewardsMetadataError(true);
+ it('should set onboarding referral code to null', () => {
+ // Arrange
+ const stateWithCode = {
+ ...initialState,
+ onboardingReferralCode: 'REF123',
+ };
+ const action = setOnboardingReferralCode(null);
- // Act
- const state = rewardsReducer(stateWithGeoData, action);
+ // Act
+ const state = rewardsReducer(stateWithCode, action);
- // Assert
- expect(state.optinAllowedForGeoError).toBe(true);
- expect(state.geoLocation).toBe('US');
- expect(state.optinAllowedForGeo).toBe(true);
- expect(state.optinAllowedForGeoLoading).toBe(true);
- });
+ // Assert
+ expect(state.onboardingReferralCode).toBeNull();
});
- describe('setCandidateSubscriptionId', () => {
- it('should set candidate subscription ID to a string value', () => {
- // Arrange
- const action = setCandidateSubscriptionId('sub-12345');
-
- // Act
- const state = rewardsReducer(initialState, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('sub-12345');
- });
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ onboardingActiveStep: OnboardingStep.STEP_2,
+ referralCode: 'KEEP123',
+ balanceTotal: 1500,
+ };
+ const action = setOnboardingReferralCode('REF789');
- it('should set candidate subscription ID to pending', () => {
- // Arrange
- const stateWithId = {
- ...initialState,
- candidateSubscriptionId: 'existing-id' as const,
- };
- const action = setCandidateSubscriptionId('pending');
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Act
- const state = rewardsReducer(stateWithId, action);
+ // Assert
+ expect(state.onboardingReferralCode).toBe('REF789');
+ expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2);
+ expect(state.referralCode).toBe('KEEP123');
+ expect(state.balanceTotal).toBe(1500);
+ });
+ });
- // Assert
- expect(state.candidateSubscriptionId).toBe('pending');
- });
+ describe('setGeoRewardsMetadata', () => {
+ it('should update geo metadata when payload is provided', () => {
+ // Arrange
+ const geoMetadata = {
+ geoLocation: 'US',
+ optinAllowedForGeo: true,
+ };
+ const action = setGeoRewardsMetadata(geoMetadata);
- it('should set candidate subscription ID to error', () => {
- // Arrange
- const action = setCandidateSubscriptionId('error');
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.geoLocation).toBe('US');
+ expect(state.optinAllowedForGeo).toBe(true);
+ expect(state.optinAllowedForGeoLoading).toBe(false);
+ });
- // Assert
- expect(state.candidateSubscriptionId).toBe('error');
- });
+ it('should update geo metadata with different location', () => {
+ // Arrange
+ const geoMetadata = {
+ geoLocation: 'CA',
+ optinAllowedForGeo: false,
+ };
+ const action = setGeoRewardsMetadata(geoMetadata);
- it('should set candidate subscription ID to retry', () => {
- // Arrange
- const action = setCandidateSubscriptionId('retry');
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.geoLocation).toBe('CA');
+ expect(state.optinAllowedForGeo).toBe(false);
+ expect(state.optinAllowedForGeoLoading).toBe(false);
+ });
- // Assert
- expect(state.candidateSubscriptionId).toBe('retry');
- });
+ it('should clear geo metadata when payload is null', () => {
+ // Arrange
+ const stateWithGeoData = {
+ ...initialState,
+ geoLocation: 'EU',
+ optinAllowedForGeo: true,
+ optinAllowedForGeoLoading: true,
+ };
+ const action = setGeoRewardsMetadata(null);
- it('should set candidate subscription ID to null', () => {
- // Arrange
- const stateWithId = {
- ...initialState,
- candidateSubscriptionId: 'existing-id' as const,
- };
- const action = setCandidateSubscriptionId(null);
+ // Act
+ const state = rewardsReducer(stateWithGeoData, action);
- // Act
- const state = rewardsReducer(stateWithId, action);
+ // Assert
+ expect(state.geoLocation).toBe(null);
+ expect(state.optinAllowedForGeo).toBe(null);
+ expect(state.optinAllowedForGeoLoading).toBe(false);
+ });
- // Assert
- expect(state.candidateSubscriptionId).toBe(null);
- });
+ it('should reset loading state when metadata is set', () => {
+ // Arrange
+ const stateWithLoading = {
+ ...initialState,
+ optinAllowedForGeoLoading: true,
+ };
+ const geoMetadata = {
+ geoLocation: 'UK',
+ optinAllowedForGeo: true,
+ };
+ const action = setGeoRewardsMetadata(geoMetadata);
- it('should not affect other state properties when changing from non-valid state', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'pending' as const,
- referralCode: 'KEEP123',
- balanceTotal: 1500,
- };
- const action = setCandidateSubscriptionId('new-id');
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Assert
+ expect(state.geoLocation).toBe('UK');
+ expect(state.optinAllowedForGeo).toBe(true);
+ expect(state.optinAllowedForGeoLoading).toBe(false);
+ });
+ });
- // Assert
- expect(state.candidateSubscriptionId).toBe('new-id');
- expect(state.referralCode).toBe('KEEP123');
- expect(state.balanceTotal).toBe(1500);
- });
+ describe('setGeoRewardsMetadataLoading', () => {
+ it('should set geo rewards metadata loading to true', () => {
+ // Arrange
+ const action = setGeoRewardsMetadataLoading(true);
- describe('state reset logic when candidate ID changes', () => {
- it('should reset UI state when changing from valid ID to different valid ID', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'old-subscription-id',
- seasonId: 'season-123',
- seasonName: 'Test Season',
- seasonStartDate: new Date('2024-01-01'),
- seasonEndDate: new Date('2024-12-31'),
- seasonTiers: [
- {
- id: 'tier-1',
- name: 'Tier 1',
- pointsNeeded: 100,
- image: {
- lightModeUrl: 'tier1.png',
- darkModeUrl: 'tier1-dark.png',
- },
- levelNumber: '1',
- rewards: [],
- },
- ],
- referralCode: 'REF123',
- refereeCount: 5,
- currentTier: {
- id: 'current-tier',
- name: 'Current Tier',
- pointsNeeded: 1000,
- image: {
- lightModeUrl: 'current.png',
- darkModeUrl: 'current-dark.png',
- },
- levelNumber: '2',
- rewards: [],
- },
- nextTier: {
- id: 'next-tier',
- name: 'Next Tier',
- pointsNeeded: 2000,
- image: {
- lightModeUrl: 'next.png',
- darkModeUrl: 'next-dark.png',
- },
- levelNumber: '3',
- rewards: [],
- },
- nextTierPointsNeeded: 1000,
- balanceTotal: 1500,
- balanceRefereePortion: 300,
- balanceUpdatedAt: new Date('2024-06-01'),
- onboardingActiveStep: OnboardingStep.STEP_2,
- onboardingReferralCode: 'ONBOARDING_REF',
- activeBoosts: [
- {
- id: 'boost-1',
- name: 'Test Boost',
- icon: {
- lightModeUrl: 'boost.png',
- darkModeUrl: 'boost-dark.png',
- },
- boostBips: 1000,
- seasonLong: true,
- backgroundColor: '#FF0000',
- },
- ],
- pointsEvents: [
- {
- id: 'event-1',
- type: 'SWAP' as const,
- timestamp: new Date('2024-01-01'),
- value: 100,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-01'),
- payload: null,
- },
- ],
- unlockedRewards: [
- {
- id: 'reward-1',
- seasonRewardId: 'season-reward-1',
- claimStatus: RewardClaimStatus.CLAIMED,
- },
- ],
- };
- const action = setCandidateSubscriptionId('new-subscription-id');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('new-subscription-id');
- // All UI state should be reset to initial values
- expect(state.seasonId).toBe(initialState.seasonId);
- expect(state.seasonName).toBe(initialState.seasonName);
- expect(state.seasonStartDate).toBe(initialState.seasonStartDate);
- expect(state.seasonEndDate).toBe(initialState.seasonEndDate);
- expect(state.seasonTiers).toEqual(initialState.seasonTiers);
- expect(state.referralCode).toBe(initialState.referralCode);
- expect(state.refereeCount).toBe(initialState.refereeCount);
- expect(state.currentTier).toBe(initialState.currentTier);
- expect(state.nextTier).toBe(initialState.nextTier);
- expect(state.nextTierPointsNeeded).toBe(
- initialState.nextTierPointsNeeded,
- );
- expect(state.balanceTotal).toBe(initialState.balanceTotal);
- expect(state.balanceRefereePortion).toBe(
- initialState.balanceRefereePortion,
- );
- expect(state.balanceUpdatedAt).toBe(initialState.balanceUpdatedAt);
- expect(state.activeBoosts).toBe(initialState.activeBoosts);
- expect(state.pointsEvents).toBe(initialState.pointsEvents);
- expect(state.unlockedRewards).toBe(initialState.unlockedRewards);
- // Onboarding state should NOT be reset
- expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2);
- expect(state.onboardingReferralCode).toBe('ONBOARDING_REF');
- });
-
- it('should NOT reset UI state when changing from pending to valid ID', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'pending' as const,
- seasonId: 'season-123',
- seasonName: 'Test Season',
- referralCode: 'REF123',
- balanceTotal: 1500,
- };
- const action = setCandidateSubscriptionId('new-subscription-id');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('new-subscription-id');
- // UI state should NOT be reset when coming from pending
- expect(state.seasonId).toBe('season-123');
- expect(state.seasonName).toBe('Test Season');
- expect(state.referralCode).toBe('REF123');
- expect(state.balanceTotal).toBe(1500);
- });
-
- it('should NOT reset UI state when changing from error to valid ID', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'error' as const,
- seasonId: 'season-456',
- seasonName: 'Error Season',
- referralCode: 'ERROR123',
- balanceTotal: 2000,
- };
- const action = setCandidateSubscriptionId('new-subscription-id');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('new-subscription-id');
- // UI state should NOT be reset when coming from error
- expect(state.seasonId).toBe('season-456');
- expect(state.seasonName).toBe('Error Season');
- expect(state.referralCode).toBe('ERROR123');
- expect(state.balanceTotal).toBe(2000);
- });
-
- it('should NOT reset UI state when changing from retry to valid ID', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'retry' as const,
- seasonId: 'season-789',
- seasonName: 'Retry Season',
- referralCode: 'RETRY123',
- balanceTotal: 3000,
- };
- const action = setCandidateSubscriptionId('new-subscription-id');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('new-subscription-id');
- // UI state should NOT be reset when coming from retry
- expect(state.seasonId).toBe('season-789');
- expect(state.seasonName).toBe('Retry Season');
- expect(state.referralCode).toBe('RETRY123');
- expect(state.balanceTotal).toBe(3000);
- });
-
- it('should NOT reset UI state when changing from null to valid ID', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: null,
- seasonId: 'season-null',
- seasonName: 'Null Season',
- referralCode: 'NULL123',
- balanceTotal: 4000,
- };
- const action = setCandidateSubscriptionId('new-subscription-id');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('new-subscription-id');
- // UI state should NOT be reset when coming from null
- expect(state.seasonId).toBe('season-null');
- expect(state.seasonName).toBe('Null Season');
- expect(state.referralCode).toBe('NULL123');
- expect(state.balanceTotal).toBe(4000);
- });
-
- it('should NOT reset UI state when changing to same valid ID', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'same-subscription-id',
- seasonId: 'season-same',
- seasonName: 'Same Season',
- referralCode: 'SAME123',
- balanceTotal: 5000,
- };
- const action = setCandidateSubscriptionId('same-subscription-id');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('same-subscription-id');
- // UI state should NOT be reset when ID doesn't change
- expect(state.seasonId).toBe('season-same');
- expect(state.seasonName).toBe('Same Season');
- expect(state.referralCode).toBe('SAME123');
- expect(state.balanceTotal).toBe(5000);
- });
-
- it('should reset UI state when changing from valid ID to pending', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'valid-subscription-id',
- seasonId: 'season-valid',
- seasonName: 'Valid Season',
- referralCode: 'VALID123',
- balanceTotal: 6000,
- };
- const action = setCandidateSubscriptionId('pending');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('pending');
- // UI state should be reset when changing from valid ID to pending
- expect(state.seasonId).toBe(initialState.seasonId);
- expect(state.seasonName).toBe(initialState.seasonName);
- expect(state.referralCode).toBe(initialState.referralCode);
- expect(state.balanceTotal).toBe(initialState.balanceTotal);
- });
-
- it('should reset UI state when changing from valid ID to error', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'valid-subscription-id',
- seasonId: 'season-valid',
- seasonName: 'Valid Season',
- referralCode: 'VALID123',
- balanceTotal: 6000,
- };
- const action = setCandidateSubscriptionId('error');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('error');
- // UI state should be reset when changing from valid ID to error
- expect(state.seasonId).toBe(initialState.seasonId);
- expect(state.seasonName).toBe(initialState.seasonName);
- expect(state.referralCode).toBe(initialState.referralCode);
- expect(state.balanceTotal).toBe(initialState.balanceTotal);
- });
-
- it('should reset UI state when changing from valid ID to retry', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'valid-subscription-id',
- seasonId: 'season-valid',
- seasonName: 'Valid Season',
- referralCode: 'VALID123',
- balanceTotal: 6000,
- };
- const action = setCandidateSubscriptionId('retry');
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('retry');
- // UI state should be reset when changing from valid ID to retry
- expect(state.seasonId).toBe(initialState.seasonId);
- expect(state.seasonName).toBe(initialState.seasonName);
- expect(state.referralCode).toBe(initialState.referralCode);
- expect(state.balanceTotal).toBe(initialState.balanceTotal);
- });
-
- it('should reset UI state when changing from valid ID to null', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- candidateSubscriptionId: 'valid-subscription-id',
- seasonId: 'season-valid',
- seasonName: 'Valid Season',
- referralCode: 'VALID123',
- balanceTotal: 6000,
- };
- const action = setCandidateSubscriptionId(null);
-
- // Act
- const state = rewardsReducer(stateWithData, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe(null);
- // UI state should be reset when changing from valid ID to null
- expect(state.seasonId).toBe(initialState.seasonId);
- expect(state.seasonName).toBe(initialState.seasonName);
- expect(state.referralCode).toBe(initialState.referralCode);
- expect(state.balanceTotal).toBe(initialState.balanceTotal);
- });
- });
+ // Act
+ const state = rewardsReducer(initialState, action);
- describe('state transitions between special states', () => {
- it('should handle transition from pending to error', () => {
- // Arrange
- const stateWithPending = {
- ...initialState,
- candidateSubscriptionId: 'pending' as const,
- seasonId: 'season-pending',
- referralCode: 'PENDING123',
- };
- const action = setCandidateSubscriptionId('error');
-
- // Act
- const state = rewardsReducer(stateWithPending, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('error');
- expect(state.seasonId).toBe('season-pending'); // Should not reset
- expect(state.referralCode).toBe('PENDING123'); // Should not reset
- });
-
- it('should handle transition from error to retry', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- candidateSubscriptionId: 'error' as const,
- seasonId: 'season-error',
- referralCode: 'ERROR123',
- };
- const action = setCandidateSubscriptionId('retry');
-
- // Act
- const state = rewardsReducer(stateWithError, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('retry');
- expect(state.seasonId).toBe('season-error'); // Should not reset
- expect(state.referralCode).toBe('ERROR123'); // Should not reset
- });
-
- it('should handle transition from retry to pending', () => {
- // Arrange
- const stateWithRetry = {
- ...initialState,
- candidateSubscriptionId: 'retry' as const,
- seasonId: 'season-retry',
- referralCode: 'RETRY123',
- };
- const action = setCandidateSubscriptionId('pending');
-
- // Act
- const state = rewardsReducer(stateWithRetry, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('pending');
- expect(state.seasonId).toBe('season-retry'); // Should not reset
- expect(state.referralCode).toBe('RETRY123'); // Should not reset
- });
-
- it('should handle transition from null to pending', () => {
- // Arrange
- const stateWithNull = {
- ...initialState,
- candidateSubscriptionId: null,
- seasonId: 'season-null',
- referralCode: 'NULL123',
- };
- const action = setCandidateSubscriptionId('pending');
-
- // Act
- const state = rewardsReducer(stateWithNull, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe('pending');
- expect(state.seasonId).toBe('season-null'); // Should not reset
- expect(state.referralCode).toBe('NULL123'); // Should not reset
- });
-
- it('should handle transition from pending to null', () => {
- // Arrange
- const stateWithPending = {
- ...initialState,
- candidateSubscriptionId: 'pending' as const,
- seasonId: 'season-pending',
- referralCode: 'PENDING123',
- };
- const action = setCandidateSubscriptionId(null);
-
- // Act
- const state = rewardsReducer(stateWithPending, action);
-
- // Assert
- expect(state.candidateSubscriptionId).toBe(null);
- expect(state.seasonId).toBe('season-pending'); // Should not reset
- expect(state.referralCode).toBe('PENDING123'); // Should not reset
- });
- });
+ // Assert
+ expect(state.optinAllowedForGeoLoading).toBe(true);
});
- describe('setHideUnlinkedAccountsBanner', () => {
- it('should set hide unlinked accounts banner to true', () => {
- // Arrange
- const action = setHideUnlinkedAccountsBanner(true);
+ it('should set geo rewards metadata loading to false', () => {
+ // Arrange
+ const stateWithLoading = {
+ ...initialState,
+ optinAllowedForGeoLoading: true,
+ };
+ const action = setGeoRewardsMetadataLoading(false);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Assert
- expect(state.hideUnlinkedAccountsBanner).toBe(true);
- });
+ // Assert
+ expect(state.optinAllowedForGeoLoading).toBe(false);
+ });
+ });
- it('should set hide unlinked accounts banner to false', () => {
- // Arrange
- const stateWithBannerHidden = {
- ...initialState,
- hideUnlinkedAccountsBanner: true,
- };
- const action = setHideUnlinkedAccountsBanner(false);
+ describe('setGeoRewardsMetadataError', () => {
+ it('should set geo rewards metadata error to true', () => {
+ // Arrange
+ const action = setGeoRewardsMetadataError(true);
- // Act
- const state = rewardsReducer(stateWithBannerHidden, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.hideUnlinkedAccountsBanner).toBe(false);
- });
+ // Assert
+ expect(state.optinAllowedForGeoError).toBe(true);
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- hideUnlinkedAccountsBanner: false,
- referralCode: 'KEEP123',
- balanceTotal: 1500,
- };
- const action = setHideUnlinkedAccountsBanner(true);
+ it('should set geo rewards metadata error to false', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ optinAllowedForGeoError: true,
+ };
+ const action = setGeoRewardsMetadataError(false);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithError, action);
- // Assert
- expect(state.hideUnlinkedAccountsBanner).toBe(true);
- expect(state.referralCode).toBe('KEEP123');
- expect(state.balanceTotal).toBe(1500);
- });
+ // Assert
+ expect(state.optinAllowedForGeoError).toBe(false);
});
- describe('setHideCurrentAccountNotOptedInBanner', () => {
- it('should add new account banner entry when it does not exist', () => {
- // Arrange
- const accountGroupId: AccountGroupId = 'keyring:wallet1/1';
- const action = setHideCurrentAccountNotOptedInBanner({
- accountGroupId,
- hide: true,
- });
+ it('should not affect other geo metadata properties', () => {
+ // Arrange
+ const stateWithGeoData = {
+ ...initialState,
+ geoLocation: 'US',
+ optinAllowedForGeo: true,
+ optinAllowedForGeoLoading: true,
+ };
+ const action = setGeoRewardsMetadataError(true);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithGeoData, action);
- // Assert
- expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1);
- expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
- accountGroupId,
- hide: true,
- });
- });
+ // Assert
+ expect(state.optinAllowedForGeoError).toBe(true);
+ expect(state.geoLocation).toBe('US');
+ expect(state.optinAllowedForGeo).toBe(true);
+ expect(state.optinAllowedForGeoLoading).toBe(true);
+ });
+ });
- it('should update existing account banner entry', () => {
- // Arrange
- const accountGroupId: AccountGroupId = 'keyring:wallet1/1';
- const stateWithExistingEntry = {
- ...initialState,
- hideCurrentAccountNotOptedInBanner: [
- {
- accountGroupId,
- hide: false,
- },
- ],
- };
- const action = setHideCurrentAccountNotOptedInBanner({
- accountGroupId,
- hide: true,
- });
+ describe('setCandidateSubscriptionId', () => {
+ it('should set candidate subscription ID to a string value', () => {
+ // Arrange
+ const action = setCandidateSubscriptionId('sub-12345');
- // Act
- const state = rewardsReducer(stateWithExistingEntry, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1);
- expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
- accountGroupId,
- hide: true,
- });
- });
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('sub-12345');
+ });
- it('should add multiple different account entries', () => {
- // Arrange
- const accountGroupId1: AccountGroupId = 'keyring:wallet1/1';
- const accountGroupId2: AccountGroupId = 'keyring:wallet2/2';
+ it('should set candidate subscription ID to pending', () => {
+ // Arrange
+ const stateWithId = {
+ ...initialState,
+ candidateSubscriptionId: 'existing-id' as const,
+ };
+ const action = setCandidateSubscriptionId('pending');
- let currentState = initialState;
+ // Act
+ const state = rewardsReducer(stateWithId, action);
- // Add first account
- const action1 = setHideCurrentAccountNotOptedInBanner({
- accountGroupId: accountGroupId1,
- hide: true,
- });
- currentState = rewardsReducer(currentState, action1);
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('pending');
+ });
- // Add second account
- const action2 = setHideCurrentAccountNotOptedInBanner({
- accountGroupId: accountGroupId2,
- hide: false,
- });
+ it('should set candidate subscription ID to error', () => {
+ // Arrange
+ const action = setCandidateSubscriptionId('error');
- // Act
- const state = rewardsReducer(currentState, action2);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2);
- expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
- accountGroupId: accountGroupId1,
- hide: true,
- });
- expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({
- accountGroupId: accountGroupId2,
- hide: false,
- });
- });
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('error');
+ });
- it('should update specific account without affecting others', () => {
- // Arrange
- const accountGroupId1: AccountGroupId = 'keyring:wallet1/1';
- const accountGroupId2: AccountGroupId = 'keyring:wallet2/2';
- const stateWithMultipleEntries = {
- ...initialState,
- hideCurrentAccountNotOptedInBanner: [
- {
- accountGroupId: accountGroupId1,
- hide: true,
- },
- {
- accountGroupId: accountGroupId2,
- hide: false,
- },
- ],
- };
- const action = setHideCurrentAccountNotOptedInBanner({
- accountGroupId: accountGroupId1,
- hide: false,
- });
+ it('should set candidate subscription ID to retry', () => {
+ // Arrange
+ const action = setCandidateSubscriptionId('retry');
- // Act
- const state = rewardsReducer(stateWithMultipleEntries, action);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2);
- expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
- accountGroupId: accountGroupId1,
- hide: false, // Updated
- });
- expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({
- accountGroupId: accountGroupId2,
- hide: false, // Unchanged
- });
- });
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('retry');
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- activeTab: 'activity' as const,
- referralCode: 'TEST123',
- hideUnlinkedAccountsBanner: true,
- };
- const accountGroupId: AccountGroupId = 'keyring:wallet1/1';
- const action = setHideCurrentAccountNotOptedInBanner({
- accountGroupId,
- hide: true,
- });
+ it('should set candidate subscription ID to null', () => {
+ // Arrange
+ const stateWithId = {
+ ...initialState,
+ candidateSubscriptionId: 'existing-id' as const,
+ };
+ const action = setCandidateSubscriptionId(null);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithId, action);
- // Assert
- expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1);
- expect(state.activeTab).toBe('activity');
- expect(state.referralCode).toBe('TEST123');
- expect(state.hideUnlinkedAccountsBanner).toBe(true);
- });
+ // Assert
+ expect(state.candidateSubscriptionId).toBe(null);
});
- describe('resetRewardsState', () => {
- it('should reset all state to initial values', () => {
- // Arrange
- const stateWithData: RewardsState = {
- activeTab: 'activity' as const,
- seasonStatusLoading: true,
- seasonId: 'test-season-id',
- referralDetailsLoading: false,
- referralCode: 'TEST123',
- refereeCount: 10,
- currentTier: {
- id: 'tier-platinum',
- name: 'Platinum',
- pointsNeeded: 1000,
- image: {
- lightModeUrl: 'platinum.png',
- darkModeUrl: 'platinum-dark.png',
- },
- levelNumber: 'Level 10',
- rewards: [],
- },
- seasonStatusError: null,
- nextTier: {
- id: 'tier-diamond',
- name: 'Diamond',
- pointsNeeded: 2000,
- image: {
- lightModeUrl: 'diamond.png',
- darkModeUrl: 'diamond-dark.png',
- },
- levelNumber: 'Level 20',
- rewards: [],
- },
- nextTierPointsNeeded: 1000,
- balanceTotal: 5000,
- balanceRefereePortion: 1000,
- balanceUpdatedAt: new Date('2024-01-01'),
- seasonName: 'Test Season',
- seasonStartDate: new Date('2024-01-01'),
- seasonEndDate: new Date('2024-12-31'),
- seasonTiers: [
- {
- id: 'tier-1',
- name: 'Tier 1',
- pointsNeeded: 100,
- image: {
- lightModeUrl: 'tier-1.png',
- darkModeUrl: 'tier-1-dark.png',
- },
- levelNumber: 'Level 1',
- rewards: [],
- },
- ],
- onboardingActiveStep: OnboardingStep.STEP_1,
- onboardingReferralCode: 'REF123',
- candidateSubscriptionId: 'some-id',
- geoLocation: 'US',
- optinAllowedForGeo: true,
- optinAllowedForGeoLoading: false,
- hideUnlinkedAccountsBanner: true,
- hideCurrentAccountNotOptedInBanner: [
- {
- accountGroupId: 'keyring:wallet1/1' as AccountGroupId,
- hide: true,
- },
- ],
- activeBoosts: [
- {
- id: 'boost-1',
- name: 'Test Boost 1',
- icon: {
- lightModeUrl: 'light1.png',
- darkModeUrl: 'dark1.png',
- },
- boostBips: 1000,
- seasonLong: true,
- backgroundColor: '#FF0000',
- },
- ],
- pointsEvents: null,
- activeBoostsLoading: false,
- activeBoostsError: false,
- unlockedRewards: [],
- unlockedRewardLoading: false,
- unlockedRewardError: false,
- referralDetailsError: false,
- optinAllowedForGeoError: false,
- };
- const action = resetRewardsState();
+ it('should not affect other state properties when changing from non-valid state', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'pending' as const,
+ referralCode: 'KEEP123',
+ balanceTotal: 1500,
+ };
+ const action = setCandidateSubscriptionId('new-id');
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Assert
- expect(state).toEqual(initialState);
- });
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('new-id');
+ expect(state.referralCode).toBe('KEEP123');
+ expect(state.balanceTotal).toBe(1500);
});
- describe('persist/REHYDRATE', () => {
- it('should restore persisted UI state while resetting non-persistent state', () => {
+ describe('state reset logic when candidate ID changes', () => {
+ it('should reset UI state when changing from valid ID to different valid ID', () => {
// Arrange
- const persistedRewardsState: RewardsState = {
- activeTab: 'activity',
- seasonStatusLoading: true,
- seasonId: 'test-season-id',
- referralDetailsLoading: false,
- referralCode: 'PERSISTED123',
- refereeCount: 15,
- currentTier: {
- id: 'tier-diamond',
- name: 'Diamond',
- pointsNeeded: 1000,
- image: {
- lightModeUrl: 'https://example.com/diamond-light.png',
- darkModeUrl: 'https://example.com/diamond-dark.png',
- },
- levelNumber: '4',
- rewards: [],
- },
- nextTier: null,
- nextTierPointsNeeded: null,
- balanceTotal: 2000,
- balanceRefereePortion: 400,
- balanceUpdatedAt: new Date('2024-05-01'),
- seasonName: 'Persisted Season',
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'old-subscription-id',
+ seasonId: 'season-123',
+ seasonName: 'Test Season',
seasonStartDate: new Date('2024-01-01'),
seasonEndDate: new Date('2024-12-31'),
seasonTiers: [
@@ -2005,111 +1321,15 @@ describe('rewardsReducer', () => {
name: 'Tier 1',
pointsNeeded: 100,
image: {
- lightModeUrl: 'https://example.com/tier1-light.png',
- darkModeUrl: 'https://example.com/tier1-dark.png',
+ lightModeUrl: 'tier1.png',
+ darkModeUrl: 'tier1-dark.png',
},
levelNumber: '1',
rewards: [],
},
],
- onboardingActiveStep: OnboardingStep.STEP_2,
- onboardingReferralCode: 'PERSISTED_REF',
- candidateSubscriptionId: 'some-id',
- geoLocation: 'CA',
- optinAllowedForGeo: true,
- optinAllowedForGeoLoading: false,
- hideUnlinkedAccountsBanner: true,
- hideCurrentAccountNotOptedInBanner: [
- {
- accountGroupId: 'keyring:wallet1/1' as AccountGroupId,
- hide: true,
- },
- ],
- activeBoosts: [
- {
- id: 'boost-1',
- name: 'Test Boost 1',
- icon: {
- lightModeUrl: 'light1.png',
- darkModeUrl: 'dark1.png',
- },
- boostBips: 1000,
- seasonLong: true,
- backgroundColor: '#FF0000',
- },
- ],
- pointsEvents: null,
- seasonStatusError: null,
- activeBoostsLoading: false,
- activeBoostsError: false,
- unlockedRewards: [],
- unlockedRewardLoading: false,
- unlockedRewardError: false,
- referralDetailsError: false,
- optinAllowedForGeoError: false,
- };
- const rehydrateAction = {
- type: 'persist/REHYDRATE',
- payload: {
- rewards: persistedRewardsState,
- },
- };
-
- // Act
- const state = rewardsReducer(initialState, rehydrateAction);
-
- // Assert - Should restore persisted UI state while keeping current non-persistent state
- const expectedState = {
- ...initialState,
- // Restored from persisted state
- seasonId: persistedRewardsState.seasonId,
- seasonName: persistedRewardsState.seasonName,
- seasonStartDate: persistedRewardsState.seasonStartDate,
- seasonEndDate: persistedRewardsState.seasonEndDate,
- seasonTiers: persistedRewardsState.seasonTiers,
- referralCode: persistedRewardsState.referralCode,
- refereeCount: persistedRewardsState.refereeCount,
- currentTier: persistedRewardsState.currentTier,
- nextTier: persistedRewardsState.nextTier,
- balanceTotal: persistedRewardsState.balanceTotal,
- balanceUpdatedAt: persistedRewardsState.balanceUpdatedAt,
- activeBoosts: persistedRewardsState.activeBoosts,
- pointsEvents: persistedRewardsState.pointsEvents,
- unlockedRewards: persistedRewardsState.unlockedRewards,
- hideUnlinkedAccountsBanner:
- persistedRewardsState.hideUnlinkedAccountsBanner,
- hideCurrentAccountNotOptedInBanner:
- persistedRewardsState.hideCurrentAccountNotOptedInBanner,
- // These fields are restored from persisted state
- nextTierPointsNeeded: persistedRewardsState.nextTierPointsNeeded,
- balanceRefereePortion: persistedRewardsState.balanceRefereePortion,
- };
- expect(state).toEqual(expectedState);
- });
-
- it('should preserve all persisted UI state fields', () => {
- // Arrange
- const persistedRewardsState: RewardsState = {
- ...initialState,
- seasonId: 'persisted-season-id',
- seasonName: 'Persisted Season Name',
- seasonStartDate: new Date('2024-01-01'),
- seasonEndDate: new Date('2024-12-31'),
- seasonTiers: [
- {
- id: 'tier-persisted',
- name: 'Persisted Tier',
- pointsNeeded: 500,
- image: {
- lightModeUrl: 'persisted.png',
- darkModeUrl: 'persisted-dark.png',
- },
- levelNumber: '2',
- rewards: [],
- },
- ],
- referralCode: 'PERSISTED_CODE',
- refereeCount: 25,
+ referralCode: 'REF123',
+ refereeCount: 5,
currentTier: {
id: 'current-tier',
name: 'Current Tier',
@@ -2118,7 +1338,7 @@ describe('rewardsReducer', () => {
lightModeUrl: 'current.png',
darkModeUrl: 'current-dark.png',
},
- levelNumber: '3',
+ levelNumber: '2',
rewards: [],
},
nextTier: {
@@ -2129,1086 +1349,1871 @@ describe('rewardsReducer', () => {
lightModeUrl: 'next.png',
darkModeUrl: 'next-dark.png',
},
- levelNumber: '4',
+ levelNumber: '3',
rewards: [],
},
- balanceTotal: 3000,
+ nextTierPointsNeeded: 1000,
+ balanceTotal: 1500,
+ balanceRefereePortion: 300,
balanceUpdatedAt: new Date('2024-06-01'),
+ onboardingActiveStep: OnboardingStep.STEP_2,
+ onboardingReferralCode: 'ONBOARDING_REF',
activeBoosts: [
{
- id: 'persisted-boost',
- name: 'Persisted Boost',
+ id: 'boost-1',
+ name: 'Test Boost',
icon: {
lightModeUrl: 'boost.png',
darkModeUrl: 'boost-dark.png',
},
- boostBips: 1500,
+ boostBips: 1000,
seasonLong: true,
- backgroundColor: '#00FF00',
+ backgroundColor: '#FF0000',
+ },
+ ],
+ pointsEvents: [
+ {
+ id: 'event-1',
+ type: 'SWAP' as const,
+ timestamp: new Date('2024-01-01'),
+ value: 100,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-01'),
+ payload: null,
},
],
- pointsEvents: [],
unlockedRewards: [
{
- id: 'unlocked-reward',
- seasonRewardId: 'season-reward-id',
- claimStatus: RewardClaimStatus.UNCLAIMED,
+ id: 'reward-1',
+ seasonRewardId: 'season-reward-1',
+ claimStatus: RewardClaimStatus.CLAIMED,
},
],
- hideUnlinkedAccountsBanner: true,
- hideCurrentAccountNotOptedInBanner: [
+ seasonActivityTypes: [
{
- accountGroupId: 'keyring:wallet1/1' as AccountGroupId,
- hide: true,
+ type: 'PREDICT',
+ title: 'Predict',
+ description: 'Prediction',
+ icon: 'Speedometer',
},
],
};
- const rehydrateAction = {
- type: 'persist/REHYDRATE',
- payload: {
- rewards: persistedRewardsState,
- },
+ const action = setCandidateSubscriptionId('new-subscription-id');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('new-subscription-id');
+ // All UI state should be reset to initial values
+ expect(state.seasonId).toBe(initialState.seasonId);
+ expect(state.seasonName).toBe(initialState.seasonName);
+ expect(state.seasonStartDate).toBe(initialState.seasonStartDate);
+ expect(state.seasonEndDate).toBe(initialState.seasonEndDate);
+ expect(state.seasonTiers).toEqual(initialState.seasonTiers);
+ expect(state.referralCode).toBe(initialState.referralCode);
+ expect(state.refereeCount).toBe(initialState.refereeCount);
+ expect(state.currentTier).toBe(initialState.currentTier);
+ expect(state.nextTier).toBe(initialState.nextTier);
+ expect(state.nextTierPointsNeeded).toBe(
+ initialState.nextTierPointsNeeded,
+ );
+ expect(state.balanceTotal).toBe(initialState.balanceTotal);
+ expect(state.balanceRefereePortion).toBe(
+ initialState.balanceRefereePortion,
+ );
+ expect(state.balanceUpdatedAt).toBe(initialState.balanceUpdatedAt);
+ expect(state.activeBoosts).toBe(initialState.activeBoosts);
+ expect(state.pointsEvents).toBe(initialState.pointsEvents);
+ expect(state.unlockedRewards).toBe(initialState.unlockedRewards);
+ expect(state.seasonActivityTypes).toEqual(
+ initialState.seasonActivityTypes,
+ );
+ // Onboarding state should NOT be reset
+ expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2);
+ expect(state.onboardingReferralCode).toBe('ONBOARDING_REF');
+ });
+
+ it('should NOT reset UI state when changing from pending to valid ID', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'pending' as const,
+ seasonId: 'season-123',
+ seasonName: 'Test Season',
+ referralCode: 'REF123',
+ balanceTotal: 1500,
+ };
+ const action = setCandidateSubscriptionId('new-subscription-id');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('new-subscription-id');
+ // UI state should NOT be reset when coming from pending
+ expect(state.seasonId).toBe('season-123');
+ expect(state.seasonName).toBe('Test Season');
+ expect(state.referralCode).toBe('REF123');
+ expect(state.balanceTotal).toBe(1500);
+ });
+
+ it('should NOT reset UI state when changing from error to valid ID', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'error' as const,
+ seasonId: 'season-456',
+ seasonName: 'Error Season',
+ referralCode: 'ERROR123',
+ balanceTotal: 2000,
+ };
+ const action = setCandidateSubscriptionId('new-subscription-id');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('new-subscription-id');
+ // UI state should NOT be reset when coming from error
+ expect(state.seasonId).toBe('season-456');
+ expect(state.seasonName).toBe('Error Season');
+ expect(state.referralCode).toBe('ERROR123');
+ expect(state.balanceTotal).toBe(2000);
+ });
+
+ it('should NOT reset UI state when changing from retry to valid ID', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'retry' as const,
+ seasonId: 'season-789',
+ seasonName: 'Retry Season',
+ referralCode: 'RETRY123',
+ balanceTotal: 3000,
+ };
+ const action = setCandidateSubscriptionId('new-subscription-id');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('new-subscription-id');
+ // UI state should NOT be reset when coming from retry
+ expect(state.seasonId).toBe('season-789');
+ expect(state.seasonName).toBe('Retry Season');
+ expect(state.referralCode).toBe('RETRY123');
+ expect(state.balanceTotal).toBe(3000);
+ });
+
+ it('should NOT reset UI state when changing from null to valid ID', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: null,
+ seasonId: 'season-null',
+ seasonName: 'Null Season',
+ referralCode: 'NULL123',
+ balanceTotal: 4000,
+ };
+ const action = setCandidateSubscriptionId('new-subscription-id');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('new-subscription-id');
+ // UI state should NOT be reset when coming from null
+ expect(state.seasonId).toBe('season-null');
+ expect(state.seasonName).toBe('Null Season');
+ expect(state.referralCode).toBe('NULL123');
+ expect(state.balanceTotal).toBe(4000);
+ });
+
+ it('should NOT reset UI state when changing to same valid ID', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'same-subscription-id',
+ seasonId: 'season-same',
+ seasonName: 'Same Season',
+ referralCode: 'SAME123',
+ balanceTotal: 5000,
};
+ const action = setCandidateSubscriptionId('same-subscription-id');
// Act
- const state = rewardsReducer(initialState, rehydrateAction);
+ const state = rewardsReducer(stateWithData, action);
- // Assert - All persisted UI state should be preserved
- expect(state.seasonId).toBe(persistedRewardsState.seasonId);
- expect(state.seasonName).toBe(persistedRewardsState.seasonName);
- expect(state.seasonStartDate).toEqual(
- persistedRewardsState.seasonStartDate,
- );
- expect(state.seasonEndDate).toEqual(
- persistedRewardsState.seasonEndDate,
- );
- expect(state.seasonTiers).toEqual(persistedRewardsState.seasonTiers);
- expect(state.referralCode).toBe(persistedRewardsState.referralCode);
- expect(state.refereeCount).toBe(persistedRewardsState.refereeCount);
- expect(state.currentTier).toEqual(persistedRewardsState.currentTier);
- expect(state.nextTier).toEqual(persistedRewardsState.nextTier);
- expect(state.balanceTotal).toBe(persistedRewardsState.balanceTotal);
- expect(state.balanceUpdatedAt).toEqual(
- persistedRewardsState.balanceUpdatedAt,
- );
- expect(state.activeBoosts).toEqual(persistedRewardsState.activeBoosts);
- expect(state.pointsEvents).toEqual(persistedRewardsState.pointsEvents);
- expect(state.unlockedRewards).toEqual(
- persistedRewardsState.unlockedRewards,
- );
- expect(state.hideUnlinkedAccountsBanner).toBe(
- persistedRewardsState.hideUnlinkedAccountsBanner,
- );
- expect(state.hideCurrentAccountNotOptedInBanner).toEqual(
- persistedRewardsState.hideCurrentAccountNotOptedInBanner,
- );
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('same-subscription-id');
+ // UI state should NOT be reset when ID doesn't change
+ expect(state.seasonId).toBe('season-same');
+ expect(state.seasonName).toBe('Same Season');
+ expect(state.referralCode).toBe('SAME123');
+ expect(state.balanceTotal).toBe(5000);
+ });
- // Non-persistent state should remain from current state
- expect(state.nextTierPointsNeeded).toBe(
- initialState.nextTierPointsNeeded,
- );
- expect(state.balanceRefereePortion).toBe(
- initialState.balanceRefereePortion,
- );
+ it('should reset UI state when changing from valid ID to pending', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'valid-subscription-id',
+ seasonId: 'season-valid',
+ seasonName: 'Valid Season',
+ referralCode: 'VALID123',
+ balanceTotal: 6000,
+ };
+ const action = setCandidateSubscriptionId('pending');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('pending');
+ // UI state should be reset when changing from valid ID to pending
+ expect(state.seasonId).toBe(initialState.seasonId);
+ expect(state.seasonName).toBe(initialState.seasonName);
+ expect(state.referralCode).toBe(initialState.referralCode);
+ expect(state.balanceTotal).toBe(initialState.balanceTotal);
});
- it('should preserve current non-persistent state while restoring persisted UI state', () => {
+ it('should reset UI state when changing from valid ID to error', () => {
// Arrange
- const currentState = {
+ const stateWithData = {
...initialState,
- nextTierPointsNeeded: 500, // This should be preserved
- balanceRefereePortion: 100, // This should be preserved
- activeTab: 'levels' as const, // This should be reset to initial
- seasonStatusLoading: true, // This should be reset to initial
- onboardingActiveStep: OnboardingStep.STEP_3, // This should be reset to initial
- onboardingReferralCode: 'CURRENT_REF', // This should be reset to initial
+ candidateSubscriptionId: 'valid-subscription-id',
+ seasonId: 'season-valid',
+ seasonName: 'Valid Season',
+ referralCode: 'VALID123',
+ balanceTotal: 6000,
};
- const persistedRewardsState: RewardsState = {
+ const action = setCandidateSubscriptionId('error');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('error');
+ // UI state should be reset when changing from valid ID to error
+ expect(state.seasonId).toBe(initialState.seasonId);
+ expect(state.seasonName).toBe(initialState.seasonName);
+ expect(state.referralCode).toBe(initialState.referralCode);
+ expect(state.balanceTotal).toBe(initialState.balanceTotal);
+ });
+
+ it('should reset UI state when changing from valid ID to retry', () => {
+ // Arrange
+ const stateWithData = {
...initialState,
- seasonId: 'persisted-season',
- seasonName: 'Persisted Season',
- referralCode: 'PERSISTED123',
- balanceTotal: 2000,
- hideUnlinkedAccountsBanner: true,
- onboardingActiveStep: OnboardingStep.STEP_4, // This should NOT be persisted
- onboardingReferralCode: 'PERSISTED_REF', // This should NOT be persisted
+ candidateSubscriptionId: 'valid-subscription-id',
+ seasonId: 'season-valid',
+ seasonName: 'Valid Season',
+ referralCode: 'VALID123',
+ balanceTotal: 6000,
};
- const rehydrateAction = {
- type: 'persist/REHYDRATE',
- payload: {
- rewards: persistedRewardsState,
- },
+ const action = setCandidateSubscriptionId('retry');
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('retry');
+ // UI state should be reset when changing from valid ID to retry
+ expect(state.seasonId).toBe(initialState.seasonId);
+ expect(state.seasonName).toBe(initialState.seasonName);
+ expect(state.referralCode).toBe(initialState.referralCode);
+ expect(state.balanceTotal).toBe(initialState.balanceTotal);
+ });
+
+ it('should reset UI state when changing from valid ID to null', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ candidateSubscriptionId: 'valid-subscription-id',
+ seasonId: 'season-valid',
+ seasonName: 'Valid Season',
+ referralCode: 'VALID123',
+ balanceTotal: 6000,
};
+ const action = setCandidateSubscriptionId(null);
// Act
- const state = rewardsReducer(currentState, rehydrateAction);
+ const state = rewardsReducer(stateWithData, action);
- // Assert - Non-persistent state should be preserved from current state
- expect(state.nextTierPointsNeeded).toBe(null); // Restored from persisted state (initialState)
- expect(state.balanceRefereePortion).toBe(0); // Restored from persisted state (initialState)
+ // Assert
+ expect(state.candidateSubscriptionId).toBe(null);
+ // UI state should be reset when changing from valid ID to null
+ expect(state.seasonId).toBe(initialState.seasonId);
+ expect(state.seasonName).toBe(initialState.seasonName);
+ expect(state.referralCode).toBe(initialState.referralCode);
+ expect(state.balanceTotal).toBe(initialState.balanceTotal);
+ });
+ });
- // Persisted UI state should be restored
- expect(state.seasonId).toBe('persisted-season');
- expect(state.seasonName).toBe('Persisted Season');
- expect(state.referralCode).toBe('PERSISTED123');
- expect(state.balanceTotal).toBe(2000);
- expect(state.hideUnlinkedAccountsBanner).toBe(true);
+ describe('state transitions between special states', () => {
+ it('should handle transition from pending to error', () => {
+ // Arrange
+ const stateWithPending = {
+ ...initialState,
+ candidateSubscriptionId: 'pending' as const,
+ seasonId: 'season-pending',
+ referralCode: 'PENDING123',
+ };
+ const action = setCandidateSubscriptionId('error');
- // Non-persistent state should be reset to initial
- expect(state.activeTab).toBe(initialState.activeTab);
- expect(state.seasonStatusLoading).toBe(
- initialState.seasonStatusLoading,
- );
- expect(state.onboardingActiveStep).toBe(
- initialState.onboardingActiveStep,
- );
- expect(state.onboardingReferralCode).toBe(
- initialState.onboardingReferralCode,
- );
+ // Act
+ const state = rewardsReducer(stateWithPending, action);
+
+ // Assert
+ expect(state.candidateSubscriptionId).toBe('error');
+ expect(state.seasonId).toBe('season-pending'); // Should not reset
+ expect(state.referralCode).toBe('PENDING123'); // Should not reset
});
- it('should return current state when no rewards data in rehydrate payload', () => {
+ it('should handle transition from error to retry', () => {
// Arrange
- const currentState = { ...initialState, referralCode: 'CURRENT123' };
- const rehydrateAction = {
- type: 'persist/REHYDRATE',
- payload: {
- someOtherReducer: {},
- },
+ const stateWithError = {
+ ...initialState,
+ candidateSubscriptionId: 'error' as const,
+ seasonId: 'season-error',
+ referralCode: 'ERROR123',
};
+ const action = setCandidateSubscriptionId('retry');
// Act
- const state = rewardsReducer(currentState, rehydrateAction);
+ const state = rewardsReducer(stateWithError, action);
// Assert
- expect(state).toEqual(currentState);
+ expect(state.candidateSubscriptionId).toBe('retry');
+ expect(state.seasonId).toBe('season-error'); // Should not reset
+ expect(state.referralCode).toBe('ERROR123'); // Should not reset
});
- it('should return current state when rehydrate payload is empty', () => {
+ it('should handle transition from retry to pending', () => {
// Arrange
- const currentState = { ...initialState, referralCode: 'CURRENT123' };
- const rehydrateAction = {
- type: 'persist/REHYDRATE',
- payload: undefined,
+ const stateWithRetry = {
+ ...initialState,
+ candidateSubscriptionId: 'retry' as const,
+ seasonId: 'season-retry',
+ referralCode: 'RETRY123',
};
+ const action = setCandidateSubscriptionId('pending');
// Act
- const state = rewardsReducer(currentState, rehydrateAction);
+ const state = rewardsReducer(stateWithRetry, action);
// Assert
- expect(state).toEqual(currentState);
+ expect(state.candidateSubscriptionId).toBe('pending');
+ expect(state.seasonId).toBe('season-retry'); // Should not reset
+ expect(state.referralCode).toBe('RETRY123'); // Should not reset
});
- });
- describe('unknown actions', () => {
- it('should return unchanged state for unknown actions', () => {
+ it('should handle transition from null to pending', () => {
// Arrange
- const stateWithData = {
+ const stateWithNull = {
...initialState,
- referralCode: 'SOME_CODE',
- balanceTotal: 1000,
- activeTab: 'activity' as const,
+ candidateSubscriptionId: null,
+ seasonId: 'season-null',
+ referralCode: 'NULL123',
};
- const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' };
+ const action = setCandidateSubscriptionId('pending');
// Act
- const state = rewardsReducer(
- stateWithData,
- unknownAction as unknown as Action,
- );
+ const state = rewardsReducer(stateWithNull, action);
// Assert
- expect(state).toEqual(stateWithData);
- expect(state).toBe(stateWithData); // Should be the same reference
+ expect(state.candidateSubscriptionId).toBe('pending');
+ expect(state.seasonId).toBe('season-null'); // Should not reset
+ expect(state.referralCode).toBe('NULL123'); // Should not reset
});
- it('should return initial state for unknown action when state is undefined', () => {
+ it('should handle transition from pending to null', () => {
// Arrange
- const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' };
+ const stateWithPending = {
+ ...initialState,
+ candidateSubscriptionId: 'pending' as const,
+ seasonId: 'season-pending',
+ referralCode: 'PENDING123',
+ };
+ const action = setCandidateSubscriptionId(null);
// Act
- const state = rewardsReducer(
- undefined,
- unknownAction as unknown as Action,
- );
+ const state = rewardsReducer(stateWithPending, action);
// Assert
- expect(state).toEqual(initialState);
+ expect(state.candidateSubscriptionId).toBe(null);
+ expect(state.seasonId).toBe('season-pending'); // Should not reset
+ expect(state.referralCode).toBe('PENDING123'); // Should not reset
});
});
});
- describe('setActiveBoosts', () => {
- it('should set active boosts array', () => {
+ describe('setHideUnlinkedAccountsBanner', () => {
+ it('should set hide unlinked accounts banner to true', () => {
// Arrange
- const mockBoosts = [
- {
- id: 'boost-1',
- name: 'Test Boost 1',
- icon: {
- lightModeUrl: 'light1.png',
- darkModeUrl: 'dark1.png',
- },
- boostBips: 1000,
- seasonLong: true,
- backgroundColor: '#FF0000',
- },
- {
- id: 'boost-2',
- name: 'Test Boost 2',
- icon: {
- lightModeUrl: 'light2.png',
- darkModeUrl: 'dark2.png',
- },
- boostBips: 500,
- seasonLong: false,
- startDate: '2024-01-01',
- endDate: '2024-01-31',
- backgroundColor: '#00FF00',
- },
- ];
- const action = setActiveBoosts(mockBoosts);
+ const action = setHideUnlinkedAccountsBanner(true);
// Act
const state = rewardsReducer(initialState, action);
// Assert
- expect(state.activeBoosts).toEqual(mockBoosts);
- expect(state.activeBoosts).toHaveLength(2);
- expect(state.activeBoosts?.[0]?.id).toBe('boost-1');
- expect(state.activeBoosts?.[1]?.seasonLong).toBe(false);
+ expect(state.hideUnlinkedAccountsBanner).toBe(true);
});
- it('should replace existing active boosts', () => {
+ it('should set hide unlinked accounts banner to false', () => {
// Arrange
- const existingBoosts = [
- {
- id: 'old-boost',
- name: 'Old Boost',
- icon: { lightModeUrl: 'old.png', darkModeUrl: 'old.png' },
- boostBips: 100,
- seasonLong: true,
- backgroundColor: '#000000',
- },
- ];
- const stateWithBoosts = {
+ const stateWithBannerHidden = {
...initialState,
- activeBoosts: existingBoosts,
+ hideUnlinkedAccountsBanner: true,
};
- const newBoosts = [
- {
- id: 'new-boost',
- name: 'New Boost',
- icon: { lightModeUrl: 'new.png', darkModeUrl: 'new.png' },
- boostBips: 2000,
- seasonLong: false,
- backgroundColor: '#FFFFFF',
- },
- ];
- const action = setActiveBoosts(newBoosts);
+ const action = setHideUnlinkedAccountsBanner(false);
// Act
- const state = rewardsReducer(stateWithBoosts, action);
+ const state = rewardsReducer(stateWithBannerHidden, action);
// Assert
- expect(state.activeBoosts).toEqual(newBoosts);
- expect(state.activeBoosts).toHaveLength(1);
- expect(state.activeBoosts?.[0]?.id).toBe('new-boost');
+ expect(state.hideUnlinkedAccountsBanner).toBe(false);
});
- it('should set empty array when no boosts provided', () => {
+ it('should not affect other state properties', () => {
// Arrange
- const stateWithBoosts = {
+ const stateWithData = {
...initialState,
- activeBoosts: [
+ hideUnlinkedAccountsBanner: false,
+ referralCode: 'KEEP123',
+ balanceTotal: 1500,
+ };
+ const action = setHideUnlinkedAccountsBanner(true);
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.hideUnlinkedAccountsBanner).toBe(true);
+ expect(state.referralCode).toBe('KEEP123');
+ expect(state.balanceTotal).toBe(1500);
+ });
+ });
+
+ describe('setHideCurrentAccountNotOptedInBanner', () => {
+ it('should add new account banner entry when it does not exist', () => {
+ // Arrange
+ const accountGroupId: AccountGroupId = 'keyring:wallet1/1';
+ const action = setHideCurrentAccountNotOptedInBanner({
+ accountGroupId,
+ hide: true,
+ });
+
+ // Act
+ const state = rewardsReducer(initialState, action);
+
+ // Assert
+ expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1);
+ expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
+ accountGroupId,
+ hide: true,
+ });
+ });
+
+ it('should update existing account banner entry', () => {
+ // Arrange
+ const accountGroupId: AccountGroupId = 'keyring:wallet1/1';
+ const stateWithExistingEntry = {
+ ...initialState,
+ hideCurrentAccountNotOptedInBanner: [
{
- id: 'existing-boost',
- name: 'Existing',
- icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
- boostBips: 500,
- seasonLong: true,
- backgroundColor: '#123456',
+ accountGroupId,
+ hide: false,
},
],
};
- const action = setActiveBoosts([]);
+ const action = setHideCurrentAccountNotOptedInBanner({
+ accountGroupId,
+ hide: true,
+ });
// Act
- const state = rewardsReducer(stateWithBoosts, action);
+ const state = rewardsReducer(stateWithExistingEntry, action);
// Assert
- expect(state.activeBoosts).toEqual([]);
- expect(state.activeBoosts).toHaveLength(0);
+ expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1);
+ expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
+ accountGroupId,
+ hide: true,
+ });
});
- it('should reset activeBoostsError to false when setting active boosts', () => {
+ it('should add multiple different account entries', () => {
// Arrange
- const stateWithError = {
+ const accountGroupId1: AccountGroupId = 'keyring:wallet1/1';
+ const accountGroupId2: AccountGroupId = 'keyring:wallet2/2';
+
+ let currentState = initialState;
+
+ // Add first account
+ const action1 = setHideCurrentAccountNotOptedInBanner({
+ accountGroupId: accountGroupId1,
+ hide: true,
+ });
+ currentState = rewardsReducer(currentState, action1);
+
+ // Add second account
+ const action2 = setHideCurrentAccountNotOptedInBanner({
+ accountGroupId: accountGroupId2,
+ hide: false,
+ });
+
+ // Act
+ const state = rewardsReducer(currentState, action2);
+
+ // Assert
+ expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2);
+ expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
+ accountGroupId: accountGroupId1,
+ hide: true,
+ });
+ expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({
+ accountGroupId: accountGroupId2,
+ hide: false,
+ });
+ });
+
+ it('should update specific account without affecting others', () => {
+ // Arrange
+ const accountGroupId1: AccountGroupId = 'keyring:wallet1/1';
+ const accountGroupId2: AccountGroupId = 'keyring:wallet2/2';
+ const stateWithMultipleEntries = {
...initialState,
- activeBoostsError: true,
- };
- const mockBoosts = [
- {
- id: 'boost-1',
- name: 'Test Boost',
- icon: {
- lightModeUrl: 'light.png',
- darkModeUrl: 'dark.png',
+ hideCurrentAccountNotOptedInBanner: [
+ {
+ accountGroupId: accountGroupId1,
+ hide: true,
},
- boostBips: 1000,
- seasonLong: true,
- backgroundColor: '#FF0000',
- },
- ];
- const action = setActiveBoosts(mockBoosts);
+ {
+ accountGroupId: accountGroupId2,
+ hide: false,
+ },
+ ],
+ };
+ const action = setHideCurrentAccountNotOptedInBanner({
+ accountGroupId: accountGroupId1,
+ hide: false,
+ });
// Act
- const state = rewardsReducer(stateWithError, action);
+ const state = rewardsReducer(stateWithMultipleEntries, action);
+
+ // Assert
+ expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2);
+ expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({
+ accountGroupId: accountGroupId1,
+ hide: false, // Updated
+ });
+ expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({
+ accountGroupId: accountGroupId2,
+ hide: false, // Unchanged
+ });
+ });
+
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'activity' as const,
+ referralCode: 'TEST123',
+ hideUnlinkedAccountsBanner: true,
+ };
+ const accountGroupId: AccountGroupId = 'keyring:wallet1/1';
+ const action = setHideCurrentAccountNotOptedInBanner({
+ accountGroupId,
+ hide: true,
+ });
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
// Assert
- expect(state.activeBoosts).toEqual(mockBoosts);
- expect(state.activeBoostsError).toBe(false); // Should be reset when successful
+ expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1);
+ expect(state.activeTab).toBe('activity');
+ expect(state.referralCode).toBe('TEST123');
+ expect(state.hideUnlinkedAccountsBanner).toBe(true);
});
});
- describe('setActiveBoostsLoading', () => {
- it('should set activeBoostsLoading to true when no active boosts exist', () => {
+ describe('resetRewardsState', () => {
+ it('should reset all state to initial values', () => {
// Arrange
- const action = setActiveBoostsLoading(true);
+ const stateWithData: RewardsState = {
+ activeTab: 'activity' as const,
+ seasonStatusLoading: true,
+ seasonId: 'test-season-id',
+ referralDetailsLoading: false,
+ referralCode: 'TEST123',
+ refereeCount: 10,
+ currentTier: {
+ id: 'tier-platinum',
+ name: 'Platinum',
+ pointsNeeded: 1000,
+ image: {
+ lightModeUrl: 'platinum.png',
+ darkModeUrl: 'platinum-dark.png',
+ },
+ levelNumber: 'Level 10',
+ rewards: [],
+ },
+ seasonStatusError: null,
+ nextTier: {
+ id: 'tier-diamond',
+ name: 'Diamond',
+ pointsNeeded: 2000,
+ image: {
+ lightModeUrl: 'diamond.png',
+ darkModeUrl: 'diamond-dark.png',
+ },
+ levelNumber: 'Level 20',
+ rewards: [],
+ },
+ nextTierPointsNeeded: 1000,
+ balanceTotal: 5000,
+ balanceRefereePortion: 1000,
+ balanceUpdatedAt: new Date('2024-01-01'),
+ seasonName: 'Test Season',
+ seasonStartDate: new Date('2024-01-01'),
+ seasonEndDate: new Date('2024-12-31'),
+ seasonTiers: [
+ {
+ id: 'tier-1',
+ name: 'Tier 1',
+ pointsNeeded: 100,
+ image: {
+ lightModeUrl: 'tier-1.png',
+ darkModeUrl: 'tier-1-dark.png',
+ },
+ levelNumber: 'Level 1',
+ rewards: [],
+ },
+ ],
+ seasonActivityTypes: [],
+ onboardingActiveStep: OnboardingStep.STEP_1,
+ onboardingReferralCode: 'REF123',
+ candidateSubscriptionId: 'some-id',
+ geoLocation: 'US',
+ optinAllowedForGeo: true,
+ optinAllowedForGeoLoading: false,
+ hideUnlinkedAccountsBanner: true,
+ hideCurrentAccountNotOptedInBanner: [
+ {
+ accountGroupId: 'keyring:wallet1/1' as AccountGroupId,
+ hide: true,
+ },
+ ],
+ activeBoosts: [
+ {
+ id: 'boost-1',
+ name: 'Test Boost 1',
+ icon: {
+ lightModeUrl: 'light1.png',
+ darkModeUrl: 'dark1.png',
+ },
+ boostBips: 1000,
+ seasonLong: true,
+ backgroundColor: '#FF0000',
+ },
+ ],
+ pointsEvents: null,
+ activeBoostsLoading: false,
+ activeBoostsError: false,
+ unlockedRewards: [],
+ unlockedRewardLoading: false,
+ unlockedRewardError: false,
+ referralDetailsError: false,
+ optinAllowedForGeoError: false,
+ };
+ const action = resetRewardsState();
// Act
- const state = rewardsReducer(initialState, action);
+ const state = rewardsReducer(stateWithData, action);
// Assert
- expect(state.activeBoostsLoading).toBe(true);
+ expect(state).toEqual(initialState);
});
+ });
- it('should not set activeBoostsLoading to true when active boosts already exist', () => {
+ describe('persist/REHYDRATE', () => {
+ it('should restore persisted UI state while resetting non-persistent state', () => {
// Arrange
- const stateWithBoosts = {
- ...initialState,
+ const persistedRewardsState: RewardsState = {
+ activeTab: 'activity',
+ seasonStatusLoading: true,
+ seasonId: 'test-season-id',
+ referralDetailsLoading: false,
+ referralCode: 'PERSISTED123',
+ refereeCount: 15,
+ currentTier: {
+ id: 'tier-diamond',
+ name: 'Diamond',
+ pointsNeeded: 1000,
+ image: {
+ lightModeUrl: 'https://example.com/diamond-light.png',
+ darkModeUrl: 'https://example.com/diamond-dark.png',
+ },
+ levelNumber: '4',
+ rewards: [],
+ },
+ nextTier: null,
+ nextTierPointsNeeded: null,
+ balanceTotal: 2000,
+ balanceRefereePortion: 400,
+ balanceUpdatedAt: new Date('2024-05-01'),
+ seasonName: 'Persisted Season',
+ seasonStartDate: new Date('2024-01-01'),
+ seasonEndDate: new Date('2024-12-31'),
+ seasonTiers: [
+ {
+ id: 'tier-1',
+ name: 'Tier 1',
+ pointsNeeded: 100,
+ image: {
+ lightModeUrl: 'https://example.com/tier1-light.png',
+ darkModeUrl: 'https://example.com/tier1-dark.png',
+ },
+ levelNumber: '1',
+ rewards: [],
+ },
+ ],
+ seasonActivityTypes: [],
+ onboardingActiveStep: OnboardingStep.STEP_2,
+ onboardingReferralCode: 'PERSISTED_REF',
+ candidateSubscriptionId: 'some-id',
+ geoLocation: 'CA',
+ optinAllowedForGeo: true,
+ optinAllowedForGeoLoading: false,
+ hideUnlinkedAccountsBanner: true,
+ hideCurrentAccountNotOptedInBanner: [
+ {
+ accountGroupId: 'keyring:wallet1/1' as AccountGroupId,
+ hide: true,
+ },
+ ],
activeBoosts: [
{
- id: 'existing-boost',
- name: 'Existing Boost',
- icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
+ id: 'boost-1',
+ name: 'Test Boost 1',
+ icon: {
+ lightModeUrl: 'light1.png',
+ darkModeUrl: 'dark1.png',
+ },
boostBips: 1000,
seasonLong: true,
backgroundColor: '#FF0000',
},
],
+ pointsEvents: null,
+ seasonStatusError: null,
activeBoostsLoading: false,
+ activeBoostsError: false,
+ unlockedRewards: [],
+ unlockedRewardLoading: false,
+ unlockedRewardError: false,
+ referralDetailsError: false,
+ optinAllowedForGeoError: false,
+ };
+ const rehydrateAction = {
+ type: 'persist/REHYDRATE',
+ payload: {
+ rewards: persistedRewardsState,
+ },
};
- const action = setActiveBoostsLoading(true);
// Act
- const state = rewardsReducer(stateWithBoosts, action);
+ const state = rewardsReducer(initialState, rehydrateAction);
- // Assert
- expect(state.activeBoostsLoading).toBe(false); // Should remain false due to guard clause
+ // Assert - Should restore persisted UI state while keeping current non-persistent state
+ const expectedState = {
+ ...initialState,
+ // Restored from persisted state
+ seasonId: persistedRewardsState.seasonId,
+ seasonName: persistedRewardsState.seasonName,
+ seasonStartDate: persistedRewardsState.seasonStartDate,
+ seasonEndDate: persistedRewardsState.seasonEndDate,
+ seasonTiers: persistedRewardsState.seasonTiers,
+ seasonActivityTypes: persistedRewardsState.seasonActivityTypes,
+ referralCode: persistedRewardsState.referralCode,
+ refereeCount: persistedRewardsState.refereeCount,
+ currentTier: persistedRewardsState.currentTier,
+ nextTier: persistedRewardsState.nextTier,
+ balanceTotal: persistedRewardsState.balanceTotal,
+ balanceUpdatedAt: persistedRewardsState.balanceUpdatedAt,
+ activeBoosts: persistedRewardsState.activeBoosts,
+ pointsEvents: persistedRewardsState.pointsEvents,
+ unlockedRewards: persistedRewardsState.unlockedRewards,
+ hideUnlinkedAccountsBanner:
+ persistedRewardsState.hideUnlinkedAccountsBanner,
+ hideCurrentAccountNotOptedInBanner:
+ persistedRewardsState.hideCurrentAccountNotOptedInBanner,
+ // These fields are restored from persisted state
+ nextTierPointsNeeded: persistedRewardsState.nextTierPointsNeeded,
+ balanceRefereePortion: persistedRewardsState.balanceRefereePortion,
+ };
+ expect(state).toEqual(expectedState);
});
- it('should set activeBoostsLoading to false', () => {
- // Arrange
- const stateWithLoading = {
+ it('should restore seasonActivityTypes from persisted state', () => {
+ const persistedRewardsState: RewardsState = {
...initialState,
- activeBoostsLoading: true,
+ seasonId: 'persisted-season-id',
+ seasonActivityTypes: [
+ {
+ type: 'MUSD_DEPOSIT',
+ title: 'mUSD deposit',
+ description: 'Deposit mUSD',
+ icon: 'Coin',
+ },
+ ],
+ };
+ const rehydrateAction = {
+ type: 'persist/REHYDRATE',
+ payload: {
+ rewards: persistedRewardsState,
+ },
};
- const action = setActiveBoostsLoading(false);
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ const state = rewardsReducer(initialState, rehydrateAction);
- // Assert
- expect(state.activeBoostsLoading).toBe(false);
+ expect(state.seasonActivityTypes).toEqual(
+ persistedRewardsState.seasonActivityTypes,
+ );
});
- it('should set activeBoostsLoading to false even when active boosts exist', () => {
+ it('should preserve all persisted UI state fields', () => {
// Arrange
- const stateWithBoostsAndLoading = {
+ const persistedRewardsState: RewardsState = {
...initialState,
+ seasonId: 'persisted-season-id',
+ seasonName: 'Persisted Season Name',
+ seasonStartDate: new Date('2024-01-01'),
+ seasonEndDate: new Date('2024-12-31'),
+ seasonTiers: [
+ {
+ id: 'tier-persisted',
+ name: 'Persisted Tier',
+ pointsNeeded: 500,
+ image: {
+ lightModeUrl: 'persisted.png',
+ darkModeUrl: 'persisted-dark.png',
+ },
+ levelNumber: '2',
+ rewards: [],
+ },
+ ],
+ referralCode: 'PERSISTED_CODE',
+ refereeCount: 25,
+ currentTier: {
+ id: 'current-tier',
+ name: 'Current Tier',
+ pointsNeeded: 1000,
+ image: {
+ lightModeUrl: 'current.png',
+ darkModeUrl: 'current-dark.png',
+ },
+ levelNumber: '3',
+ rewards: [],
+ },
+ nextTier: {
+ id: 'next-tier',
+ name: 'Next Tier',
+ pointsNeeded: 2000,
+ image: {
+ lightModeUrl: 'next.png',
+ darkModeUrl: 'next-dark.png',
+ },
+ levelNumber: '4',
+ rewards: [],
+ },
+ balanceTotal: 3000,
+ balanceUpdatedAt: new Date('2024-06-01'),
activeBoosts: [
{
- id: 'existing-boost',
- name: 'Existing Boost',
- icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
- boostBips: 1000,
+ id: 'persisted-boost',
+ name: 'Persisted Boost',
+ icon: {
+ lightModeUrl: 'boost.png',
+ darkModeUrl: 'boost-dark.png',
+ },
+ boostBips: 1500,
seasonLong: true,
- backgroundColor: '#FF0000',
+ backgroundColor: '#00FF00',
+ },
+ ],
+ pointsEvents: [],
+ unlockedRewards: [
+ {
+ id: 'unlocked-reward',
+ seasonRewardId: 'season-reward-id',
+ claimStatus: RewardClaimStatus.UNCLAIMED,
},
],
- activeBoostsLoading: true,
+ hideUnlinkedAccountsBanner: true,
+ hideCurrentAccountNotOptedInBanner: [
+ {
+ accountGroupId: 'keyring:wallet1/1' as AccountGroupId,
+ hide: true,
+ },
+ ],
+ };
+ const rehydrateAction = {
+ type: 'persist/REHYDRATE',
+ payload: {
+ rewards: persistedRewardsState,
+ },
};
- const action = setActiveBoostsLoading(false);
// Act
- const state = rewardsReducer(stateWithBoostsAndLoading, action);
+ const state = rewardsReducer(initialState, rehydrateAction);
- // Assert
- expect(state.activeBoostsLoading).toBe(false);
+ // Assert - All persisted UI state should be preserved
+ expect(state.seasonId).toBe(persistedRewardsState.seasonId);
+ expect(state.seasonName).toBe(persistedRewardsState.seasonName);
+ expect(state.seasonStartDate).toEqual(
+ persistedRewardsState.seasonStartDate,
+ );
+ expect(state.seasonEndDate).toEqual(persistedRewardsState.seasonEndDate);
+ expect(state.seasonTiers).toEqual(persistedRewardsState.seasonTiers);
+ expect(state.referralCode).toBe(persistedRewardsState.referralCode);
+ expect(state.refereeCount).toBe(persistedRewardsState.refereeCount);
+ expect(state.currentTier).toEqual(persistedRewardsState.currentTier);
+ expect(state.nextTier).toEqual(persistedRewardsState.nextTier);
+ expect(state.balanceTotal).toBe(persistedRewardsState.balanceTotal);
+ expect(state.balanceUpdatedAt).toEqual(
+ persistedRewardsState.balanceUpdatedAt,
+ );
+ expect(state.activeBoosts).toEqual(persistedRewardsState.activeBoosts);
+ expect(state.pointsEvents).toEqual(persistedRewardsState.pointsEvents);
+ expect(state.unlockedRewards).toEqual(
+ persistedRewardsState.unlockedRewards,
+ );
+ expect(state.hideUnlinkedAccountsBanner).toBe(
+ persistedRewardsState.hideUnlinkedAccountsBanner,
+ );
+ expect(state.hideCurrentAccountNotOptedInBanner).toEqual(
+ persistedRewardsState.hideCurrentAccountNotOptedInBanner,
+ );
+
+ // Non-persistent state should remain from current state
+ expect(state.nextTierPointsNeeded).toBe(
+ initialState.nextTierPointsNeeded,
+ );
+ expect(state.balanceRefereePortion).toBe(
+ initialState.balanceRefereePortion,
+ );
});
- it('should not affect other state properties', () => {
+ it('should preserve current non-persistent state while restoring persisted UI state', () => {
// Arrange
- const stateWithData = {
+ const currentState = {
...initialState,
- activeTab: 'activity' as const,
- referralCode: 'TEST123',
+ nextTierPointsNeeded: 500, // This should be preserved
+ balanceRefereePortion: 100, // This should be preserved
+ activeTab: 'levels' as const, // This should be reset to initial
+ seasonStatusLoading: true, // This should be reset to initial
+ onboardingActiveStep: OnboardingStep.STEP_3, // This should be reset to initial
+ onboardingReferralCode: 'CURRENT_REF', // This should be reset to initial
+ };
+ const persistedRewardsState: RewardsState = {
+ ...initialState,
+ seasonId: 'persisted-season',
+ seasonName: 'Persisted Season',
+ referralCode: 'PERSISTED123',
+ balanceTotal: 2000,
+ hideUnlinkedAccountsBanner: true,
+ onboardingActiveStep: OnboardingStep.STEP_4, // This should NOT be persisted
+ onboardingReferralCode: 'PERSISTED_REF', // This should NOT be persisted
+ };
+ const rehydrateAction = {
+ type: 'persist/REHYDRATE',
+ payload: {
+ rewards: persistedRewardsState,
+ },
};
- const action = setActiveBoostsLoading(true);
// Act
- const state = rewardsReducer(stateWithData, action);
+ const state = rewardsReducer(currentState, rehydrateAction);
- // Assert
- expect(state.activeBoostsLoading).toBe(true);
- expect(state.activeTab).toBe('activity');
- expect(state.referralCode).toBe('TEST123');
- expect(state.activeBoosts).toBeNull();
+ // Assert - Non-persistent state should be preserved from current state
+ expect(state.nextTierPointsNeeded).toBe(null); // Restored from persisted state (initialState)
+ expect(state.balanceRefereePortion).toBe(0); // Restored from persisted state (initialState)
+
+ // Persisted UI state should be restored
+ expect(state.seasonId).toBe('persisted-season');
+ expect(state.seasonName).toBe('Persisted Season');
+ expect(state.referralCode).toBe('PERSISTED123');
+ expect(state.balanceTotal).toBe(2000);
+ expect(state.hideUnlinkedAccountsBanner).toBe(true);
+
+ // Non-persistent state should be reset to initial
+ expect(state.activeTab).toBe(initialState.activeTab);
+ expect(state.seasonStatusLoading).toBe(initialState.seasonStatusLoading);
+ expect(state.onboardingActiveStep).toBe(
+ initialState.onboardingActiveStep,
+ );
+ expect(state.onboardingReferralCode).toBe(
+ initialState.onboardingReferralCode,
+ );
});
- });
- describe('setActiveBoostsError', () => {
- it('should set activeBoostsError to true', () => {
+ it('should return current state when no rewards data in rehydrate payload', () => {
// Arrange
- const action = setActiveBoostsError(true);
+ const currentState = { ...initialState, referralCode: 'CURRENT123' };
+ const rehydrateAction = {
+ type: 'persist/REHYDRATE',
+ payload: {
+ someOtherReducer: {},
+ },
+ };
// Act
- const state = rewardsReducer(initialState, action);
+ const state = rewardsReducer(currentState, rehydrateAction);
// Assert
- expect(state.activeBoostsError).toBe(true);
+ expect(state).toEqual(currentState);
});
- it('should set activeBoostsError to false', () => {
+ it('should return current state when rehydrate payload is empty', () => {
// Arrange
- const stateWithError = {
- ...initialState,
- activeBoostsError: true,
+ const currentState = { ...initialState, referralCode: 'CURRENT123' };
+ const rehydrateAction = {
+ type: 'persist/REHYDRATE',
+ payload: undefined,
};
- const action = setActiveBoostsError(false);
// Act
- const state = rewardsReducer(stateWithError, action);
+ const state = rewardsReducer(currentState, rehydrateAction);
// Assert
- expect(state.activeBoostsError).toBe(false);
+ expect(state).toEqual(currentState);
});
+ });
- it('should not affect other state properties', () => {
+ describe('unknown actions', () => {
+ it('should return unchanged state for unknown actions', () => {
// Arrange
const stateWithData = {
...initialState,
+ referralCode: 'SOME_CODE',
+ balanceTotal: 1000,
activeTab: 'activity' as const,
- referralCode: 'TEST123',
- activeBoosts: [
- {
- id: 'test-boost',
- name: 'Test',
- icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
- boostBips: 1000,
- seasonLong: true,
- backgroundColor: '#FF0000',
- },
- ],
- activeBoostsLoading: true,
};
- const action = setActiveBoostsError(true);
+ const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' };
// Act
- const state = rewardsReducer(stateWithData, action);
+ const state = rewardsReducer(
+ stateWithData,
+ unknownAction as unknown as Action,
+ );
// Assert
- expect(state.activeBoostsError).toBe(true);
- expect(state.activeTab).toBe('activity');
- expect(state.referralCode).toBe('TEST123');
- expect(state.activeBoosts).toEqual(stateWithData.activeBoosts);
- expect(state.activeBoostsLoading).toBe(true); // Should remain unchanged
+ expect(state).toEqual(stateWithData);
+ expect(state).toBe(stateWithData); // Should be the same reference
});
- it('should handle multiple error state changes', () => {
+ it('should return initial state for unknown action when state is undefined', () => {
// Arrange
- let currentState = initialState;
-
- // Act & Assert - Set error to true
- let action = setActiveBoostsError(true);
- currentState = rewardsReducer(currentState, action);
- expect(currentState.activeBoostsError).toBe(true);
+ const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' };
- // Act & Assert - Set error back to false
- action = setActiveBoostsError(false);
- currentState = rewardsReducer(currentState, action);
- expect(currentState.activeBoostsError).toBe(false);
+ // Act
+ const state = rewardsReducer(
+ undefined,
+ unknownAction as unknown as Action,
+ );
- // Act & Assert - Set error to true again
- action = setActiveBoostsError(true);
- currentState = rewardsReducer(currentState, action);
- expect(currentState.activeBoostsError).toBe(true);
+ // Assert
+ expect(state).toEqual(initialState);
});
});
+});
- describe('setUnlockedRewards', () => {
- it('should set unlocked rewards in state', () => {
- // Arrange
- const mockUnlockedRewards = [
- {
- id: 'reward-1',
- seasonRewardId: 'season-reward-1',
- claimStatus: RewardClaimStatus.CLAIMED,
+describe('setActiveBoosts', () => {
+ it('should set active boosts array', () => {
+ // Arrange
+ const mockBoosts = [
+ {
+ id: 'boost-1',
+ name: 'Test Boost 1',
+ icon: {
+ lightModeUrl: 'light1.png',
+ darkModeUrl: 'dark1.png',
},
- {
- id: 'reward-2',
- seasonRewardId: 'season-reward-2',
- claimStatus: RewardClaimStatus.UNCLAIMED,
+ boostBips: 1000,
+ seasonLong: true,
+ backgroundColor: '#FF0000',
+ },
+ {
+ id: 'boost-2',
+ name: 'Test Boost 2',
+ icon: {
+ lightModeUrl: 'light2.png',
+ darkModeUrl: 'dark2.png',
},
- ];
- const action = setUnlockedRewards(mockUnlockedRewards);
+ boostBips: 500,
+ seasonLong: false,
+ startDate: '2024-01-01',
+ endDate: '2024-01-31',
+ backgroundColor: '#00FF00',
+ },
+ ];
+ const action = setActiveBoosts(mockBoosts);
+
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Assert
+ expect(state.activeBoosts).toEqual(mockBoosts);
+ expect(state.activeBoosts).toHaveLength(2);
+ expect(state.activeBoosts?.[0]?.id).toBe('boost-1');
+ expect(state.activeBoosts?.[1]?.seasonLong).toBe(false);
+ });
- // Assert
- expect(state.unlockedRewards).toEqual(mockUnlockedRewards);
- expect(state.unlockedRewards).toHaveLength(2);
- expect(state.unlockedRewards?.[0]?.id).toBe('reward-1');
- expect(state.unlockedRewards?.[1]?.claimStatus).toBe(
- RewardClaimStatus.UNCLAIMED,
- );
- });
+ it('should replace existing active boosts', () => {
+ // Arrange
+ const existingBoosts = [
+ {
+ id: 'old-boost',
+ name: 'Old Boost',
+ icon: { lightModeUrl: 'old.png', darkModeUrl: 'old.png' },
+ boostBips: 100,
+ seasonLong: true,
+ backgroundColor: '#000000',
+ },
+ ];
+ const stateWithBoosts = {
+ ...initialState,
+ activeBoosts: existingBoosts,
+ };
+ const newBoosts = [
+ {
+ id: 'new-boost',
+ name: 'New Boost',
+ icon: { lightModeUrl: 'new.png', darkModeUrl: 'new.png' },
+ boostBips: 2000,
+ seasonLong: false,
+ backgroundColor: '#FFFFFF',
+ },
+ ];
+ const action = setActiveBoosts(newBoosts);
+
+ // Act
+ const state = rewardsReducer(stateWithBoosts, action);
- it('should replace existing unlocked rewards', () => {
- // Arrange
- const existingRewards = [
+ // Assert
+ expect(state.activeBoosts).toEqual(newBoosts);
+ expect(state.activeBoosts).toHaveLength(1);
+ expect(state.activeBoosts?.[0]?.id).toBe('new-boost');
+ });
+
+ it('should set empty array when no boosts provided', () => {
+ // Arrange
+ const stateWithBoosts = {
+ ...initialState,
+ activeBoosts: [
{
- id: 'old-reward',
- seasonRewardId: 'old-season-reward',
- claimStatus: RewardClaimStatus.CLAIMED,
+ id: 'existing-boost',
+ name: 'Existing',
+ icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
+ boostBips: 500,
+ seasonLong: true,
+ backgroundColor: '#123456',
},
- ];
- const stateWithRewards = {
- ...initialState,
- unlockedRewards: existingRewards,
- };
- const newRewards = [
- {
- id: 'new-reward-1',
- seasonRewardId: 'new-season-reward-1',
- claimStatus: RewardClaimStatus.UNCLAIMED,
+ ],
+ };
+ const action = setActiveBoosts([]);
+
+ // Act
+ const state = rewardsReducer(stateWithBoosts, action);
+
+ // Assert
+ expect(state.activeBoosts).toEqual([]);
+ expect(state.activeBoosts).toHaveLength(0);
+ });
+
+ it('should reset activeBoostsError to false when setting active boosts', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ activeBoostsError: true,
+ };
+ const mockBoosts = [
+ {
+ id: 'boost-1',
+ name: 'Test Boost',
+ icon: {
+ lightModeUrl: 'light.png',
+ darkModeUrl: 'dark.png',
},
+ boostBips: 1000,
+ seasonLong: true,
+ backgroundColor: '#FF0000',
+ },
+ ];
+ const action = setActiveBoosts(mockBoosts);
+
+ // Act
+ const state = rewardsReducer(stateWithError, action);
+
+ // Assert
+ expect(state.activeBoosts).toEqual(mockBoosts);
+ expect(state.activeBoostsError).toBe(false); // Should be reset when successful
+ });
+});
+
+describe('setActiveBoostsLoading', () => {
+ it('should set activeBoostsLoading to true when no active boosts exist', () => {
+ // Arrange
+ const action = setActiveBoostsLoading(true);
+
+ // Act
+ const state = rewardsReducer(initialState, action);
+
+ // Assert
+ expect(state.activeBoostsLoading).toBe(true);
+ });
+
+ it('should not set activeBoostsLoading to true when active boosts already exist', () => {
+ // Arrange
+ const stateWithBoosts = {
+ ...initialState,
+ activeBoosts: [
{
- id: 'new-reward-2',
- seasonRewardId: 'new-season-reward-2',
- claimStatus: RewardClaimStatus.CLAIMED,
+ id: 'existing-boost',
+ name: 'Existing Boost',
+ icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
+ boostBips: 1000,
+ seasonLong: true,
+ backgroundColor: '#FF0000',
},
- ];
- const action = setUnlockedRewards(newRewards);
+ ],
+ activeBoostsLoading: false,
+ };
+ const action = setActiveBoostsLoading(true);
- // Act
- const state = rewardsReducer(stateWithRewards, action);
+ // Act
+ const state = rewardsReducer(stateWithBoosts, action);
- // Assert
- expect(state.unlockedRewards).toEqual(newRewards);
- expect(state.unlockedRewards).toHaveLength(2);
- expect(state.unlockedRewards?.[0]?.id).toBe('new-reward-1');
- expect(state.unlockedRewards?.[1]?.id).toBe('new-reward-2');
- });
+ // Assert
+ expect(state.activeBoostsLoading).toBe(false); // Should remain false due to guard clause
+ });
- it('should set empty array when no rewards provided', () => {
- // Arrange
- const stateWithRewards = {
- ...initialState,
- unlockedRewards: [
- {
- id: 'existing-reward',
- seasonRewardId: 'existing-season-reward',
- claimStatus: RewardClaimStatus.CLAIMED,
- },
- ],
- };
- const action = setUnlockedRewards([]);
+ it('should set activeBoostsLoading to false', () => {
+ // Arrange
+ const stateWithLoading = {
+ ...initialState,
+ activeBoostsLoading: true,
+ };
+ const action = setActiveBoostsLoading(false);
- // Act
- const state = rewardsReducer(stateWithRewards, action);
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Assert
- expect(state.unlockedRewards).toEqual([]);
- expect(state.unlockedRewards).toHaveLength(0);
- });
+ // Assert
+ expect(state.activeBoostsLoading).toBe(false);
+ });
- it('should reset unlockedRewardError to false when setting unlocked rewards', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- unlockedRewardError: true,
- };
- const mockRewards = [
+ it('should set activeBoostsLoading to false even when active boosts exist', () => {
+ // Arrange
+ const stateWithBoostsAndLoading = {
+ ...initialState,
+ activeBoosts: [
{
- id: 'test-reward',
- seasonRewardId: 'test-season-reward',
- claimStatus: RewardClaimStatus.CLAIMED,
+ id: 'existing-boost',
+ name: 'Existing Boost',
+ icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
+ boostBips: 1000,
+ seasonLong: true,
+ backgroundColor: '#FF0000',
},
- ];
- const action = setUnlockedRewards(mockRewards);
+ ],
+ activeBoostsLoading: true,
+ };
+ const action = setActiveBoostsLoading(false);
- // Act
- const state = rewardsReducer(stateWithError, action);
+ // Act
+ const state = rewardsReducer(stateWithBoostsAndLoading, action);
- // Assert
- expect(state.unlockedRewards).toEqual(mockRewards);
- expect(state.unlockedRewardError).toBe(false); // Should be reset when successful
- });
+ // Assert
+ expect(state.activeBoostsLoading).toBe(false);
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- activeTab: 'levels' as const,
- referralCode: 'TEST123',
- balanceTotal: 1000,
- activeBoostsLoading: true,
- };
- const mockRewards = [
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'activity' as const,
+ referralCode: 'TEST123',
+ };
+ const action = setActiveBoostsLoading(true);
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.activeBoostsLoading).toBe(true);
+ expect(state.activeTab).toBe('activity');
+ expect(state.referralCode).toBe('TEST123');
+ expect(state.activeBoosts).toBeNull();
+ });
+});
+
+describe('setActiveBoostsError', () => {
+ it('should set activeBoostsError to true', () => {
+ // Arrange
+ const action = setActiveBoostsError(true);
+
+ // Act
+ const state = rewardsReducer(initialState, action);
+
+ // Assert
+ expect(state.activeBoostsError).toBe(true);
+ });
+
+ it('should set activeBoostsError to false', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ activeBoostsError: true,
+ };
+ const action = setActiveBoostsError(false);
+
+ // Act
+ const state = rewardsReducer(stateWithError, action);
+
+ // Assert
+ expect(state.activeBoostsError).toBe(false);
+ });
+
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'activity' as const,
+ referralCode: 'TEST123',
+ activeBoosts: [
{
- id: 'test-reward',
- seasonRewardId: 'test-season-reward',
- claimStatus: RewardClaimStatus.CLAIMED,
+ id: 'test-boost',
+ name: 'Test',
+ icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' },
+ boostBips: 1000,
+ seasonLong: true,
+ backgroundColor: '#FF0000',
},
- ];
- const action = setUnlockedRewards(mockRewards);
+ ],
+ activeBoostsLoading: true,
+ };
+ const action = setActiveBoostsError(true);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Assert
- expect(state.unlockedRewards).toEqual(mockRewards);
- expect(state.activeTab).toBe('levels');
- expect(state.referralCode).toBe('TEST123');
- expect(state.balanceTotal).toBe(1000);
- expect(state.activeBoostsLoading).toBe(true);
- });
+ // Assert
+ expect(state.activeBoostsError).toBe(true);
+ expect(state.activeTab).toBe('activity');
+ expect(state.referralCode).toBe('TEST123');
+ expect(state.activeBoosts).toEqual(stateWithData.activeBoosts);
+ expect(state.activeBoostsLoading).toBe(true); // Should remain unchanged
});
- describe('setUnlockedRewardLoading', () => {
- it('should set unlocked reward loading to true when no unlocked rewards exist', () => {
- // Arrange
- const action = setUnlockedRewardLoading(true);
+ it('should handle multiple error state changes', () => {
+ // Arrange
+ let currentState = initialState;
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act & Assert - Set error to true
+ let action = setActiveBoostsError(true);
+ currentState = rewardsReducer(currentState, action);
+ expect(currentState.activeBoostsError).toBe(true);
- // Assert
- expect(state.unlockedRewardLoading).toBe(true);
- });
+ // Act & Assert - Set error back to false
+ action = setActiveBoostsError(false);
+ currentState = rewardsReducer(currentState, action);
+ expect(currentState.activeBoostsError).toBe(false);
- it('should not set unlocked reward loading to true when unlocked rewards already exist', () => {
- // Arrange
- const stateWithRewards = {
- ...initialState,
- unlockedRewards: [
- {
- id: 'existing-reward',
- seasonRewardId: 'existing-season-reward',
- claimStatus: RewardClaimStatus.CLAIMED,
- },
- ],
- unlockedRewardLoading: false,
- };
- const action = setUnlockedRewardLoading(true);
+ // Act & Assert - Set error to true again
+ action = setActiveBoostsError(true);
+ currentState = rewardsReducer(currentState, action);
+ expect(currentState.activeBoostsError).toBe(true);
+ });
+});
- // Act
- const state = rewardsReducer(stateWithRewards, action);
+describe('setUnlockedRewards', () => {
+ it('should set unlocked rewards in state', () => {
+ // Arrange
+ const mockUnlockedRewards = [
+ {
+ id: 'reward-1',
+ seasonRewardId: 'season-reward-1',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ {
+ id: 'reward-2',
+ seasonRewardId: 'season-reward-2',
+ claimStatus: RewardClaimStatus.UNCLAIMED,
+ },
+ ];
+ const action = setUnlockedRewards(mockUnlockedRewards);
+
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.unlockedRewardLoading).toBe(false); // Should remain false due to guard clause
- });
+ // Assert
+ expect(state.unlockedRewards).toEqual(mockUnlockedRewards);
+ expect(state.unlockedRewards).toHaveLength(2);
+ expect(state.unlockedRewards?.[0]?.id).toBe('reward-1');
+ expect(state.unlockedRewards?.[1]?.claimStatus).toBe(
+ RewardClaimStatus.UNCLAIMED,
+ );
+ });
- it('should set unlocked reward loading to false', () => {
- // Arrange
- const stateWithLoading = {
- ...initialState,
- unlockedRewardLoading: true,
- };
- const action = setUnlockedRewardLoading(false);
+ it('should replace existing unlocked rewards', () => {
+ // Arrange
+ const existingRewards = [
+ {
+ id: 'old-reward',
+ seasonRewardId: 'old-season-reward',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ];
+ const stateWithRewards = {
+ ...initialState,
+ unlockedRewards: existingRewards,
+ };
+ const newRewards = [
+ {
+ id: 'new-reward-1',
+ seasonRewardId: 'new-season-reward-1',
+ claimStatus: RewardClaimStatus.UNCLAIMED,
+ },
+ {
+ id: 'new-reward-2',
+ seasonRewardId: 'new-season-reward-2',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ];
+ const action = setUnlockedRewards(newRewards);
+
+ // Act
+ const state = rewardsReducer(stateWithRewards, action);
- // Act
- const state = rewardsReducer(stateWithLoading, action);
+ // Assert
+ expect(state.unlockedRewards).toEqual(newRewards);
+ expect(state.unlockedRewards).toHaveLength(2);
+ expect(state.unlockedRewards?.[0]?.id).toBe('new-reward-1');
+ expect(state.unlockedRewards?.[1]?.id).toBe('new-reward-2');
+ });
- // Assert
- expect(state.unlockedRewardLoading).toBe(false);
- });
+ it('should set empty array when no rewards provided', () => {
+ // Arrange
+ const stateWithRewards = {
+ ...initialState,
+ unlockedRewards: [
+ {
+ id: 'existing-reward',
+ seasonRewardId: 'existing-season-reward',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ],
+ };
+ const action = setUnlockedRewards([]);
- it('should set unlocked reward loading to false even when unlocked rewards exist', () => {
- // Arrange
- const stateWithRewardsAndLoading = {
- ...initialState,
- unlockedRewards: [
- {
- id: 'existing-reward',
- seasonRewardId: 'existing-season-reward',
- claimStatus: RewardClaimStatus.CLAIMED,
- },
- ],
- unlockedRewardLoading: true,
- };
- const action = setUnlockedRewardLoading(false);
+ // Act
+ const state = rewardsReducer(stateWithRewards, action);
+
+ // Assert
+ expect(state.unlockedRewards).toEqual([]);
+ expect(state.unlockedRewards).toHaveLength(0);
+ });
- // Act
- const state = rewardsReducer(stateWithRewardsAndLoading, action);
+ it('should reset unlockedRewardError to false when setting unlocked rewards', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ unlockedRewardError: true,
+ };
+ const mockRewards = [
+ {
+ id: 'test-reward',
+ seasonRewardId: 'test-season-reward',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ];
+ const action = setUnlockedRewards(mockRewards);
+
+ // Act
+ const state = rewardsReducer(stateWithError, action);
- // Assert
- expect(state.unlockedRewardLoading).toBe(false);
- });
+ // Assert
+ expect(state.unlockedRewards).toEqual(mockRewards);
+ expect(state.unlockedRewardError).toBe(false); // Should be reset when successful
+ });
- it('should toggle loading state correctly when no rewards exist', () => {
- // Arrange - Start with false and no rewards
- let currentState = initialState;
- expect(currentState.unlockedRewardLoading).toBe(false);
- expect(currentState.unlockedRewards).toBeNull();
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'levels' as const,
+ referralCode: 'TEST123',
+ balanceTotal: 1000,
+ activeBoostsLoading: true,
+ };
+ const mockRewards = [
+ {
+ id: 'test-reward',
+ seasonRewardId: 'test-season-reward',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ];
+ const action = setUnlockedRewards(mockRewards);
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Act - Set to true (should work since no rewards exist)
- currentState = rewardsReducer(
- currentState,
- setUnlockedRewardLoading(true),
- );
- expect(currentState.unlockedRewardLoading).toBe(true);
+ // Assert
+ expect(state.unlockedRewards).toEqual(mockRewards);
+ expect(state.activeTab).toBe('levels');
+ expect(state.referralCode).toBe('TEST123');
+ expect(state.balanceTotal).toBe(1000);
+ expect(state.activeBoostsLoading).toBe(true);
+ });
+});
- // Act - Set back to false
- currentState = rewardsReducer(
- currentState,
- setUnlockedRewardLoading(false),
- );
- expect(currentState.unlockedRewardLoading).toBe(false);
- });
+describe('setUnlockedRewardLoading', () => {
+ it('should set unlocked reward loading to true when no unlocked rewards exist', () => {
+ // Arrange
+ const action = setUnlockedRewardLoading(true);
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- activeTab: 'activity' as const,
- referralCode: 'TEST456',
- activeBoostsLoading: false,
- };
- const action = setUnlockedRewardLoading(true);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Assert
+ expect(state.unlockedRewardLoading).toBe(true);
+ });
- // Assert
- expect(state.unlockedRewardLoading).toBe(true);
- expect(state.activeTab).toBe('activity');
- expect(state.referralCode).toBe('TEST456');
- expect(state.unlockedRewards).toBeNull();
- expect(state.activeBoostsLoading).toBe(false);
- });
+ it('should not set unlocked reward loading to true when unlocked rewards already exist', () => {
+ // Arrange
+ const stateWithRewards = {
+ ...initialState,
+ unlockedRewards: [
+ {
+ id: 'existing-reward',
+ seasonRewardId: 'existing-season-reward',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ],
+ unlockedRewardLoading: false,
+ };
+ const action = setUnlockedRewardLoading(true);
+
+ // Act
+ const state = rewardsReducer(stateWithRewards, action);
+
+ // Assert
+ expect(state.unlockedRewardLoading).toBe(false); // Should remain false due to guard clause
});
- describe('setUnlockedRewardError', () => {
- it('should set unlockedRewardError to true', () => {
- // Arrange
- const action = setUnlockedRewardError(true);
+ it('should set unlocked reward loading to false', () => {
+ // Arrange
+ const stateWithLoading = {
+ ...initialState,
+ unlockedRewardLoading: true,
+ };
+ const action = setUnlockedRewardLoading(false);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithLoading, action);
- // Assert
- expect(state.unlockedRewardError).toBe(true);
- });
+ // Assert
+ expect(state.unlockedRewardLoading).toBe(false);
+ });
- it('should set unlockedRewardError to false', () => {
- // Arrange
- const stateWithError = {
- ...initialState,
- unlockedRewardError: true,
- };
- const action = setUnlockedRewardError(false);
+ it('should set unlocked reward loading to false even when unlocked rewards exist', () => {
+ // Arrange
+ const stateWithRewardsAndLoading = {
+ ...initialState,
+ unlockedRewards: [
+ {
+ id: 'existing-reward',
+ seasonRewardId: 'existing-season-reward',
+ claimStatus: RewardClaimStatus.CLAIMED,
+ },
+ ],
+ unlockedRewardLoading: true,
+ };
+ const action = setUnlockedRewardLoading(false);
- // Act
- const state = rewardsReducer(stateWithError, action);
+ // Act
+ const state = rewardsReducer(stateWithRewardsAndLoading, action);
- // Assert
- expect(state.unlockedRewardError).toBe(false);
- });
+ // Assert
+ expect(state.unlockedRewardLoading).toBe(false);
+ });
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- activeTab: 'levels' as const,
- referralCode: 'TEST789',
- balanceTotal: 2000,
- unlockedRewardLoading: true,
- };
- const action = setUnlockedRewardError(true);
+ it('should toggle loading state correctly when no rewards exist', () => {
+ // Arrange - Start with false and no rewards
+ let currentState = initialState;
+ expect(currentState.unlockedRewardLoading).toBe(false);
+ expect(currentState.unlockedRewards).toBeNull();
+
+ // Act - Set to true (should work since no rewards exist)
+ currentState = rewardsReducer(currentState, setUnlockedRewardLoading(true));
+ expect(currentState.unlockedRewardLoading).toBe(true);
+
+ // Act - Set back to false
+ currentState = rewardsReducer(
+ currentState,
+ setUnlockedRewardLoading(false),
+ );
+ expect(currentState.unlockedRewardLoading).toBe(false);
+ });
- // Act
- const state = rewardsReducer(stateWithData, action);
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'activity' as const,
+ referralCode: 'TEST456',
+ activeBoostsLoading: false,
+ };
+ const action = setUnlockedRewardLoading(true);
- // Assert
- expect(state.unlockedRewardError).toBe(true);
- expect(state.activeTab).toBe('levels');
- expect(state.referralCode).toBe('TEST789');
- expect(state.balanceTotal).toBe(2000);
- expect(state.unlockedRewardLoading).toBe(true); // Should remain unchanged
- });
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- it('should handle multiple error state changes', () => {
- // Arrange
- let currentState = initialState;
+ // Assert
+ expect(state.unlockedRewardLoading).toBe(true);
+ expect(state.activeTab).toBe('activity');
+ expect(state.referralCode).toBe('TEST456');
+ expect(state.unlockedRewards).toBeNull();
+ expect(state.activeBoostsLoading).toBe(false);
+ });
+});
- // Act & Assert - Set error to true
- let action = setUnlockedRewardError(true);
- currentState = rewardsReducer(currentState, action);
- expect(currentState.unlockedRewardError).toBe(true);
+describe('setUnlockedRewardError', () => {
+ it('should set unlockedRewardError to true', () => {
+ // Arrange
+ const action = setUnlockedRewardError(true);
- // Act & Assert - Set error back to false
- action = setUnlockedRewardError(false);
- currentState = rewardsReducer(currentState, action);
- expect(currentState.unlockedRewardError).toBe(false);
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act & Assert - Set error to true again
- action = setUnlockedRewardError(true);
- currentState = rewardsReducer(currentState, action);
- expect(currentState.unlockedRewardError).toBe(true);
- });
+ // Assert
+ expect(state.unlockedRewardError).toBe(true);
});
- describe('setPointsEvents', () => {
- it('should set points events array', () => {
- // Arrange
- const mockPointsEvents: PointsEventDto[] = [
- {
- id: 'event-1',
- type: 'SWAP' as const,
- timestamp: new Date('2024-01-01T00:00:00Z'),
- value: 100,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-01T00:00:00Z'),
- payload: {
- srcAsset: {
- amount: '1000000000000000000',
- type: 'eip155:1/slip44:60',
- decimals: 18,
- name: 'Ethereum',
- symbol: 'ETH',
- },
- destAsset: {
- amount: '3000000000',
- type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C',
- decimals: 6,
- name: 'USD Coin',
- symbol: 'USDC',
- },
- },
- },
- {
- id: 'event-2',
- type: 'REFERRAL' as const,
- timestamp: new Date('2024-01-02T00:00:00Z'),
- value: 50,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-02T00:00:00Z'),
- payload: null,
- },
- ];
- const action = setPointsEvents(mockPointsEvents);
+ it('should set unlockedRewardError to false', () => {
+ // Arrange
+ const stateWithError = {
+ ...initialState,
+ unlockedRewardError: true,
+ };
+ const action = setUnlockedRewardError(false);
- // Act
- const state = rewardsReducer(initialState, action);
+ // Act
+ const state = rewardsReducer(stateWithError, action);
- // Assert
- expect(state.pointsEvents).toEqual(mockPointsEvents);
- expect(state.pointsEvents).toHaveLength(2);
- expect(state.pointsEvents?.[0]?.id).toBe('event-1');
- expect(state.pointsEvents?.[0]?.type).toBe('SWAP');
- expect(state.pointsEvents?.[1]?.type).toBe('REFERRAL');
- });
+ // Assert
+ expect(state.unlockedRewardError).toBe(false);
+ });
- it('should replace existing points events', () => {
- // Arrange
- const existingEvents: PointsEventDto[] = [
- {
- id: 'old-event',
- type: 'SIGN_UP_BONUS' as const,
- timestamp: new Date('2024-01-01T00:00:00Z'),
- value: 200,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-01T00:00:00Z'),
- payload: null,
- },
- ];
- const stateWithEvents = {
- ...initialState,
- pointsEvents: existingEvents,
- };
- const newEvents: PointsEventDto[] = [
- {
- id: 'new-event-1',
- type: 'PERPS' as const,
- timestamp: new Date('2024-01-02T00:00:00Z'),
- value: 300,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-02T00:00:00Z'),
- payload: {
- type: 'OPEN_POSITION',
- direction: 'LONG',
- asset: {
- amount: '1000000000000000000',
- type: 'eip155:1/slip44:60',
- decimals: 18,
- name: 'Ethereum',
- symbol: 'ETH',
- },
- },
- },
- {
- id: 'new-event-2',
- type: 'LOYALTY_BONUS' as const,
- timestamp: new Date('2024-01-03T00:00:00Z'),
- value: 75,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-03T00:00:00Z'),
- payload: null,
- },
- ];
- const action = setPointsEvents(newEvents);
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'levels' as const,
+ referralCode: 'TEST789',
+ balanceTotal: 2000,
+ unlockedRewardLoading: true,
+ };
+ const action = setUnlockedRewardError(true);
- // Act
- const state = rewardsReducer(stateWithEvents, action);
+ // Act
+ const state = rewardsReducer(stateWithData, action);
- // Assert
- expect(state.pointsEvents).toEqual(newEvents);
- expect(state.pointsEvents).toHaveLength(2);
- expect(state.pointsEvents?.[0]?.id).toBe('new-event-1');
- expect(state.pointsEvents?.[1]?.id).toBe('new-event-2');
- });
+ // Assert
+ expect(state.unlockedRewardError).toBe(true);
+ expect(state.activeTab).toBe('levels');
+ expect(state.referralCode).toBe('TEST789');
+ expect(state.balanceTotal).toBe(2000);
+ expect(state.unlockedRewardLoading).toBe(true); // Should remain unchanged
+ });
- it('should set empty array when no events provided', () => {
- // Arrange
- const stateWithEvents = {
- ...initialState,
- pointsEvents: [
- {
- id: 'existing-event',
- type: 'ONE_TIME_BONUS' as const,
- timestamp: new Date('2024-01-01T00:00:00Z'),
- value: 500,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-01T00:00:00Z'),
- payload: null,
- },
- ],
- };
- const action = setPointsEvents([]);
+ it('should handle multiple error state changes', () => {
+ // Arrange
+ let currentState = initialState;
- // Act
- const state = rewardsReducer(stateWithEvents, action);
+ // Act & Assert - Set error to true
+ let action = setUnlockedRewardError(true);
+ currentState = rewardsReducer(currentState, action);
+ expect(currentState.unlockedRewardError).toBe(true);
- // Assert
- expect(state.pointsEvents).toEqual([]);
- expect(state.pointsEvents).toHaveLength(0);
- });
+ // Act & Assert - Set error back to false
+ action = setUnlockedRewardError(false);
+ currentState = rewardsReducer(currentState, action);
+ expect(currentState.unlockedRewardError).toBe(false);
- it('should set points events to null', () => {
- // Arrange
- const stateWithEvents = {
- ...initialState,
- pointsEvents: [
- {
- id: 'existing-event',
- type: 'SWAP' as const,
- timestamp: new Date('2024-01-01T00:00:00Z'),
- value: 100,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-01T00:00:00Z'),
- payload: {
- srcAsset: {
- amount: '1000000000000000000',
- type: 'eip155:1/slip44:60',
- decimals: 18,
- name: 'Ethereum',
- symbol: 'ETH',
- },
- destAsset: {
- amount: '3000000000',
- type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C',
- decimals: 6,
- name: 'USD Coin',
- symbol: 'USDC',
- },
- },
+ // Act & Assert - Set error to true again
+ action = setUnlockedRewardError(true);
+ currentState = rewardsReducer(currentState, action);
+ expect(currentState.unlockedRewardError).toBe(true);
+ });
+});
+
+describe('setPointsEvents', () => {
+ it('should set points events array', () => {
+ // Arrange
+ const mockPointsEvents: PointsEventDto[] = [
+ {
+ id: 'event-1',
+ type: 'SWAP' as const,
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ value: 100,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-01T00:00:00Z'),
+ payload: {
+ srcAsset: {
+ amount: '1000000000000000000',
+ type: 'eip155:1/slip44:60',
+ decimals: 18,
+ name: 'Ethereum',
+ symbol: 'ETH',
},
- ],
- };
- const action = setPointsEvents(null);
+ destAsset: {
+ amount: '3000000000',
+ type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C',
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ },
+ },
+ },
+ {
+ id: 'event-2',
+ type: 'REFERRAL' as const,
+ timestamp: new Date('2024-01-02T00:00:00Z'),
+ value: 50,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ payload: null,
+ },
+ ];
+ const action = setPointsEvents(mockPointsEvents);
+
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Act
- const state = rewardsReducer(stateWithEvents, action);
+ // Assert
+ expect(state.pointsEvents).toEqual(mockPointsEvents);
+ expect(state.pointsEvents).toHaveLength(2);
+ expect(state.pointsEvents?.[0]?.id).toBe('event-1');
+ expect(state.pointsEvents?.[0]?.type).toBe('SWAP');
+ expect(state.pointsEvents?.[1]?.type).toBe('REFERRAL');
+ });
- // Assert
- expect(state.pointsEvents).toBeNull();
- });
+ it('should replace existing points events', () => {
+ // Arrange
+ const existingEvents: PointsEventDto[] = [
+ {
+ id: 'old-event',
+ type: 'SIGN_UP_BONUS' as const,
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ value: 200,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-01T00:00:00Z'),
+ payload: null,
+ },
+ ];
+ const stateWithEvents = {
+ ...initialState,
+ pointsEvents: existingEvents,
+ };
+ const newEvents: PointsEventDto[] = [
+ {
+ id: 'new-event-1',
+ type: 'PERPS' as const,
+ timestamp: new Date('2024-01-02T00:00:00Z'),
+ value: 300,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ payload: {
+ type: 'OPEN_POSITION',
+ direction: 'LONG',
+ asset: {
+ amount: '1000000000000000000',
+ type: 'eip155:1/slip44:60',
+ decimals: 18,
+ name: 'Ethereum',
+ symbol: 'ETH',
+ },
+ },
+ },
+ {
+ id: 'new-event-2',
+ type: 'LOYALTY_BONUS' as const,
+ timestamp: new Date('2024-01-03T00:00:00Z'),
+ value: 75,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-03T00:00:00Z'),
+ payload: null,
+ },
+ ];
+ const action = setPointsEvents(newEvents);
+
+ // Act
+ const state = rewardsReducer(stateWithEvents, action);
- it('should not affect other state properties', () => {
- // Arrange
- const stateWithData = {
- ...initialState,
- activeTab: 'activity' as const,
- referralCode: 'TEST123',
- balanceTotal: 1000,
- activeBoostsLoading: true,
- };
- const mockEvents: PointsEventDto[] = [
+ // Assert
+ expect(state.pointsEvents).toEqual(newEvents);
+ expect(state.pointsEvents).toHaveLength(2);
+ expect(state.pointsEvents?.[0]?.id).toBe('new-event-1');
+ expect(state.pointsEvents?.[1]?.id).toBe('new-event-2');
+ });
+
+ it('should set empty array when no events provided', () => {
+ // Arrange
+ const stateWithEvents = {
+ ...initialState,
+ pointsEvents: [
{
- id: 'test-event',
- type: 'SWAP' as const,
+ id: 'existing-event',
+ type: 'ONE_TIME_BONUS' as const,
timestamp: new Date('2024-01-01T00:00:00Z'),
- value: 150,
+ value: 500,
bonus: null,
accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
updatedAt: new Date('2024-01-01T00:00:00Z'),
- payload: {
- srcAsset: {
- amount: '10000000',
- type: 'eip155:1/slip44:0',
- decimals: 8,
- name: 'Bitcoin',
- symbol: 'BTC',
- },
- destAsset: {
- amount: '2500000000000000000',
- type: 'eip155:1/slip44:60',
- decimals: 18,
- name: 'Ethereum',
- symbol: 'ETH',
- },
- },
+ payload: null,
},
- ];
- const action = setPointsEvents(mockEvents);
+ ],
+ };
+ const action = setPointsEvents([]);
- // Act
- const state = rewardsReducer(stateWithData, action);
+ // Act
+ const state = rewardsReducer(stateWithEvents, action);
- // Assert
- expect(state.pointsEvents).toEqual(mockEvents);
- expect(state.activeTab).toBe('activity');
- expect(state.referralCode).toBe('TEST123');
- expect(state.balanceTotal).toBe(1000);
- expect(state.activeBoostsLoading).toBe(true);
- });
+ // Assert
+ expect(state.pointsEvents).toEqual([]);
+ expect(state.pointsEvents).toHaveLength(0);
+ });
- it('should handle mixed event types', () => {
- // Arrange
- const mixedEvents: PointsEventDto[] = [
+ it('should set points events to null', () => {
+ // Arrange
+ const stateWithEvents = {
+ ...initialState,
+ pointsEvents: [
{
- id: 'swap-event',
+ id: 'existing-event',
type: 'SWAP' as const,
timestamp: new Date('2024-01-01T00:00:00Z'),
value: 100,
@@ -3232,81 +3237,168 @@ describe('rewardsReducer', () => {
},
},
},
- {
- id: 'perps-event',
- type: 'PERPS' as const,
- timestamp: new Date('2024-01-02T00:00:00Z'),
- value: 200,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-02T00:00:00Z'),
- payload: {
- type: 'CLOSE_POSITION',
- direction: 'SHORT',
- asset: {
- amount: '5000000000000000000',
- type: 'eip155:1/slip44:60',
- decimals: 18,
- name: 'Ethereum',
- symbol: 'ETH',
- },
+ ],
+ };
+ const action = setPointsEvents(null);
+
+ // Act
+ const state = rewardsReducer(stateWithEvents, action);
+
+ // Assert
+ expect(state.pointsEvents).toBeNull();
+ });
+
+ it('should not affect other state properties', () => {
+ // Arrange
+ const stateWithData = {
+ ...initialState,
+ activeTab: 'activity' as const,
+ referralCode: 'TEST123',
+ balanceTotal: 1000,
+ activeBoostsLoading: true,
+ };
+ const mockEvents: PointsEventDto[] = [
+ {
+ id: 'test-event',
+ type: 'SWAP' as const,
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ value: 150,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-01T00:00:00Z'),
+ payload: {
+ srcAsset: {
+ amount: '10000000',
+ type: 'eip155:1/slip44:0',
+ decimals: 8,
+ name: 'Bitcoin',
+ symbol: 'BTC',
+ },
+ destAsset: {
+ amount: '2500000000000000000',
+ type: 'eip155:1/slip44:60',
+ decimals: 18,
+ name: 'Ethereum',
+ symbol: 'ETH',
},
},
- {
- id: 'referral-event',
- type: 'REFERRAL' as const,
- timestamp: new Date('2024-01-03T00:00:00Z'),
- value: 50,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-03T00:00:00Z'),
- payload: null,
- },
- {
- id: 'signup-event',
- type: 'SIGN_UP_BONUS' as const,
- timestamp: new Date('2024-01-04T00:00:00Z'),
- value: 1000,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-04T00:00:00Z'),
- payload: null,
- },
- {
- id: 'loyalty-event',
- type: 'LOYALTY_BONUS' as const,
- timestamp: new Date('2024-01-05T00:00:00Z'),
- value: 75,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-05T00:00:00Z'),
- payload: null,
+ },
+ ];
+ const action = setPointsEvents(mockEvents);
+
+ // Act
+ const state = rewardsReducer(stateWithData, action);
+
+ // Assert
+ expect(state.pointsEvents).toEqual(mockEvents);
+ expect(state.activeTab).toBe('activity');
+ expect(state.referralCode).toBe('TEST123');
+ expect(state.balanceTotal).toBe(1000);
+ expect(state.activeBoostsLoading).toBe(true);
+ });
+
+ it('should handle mixed event types', () => {
+ // Arrange
+ const mixedEvents: PointsEventDto[] = [
+ {
+ id: 'swap-event',
+ type: 'SWAP' as const,
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ value: 100,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-01T00:00:00Z'),
+ payload: {
+ srcAsset: {
+ amount: '1000000000000000000',
+ type: 'eip155:1/slip44:60',
+ decimals: 18,
+ name: 'Ethereum',
+ symbol: 'ETH',
+ },
+ destAsset: {
+ amount: '3000000000',
+ type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C',
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ },
},
- {
- id: 'onetime-event',
- type: 'ONE_TIME_BONUS' as const,
- timestamp: new Date('2024-01-06T00:00:00Z'),
- value: 500,
- bonus: null,
- accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
- updatedAt: new Date('2024-01-06T00:00:00Z'),
- payload: null,
+ },
+ {
+ id: 'perps-event',
+ type: 'PERPS' as const,
+ timestamp: new Date('2024-01-02T00:00:00Z'),
+ value: 200,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ payload: {
+ type: 'CLOSE_POSITION',
+ direction: 'SHORT',
+ asset: {
+ amount: '5000000000000000000',
+ type: 'eip155:1/slip44:60',
+ decimals: 18,
+ name: 'Ethereum',
+ symbol: 'ETH',
+ },
},
- ];
- const action = setPointsEvents(mixedEvents);
-
- // Act
- const state = rewardsReducer(initialState, action);
+ },
+ {
+ id: 'referral-event',
+ type: 'REFERRAL' as const,
+ timestamp: new Date('2024-01-03T00:00:00Z'),
+ value: 50,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-03T00:00:00Z'),
+ payload: null,
+ },
+ {
+ id: 'signup-event',
+ type: 'SIGN_UP_BONUS' as const,
+ timestamp: new Date('2024-01-04T00:00:00Z'),
+ value: 1000,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-04T00:00:00Z'),
+ payload: null,
+ },
+ {
+ id: 'loyalty-event',
+ type: 'LOYALTY_BONUS' as const,
+ timestamp: new Date('2024-01-05T00:00:00Z'),
+ value: 75,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-05T00:00:00Z'),
+ payload: null,
+ },
+ {
+ id: 'onetime-event',
+ type: 'ONE_TIME_BONUS' as const,
+ timestamp: new Date('2024-01-06T00:00:00Z'),
+ value: 500,
+ bonus: null,
+ accountAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ updatedAt: new Date('2024-01-06T00:00:00Z'),
+ payload: null,
+ },
+ ];
+ const action = setPointsEvents(mixedEvents);
+
+ // Act
+ const state = rewardsReducer(initialState, action);
- // Assert
- expect(state.pointsEvents).toEqual(mixedEvents);
- expect(state.pointsEvents).toHaveLength(6);
- expect(state.pointsEvents?.[0]?.type).toBe('SWAP');
- expect(state.pointsEvents?.[1]?.type).toBe('PERPS');
- expect(state.pointsEvents?.[2]?.type).toBe('REFERRAL');
- expect(state.pointsEvents?.[3]?.type).toBe('SIGN_UP_BONUS');
- expect(state.pointsEvents?.[4]?.type).toBe('LOYALTY_BONUS');
- expect(state.pointsEvents?.[5]?.type).toBe('ONE_TIME_BONUS');
- });
+ // Assert
+ expect(state.pointsEvents).toEqual(mixedEvents);
+ expect(state.pointsEvents).toHaveLength(6);
+ expect(state.pointsEvents?.[0]?.type).toBe('SWAP');
+ expect(state.pointsEvents?.[1]?.type).toBe('PERPS');
+ expect(state.pointsEvents?.[2]?.type).toBe('REFERRAL');
+ expect(state.pointsEvents?.[3]?.type).toBe('SIGN_UP_BONUS');
+ expect(state.pointsEvents?.[4]?.type).toBe('LOYALTY_BONUS');
+ expect(state.pointsEvents?.[5]?.type).toBe('ONE_TIME_BONUS');
});
});
diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts
index 286c926e8e02..a0ed7f783ee5 100644
--- a/app/reducers/rewards/index.ts
+++ b/app/reducers/rewards/index.ts
@@ -6,6 +6,7 @@ import {
PointsBoostDto,
RewardDto,
PointsEventDto,
+ SeasonActivityTypeDto,
} from '../../core/Engine/controllers/rewards-controller/types';
import { OnboardingStep } from './types';
import { AccountGroupId } from '@metamask/account-api';
@@ -26,6 +27,7 @@ export interface RewardsState {
seasonStartDate: Date | null;
seasonEndDate: Date | null;
seasonTiers: SeasonTierDto[];
+ seasonActivityTypes: SeasonActivityTypeDto[];
// Subscription Referral state
referralDetailsLoading: boolean;
@@ -84,6 +86,7 @@ export const initialState: RewardsState = {
seasonStartDate: null,
seasonEndDate: null,
seasonTiers: [],
+ seasonActivityTypes: [],
referralDetailsLoading: false,
referralDetailsError: false,
@@ -153,6 +156,7 @@ const rewardsSlice = createSlice({
? new Date(action.payload.season.endDate)
: null;
state.seasonTiers = action.payload?.season.tiers || [];
+ state.seasonActivityTypes = action.payload?.season.activityTypes || [];
// Season Balance state
state.balanceTotal =
@@ -257,6 +261,7 @@ const rewardsSlice = createSlice({
state.seasonStartDate = initialState.seasonStartDate;
state.seasonEndDate = initialState.seasonEndDate;
state.seasonTiers = initialState.seasonTiers;
+ state.seasonActivityTypes = initialState.seasonActivityTypes;
state.referralCode = initialState.referralCode;
state.refereeCount = initialState.refereeCount;
state.currentTier = initialState.currentTier;
@@ -370,6 +375,7 @@ const rewardsSlice = createSlice({
seasonStartDate: action.payload.rewards.seasonStartDate,
seasonEndDate: action.payload.rewards.seasonEndDate,
seasonTiers: action.payload.rewards.seasonTiers,
+ seasonActivityTypes: action.payload.rewards.seasonActivityTypes,
referralCode: action.payload.rewards.referralCode,
refereeCount: action.payload.rewards.refereeCount,
currentTier: action.payload.rewards.currentTier,
diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts
index 06d6ec5fd3f0..2777a5cb3020 100644
--- a/app/reducers/rewards/selectors.test.ts
+++ b/app/reducers/rewards/selectors.test.ts
@@ -17,6 +17,7 @@ import {
selectSeasonStartDate,
selectSeasonEndDate,
selectSeasonTiers,
+ selectSeasonActivityTypes,
selectOnboardingActiveStep,
selectOnboardingReferralCode,
selectGeoLocation,
@@ -41,6 +42,7 @@ import { OnboardingStep } from './types';
import {
RewardDto,
SeasonTierDto,
+ SeasonActivityTypeDto,
PointsEventDto,
} from '../../core/Engine/controllers/rewards-controller/types';
import { RootState } from '..';
@@ -521,6 +523,42 @@ describe('Rewards selectors', () => {
});
});
+ describe('selectSeasonActivityTypes', () => {
+ it('returns empty array when season activity types are not set', () => {
+ const mockState = { rewards: { seasonActivityTypes: [] } };
+ mockedUseSelector.mockImplementation((selector) => selector(mockState));
+
+ const { result } = renderHook(() =>
+ useSelector(selectSeasonActivityTypes),
+ );
+ expect(result.current).toEqual([]);
+ });
+
+ it('returns season activity types when set', () => {
+ const mockActivityTypes: SeasonActivityTypeDto[] = [
+ {
+ type: 'SWAP',
+ title: 'Swap',
+ description: 'Swap tokens',
+ icon: 'SwapVertical',
+ },
+ {
+ type: 'CARD',
+ title: 'Card spend',
+ description: 'Spend with card',
+ icon: 'Card',
+ },
+ ];
+ const mockState = { rewards: { seasonActivityTypes: mockActivityTypes } };
+ mockedUseSelector.mockImplementation((selector) => selector(mockState));
+
+ const { result } = renderHook(() =>
+ useSelector(selectSeasonActivityTypes),
+ );
+ expect(result.current).toEqual(mockActivityTypes);
+ });
+ });
+
describe('selectOnboardingActiveStep', () => {
it('returns INTRO step when set', () => {
const mockState = {
diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts
index 09560d171cce..e24134f5f651 100644
--- a/app/reducers/rewards/selectors.ts
+++ b/app/reducers/rewards/selectors.ts
@@ -46,6 +46,9 @@ export const selectSeasonEndDate = (state: RootState) =>
export const selectSeasonTiers = (state: RootState) =>
state.rewards.seasonTiers;
+export const selectSeasonActivityTypes = (state: RootState) =>
+ state.rewards.seasonActivityTypes;
+
export const selectOnboardingActiveStep = (state: RootState): OnboardingStep =>
state.rewards.onboardingActiveStep;
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 770bd551694b..680e9a1688e5 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -6800,7 +6800,8 @@
"points": "Points",
"points_base": "Base",
"points_boost": "Boost",
- "points_total": "Total"
+ "points_total": "Total",
+ "description": "Description"
},
"onboarding": {
"not_supported_region_title": "Region not supported",
From 16172634b84f0125847b447dca803c02d40b7304 Mon Sep 17 00:00:00 2001
From: Vince Howard
Date: Wed, 26 Nov 2025 10:27:56 -0700
Subject: [PATCH 16/16] chore: Remove MM_REMOVE_GLOBAL_NETWORK_SELECTOR feature
flag from component level (#22067)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This is the a PR to remove the rest of the
`MM_REMOVE_GLOBAL_NETWORK_SELECTOR` feature flag on a component level
Previous PRs:
[chore: remove MM_REMOVE_GLOBAL_NETWORK_SELECTOR from hooks, lists, and
control bars](https://github.com/MetaMask/metamask-mobile/pull/22574)
[chore: remove global network selector feature flag from selectors and
polling](https://github.com/MetaMask/metamask-mobile/pull/22463)
There will be one more PR to review the feature flag from Github
workflows and E2E tests
## **Changelog**
CHANGELOG entry:null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1132
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
`~`
### **Before**
`~`
### **After**
`~`
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [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]
> Eliminates MM_REMOVE_GLOBAL_NETWORK_SELECTOR flag and defaults Network
Manager behaviors across UI, logic, tests, and CI.
>
> - **Network Manager defaulted (flag removed)**:
> - Remove `isRemoveGlobalNetworkSelectorEnabled` usage; always call
`selectNetwork`/enable networks (e.g., `SwitchChainApproval`,
`NetworkModal`, `Swaps`, `QuotesView`, `SendFlow`).
> - Always show network pickers (e.g., `ContextualNetworkPicker`,
`PickerNetwork`).
> - Always set token network filter on actions (e.g., add/update
network, switch RPC).
> - **Transactions & Filtering**:
> - `TransactionsView` and `UnifiedTransactionsView` filter by enabled
EVM chains; simplify block explorer logic to single selected EVM chain.
> - `TransactionElement` multichain logic no longer gated by the flag.
> - **Address Book / Send flow**:
> - `AddressList` shows contacts across chains when rendering address
book; network badges shown on entries.
> - `ContactForm`: address immutable in edit mode; always shows network
selector.
> - `AddressElement`: network badge controlled solely by
`displayNetworkBadge` prop.
> - **Tests**:
> - Update/removal of feature-flag branches and expectations across unit
tests (Approvals, NetworkModal, PaymentRequest, Swaps, Transactions,
UnifiedTransactions, RPC modal, Contacts, Send flow, Network Settings).
> - **CI/Config**:
> - Remove `MM_REMOVE_GLOBAL_NETWORK_SELECTOR` env from Bitrise and
Jest; delete flag export from `util/networks`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ee65b6acbb823aef0ef8b97d7080621e7c7ecfba. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../SwitchChainApproval.test.tsx | 32 +-
.../SwitchChainApproval.tsx | 5 +-
app/components/UI/NetworkModal/index.test.tsx | 45 +--
app/components/UI/NetworkModal/index.tsx | 9 +-
app/components/UI/PaymentRequest/index.js | 22 +-
.../UI/PaymentRequest/index.test.tsx | 10 -
app/components/UI/Swaps/QuotesView.js | 5 +-
app/components/UI/Swaps/QuotesView.test.ts | 47 ---
app/components/UI/Swaps/index.js | 17 +-
app/components/UI/TransactionElement/index.js | 5 +-
.../RpcSelectionModal.test.tsx | 82 +-----
.../RpcSelectionModal/RpcSelectionModal.tsx | 15 +-
.../__snapshots__/index.test.tsx.snap | 114 ++++++++
.../Settings/Contacts/ContactForm/index.js | 116 ++++----
.../Contacts/ContactForm/index.test.tsx | 13 +-
.../NetworksSettings/NetworkSettings/index.js | 7 +-
.../NetworkSettings/index.test.tsx | 72 +----
.../Views/TransactionsView/index.js | 274 +++++++-----------
.../Views/TransactionsView/index.test.tsx | 163 ++---------
.../UnifiedTransactionsView.test.tsx | 52 +---
.../UnifiedTransactionsView.tsx | 57 +---
app/components/Views/Wallet/index.tsx | 1 -
.../AddressElement/AddressElement.test.tsx | 13 +-
.../AddressElement/AddressElement.tsx | 3 +-
.../SendFlow/AddressList/AddressList.test.tsx | 56 +---
.../SendFlow/AddressList/AddressList.tsx | 18 +-
.../legacy/SendFlow/SendTo/index.js | 36 +--
app/util/networks/index.js | 2 -
bitrise.yml | 6 -
jest.config.js | 1 -
30 files changed, 403 insertions(+), 895 deletions(-)
diff --git a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx
index 6a765d7db112..4269515c41c1 100644
--- a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx
+++ b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx
@@ -4,8 +4,6 @@ import { shallow } from 'enzyme';
import { ApprovalTypes } from '../../../core/RPCMethods/RPCMethodMiddleware';
import SwitchChainApproval from './SwitchChainApproval';
import { networkSwitched } from '../../../actions/onboardNetwork';
-// eslint-disable-next-line import/no-namespace
-import * as networks from '../../../util/networks';
import {
Caip25CaveatType,
Caip25EndowmentPermissionName,
@@ -102,9 +100,6 @@ const mockApprovalRequestData = {
describe('SwitchChainApproval', () => {
beforeEach(() => {
jest.clearAllMocks();
- jest
- .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled')
- .mockReturnValue(false);
});
it('renders', () => {
@@ -166,11 +161,7 @@ describe('SwitchChainApproval', () => {
});
});
- it('calls selectNetwork when remove global network selector are enabled', () => {
- jest
- .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled')
- .mockReturnValue(true);
-
+ it('calls selectNetwork when confirm is pressed', () => {
mockApprovalRequest({
type: ApprovalTypes.SWITCH_ETHEREUM_CHAIN,
requestData: mockApprovalRequestData,
@@ -187,25 +178,4 @@ describe('SwitchChainApproval', () => {
networkStatus: true,
});
});
-
- it('does not call selectNetwork when remove global network selector is disabled', () => {
- jest
- .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled')
- .mockReturnValue(false);
-
- mockApprovalRequest({
- type: ApprovalTypes.SWITCH_ETHEREUM_CHAIN,
- requestData: mockApprovalRequestData,
- });
-
- const wrapper = shallow();
- wrapper.find('SwitchCustomNetwork').simulate('confirm');
-
- expect(mockSelectNetwork).not.toHaveBeenCalled();
- expect(networkSwitched).toHaveBeenCalledTimes(1);
- expect(networkSwitched).toHaveBeenCalledWith({
- networkUrl: URL_MOCK,
- networkStatus: true,
- });
- });
});
diff --git a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx
index 1dc9ac328dd0..1401f4f96d9f 100644
--- a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx
+++ b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx
@@ -5,7 +5,6 @@ import ApprovalModal from '../ApprovalModal';
import SwitchCustomNetwork from '../../UI/SwitchCustomNetwork';
import { networkSwitched } from '../../../actions/onboardNetwork';
import { useDispatch, useSelector } from 'react-redux';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks';
import {
NetworkType,
useNetworksByNamespace,
@@ -54,9 +53,7 @@ const SwitchChainApproval = () => {
defaultOnConfirm();
// If remove global network selector is enabled should set network filter
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- selectNetwork(chainId);
- }
+ selectNetwork(chainId);
dispatch(
networkSwitched({
diff --git a/app/components/UI/NetworkModal/index.test.tsx b/app/components/UI/NetworkModal/index.test.tsx
index 88adea936d35..ed1e00034bea 100644
--- a/app/components/UI/NetworkModal/index.test.tsx
+++ b/app/components/UI/NetworkModal/index.test.tsx
@@ -12,7 +12,6 @@ import { selectNetworkConfigurations } from '../../../selectors/networkControlle
jest.mock('../../../util/networks', () => ({
...jest.requireActual('../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false),
isPrivateConnection: jest.fn().mockReturnValue(false),
}));
@@ -384,15 +383,12 @@ describe('NetworkDetails', () => {
});
});
- describe('when isRemoveGlobalNetworkSelectorEnabled is true', () => {
+ describe('Network Manager Integration', () => {
let mockSelectNetwork: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
- const networksModule = jest.requireMock('../../../util/networks');
- networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
-
mockSelectNetwork = jest.fn();
const useNetworkSelectionModule = jest.requireMock(
'../../hooks/useNetworkSelection/useNetworkSelection',
@@ -404,7 +400,7 @@ describe('NetworkDetails', () => {
});
});
- it('should call selectNetwork when adding a new network and feature flag is enabled', async () => {
+ it('should call selectNetwork when adding a new network', async () => {
(useSelector as jest.Mock).mockImplementation((selector) => {
if (selector === selectNetworkConfigurations) return {};
return {};
@@ -435,7 +431,7 @@ describe('NetworkDetails', () => {
expect(mockSelectNetwork).toHaveBeenCalledWith('0x1');
});
- it('should call selectNetwork when switching networks and feature flag is enabled', async () => {
+ it('should call selectNetwork when switching networks', async () => {
const { getByTestId } = renderWithTheme();
const approveButton = getByTestId(
@@ -462,7 +458,7 @@ describe('NetworkDetails', () => {
expect(mockSelectNetwork).toHaveBeenCalledWith('0x1');
});
- it('should call selectNetwork when updating an existing network and feature flag is enabled', async () => {
+ it('should call selectNetwork when updating an existing network', async () => {
(useSelector as jest.Mock).mockImplementation((selector) => {
if (selector === selectNetworkName) return 'Ethereum Main Network';
if (selector === selectUseSafeChainsListValidation) return true;
@@ -500,38 +496,7 @@ describe('NetworkDetails', () => {
expect(mockSelectNetwork).toHaveBeenCalledWith('0x1');
});
- it('should not call selectNetwork when feature flag is disabled', async () => {
- const networksModule = jest.requireMock('../../../util/networks');
- networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(
- false,
- );
-
- const { getByTestId } = renderWithTheme();
-
- const approveButton = getByTestId(
- NetworkApprovalBottomSheetSelectorsIDs.APPROVE_BUTTON,
- );
- fireEvent.press(approveButton);
-
- const switchButton = getByTestId(
- NetworkAddedBottomSheetSelectorsIDs.SWITCH_NETWORK_BUTTON,
- );
-
- (
- Engine.context.NetworkController.addNetwork as jest.Mock
- ).mockResolvedValue({
- rpcEndpoints: [{ networkClientId: 'test-network-id' }],
- defaultRpcEndpointIndex: 0,
- });
-
- await act(async () => {
- fireEvent.press(switchButton);
- });
-
- expect(mockSelectNetwork).not.toHaveBeenCalled();
- });
-
- it('should call selectNetwork with correct chainId format when feature flag is enabled', async () => {
+ it('should call selectNetwork with correct chainId format', async () => {
const propsWithDifferentChainId = {
...props,
networkConfiguration: {
diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx
index b42643e2c5ba..95f90c9207da 100644
--- a/app/components/UI/NetworkModal/index.tsx
+++ b/app/components/UI/NetworkModal/index.tsx
@@ -6,10 +6,7 @@ import Text from '../../Base/Text';
import NetworkDetails from './NetworkDetails';
import NetworkAdded from './NetworkAdded';
import Engine from '../../../core/Engine';
-import {
- isPrivateConnection,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../util/networks';
+import { isPrivateConnection } from '../../../util/networks';
import { toggleUseSafeChainsListValidation } from '../../../util/networks/engineNetworkUtils';
import getDecimalChainId from '../../../util/networks/getDecimalChainId';
import URLPARSE from 'url-parse';
@@ -142,9 +139,7 @@ const NetworkModals = (props: NetworkProps) => {
};
const onUpdateNetworkFilter = useCallback(() => {
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- selectNetwork(chainId as `0x${string}`);
- }
+ selectNetwork(chainId as `0x${string}`);
}, [chainId, selectNetwork]);
const cancelButtonProps: ButtonProps = {
diff --git a/app/components/UI/PaymentRequest/index.js b/app/components/UI/PaymentRequest/index.js
index d46a8e34f183..f9d6b6de6ad1 100644
--- a/app/components/UI/PaymentRequest/index.js
+++ b/app/components/UI/PaymentRequest/index.js
@@ -46,11 +46,7 @@ import { getTicker } from '../../../util/transactions';
import { toLowerCaseEquals } from '../../../util/general';
import { utils as ethersUtils } from 'ethers';
import { ThemeContext, mockTheme } from '../../../util/theme';
-import {
- isTestNet,
- getDecimalChainId,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../util/networks';
+import { isTestNet, getDecimalChainId } from '../../../util/networks';
import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers';
import {
selectChainId,
@@ -934,15 +930,13 @@ class PaymentRequest extends PureComponent {
return (
- {isRemoveGlobalNetworkSelectorEnabled() && (
-
-
-
- )}
+
+
+
({
@@ -169,10 +167,6 @@ describe('PaymentRequest', () => {
});
it('renders correctly with network picker when feature flag is enabled', () => {
- jest
- .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled')
- .mockReturnValue(true);
-
const { toJSON } = renderComponent({
chainId: '0x1',
networkImageSource: ethLogo,
@@ -265,10 +259,6 @@ describe('PaymentRequest', () => {
describe('handleNetworkPickerPress', () => {
it('should navigate to network selector modal when feature flag is enabled', () => {
- jest
- .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled')
- .mockReturnValue(true);
-
const mockMetrics = {
trackEvent: jest.fn(),
createEventBuilder: jest.fn(() => ({
diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js
index 7c3501a77268..8be464b4648e 100644
--- a/app/components/UI/Swaps/QuotesView.js
+++ b/app/components/UI/Swaps/QuotesView.js
@@ -39,7 +39,6 @@ import {
isMainnetByChainId,
isMultiLayerFeeNetwork,
getDecimalChainId,
- isRemoveGlobalNetworkSelectorEnabled,
} from '../../../util/networks';
import { fetchEstimatedMultiLayerL1Fee } from '../../../util/networks/engineNetworkUtils';
import {
@@ -1169,9 +1168,7 @@ function SwapsQuotesView({
let approvalTransactionMetaId;
// Enable the network if it's not enabled for the Network Manager
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- tryEnableEvmNetwork(chainId);
- }
+ tryEnableEvmNetwork(chainId);
if (shouldUseSmartTransaction) {
try {
diff --git a/app/components/UI/Swaps/QuotesView.test.ts b/app/components/UI/Swaps/QuotesView.test.ts
index 236fa6a19a9b..5e805c61d836 100644
--- a/app/components/UI/Swaps/QuotesView.test.ts
+++ b/app/components/UI/Swaps/QuotesView.test.ts
@@ -23,7 +23,6 @@ import { useSwapsSmartTransaction } from './utils/useSwapsSmartTransaction';
import { query } from '@metamask/controller-utils';
import { TransactionStatus } from '@metamask/transaction-controller';
import { useNetworkEnablement } from '../../hooks/useNetworkEnablement/useNetworkEnablement';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks';
import { isHardwareAccount } from '../../../util/address';
jest.mock('../../../util/networks/global-network', () => ({
@@ -64,7 +63,6 @@ jest.mock('../../hooks/useNetworkEnablement/useNetworkEnablement', () => ({
jest.mock('../../../util/networks', () => ({
...jest.requireActual('../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(),
}));
jest.mock('../../../util/address', () => ({
@@ -342,7 +340,6 @@ describe('QuotesView', () => {
(useNetworkEnablement as jest.Mock).mockReturnValue({
tryEnableEvmNetwork: mockTryEnableEvmNetwork,
});
- (isRemoveGlobalNetworkSelectorEnabled as jest.Mock).mockReturnValue(true);
});
it('should render quote screen', async () => {
@@ -625,49 +622,5 @@ describe('QuotesView', () => {
expect(mockTryEnableEvmNetwork).toHaveBeenCalledWith('0x1');
});
});
-
- it('should not call tryEnableEvmNetwork when feature flag is disabled', async () => {
- (isRemoveGlobalNetworkSelectorEnabled as jest.Mock).mockReturnValue(
- false,
- );
-
- const state = merge({}, mockInitialState);
- jest.mocked(query).mockResolvedValueOnce(123).mockResolvedValueOnce({
- timestamp: 1234,
- });
- jest
- .spyOn(Engine.context.TransactionController, 'addTransaction')
- .mockResolvedValue({
- result: Promise.resolve('mock-tx-hash'),
- transactionMeta: {
- id: 'mock-id',
- networkClientId: 'mock-network-id',
- time: Date.now(),
- chainId: '0x1',
- status: 'submitted' as TransactionStatus,
- txParams: {
- from: '0x0',
- to: '0x1',
- value: '0x0',
- gas: '0x0',
- gasPrice: '0x0',
- },
- },
- });
-
- const wrapper = render(QuotesView, state);
-
- const swapButton = await wrapper.findByTestId(
- SwapsViewSelectorsIDs.SWAP_BUTTON,
- );
-
- act(() => {
- fireEvent.press(swapButton);
- });
-
- await waitFor(() => {
- expect(mockTryEnableEvmNetwork).not.toHaveBeenCalled();
- });
- });
});
});
diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js
index b79d63a09ea9..0cdcf84ab720 100644
--- a/app/components/UI/Swaps/index.js
+++ b/app/components/UI/Swaps/index.js
@@ -81,10 +81,7 @@ import { selectContractBalances } from '../../../selectors/tokenBalancesControll
import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController';
import AccountSelector from '../Ramp/Aggregator/components/AccountSelector';
import { QuoteViewSelectorIDs } from '../../../../e2e/selectors/swaps/QuoteView.selectors';
-import {
- getDecimalChainId,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../util/networks';
+import { getDecimalChainId } from '../../../util/networks';
import { useMetrics } from '../../../components/hooks/useMetrics';
import { getSwapsLiveness } from '../../../reducers/swaps/utils';
import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController';
@@ -723,13 +720,11 @@ function SwapsAmountView({
contentContainerStyle={styles.screen}
keyboardShouldPersistTaps="handled"
>
- {isRemoveGlobalNetworkSelectorEnabled() ? (
-
- ) : null}
+
diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js
index 145a93d362d7..8c094b290c2a 100644
--- a/app/components/UI/TransactionElement/index.js
+++ b/app/components/UI/TransactionElement/index.js
@@ -323,10 +323,7 @@ class TransactionElement extends PureComponent {
let incoming = false;
let selfSent = false;
- if (
- this.props.isMultichainAccountsState2Enabled &&
- process.env.MM_REMOVE_GLOBAL_NETWORK_SELECTOR === 'true'
- ) {
+ if (this.props.isMultichainAccountsState2Enabled) {
const selectedAddresses = selectSelectedAccountGroupInternalAccounts.map(
(account) => account.address,
);
diff --git a/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.test.tsx b/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.test.tsx
index ffddca6e15ae..3132d1170a4d 100644
--- a/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.test.tsx
+++ b/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.test.tsx
@@ -18,12 +18,9 @@ import {
} from '../../../../selectors/networkController';
import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks';
import { Hex } from '@metamask/utils';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../util/networks';
-// Mock the feature flags
jest.mock('../../../../util/networks', () => ({
...jest.requireActual('../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(),
getNetworkImageSource: jest.fn(),
mainnet: {
name: 'Ethereum Main Network',
@@ -31,11 +28,6 @@ jest.mock('../../../../util/networks', () => ({
}));
const { PreferencesController, NetworkController } = Engine.context;
-const mockIsRemoveGlobalNetworkSelectorEnabled =
- isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction<
- typeof isRemoveGlobalNetworkSelectorEnabled
- >;
-
const MOCK_STORE_STATE = {
engine: {
backgroundState: {
@@ -75,7 +67,7 @@ const MOCK_STORE_STATE = {
decimals: 18,
},
},
- '0xtest': {
+ '0x999': {
rpcEndpoints: [
{
url: 'https://test.infura.io/v3/{infuraProjectId}',
@@ -84,10 +76,10 @@ const MOCK_STORE_STATE = {
],
defaultRpcEndpointIndex: 0,
blockExplorerUrls: ['https://lineascan.io'],
- chainId: '0xtest',
+ chainId: '0x999',
name: 'Test Mainnet',
nativeCurrency: {
- name: 'Linea Ether',
+ name: 'Test Ether',
symbol: 'ETH',
decimals: 18,
},
@@ -261,9 +253,6 @@ describe('RpcSelectionModal', () => {
}
return null;
});
-
- // Reset feature flag mock
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
afterEach(() => {
jest.clearAllMocks();
@@ -375,7 +364,7 @@ describe('RpcSelectionModal', () => {
{...defaultProps}
showMultiRpcSelectModal={{
isVisible: true,
- chainId: '0xtest',
+ chainId: '0x999',
networkName: 'Test Mainnet',
}}
/>,
@@ -387,75 +376,30 @@ describe('RpcSelectionModal', () => {
);
});
- describe('Feature Flag: isRemoveGlobalNetworkSelectorEnabled', () => {
- // Common test configurations
- const renderAndPressRpc = () => {
+ describe('Network Manager Integration', () => {
+ it('calls updateNetwork when RPC is selected', () => {
const { getByText } = renderWithProvider(
,
);
const rpcUrlElement = getByText('mainnet.infura.io/v3');
- fireEvent.press(rpcUrlElement);
- return { getByText };
- };
-
- const verifyControllersAvailable = () => {
- expect(NetworkController.updateNetwork).toBeDefined();
- expect(PreferencesController.setTokenNetworkFilter).toBeDefined();
- };
-
- describe('when feature flag is enabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
- it('should call selectNetwork', () => {
- renderAndPressRpc();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(NetworkController.updateNetwork).toHaveBeenCalled();
- expect(defaultProps.closeRpcModal).toHaveBeenCalled();
- });
+ fireEvent.press(rpcUrlElement);
- it('should have proper hook setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- verifyControllersAvailable();
- });
+ expect(NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(defaultProps.closeRpcModal).toHaveBeenCalled();
});
- describe('when feature flag is disabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- });
-
- it('should not call selectNetwork', () => {
- renderAndPressRpc();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(NetworkController.updateNetwork).toHaveBeenCalled();
- expect(defaultProps.closeRpcModal).toHaveBeenCalled();
- });
-
- it('should have proper hook setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- verifyControllersAvailable();
- });
+ it('initializes with Engine controllers available', () => {
+ expect(NetworkController.updateNetwork).toBeDefined();
+ expect(PreferencesController.setTokenNetworkFilter).toBeDefined();
});
- });
- describe('Hook Configuration', () => {
- it('should properly initialize hooks with default values', () => {
+ it('renders with default props', () => {
const { getByText } = renderWithProvider(
,
);
- // Verify that the component renders correctly
expect(getByText('Mainnet')).toBeTruthy();
});
-
- it('should have all necessary Engine controllers available', () => {
- // Verify that all necessary controllers are available
- expect(NetworkController.updateNetwork).toBeDefined();
- expect(PreferencesController.setTokenNetworkFilter).toBeDefined();
- });
});
});
diff --git a/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx b/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx
index f8752f8af80c..ee63cdf34b3b 100644
--- a/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx
+++ b/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx
@@ -16,10 +16,7 @@ import {
AvatarVariant,
} from '../../../../component-library/components/Avatars/Avatar';
import { TextVariant } from '../../../../component-library/components/Texts/Text';
-import Networks, {
- getNetworkImageSource,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../../util/networks';
+import Networks, { getNetworkImageSource } from '../../../../util/networks';
import { strings } from '../../../../../locales/i18n';
import { CHAIN_IDS } from '@metamask/transaction-controller';
import images from 'images/image-icons';
@@ -121,10 +118,8 @@ const RpcSelectionModal: FC = ({
[chainId]: true,
});
}
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- const caipChainId = formatChainIdToCaip(chainId);
- selectNetwork(caipChainId);
- }
+ const caipChainId = formatChainIdToCaip(chainId);
+ selectNetwork(caipChainId);
},
[isAllNetwork, selectNetwork],
);
@@ -145,9 +140,7 @@ const RpcSelectionModal: FC = ({
(networkClientId: string, chainIdArg: `0x${string}`) => {
onRpcSelect(networkClientId, chainIdArg);
setTokenNetworkFilter(chainIdArg);
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- selectNetwork(chainIdArg);
- }
+ selectNetwork(chainIdArg);
closeRpcModal();
},
[onRpcSelect, setTokenNetworkFilter, closeRpcModal, selectNetwork],
diff --git a/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap
index 270208acf8f1..e419f3b49476 100644
--- a/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap
@@ -265,6 +265,120 @@ exports[`ContactForm renders correctly 1`] = `
/>
+
+ Network
+
+
+
+
+
+
+
+
+
+
+
+
- {((editable &&
- !isRemoveGlobalNetworkSelectorFeatureFlagEnabled) ||
- isAddMode) && (
+ {isAddMode && (
- {isRemoveGlobalNetworkSelectorFeatureFlagEnabled && (
- <>
-
- {strings('address_book.network')}
-
- {
- if (this.state.editable) {
- this.setOpenNetworkSelector(true);
- }
- }}
- onLongPress={() => {
- if (this.state.editable) {
- this.setOpenNetworkSelector(true);
- }
- }}
- testID={AddContactViewSelectorsIDs.NETWORK_INPUT}
- >
-
-
-
- {networkName}
-
-
- {!!editable && (
- {
- if (this.state.editable) {
- this.setOpenNetworkSelector(true);
- }
- }}
- accessibilityRole="button"
- style={styles.buttonIcon}
- />
- )}
-
- >
- )}
+ <>
+
+ {strings('address_book.network')}
+
+ {
+ if (this.state.editable) {
+ this.setOpenNetworkSelector(true);
+ }
+ }}
+ onLongPress={() => {
+ if (this.state.editable) {
+ this.setOpenNetworkSelector(true);
+ }
+ }}
+ testID={AddContactViewSelectorsIDs.NETWORK_INPUT}
+ >
+
+
+
+ {networkName}
+
+
+ {!!editable && (
+ {
+ if (this.state.editable) {
+ this.setOpenNetworkSelector(true);
+ }
+ }}
+ accessibilityRole="button"
+ style={styles.buttonIcon}
+ />
+ )}
+
+ >
{addressError && (
diff --git a/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx b/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx
index abae7975c19f..a2ab9202c338 100644
--- a/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx
+++ b/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx
@@ -11,10 +11,6 @@ const MOCK_ADDRESS = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272';
const MOCK_ADDRESS_2 = '0xf55C0d639d99699bFd7EC54d9FAFee40E4d272C4';
const ENS_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12';
-const mockIsRemoveGlobalNetworkSelectorEnabled = jest
- .fn()
- .mockReturnValue(false);
-
jest.mock('../../../../../util/address', () => ({
...jest.requireActual('../../../../../util/address'),
renderShortAddress: jest.fn((address) => `0x123...${address.slice(-4)}`),
@@ -33,8 +29,6 @@ jest.mock('../../../../../util/address', () => ({
jest.mock('../../../../../util/networks', () => ({
...jest.requireActual('../../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: () =>
- mockIsRemoveGlobalNetworkSelectorEnabled(),
getNetworkImageSource: jest.fn(() => ({ uri: 'mock-image-uri' })),
}));
@@ -135,7 +129,6 @@ const renderContactForm = (
describe('ContactForm', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
it('renders correctly', () => {
@@ -261,9 +254,7 @@ describe('ContactForm', () => {
});
});
- it('handles network selection when isRemoveGlobalNetworkSelectorEnabled is true', async () => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
-
+ it('handles network selection', async () => {
const { findByTestId } = renderContactForm();
await waitFor(() => {
@@ -353,7 +344,7 @@ describe('ContactForm', () => {
await waitFor(() => {
expect(addressInput.props.value).toBe(MOCK_ADDRESS);
- expect(addressInput.props.editable).toBeTruthy();
+ expect(addressInput.props.editable).toBeFalsy(); // Address is immutable in edit mode
expect(nameInput.props.editable).toBeTruthy();
expect(memoInput.props.editable).toBeTruthy();
});
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
index 0e49cbc92bc6..6d193578295e 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
@@ -90,7 +90,6 @@ import {
addItemToChainIdList,
removeItemFromChainIdList,
} from '../../../../../util/metrics/MultichainAPI/networkMetricUtils';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks';
import { NETWORK_TO_NAME_MAP } from '../../../../../core/Engine/constants';
import { createStyles } from './index.styles';
@@ -701,10 +700,8 @@ export class NetworkSettings extends PureComponent {
});
}
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- const { NetworkEnablementController } = Engine.context;
- NetworkEnablementController.enableNetwork(chainId);
- }
+ const { NetworkEnablementController } = Engine.context;
+ NetworkEnablementController.enableNetwork(chainId);
await this.handleNetworkUpdate({
rpcUrl,
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
index b2cc6d34a4bb..be2de00f4ddf 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
@@ -23,7 +23,6 @@ import { mockNetworkState } from '../../../../../util/test/network';
import * as jsonRequest from '../../../../../util/jsonRpcRequest';
import Logger from '../../../../../util/Logger';
import Engine from '../../../../../core/Engine';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
const { PreferencesController } = Engine.context;
@@ -68,12 +67,9 @@ jest.mock('../../../../../components/hooks/useMetrics', () => ({
// Mock the feature flag
jest.mock('../../../../../util/networks', () => {
const mockGetAllNetworks = jest.fn(() => ['mainnet', 'sepolia']);
- const mockIsRemoveGlobalNetworkSelectorEnabled = jest.fn();
return {
...jest.requireActual('../../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled:
- mockIsRemoveGlobalNetworkSelectorEnabled,
getAllNetworks: mockGetAllNetworks,
mainnet: {
name: 'Ethereum Main Network',
@@ -2133,33 +2129,23 @@ describe('NetworkSettings', () => {
});
});
- describe('Feature Flag: isRemoveGlobalNetworkSelectorEnabled', () => {
- const mockIsRemoveGlobalNetworkSelectorEnabled =
- isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction<
- typeof isRemoveGlobalNetworkSelectorEnabled
- >;
-
- beforeEach(() => {
- // After feature flag removal, always returns true
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
-
- it('should call NetworkEnablementController.enableNetwork when adding a network', async () => {
+ describe('Network Manager Integration', () => {
+ it('calls NetworkEnablementController.enableNetwork when adding a network', async () => {
const { NetworkEnablementController } = Engine.context;
const enableNetworkSpy = jest.spyOn(
NetworkEnablementController,
'enableNetwork',
);
- // Mock validateChainIdOnSubmit to return true so it doesn't return early
- jest
- .spyOn(wrapper.instance(), 'validateChainIdOnSubmit')
- .mockResolvedValue(true);
+ const instance = wrapper.instance();
- // Mock handleNetworkUpdate to prevent actual network addition
+ jest.spyOn(instance, 'disabledByChainId').mockReturnValue(false);
+ jest.spyOn(instance, 'disabledBySymbol').mockReturnValue(false);
jest
- .spyOn(wrapper.instance(), 'handleNetworkUpdate')
- .mockResolvedValue({});
+ .spyOn(instance, 'checkIfNetworkNotExistsByChainId')
+ .mockResolvedValue([]);
+ jest.spyOn(instance, 'validateChainIdOnSubmit').mockResolvedValue(true);
+ jest.spyOn(instance, 'handleNetworkUpdate').mockResolvedValue({});
wrapper.setState({
rpcUrl: 'http://localhost:8545',
@@ -2173,19 +2159,13 @@ describe('NetworkSettings', () => {
blockExplorerUrls: [],
});
- await wrapper.instance().addRpcUrl();
-
- // Verify that the feature flag is enabled
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
+ await instance.addRpcUrl();
// Verify that enableNetwork was called with the correct chainId
expect(enableNetworkSpy).toHaveBeenCalledWith('0x1');
});
it('should have proper Engine controller setup', () => {
- // Verify that the feature flag is enabled
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
-
// Verify that the necessary controllers are available
expect(
Engine.context.NetworkEnablementController.enableNetwork,
@@ -2193,38 +2173,6 @@ describe('NetworkSettings', () => {
expect(Engine.context.NetworkController.addNetwork).toBeDefined();
expect(Engine.context.NetworkController.updateNetwork).toBeDefined();
});
-
- it('should not call NetworkEnablementController.enableNetwork when feature flag is disabled (legacy test)', async () => {
- // Temporarily mock the feature flag as disabled for this legacy test
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
-
- const { NetworkEnablementController } = Engine.context;
- const setEnabledNetworkSpy = jest.spyOn(
- NetworkEnablementController,
- 'enableNetwork',
- );
-
- wrapper.setState({
- rpcUrl: 'http://localhost:8545',
- chainId: '0x1',
- ticker: 'ETH',
- nickname: 'Localhost',
- enableAction: true,
- addMode: true,
- editable: false,
- });
-
- await wrapper.instance().addRpcUrl();
-
- // Verify that the feature flag is disabled
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
-
- // Verify that setEnabledNetwork was not called
- expect(setEnabledNetworkSpy).not.toHaveBeenCalled();
-
- // Reset for other tests
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
});
});
diff --git a/app/components/Views/TransactionsView/index.js b/app/components/Views/TransactionsView/index.js
index 5b37954eb4ca..9843e6b78fee 100644
--- a/app/components/Views/TransactionsView/index.js
+++ b/app/components/Views/TransactionsView/index.js
@@ -20,12 +20,7 @@ import {
} from '../../../util/activity';
import { areAddressesEqual } from '../../../util/address';
import { addAccountTimeFlagFilter } from '../../../util/transactions';
-import {
- selectChainId,
- selectIsPopularNetwork,
- selectProviderType,
- selectSelectedNetworkClientId,
-} from '../../../selectors/networkController';
+import { selectProviderType } from '../../../selectors/networkController';
import {
selectConversionRate,
selectCurrentCurrency,
@@ -42,10 +37,6 @@ import { selectNonEvmTransactions } from '../../../selectors/multichain';
import { isEvmAccountType } from '@metamask/keyring-api';
///: END:ONLY_INCLUDE_IF
import { toChecksumHexAddress } from '@metamask/controller-utils';
-import { selectTokenNetworkFilter } from '../../../selectors/preferencesController';
-import { CHAIN_IDS } from '@metamask/transaction-controller';
-import { PopularList } from '../../../util/networks/customNetworks';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks';
import useCurrencyRatePolling from '../../hooks/AssetPolling/useCurrencyRatePolling';
import useTokenRatesPolling from '../../hooks/AssetPolling/useTokenRatesPolling';
import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController';
@@ -63,7 +54,6 @@ const TransactionsView = ({
networkType,
currentCurrency,
transactions,
- chainId,
tokens,
tokenNetworkFilter,
}) => {
@@ -71,7 +61,6 @@ const TransactionsView = ({
const [submittedTxs, setSubmittedTxs] = useState([]);
const [confirmedTxs, setConfirmedTxs] = useState([]);
const [loading, setLoading] = useState();
- const selectedNetworkClientId = useSelector(selectSelectedNetworkClientId);
const bridgeHistory = useSelector(selectBridgeHistoryForAccount);
const enabledNetworksByNamespace = useSelector(
@@ -85,162 +74,127 @@ const TransactionsView = ({
selectedInternalAccount?.address,
);
- const isPopularNetwork = useSelector(selectIsPopularNetwork);
+ const filterTransactions = useCallback(() => {
+ let accountAddedTimeInsertPointFound = false;
+ const addedAccountTime = selectedInternalAccount?.metadata.importTime;
- const filterTransactions = useCallback(
- (networkId) => {
- let accountAddedTimeInsertPointFound = false;
- const addedAccountTime = selectedInternalAccount?.metadata.importTime;
+ const submittedTxs = [];
+ const confirmedTxs = [];
+ const submittedNonces = [];
- const submittedTxs = [];
- const confirmedTxs = [];
- const submittedNonces = [];
+ const allTransactionsSorted = sortTransactions(transactions).filter(
+ (tx, index, self) => self.findIndex((_tx) => _tx.id === tx.id) === index,
+ );
- const allTransactionsSorted = sortTransactions(transactions).filter(
- (tx, index, self) =>
- self.findIndex((_tx) => _tx.id === tx.id) === index,
+ const allTransactions = allTransactionsSorted.filter((tx) => {
+ const filter = filterByAddressAndNetwork(
+ tx,
+ tokens,
+ selectedAddress,
+ tokenNetworkFilter,
+ allTransactionsSorted,
+ bridgeHistory,
);
- const allTransactions = allTransactionsSorted.filter((tx) => {
- const filter = filterByAddressAndNetwork(
- tx,
- tokens,
- selectedAddress,
- tokenNetworkFilter,
- allTransactionsSorted,
- bridgeHistory,
- );
+ if (!filter) return false;
- if (!filter) return false;
+ const insertImportTime = addAccountTimeFlagFilter(
+ tx,
+ addedAccountTime,
+ accountAddedTimeInsertPointFound,
+ );
- const insertImportTime = addAccountTimeFlagFilter(
- tx,
- addedAccountTime,
- accountAddedTimeInsertPointFound,
- );
+ // Create a new transaction object with the insertImportTime property
+ const updatedTx = {
+ ...tx,
+ insertImportTime,
+ };
+
+ if (updatedTx.insertImportTime) accountAddedTimeInsertPointFound = true;
+
+ switch (tx.status) {
+ case TX_SUBMITTED:
+ case TX_SIGNED:
+ case TX_UNAPPROVED:
+ case TX_PENDING:
+ submittedTxs.push(updatedTx);
+ return false;
+ case TX_CONFIRMED:
+ confirmedTxs.push(updatedTx);
+ break;
+ }
- // Create a new transaction object with the insertImportTime property
- const updatedTx = {
- ...tx,
- insertImportTime,
- };
-
- if (updatedTx.insertImportTime) accountAddedTimeInsertPointFound = true;
-
- switch (tx.status) {
- case TX_SUBMITTED:
- case TX_SIGNED:
- case TX_UNAPPROVED:
- case TX_PENDING:
- submittedTxs.push(updatedTx);
- return false;
- case TX_CONFIRMED:
- confirmedTxs.push(updatedTx);
- break;
+ return filter;
+ });
+
+ // TODO: Make sure to come back and check on how Solana transactions are handled
+ const allTransactionsFiltered = allTransactions.filter((tx) => {
+ const enabledChainIds = Object.entries(
+ enabledNetworksByNamespace?.[KnownCaipNamespace.Eip155] ?? {},
+ )
+ .filter(([, enabled]) => enabled)
+ .map(([chainId]) => chainId);
+
+ return isTransactionOnChains(tx, enabledChainIds, allTransactionsSorted);
+ });
+
+ const submittedTxsFiltered = submittedTxs.filter(
+ ({ chainId, txParams }) => {
+ const { from, nonce } = txParams;
+
+ if (!areAddressesEqual(from, selectedAddress)) {
+ return false;
}
- return filter;
- });
-
- let allTransactionsFiltered;
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- // TODO: Make sure to come back and check on how Solana transactions are handled
- allTransactionsFiltered = allTransactions.filter((tx) => {
- const enabledChainIds = Object.entries(
- enabledNetworksByNamespace?.[KnownCaipNamespace.Eip155] ?? {},
- )
- .filter(([, enabled]) => enabled)
- .map(([chainId]) => chainId);
-
- return isTransactionOnChains(
- tx,
- enabledChainIds,
- allTransactionsSorted,
- );
- });
- } else {
- allTransactionsFiltered = isPopularNetwork
- ? allTransactions.filter((tx) => {
- const popularChainIds = [
- CHAIN_IDS.MAINNET,
- CHAIN_IDS.LINEA_MAINNET,
- ...PopularList.map((n) => n.chainId),
- ];
- return isTransactionOnChains(
- tx,
- popularChainIds,
- allTransactions,
- );
- })
- : allTransactions.filter((tx) =>
- isTransactionOnChains(tx, [chainId], allTransactionsSorted),
- );
- }
+ const nonceKey = `${chainId}-${nonce}`;
+ const alreadySubmitted = submittedNonces.includes(nonceKey);
+ const alreadyConfirmed = confirmedTxs.find(
+ (tx) =>
+ areAddressesEqual(tx.txParams.from, selectedAddress) &&
+ tx.chainId === chainId &&
+ tx.txParams.nonce === nonce,
+ );
- const submittedTxsFiltered = submittedTxs.filter(
- ({ chainId, txParams }) => {
- const { from, nonce } = txParams;
-
- if (!areAddressesEqual(from, selectedAddress)) {
- return false;
- }
-
- const nonceKey = `${chainId}-${nonce}`;
- const alreadySubmitted = submittedNonces.includes(nonceKey);
- const alreadyConfirmed = confirmedTxs.find(
- (tx) =>
- areAddressesEqual(tx.txParams.from, selectedAddress) &&
- tx.chainId === chainId &&
- tx.txParams.nonce === nonce,
- );
-
- if (alreadyConfirmed) {
- return false;
- }
-
- submittedNonces.push(nonceKey);
- return !alreadySubmitted;
- },
- );
+ if (alreadyConfirmed) {
+ return false;
+ }
- // If the account added insert point is not found, add it to the last transaction
- if (
- !accountAddedTimeInsertPointFound &&
- allTransactionsFiltered &&
- allTransactionsFiltered.length
- ) {
- const lastIndex = allTransactionsFiltered.length - 1;
- allTransactionsFiltered[lastIndex] = {
- ...allTransactionsFiltered[lastIndex],
- insertImportTime: true,
- };
- }
+ submittedNonces.push(nonceKey);
+ return !alreadySubmitted;
+ },
+ );
- setAllTransactions(allTransactionsFiltered);
- setSubmittedTxs(submittedTxsFiltered);
- setConfirmedTxs(confirmedTxs);
- setLoading(false);
- },
- [
- transactions,
- selectedInternalAccount,
- selectedAddress,
- tokens,
- chainId,
- tokenNetworkFilter,
- isPopularNetwork,
- enabledNetworksByNamespace,
- bridgeHistory,
- ],
- );
+ // If the account added insert point is not found, add it to the last transaction
+ if (
+ !accountAddedTimeInsertPointFound &&
+ allTransactionsFiltered &&
+ allTransactionsFiltered.length
+ ) {
+ const lastIndex = allTransactionsFiltered.length - 1;
+ allTransactionsFiltered[lastIndex] = {
+ ...allTransactionsFiltered[lastIndex],
+ insertImportTime: true,
+ };
+ }
+
+ setAllTransactions(allTransactionsFiltered);
+ setSubmittedTxs(submittedTxsFiltered);
+ setConfirmedTxs(confirmedTxs);
+ setLoading(false);
+ }, [
+ transactions,
+ selectedInternalAccount,
+ selectedAddress,
+ tokens,
+ tokenNetworkFilter,
+ enabledNetworksByNamespace,
+ bridgeHistory,
+ ]);
useEffect(() => {
setLoading(true);
-
- if (selectedNetworkClientId) {
- filterTransactions(selectedNetworkClientId);
- }
- }, [filterTransactions, selectedNetworkClientId]);
+ filterTransactions();
+ }, [filterTransactions]);
return (
@@ -288,10 +242,6 @@ TransactionsView.propTypes = {
* Array of ERC20 assets
*/
tokens: PropTypes.array,
- /**
- * Current chainId
- */
- chainId: PropTypes.string,
/**
* Array of network tokens filter
*/
@@ -299,7 +249,6 @@ TransactionsView.propTypes = {
};
const mapStateToProps = (state) => {
- const chainId = selectChainId(state);
const selectedInternalAccount = selectSelectedInternalAccount(state);
const evmTransactions = selectSortedTransactions(state);
@@ -325,13 +274,10 @@ const mapStateToProps = (state) => {
selectedInternalAccount,
transactions: allTransactions,
networkType: selectProviderType(state),
- chainId,
- tokenNetworkFilter: isRemoveGlobalNetworkSelectorEnabled()
- ? selectEVMEnabledNetworks(state).reduce(
- (acc, network) => ({ ...acc, [network]: true }),
- {},
- )
- : selectTokenNetworkFilter(state),
+ tokenNetworkFilter: selectEVMEnabledNetworks(state).reduce(
+ (acc, network) => ({ ...acc, [network]: true }),
+ {},
+ ),
};
};
diff --git a/app/components/Views/TransactionsView/index.test.tsx b/app/components/Views/TransactionsView/index.test.tsx
index 36d1f5a7a488..265d555d03ad 100644
--- a/app/components/Views/TransactionsView/index.test.tsx
+++ b/app/components/Views/TransactionsView/index.test.tsx
@@ -85,11 +85,7 @@ jest.mock('../../../selectors/multichain', () => ({
})),
}));
-const mockSelectIsPopularNetwork = jest.fn(() => false);
-
jest.mock('../../../selectors/networkController', () => ({
- selectChainId: jest.fn(() => '0x1'),
- selectIsPopularNetwork: jest.fn(() => false),
selectProviderType: jest.fn(() => 'mainnet'),
selectSelectedNetworkClientId: jest.fn(() => 'selectedNetworkClientId'),
}));
@@ -156,19 +152,6 @@ jest.mock('@metamask/keyring-api', () => ({
isEvmAccountType: jest.fn(() => true),
}));
-jest.mock('../../../util/networks/customNetworks', () => ({
- PopularList: [
- { chainId: '0x89' }, // Polygon
- { chainId: '0xa4b1' }, // Arbitrum
- ],
-}));
-
-const mockIsRemoveGlobalNetworkSelectorEnabled = jest.fn(() => false);
-
-jest.mock('../../../util/networks', () => ({
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false),
-}));
-
jest.mock('../../UI/Transactions', () => jest.fn());
jest.mock('../../../core/Engine', () => ({
@@ -499,34 +482,6 @@ describe('TransactionsView', () => {
expect(filterByAddressAndNetwork).toHaveBeenCalledTimes(2);
});
- it('filters transactions by popular networks when enabled', async () => {
- const mockTransactions = [
- createMockTransaction({ id: 'tx-1', chainId: '0x1' }), // Mainnet
- createMockTransaction({ id: 'tx-2', chainId: '0xe708' }), // Linea
- createMockTransaction({ id: 'tx-3', chainId: '0x89' }), // Polygon
- createMockTransaction({ id: 'tx-4', chainId: '0x999' }), // Unknown chain
- ];
-
- (
- selectSortedTransactions as jest.MockedFunction<
- typeof selectSortedTransactions
- >
- ).mockReturnValue(mockTransactions);
- (
- selectSelectedInternalAccount as jest.MockedFunction<
- typeof selectSelectedInternalAccount
- >
- ).mockReturnValue(createMockAccount());
-
- renderTransactionsView();
-
- act(() => {
- jest.runAllTimers();
- });
-
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
- });
-
it('handles submitted transactions filtering', async () => {
const mockTransactions = [
createMockTransaction({
@@ -829,7 +784,7 @@ describe('TransactionsView', () => {
});
});
- describe('Feature Flag: isRemoveGlobalNetworkSelectorEnabled', () => {
+ describe('Network Filtering', () => {
// Common test configurations
const createMockTransactions = (
transactions: { id: string; chainId: string }[],
@@ -858,103 +813,45 @@ describe('TransactionsView', () => {
});
};
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- });
-
- describe('when feature flag is enabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
-
- it('should filter transactions based on enabledNetworksByNamespace', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' }, // Enabled network
- { id: 'tx-2', chainId: '0x89' }, // Disabled network
- { id: 'tx-3', chainId: '0xa4b1' }, // Disabled network
- ]);
-
- setupSelectors(mockTransactions);
- mockSelectEnabledNetworksByNamespace.mockReturnValue({
- eip155: {
- '0x1': true, // Only mainnet is enabled
- },
- });
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
+ it('should filter transactions based on enabledNetworksByNamespace', async () => {
+ const mockTransactions = createMockTransactions([
+ { id: 'tx-1', chainId: '0x1' }, // Enabled network
+ { id: 'tx-2', chainId: '0x89' }, // Disabled network
+ { id: 'tx-3', chainId: '0xa4b1' }, // Disabled network
+ ]);
+
+ setupSelectors(mockTransactions);
+ mockSelectEnabledNetworksByNamespace.mockReturnValue({
+ eip155: {
+ '0x1': true, // Only mainnet is enabled
+ },
});
- it('should handle empty enabledNetworksByNamespace gracefully', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' },
- { id: 'tx-2', chainId: '0x89' },
- ]);
-
- setupSelectors(mockTransactions);
- mockSelectEnabledNetworksByNamespace.mockReturnValue({
- eip155: {
- '0x1': false, // No enabled networks
- },
- });
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
- });
+ runTestWithTimers();
- it('should have proper selector setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(mockSelectEnabledNetworksByNamespace).toBeDefined();
- expect(mockSelectIsPopularNetwork).toBeDefined();
- });
+ expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
});
- describe('when feature flag is disabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- });
-
- it('should use popular network filtering when isPopularNetwork is true', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' }, // Mainnet
- { id: 'tx-2', chainId: '0xe708' }, // Linea
- { id: 'tx-3', chainId: '0x89' }, // Polygon (in PopularList)
- { id: 'tx-4', chainId: '0x999' }, // Unknown chain
- ]);
+ it('should handle empty enabledNetworksByNamespace gracefully', async () => {
+ const mockTransactions = createMockTransactions([
+ { id: 'tx-1', chainId: '0x1' },
+ { id: 'tx-2', chainId: '0x89' },
+ ]);
- setupSelectors(mockTransactions);
- mockSelectIsPopularNetwork.mockReturnValue(true);
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
+ setupSelectors(mockTransactions);
+ mockSelectEnabledNetworksByNamespace.mockReturnValue({
+ eip155: {
+ '0x1': false, // No enabled networks
+ },
});
- it('should use chainId filtering when isPopularNetwork is false', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' }, // Current chainId
- { id: 'tx-2', chainId: '0x89' }, // Different chainId
- ]);
+ runTestWithTimers();
- setupSelectors(mockTransactions);
- mockSelectIsPopularNetwork.mockReturnValue(false);
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
- });
+ expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
+ });
- it('should still have proper selector setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(mockSelectEnabledNetworksByNamespace).toBeDefined();
- expect(mockSelectIsPopularNetwork).toBeDefined();
- });
+ it('should have proper selector setup', () => {
+ expect(mockSelectEnabledNetworksByNamespace).toBeDefined();
});
});
});
diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx
index a0b1bb6101f7..3b88652f62c2 100644
--- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx
+++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx
@@ -92,8 +92,6 @@ jest.mock(
}),
);
jest.mock('../../../selectors/networkController', () => ({
- selectChainId: jest.fn(),
- selectIsPopularNetwork: jest.fn(),
selectEvmNetworkConfigurationsByChainId: jest.fn(),
selectNetworkConfigurations: jest.fn(),
selectProviderType: jest.fn(),
@@ -120,7 +118,6 @@ jest.mock('../../../util/transactions', () => ({
jest.mock('../../../util/networks', () => ({
__esModule: true,
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false),
findBlockExplorerForRpc: jest.fn(() => 'https://explorer.example'),
getBlockExplorerAddressUrl: jest.fn(),
}));
@@ -302,8 +299,6 @@ const { selectTokens } = jest.requireMock(
'../../../selectors/tokensController',
);
const {
- selectChainId,
- selectIsPopularNetwork,
selectEvmNetworkConfigurationsByChainId,
selectNetworkConfigurations,
selectProviderType,
@@ -319,7 +314,6 @@ const { updateIncomingTransactions } = jest.requireMock(
'../../../util/transaction-controller',
);
const networksMock = jest.requireMock('../../../util/networks');
-const { isRemoveGlobalNetworkSelectorEnabled } = networksMock;
describe('UnifiedTransactionsView', () => {
const mockUseSelector = useSelector as unknown as jest.Mock;
@@ -341,7 +335,6 @@ describe('UnifiedTransactionsView', () => {
url: 'https://explorer.example/address/0xabc',
title: 'explorer.example',
}));
- isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
mockUseSelector.mockImplementation((selector: unknown) => {
if (selector === selectSortedEVMTransactionsForSelectedAccountGroup)
@@ -359,8 +352,6 @@ describe('UnifiedTransactionsView', () => {
},
];
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEvmNetworkConfigurationsByChainId)
return {
'0x1': {
@@ -418,8 +409,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEVMEnabledNetworks) return ['0x1'];
if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet'];
if (selector === selectCurrentCurrency) return 'USD';
@@ -462,8 +451,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEVMEnabledNetworks) return ['0x1'];
if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet'];
if (selector === selectCurrentCurrency) return 'USD';
@@ -491,8 +478,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEvmNetworkConfigurationsByChainId)
return {
'0x1': {
@@ -524,8 +509,7 @@ describe('UnifiedTransactionsView', () => {
});
describe('block explorer url', () => {
- it('uses selected chain block explorer when global selector is enabled with a single chain', () => {
- isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
+ it('uses selected chain block explorer when a single chain is enabled', () => {
mockUseSelector.mockImplementation((selector: unknown) => {
if (selector === selectSortedEVMTransactionsForSelectedAccountGroup)
return [];
@@ -536,8 +520,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEvmNetworkConfigurationsByChainId)
return {
'0x5': {
@@ -573,15 +555,9 @@ describe('UnifiedTransactionsView', () => {
'https://explorer1.example',
);
expect(networksMock.getBlockExplorerAddressUrl).toHaveBeenCalledTimes(1);
- isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
- it('omits block explorer when multiple EVM chains are selected with global selector enabled', () => {
- isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- networksMock.getBlockExplorerAddressUrl.mockImplementationOnce(() => ({
- url: undefined,
- title: 'explorer.example',
- }));
+ it('omits block explorer when multiple EVM chains are selected', () => {
mockUseSelector.mockImplementation((selector: unknown) => {
if (selector === selectSortedEVMTransactionsForSelectedAccountGroup)
return [];
@@ -592,8 +568,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEvmNetworkConfigurationsByChainId)
return {
'0x1': {
@@ -623,13 +597,12 @@ describe('UnifiedTransactionsView', () => {
rpcBlockExplorer?: string;
onViewBlockExplorer?: () => void;
};
+
+ // When multiple chains are selected, block explorer should be omitted
expect(footerProps.rpcBlockExplorer).toBeUndefined();
- footerProps.onViewBlockExplorer?.();
- // When configBlockExplorerUrl is undefined (multiple chains case),
- // the component uses blockExplorerUrl directly without calling getBlockExplorerAddressUrl
+ // Block explorer address URL should not be called since no single chain is selected
expect(networksMock.getBlockExplorerAddressUrl).not.toHaveBeenCalled();
- isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
});
@@ -654,8 +627,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectNetworkConfigurations) return {};
if (selector === selectProviderType) return 'rpc';
if (selector === selectRpcUrl) return 'https://rpc.example';
@@ -702,9 +673,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: 'bc1abcd', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId)
- return 'bip122:000000000019d6689c085ae165831e93';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectNetworkConfigurations) return {};
if (selector === selectProviderType) return 'rpc';
if (selector === selectRpcUrl) return 'https://rpc.example';
@@ -746,8 +714,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectProviderConfig)
return { type: 'rpc', rpcUrl: 'https://rpc.example' };
if (selector === selectEVMEnabledNetworks) return [];
@@ -782,8 +748,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectProviderConfig)
return { type: 'rpc', rpcUrl: 'https://rpc.example' };
if (selector === selectEVMEnabledNetworks) return [];
@@ -829,8 +793,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEVMEnabledNetworks) return ['0x1'];
if (selector === selectNonEVMEnabledNetworks)
return ['solana:mainnet'];
@@ -866,8 +828,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEVMEnabledNetworks) return ['0x1'];
if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet'];
if (selector === selectCurrentCurrency) return 'USD';
@@ -901,8 +861,6 @@ describe('UnifiedTransactionsView', () => {
if (selector === selectSelectedInternalAccount)
return { address: '0xabc', metadata: { importTime: 0 } };
if (selector === selectTokens) return [];
- if (selector === selectChainId) return '0x1';
- if (selector === selectIsPopularNetwork) return false;
if (selector === selectEVMEnabledNetworks) return ['0x1'];
if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet'];
if (selector === selectCurrentCurrency) return 'USD';
diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx
index 7dfbd2651c7d..d3b43135b201 100644
--- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx
+++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx
@@ -1,8 +1,7 @@
import { Transaction as NonEvmTransaction } from '@metamask/keyring-api';
import { SupportedCaipChainId } from '@metamask/multichain-network-controller';
import { SmartTransaction } from '@metamask/smart-transactions-controller';
-import { CHAIN_IDS, TransactionMeta } from '@metamask/transaction-controller';
-import { Hex } from '@metamask/utils';
+import { TransactionMeta } from '@metamask/transaction-controller';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { FlashList, FlashListRef } from '@shopify/flash-list';
import React, { useCallback, useMemo, useRef, useState } from 'react';
@@ -18,8 +17,6 @@ import { selectCurrentCurrency } from '../../../selectors/currencyRateController
import { selectNonEvmTransactionsForSelectedAccountGroup } from '../../../selectors/multichain/multichain';
import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController';
import {
- selectChainId,
- selectIsPopularNetwork,
selectEvmNetworkConfigurationsByChainId,
selectProviderType,
} from '../../../selectors/networkController';
@@ -36,11 +33,7 @@ import {
sortTransactions,
} from '../../../util/activity';
import { areAddressesEqual, isHardwareAccount } from '../../../util/address';
-import {
- getBlockExplorerAddressUrl,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../util/networks';
-import { PopularList } from '../../../util/networks/customNetworks';
+import { getBlockExplorerAddressUrl } from '../../../util/networks';
import { useTheme } from '../../../util/theme';
import { updateIncomingTransactions } from '../../../util/transaction-controller';
import { addAccountTimeFlagFilter } from '../../../util/transactions';
@@ -139,7 +132,7 @@ const UnifiedTransactionsView = ({
);
return solanaAccount?.address ?? '';
}, [selectedAccountGroupInternalAccounts]);
- const isPopularNetwork = useSelector(selectIsPopularNetwork);
+
const enabledEVMNetworks = useSelector(selectEVMEnabledNetworks);
const enabledEVMChainIds = useMemo(
() => enabledEVMNetworks ?? [],
@@ -155,10 +148,6 @@ const UnifiedTransactionsView = ({
selectEvmNetworkConfigurationsByChainId,
);
- // TODO: This should be deleted once we deprecate the global network selector,
- // we need to use the selected account group chain ids
- const currentEvmChainId = useSelector(selectChainId);
-
const bridgeHistory = useSelector(selectBridgeHistoryForAccount);
const { data, nonEvmTransactionsForSelectedChain } = useMemo<{
@@ -232,29 +221,10 @@ const UnifiedTransactionsView = ({
}) as TransactionMetaWithImport[];
// Network filtering for confirmed EVM txs
- let allConfirmedFiltered: TransactionMetaWithImport[] = [];
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- allConfirmedFiltered = allConfirmed.filter((tx) =>
+ const allConfirmedFiltered: TransactionMetaWithImport[] =
+ allConfirmed.filter((tx) =>
isTransactionOnChains(tx, enabledEVMChainIds, transactionMetaPool),
);
- } else if (isPopularNetwork) {
- const popularChainIds: Hex[] = [
- CHAIN_IDS.MAINNET as Hex,
- CHAIN_IDS.LINEA_MAINNET as Hex,
- ...PopularList.map((n) => n.chainId as Hex),
- ];
- allConfirmedFiltered = allConfirmed.filter((tx) =>
- isTransactionOnChains(tx, popularChainIds, transactionMetaPool),
- );
- } else {
- allConfirmedFiltered = allConfirmed.filter((tx) =>
- isTransactionOnChains(
- tx,
- currentEvmChainId ? [currentEvmChainId as Hex] : [],
- transactionMetaPool,
- ),
- );
- }
// Deduplicate submitted by (address + chain + nonce) and drop if already confirmed
const seenSubmittedNonces = new Set();
const submittedTxsFiltered = submittedTxs.filter(
@@ -355,10 +325,8 @@ const UnifiedTransactionsView = ({
selectedAccountGroupInternalAccountsAddresses,
enabledEVMChainIds,
enabledNonEVMChainIds,
- isPopularNetwork,
selectedInternalAccount,
tokens,
- currentEvmChainId,
bridgeHistory,
]);
@@ -370,18 +338,11 @@ const UnifiedTransactionsView = ({
const configBlockExplorerUrl = useMemo(() => {
// When using the per-dapp/multiselect network selector, only return a block
// explorer if exactly one EVM chain is selected. Otherwise, undefined.
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- if (!enabledEVMChainIds?.length || enabledEVMChainIds.length !== 1) {
- return undefined;
- }
- const selectedChainId = enabledEVMChainIds[0];
- const config = evmNetworkConfigurationsByChainId?.[selectedChainId];
- if (!config) return undefined;
- const index = config.defaultBlockExplorerUrlIndex ?? 0;
- return config.blockExplorerUrls?.[index];
+ if (!enabledEVMChainIds?.length || enabledEVMChainIds.length !== 1) {
+ return undefined;
}
-
- const config = evmNetworkConfigurationsByChainId?.[enabledEVMChainIds[0]];
+ const selectedChainId = enabledEVMChainIds[0];
+ const config = evmNetworkConfigurationsByChainId?.[selectedChainId];
if (!config) return undefined;
const index = config.defaultBlockExplorerUrlIndex ?? 0;
return config.blockExplorerUrls?.[index];
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index d06969bb48e1..833c34fa049a 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -572,7 +572,6 @@ const Wallet = ({
}
return false;
}
-
return enabledNetworks.some((network) => isTestNet(network));
}, [enabledNetworks, isMultichainAccountsState2Enabled, allEnabledNetworks]);
diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx
index 13d6b7e4167f..a98a6cf9a549 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx
+++ b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx
@@ -18,16 +18,6 @@ const mockedNetworkControllerState = mockNetworkState({
ticker: 'ETH',
});
-const mockIsRemoveGlobalNetworkSelectorEnabled = jest
- .fn()
- .mockReturnValue(false);
-
-jest.mock('../../../../../../util/networks', () => ({
- ...jest.requireActual('../../../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: () =>
- mockIsRemoveGlobalNetworkSelectorEnabled(),
-}));
-
jest.mock('../../../../../../core/Engine', () => {
const { MOCK_ACCOUNTS_CONTROLLER_STATE } = jest.requireActual(
'../../../../../../util/test/accountsControllerTestUtils',
@@ -102,8 +92,7 @@ describe('AddressElement', () => {
expect(addressText).toBeDefined();
});
- it('renders the network badge when displayNetworkBadge is true and the isRemoveGlobalNetworkSelectorEnabled feature flag is enabled', () => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
+ it('renders the network badge when displayNetworkBadge is true', () => {
const { getByTestId } = renderComponent(
{
...initialState,
diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx
index 8c32051465bb..8c2369073794 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx
+++ b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx
@@ -33,7 +33,6 @@ import Badge, {
BadgeVariant,
} from '../../../../../../component-library/components/Badges/Badge';
import { NetworkBadgeSource } from '../../../../../UI/AssetOverview/Balance/Balance';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks';
const AddressElement: React.FC = ({
name,
@@ -54,7 +53,7 @@ const AddressElement: React.FC = ({
const addressElementNetwork = allNetworks[chainId];
const shouldDisplayNetworkBadge = useMemo(
- () => isRemoveGlobalNetworkSelectorEnabled() && displayNetworkBadge,
+ () => displayNetworkBadge,
[displayNetworkBadge],
);
diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx
index 595bf1bfddd7..91e9e85e9ba0 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx
+++ b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx
@@ -15,17 +15,6 @@ const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
MOCK_ADDRESS,
]);
-// Mock isRemoveGlobalNetworkSelectorEnabled utility
-const mockIsRemoveGlobalNetworkSelectorEnabled = jest
- .fn()
- .mockReturnValue(false);
-
-jest.mock('../../../../../../util/networks', () => ({
- ...jest.requireActual('../../../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: () =>
- mockIsRemoveGlobalNetworkSelectorEnabled(),
-}));
-
// Mock isSmartContractAddress to avoid actual network calls during tests
jest.mock('../../../../../../util/transactions', () => ({
...jest.requireActual('../../../../../../util/transactions'),
@@ -119,7 +108,6 @@ const renderComponent = (
describe('AddressList', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
it('renders correctly', () => {
@@ -147,52 +135,26 @@ describe('AddressList', () => {
});
});
- it('filters contacts by current chainId when isRemoveGlobalNetworkSelectorEnabled is false', async () => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- const { queryByText } = renderComponent(initialState);
-
- await waitFor(() => {
- // Contact from chainId 0x1 should be visible
- expect(queryByText(textElements.firstContact)).toBeTruthy();
- // Contact from chainId 0x5 should not be visible
- expect(queryByText(textElements.secondContact)).toBeNull();
- });
- });
-
- it('shows contacts from all chains when onlyRenderAddressBook is true and isRemoveGlobalNetworkSelectorEnabled is true', async () => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
+ it('shows contacts from all chains when rendering address book', async () => {
const { queryByText } = renderComponent(initialState, {
onlyRenderAddressBook: true,
});
- // Wait for contacts to be processed
await waitFor(() => {
- // Both contacts from different chains should be visible
+ // Both contacts from different chains are visible
expect(queryByText(textElements.firstContact)).toBeTruthy();
expect(queryByText(textElements.secondContact)).toBeTruthy();
});
});
- it('only shows contacts from current chain when onlyRenderAddressBook is true but isRemoveGlobalNetworkSelectorEnabled is false', async () => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- const { queryByText } = renderComponent(initialState, {
- onlyRenderAddressBook: true,
- });
-
- await waitFor(() => {
- // Only contact from current chain should be visible
- expect(queryByText(textElements.firstContact)).toBeTruthy();
- expect(queryByText(textElements.secondContact)).toBeNull();
- });
- });
-
- it('sets displayNetworkBadge to true when rendering address elements', async () => {
- const { findByTestId } = renderComponent(initialState);
+ it('renders address elements with network badges', async () => {
+ const { findAllByTestId } = renderComponent(initialState);
- const addressElement = await findByTestId('address-book-account');
- expect(addressElement).toBeTruthy();
+ const addressElements = await findAllByTestId('address-book-account');
+ expect(addressElements.length).toBeGreaterThan(0);
- // This implicitly tests that renderAddressElementWithNetworkBadge is setting displayNetworkBadge to true
- // The actual rendering of the badge is tested in AddressElement.test.tsx
+ // Verify network badges are present
+ const networkBadges = await findAllByTestId('badgenetwork');
+ expect(networkBadges.length).toBeGreaterThan(0);
});
});
diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx
index b2b64a2be046..858795b01bd4 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx
+++ b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx
@@ -23,7 +23,6 @@ import {
AddressBookEntryWithRelaxedChainId,
InternalAddressBookEntry,
} from './AddressList.types';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks';
const LabelElement = (styles: ReturnType, label: string) => (
@@ -142,22 +141,11 @@ const AddressList = ({
return fuse.search(inputSearch);
}
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- return completeAndFlattenedAddressBook;
- }
-
- return completeAndFlattenedAddressBookFilteredByCurrentChainId;
- }, [
- fuse,
- inputSearch,
- completeAndFlattenedAddressBook,
- completeAndFlattenedAddressBookFilteredByCurrentChainId,
- ]);
+ return completeAndFlattenedAddressBook;
+ }, [fuse, inputSearch, completeAndFlattenedAddressBook]);
useEffect(() => {
- const fuseAddressBook = isRemoveGlobalNetworkSelectorEnabled()
- ? completeAndFlattenedAddressBook
- : completeAndFlattenedAddressBookFilteredByCurrentChainId;
+ const fuseAddressBook = completeAndFlattenedAddressBook;
const newFuse = new Fuse(fuseAddressBook, {
shouldSort: true,
diff --git a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js
index 832fe8eaf03c..7559e05aa188 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js
+++ b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js
@@ -10,10 +10,7 @@ import WarningMessage from '../WarningMessage';
import { getSendFlowTitle } from '../../../../../UI/Navbar';
import StyledButton from '../../../../../UI/StyledButton';
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
-import {
- getDecimalChainId,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../../../../util/networks';
+import { getDecimalChainId } from '../../../../../../util/networks';
import { handleNetworkSwitch } from '../../../../../../util/networks/handleNetworkSwitch';
import {
isENS,
@@ -408,19 +405,16 @@ class SendFlow extends PureComponent {
};
getAddressNameFromBookOrInternalAccounts = (toAccount) => {
- const { addressBook, internalAccounts, globalChainId } = this.props;
+ const { addressBook, internalAccounts } = this.props;
if (!toAccount) return;
- let filteredAddressBook = addressBook[globalChainId] || {};
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- filteredAddressBook = Object.values(addressBook).reduce(
- (acc, networkAddressBook) => ({
- ...acc,
- ...networkAddressBook,
- }),
- {},
- );
- }
+ const filteredAddressBook = Object.values(addressBook).reduce(
+ (acc, networkAddressBook) => ({
+ ...acc,
+ ...networkAddressBook,
+ }),
+ {},
+ );
const checksummedAddress = this.safeChecksumAddress(toAccount);
const matchingAccount = internalAccounts.find((account) =>
@@ -588,13 +582,11 @@ class SendFlow extends PureComponent {
style={styles.wrapper}
{...generateTestId(Platform, SendViewSelectorsIDs.CONTAINER_ID)}
>
- {isRemoveGlobalNetworkSelectorEnabled() ? (
-
- ) : null}
+
export const isPermissionsSettingsV1Enabled =
process.env.MM_PERMISSIONS_SETTINGS_V1_ENABLED === 'true';
-export const isRemoveGlobalNetworkSelectorEnabled = () => true;
-
// The whitelisted network names for the given chain IDs to prevent showing warnings on Network Settings.
export const WHILELIST_NETWORK_NAME = {
[ChainId.mainnet]: 'Mainnet',
diff --git a/bitrise.yml b/bitrise.yml
index f33d29b64f65..0563c419dcff 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -905,13 +905,11 @@ workflows:
ios_run_regression_network_abstraction_tests:
envs:
- TEST_SUITE_TAG: 'RegressionNetworkAbstractions'
- - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'true'
after_run:
- ios_e2e_test
ios_run_regression_network_abstraction_tests_gns_disabled:
envs:
- TEST_SUITE_TAG: 'RegressionNetworkAbstractions'
- - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'false'
after_run:
- ios_e2e_test
ios_run_regression_network_expansion_tests:
@@ -3255,7 +3253,6 @@ workflows:
- APP_NAME: "MetaMask"
- INFO_PLIST_NAME: "Info.plist"
- COMMAND_YARN: 'build:ios:main:e2e'
- - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'false'
after_run:
- _ios_build_template
build_ios_release_and_upload_sourcemaps:
@@ -3614,9 +3611,6 @@ app:
- opts:
is_expand: false
SEEDLESS_ONBOARDING_ENABLED: true
- - opts:
- is_expand: false
- MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'true'
meta:
bitrise.io:
stack: osx-xcode-16.3.x
diff --git a/jest.config.js b/jest.config.js
index 7b0c32abce89..fa804826a36e 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -7,7 +7,6 @@ process.env.MM_FOX_CODE = 'EXAMPLE_FOX_CODE';
process.env.MM_SECURITY_ALERTS_API_ENABLED = 'true';
process.env.SECURITY_ALERTS_API_URL = 'https://example.com';
-process.env.MM_REMOVE_GLOBAL_NETWORK_SELECTOR = 'true';
process.env.LAUNCH_DARKLY_URL =
'https://client-config.dev-api.cx.metamask.io/v1';