diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml
index 1a297e91905..ea9e35aa3c9 100644
--- a/.github/workflows/run-e2e-smoke-tests-android.yml
+++ b/.github/workflows/run-e2e-smoke-tests-android.yml
@@ -134,20 +134,20 @@ jobs:
changed_files: ${{ inputs.changed_files }}
secrets: inherit
- # prediction-market-android-smoke:
- # strategy:
- # matrix:
- # split: [1]
- # fail-fast: false
- # uses: ./.github/workflows/run-e2e-workflow.yml
- # with:
- # test-suite-name: prediction_market_android_smoke-${{ matrix.split }}
- # platform: android
- # test_suite_tag: 'SmokePredictions'
- # split_number: ${{ matrix.split }}
- # total_splits: 1
- # changed_files: ${{ inputs.changed_files }}
- # secrets: inherit
+ prediction-market-android-smoke:
+ strategy:
+ matrix:
+ split: [1]
+ fail-fast: false
+ uses: ./.github/workflows/run-e2e-workflow.yml
+ with:
+ test-suite-name: prediction_market_android_smoke-${{ matrix.split }}
+ platform: android
+ test_suite_tag: 'SmokePredictions'
+ split_number: ${{ matrix.split }}
+ total_splits: 1
+ changed_files: ${{ inputs.changed_files }}
+ secrets: inherit
rewards-android-smoke:
strategy:
@@ -177,7 +177,7 @@ jobs:
- network-abstraction-android-smoke
- network-expansion-android-smoke
- confirmations-redesigned-android-smoke
- # - prediction-market-android-smoke
+ - prediction-market-android-smoke
- rewards-android-smoke
steps:
- name: Checkout
diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml
index 812d7adc8a6..3265ab8d1a2 100644
--- a/.github/workflows/run-e2e-smoke-tests-ios.yml
+++ b/.github/workflows/run-e2e-smoke-tests-ios.yml
@@ -134,20 +134,20 @@ jobs:
changed_files: ${{ inputs.changed_files }}
secrets: inherit
- # prediction-market-ios-smoke:
- # strategy:
- # matrix:
- # split: [1]
- # fail-fast: false
- # uses: ./.github/workflows/run-e2e-workflow.yml
- # with:
- # test-suite-name: prediction_market_ios_smoke-${{ matrix.split }}
- # platform: ios
- # test_suite_tag: 'SmokePredictions'
- # split_number: ${{ matrix.split }}
- # total_splits: 1
- # changed_files: ${{ inputs.changed_files }}
- # secrets: inherit
+ prediction-market-ios-smoke:
+ strategy:
+ matrix:
+ split: [1]
+ fail-fast: false
+ uses: ./.github/workflows/run-e2e-workflow.yml
+ with:
+ test-suite-name: prediction_market_ios_smoke-${{ matrix.split }}
+ platform: ios
+ test_suite_tag: 'SmokePredictions'
+ split_number: ${{ matrix.split }}
+ total_splits: 1
+ changed_files: ${{ inputs.changed_files }}
+ secrets: inherit
rewards-ios-smoke:
strategy:
@@ -177,7 +177,7 @@ jobs:
- accounts-ios-smoke
- network-abstraction-ios-smoke
- network-expansion-ios-smoke
- # - prediction-market-ios-smoke
+ - prediction-market-ios-smoke
- rewards-ios-smoke
steps:
- name: Checkout
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index 5985222e771..f6b0463d12f 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -60,6 +60,7 @@ import RampRoutes from '../../UI/Ramp/Aggregator/routes';
import { RampType } from '../../UI/Ramp/Aggregator/types';
import RampSettings from '../../UI/Ramp/Aggregator/Views/Settings';
import RampActivationKeyForm from '../../UI/Ramp/Aggregator/Views/Settings/ActivationKeyForm';
+import RampTokenSelection from '../../UI/Ramp/components/TokenSelection';
import DepositOrderDetails from '../../UI/Ramp/Deposit/Views/DepositOrderDetails/DepositOrderDetails';
import DepositRoutes from '../../UI/Ramp/Deposit/routes';
@@ -1065,6 +1066,10 @@ const MainNavigator = () => {
options={{ headerShown: false }}
/>
+
{() => }
diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap
index 53644c664ca..a28adbe9dfc 100644
--- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap
+++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap
@@ -128,6 +128,10 @@ exports[`MainNavigator matches rendered snapshot 1`] = `
component={[Function]}
name="PaymentRequestView"
/>
+
diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx
index 0de895d4e4d..f08859cc7e5 100644
--- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx
+++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx
@@ -152,3 +152,105 @@ describe('BridgeDestNetworkSelector', () => {
expect(queryByText('Optimism')).toBeTruthy();
});
});
+
+describe('BridgeDestNetworkSelector - ChainPopularity fallback', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('assigns Infinity to chains without defined popularity', () => {
+ // Add networks with and without defined popularity to test all branch combinations:
+ // - Optimism: HAS defined popularity (10 in ChainPopularity)
+ // - Palm: NO defined popularity (triggers ?? Infinity)
+ // - zkSync Era: NO defined popularity (triggers ?? Infinity)
+ // This ensures all branch combinations are tested:
+ // 1. Both have defined popularity (Optimism already tested in existing tests)
+ // 2. Both lack defined popularity (Palm vs zkSync Era)
+ // 3. One has, one doesn't (Optimism vs Palm/zkSync)
+ const stateWithMultipleNetworks = {
+ ...initialState,
+ engine: {
+ ...initialState.engine,
+ backgroundState: {
+ ...initialState.engine.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ bridgeConfig: {
+ minimumVersion: '0.0.0',
+ maxRefreshCount: 5,
+ refreshRate: 30000,
+ support: true,
+ chains: {
+ 'eip155:1': {
+ isActiveSrc: true,
+ isActiveDest: true,
+ },
+ 'eip155:10': {
+ // Optimism - HAS defined popularity
+ isActiveSrc: true,
+ isActiveDest: true,
+ },
+ 'eip155:11297108109': {
+ // Palm - NOT in ChainPopularity
+ isActiveSrc: true,
+ isActiveDest: true,
+ },
+ 'eip155:324': {
+ // zkSync Era - NOT in ChainPopularity
+ isActiveSrc: true,
+ isActiveDest: true,
+ },
+ },
+ },
+ bridgeConfigV2: {
+ minimumVersion: '0.0.0',
+ maxRefreshCount: 5,
+ refreshRate: 30000,
+ support: true,
+ chains: {
+ 'eip155:1': {
+ isActiveSrc: true,
+ isActiveDest: true,
+ isGaslessSwapEnabled: true,
+ },
+ 'eip155:10': {
+ // Optimism
+ isActiveSrc: true,
+ isActiveDest: true,
+ isGaslessSwapEnabled: false,
+ },
+ 'eip155:11297108109': {
+ // Palm
+ isActiveSrc: true,
+ isActiveDest: true,
+ isGaslessSwapEnabled: false,
+ },
+ 'eip155:324': {
+ // zkSync Era
+ isActiveSrc: true,
+ isActiveDest: true,
+ isGaslessSwapEnabled: false,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const { getByText } = renderScreen(
+ BridgeDestNetworkSelector,
+ {
+ name: Routes.BRIDGE.MODALS.DEST_NETWORK_SELECTOR,
+ },
+ { state: stateWithMultipleNetworks },
+ );
+
+ // All three networks should be visible and sorted by popularity
+ // Optimism (popularity 10) should appear before Palm and zkSync Era (both Infinity)
+ expect(getByText('Optimism')).toBeTruthy();
+ expect(getByText('Palm')).toBeTruthy();
+ expect(getByText('zkSync Era')).toBeTruthy();
+ });
+});
diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap
index 62e8094525b..4f2f44449b7 100644
--- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap
+++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap
@@ -637,7 +637,7 @@ exports[`BridgeDestNetworkSelector renders with initial state and displays netwo
}
}
>
- Optimism
+ Bitcoin
@@ -831,7 +831,7 @@ exports[`BridgeDestNetworkSelector renders with initial state and displays netwo
}
}
>
- Bitcoin
+ Optimism
diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx
index 6300a61aaf7..be92828a3bc 100644
--- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx
+++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx
@@ -17,6 +17,7 @@ import { NetworkRow } from '../NetworkRow';
import Routes from '../../../../../constants/navigation/Routes';
import { selectChainId } from '../../../../../selectors/networkController';
import { BridgeViewMode } from '../../types';
+import { ChainPopularity } from '../BridgeDestNetworksBar';
export interface BridgeDestNetworkSelectorRouteParams {
shouldGoToTokens?: boolean;
@@ -68,6 +69,11 @@ export const BridgeDestNetworkSelector: React.FC = () => {
}
return chain.chainId !== currentChainId;
})
+ .sort((a, b) => {
+ const aPopularity = ChainPopularity[a.chainId] ?? Infinity;
+ const bPopularity = ChainPopularity[b.chainId] ?? Infinity;
+ return aPopularity - bPopularity;
+ })
.map((chain) => (
{
@@ -72,21 +72,22 @@ const createStyles = (params: { theme: Theme }) => {
* 1 = most popular
* Infinity = least popular
*/
-const ChainPopularity: Record = {
+export const ChainPopularity: Record = {
[ETH_CHAIN_ID]: 1,
+ [BSC_CHAIN_ID]: 2,
///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
- [SolScope.Mainnet]: 2,
+ [BtcScope.Mainnet]: 3,
+ [SolScope.Mainnet]: 4,
///: END:ONLY_INCLUDE_IF
- [BASE_CHAIN_ID]: 3,
- [BSC_CHAIN_ID]: 4,
- [LINEA_CHAIN_ID]: 5,
- [OPTIMISM_CHAIN_ID]: 6,
- [ARBITRUM_CHAIN_ID]: 7,
- [AVALANCHE_CHAIN_ID]: 9,
+ [BASE_CHAIN_ID]: 5,
+ [ARBITRUM_CHAIN_ID]: 6,
+ [LINEA_CHAIN_ID]: 7,
[POLYGON_CHAIN_ID]: 8,
- [ZKSYNC_ERA_CHAIN_ID]: 10,
- [NETWORKS_CHAIN_ID.SEI]: 11,
- [NETWORKS_CHAIN_ID.MONAD]: 12,
+ [AVALANCHE_CHAIN_ID]: 9,
+ [OPTIMISM_CHAIN_ID]: 10,
+ [ZKSYNC_ERA_CHAIN_ID]: 11,
+ [NETWORKS_CHAIN_ID.SEI]: 12,
+ [NETWORKS_CHAIN_ID.MONAD]: 13,
};
const ShortChainNames: Record = {
diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap
index ab71cd059c7..b8a83c85e58 100644
--- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap
+++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap
@@ -663,7 +663,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens
}
}
>
- Solana
+ Bitcoin
@@ -717,7 +717,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens
}
}
>
- Optimism
+ Solana
@@ -771,7 +771,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens
}
}
>
- Bitcoin
+ Optimism
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts
index 6eea4d028f5..5821b66aa82 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts
@@ -17,14 +17,12 @@ import {
fromTokenMinimalUnit,
isNumberValue,
} from '../../../../../util/number';
-import { selectPrimaryCurrency } from '../../../../../selectors/settings';
import {
isQuoteExpired,
getQuoteRefreshRate,
shouldRefreshQuote,
} from '../../utils/quoteUtils';
-import { selectTicker } from '../../../../../selectors/networkController';
import { BigNumber } from 'bignumber.js';
import I18n from '../../../../../../locales/i18n';
import useFiatFormatter from '../../../SimulationDetails/FiatDisplay/useFiatFormatter';
@@ -51,8 +49,6 @@ export const useBridgeQuoteData = ({
const isSubmittingTx = useSelector(selectIsSubmittingTx);
const locale = I18n.locale;
const fiatFormatter = useFiatFormatter();
- const primaryCurrency = useSelector(selectPrimaryCurrency) ?? 'ETH';
- const ticker = useSelector(selectTicker);
const quotes = useSelector(selectBridgeQuotes);
const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags);
const isSolanaSwap = useSelector(selectIsSolanaSwap);
@@ -123,20 +119,12 @@ export const useBridgeQuoteData = ({
return '-';
}
- const networkFeeFormatter = getIntlNumberFormatter(locale, {
- maximumFractionDigits: 6,
- });
- const formattedAmount = `${networkFeeFormatter.format(
- Number(amount),
- )} ${ticker}`;
const formattedValueInCurrency = fiatFormatter(
new BigNumber(valueInCurrency),
);
- return primaryCurrency === 'ETH'
- ? formattedAmount
- : formattedValueInCurrency;
- }, [activeQuote, locale, ticker, fiatFormatter, primaryCurrency]);
+ return formattedValueInCurrency;
+ }, [activeQuote, fiatFormatter]);
const formattedQuoteData = useMemo(() => {
if (!activeQuote) return undefined;
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts
index 0e53507e4c5..13012a03ba2 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts
@@ -25,12 +25,6 @@ jest.mock('../../utils/quoteUtils', () => ({
shouldRefreshQuote: jest.fn(),
}));
-const mockSelectPrimaryCurrency = jest.fn();
-jest.mock('../../../../../selectors/settings', () => ({
- ...jest.requireActual('../../../../../selectors/settings'),
- selectPrimaryCurrency: () => mockSelectPrimaryCurrency(),
-}));
-
// Mock the bridge-controller module
jest.mock('@metamask/bridge-controller', () => {
const actual = jest.requireActual('@metamask/bridge-controller');
@@ -90,7 +84,6 @@ describe('useBridgeQuoteData', () => {
(isQuoteExpired as jest.Mock).mockReturnValue(false);
(getQuoteRefreshRate as jest.Mock).mockReturnValue(5000);
(shouldRefreshQuote as jest.Mock).mockReturnValue(false);
- mockSelectPrimaryCurrency.mockReturnValue('ETH');
mockUseIsInsufficientBalance.mockReturnValue(false);
mockValidateBridgeTx.mockResolvedValue({ status: 'SUCCESS' });
});
@@ -329,17 +322,9 @@ describe('useBridgeQuoteData', () => {
});
});
- it('formats network fee in ETH currency', () => {
- mockSelectPrimaryCurrency.mockReturnValue('ETH');
-
+ it('returns undefined when activeQuote is undefined', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
- recommendedQuote: {
- ...mockQuoteWithMetadata,
- totalNetworkFee: {
- amount: '0.01',
- valueInCurrency: '10',
- },
- },
+ recommendedQuote: undefined,
alternativeQuotes: [],
}));
@@ -349,19 +334,14 @@ describe('useBridgeQuoteData', () => {
state: testState,
});
- expect(result.current.formattedQuoteData?.networkFee).toBe('0.01 ETH');
+ expect(result.current.formattedQuoteData?.networkFee).toBe(undefined);
});
- it('formats network fee in USD currency', () => {
- mockSelectPrimaryCurrency.mockReturnValue('USD');
-
+ it('returns "-" when totalNetworkFee is missing', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
- totalNetworkFee: {
- amount: '0.01',
- valueInCurrency: '10',
- },
+ totalNetworkFee: null,
},
alternativeQuotes: [],
}));
@@ -372,29 +352,16 @@ describe('useBridgeQuoteData', () => {
state: testState,
});
- expect(result.current.formattedQuoteData?.networkFee).toBe('$10');
- });
-
- it('returns undefined when activeQuote is undefined', () => {
- (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
- recommendedQuote: undefined,
- alternativeQuotes: [],
- }));
-
- const testState = createBridgeTestState({});
-
- const { result } = renderHookWithProvider(() => useBridgeQuoteData(), {
- state: testState,
- });
-
- expect(result.current.formattedQuoteData?.networkFee).toBe(undefined);
+ expect(result.current.formattedQuoteData?.networkFee).toBe('-');
});
- it('returns "-" when totalNetworkFee is missing', () => {
+ it('returns "-" when totalNetworkFee amount is missing', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
- totalNetworkFee: null,
+ totalNetworkFee: {
+ valueInCurrency: '10',
+ },
},
alternativeQuotes: [],
}));
@@ -408,12 +375,12 @@ describe('useBridgeQuoteData', () => {
expect(result.current.formattedQuoteData?.networkFee).toBe('-');
});
- it('returns "-" when totalNetworkFee amount is missing', () => {
+ it('returns "-" when totalNetworkFee valueInCurrency is missing', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
totalNetworkFee: {
- valueInCurrency: '10',
+ amount: '0.01',
},
},
alternativeQuotes: [],
@@ -428,12 +395,13 @@ describe('useBridgeQuoteData', () => {
expect(result.current.formattedQuoteData?.networkFee).toBe('-');
});
- it('returns "-" when totalNetworkFee valueInCurrency is missing', () => {
+ it('formats network fee with fiat formatter for normal values', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
totalNetworkFee: {
amount: '0.01',
+ valueInCurrency: '10',
},
},
alternativeQuotes: [],
@@ -445,16 +413,16 @@ describe('useBridgeQuoteData', () => {
state: testState,
});
- expect(result.current.formattedQuoteData?.networkFee).toBe('-');
+ expect(result.current.formattedQuoteData?.networkFee).toBe('$10');
});
- it('formats network fee with valid totalNetworkFee data', () => {
+ it('formats network fee as "<$0.01" when value is less than 0.01', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
totalNetworkFee: {
- amount: '0.01',
- valueInCurrency: '10',
+ amount: '0.0001',
+ valueInCurrency: '0.005',
},
},
alternativeQuotes: [],
@@ -466,19 +434,16 @@ describe('useBridgeQuoteData', () => {
state: testState,
});
- // The exact format will depend on the locale and formatter, but we can check it's not '-'
- expect(result.current.formattedQuoteData?.networkFee).not.toBe('-');
+ expect(result.current.formattedQuoteData?.networkFee).toBe('<$0.01');
});
- it('formats network fee in ETH currency when primary currency is not specified', () => {
- mockSelectPrimaryCurrency.mockReturnValue(undefined);
-
+ it('formats network fee normally when value is exactly 0.01', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
totalNetworkFee: {
- amount: '0.01',
- valueInCurrency: '10',
+ amount: '0.0001',
+ valueInCurrency: '0.01',
},
},
alternativeQuotes: [],
@@ -490,19 +455,16 @@ describe('useBridgeQuoteData', () => {
state: testState,
});
- // Should default to ETH format when primary currency is not specified
- expect(result.current.formattedQuoteData?.networkFee).toContain('ETH');
+ expect(result.current.formattedQuoteData?.networkFee).toBe('$0.01');
});
- it('formats network fee in USD currency when primary currency is not ETH', () => {
- mockSelectPrimaryCurrency.mockReturnValue('GBP');
-
+ it('formats network fee normally when value is 0', () => {
(selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({
recommendedQuote: {
...mockQuoteWithMetadata,
totalNetworkFee: {
- amount: '0.01',
- valueInCurrency: '10',
+ amount: '0',
+ valueInCurrency: '0',
},
},
alternativeQuotes: [],
@@ -514,8 +476,7 @@ describe('useBridgeQuoteData', () => {
state: testState,
});
- // Should use USD format when primary currency is not ETH
- expect(result.current.formattedQuoteData?.networkFee).toBe('$10');
+ expect(result.current.formattedQuoteData?.networkFee).toBe('$0');
});
// Additional coverage tests
diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js
index 901ebb03e69..9294084150b 100644
--- a/app/components/UI/Navbar/index.js
+++ b/app/components/UI/Navbar/index.js
@@ -1962,7 +1962,7 @@ export function getDepositNavbarOptions(
? () => (
{
diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx
index f4a13e84591..50b55fa3496 100644
--- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx
@@ -74,6 +74,7 @@ const PerpsMarketListView = ({
const fadeAnimation = useRef(new Animated.Value(0)).current;
const tabScrollViewRef = useRef(null);
+ const isScrollingProgrammatically = useRef(false);
const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false);
const [isStocksCommoditiesSheetVisible, setIsStocksCommoditiesSheetVisible] =
useState(false);
@@ -132,83 +133,20 @@ const PerpsMarketListView = ({
[onMarketSelect, perpsNavigation, route.params?.source],
);
- // Get filtered markets for specific tab (used within each tab)
- const getFilteredMarketsForTab = useCallback(
- (filter: 'all' | 'crypto' | 'stocks_and_commodities') => {
- if (searchQuery.trim()) {
- // When searching, show all search results (filtering handled by search)
- return filteredMarkets;
- }
-
- // Filter by tab when not searching
- if (filter === 'all') {
- // All = Crypto + Stocks + Commodities (excluding forex)
- return filteredMarkets.filter(
- (m) =>
- !m.marketType ||
- m.marketType === 'equity' ||
- m.marketType === 'commodity',
- );
- }
- if (filter === 'crypto') {
- // Crypto markets have no marketType set
- return filteredMarkets.filter((m) => !m.marketType);
- }
- if (filter === 'stocks_and_commodities') {
- // Combined stocks and commodities filter - apply sub-filter
- let stocksCommoditiesMarkets = filteredMarkets.filter(
- (m) => m.marketType === 'equity' || m.marketType === 'commodity',
- );
-
- // Apply stocks/commodities sub-filter if not 'all'
- if (stocksCommoditiesFilter !== 'all') {
- stocksCommoditiesMarkets = stocksCommoditiesMarkets.filter(
- (m) => m.marketType === stocksCommoditiesFilter,
- );
- }
-
- return stocksCommoditiesMarkets;
- }
- return filteredMarkets;
- },
- [filteredMarkets, searchQuery, stocksCommoditiesFilter],
- );
-
- // Market type tab content component (filters markets by tab type)
- // tabLabel is extracted by TabsList component for display, not used here
- const MarketTypeTabContent = useCallback(
- ({
- tabFilter,
- tabLabel: _tabLabel,
- }: {
- tabFilter: 'all' | 'crypto' | 'stocks_and_commodities';
- tabLabel: string;
- }) => {
- const tabMarkets = getFilteredMarketsForTab(tabFilter);
- return (
-
-
-
+ // Apply stocks/commodities sub-filter when on Stocks tab
+ const displayMarkets = useMemo(() => {
+ // If on stocks_and_commodities tab and sub-filter is active, apply it
+ if (
+ marketTypeFilter === 'stocks_and_commodities' &&
+ stocksCommoditiesFilter !== 'all'
+ ) {
+ return filteredMarkets.filter(
+ (m) => m.marketType === stocksCommoditiesFilter,
);
- },
- [
- getFilteredMarketsForTab,
- handleMarketPress,
- sortBy,
- fadeAnimation,
- styles.animatedListContainer,
- styles.tabContentContainer,
- ],
- );
+ }
+ // Otherwise, use markets already filtered by the hook
+ return filteredMarkets;
+ }, [filteredMarkets, marketTypeFilter, stocksCommoditiesFilter]);
// Build tabs data for TabsBar
const tabsData = useMemo(() => {
@@ -249,19 +187,6 @@ const PerpsMarketListView = ({
return tabs;
}, [marketCounts]);
- // Build tab content components
- const tabsToRender = useMemo(
- () =>
- tabsData.map((tab) => (
-
- )),
- [tabsData, MarketTypeTabContent],
- );
-
// Calculate active tab index from current marketTypeFilter
const activeTabIndex = useMemo(() => {
if (tabsData.length === 0) {
@@ -294,9 +219,14 @@ const PerpsMarketListView = ({
[tabsData, setMarketTypeFilter],
);
- // Handle scroll to sync active tab
+ // Handle scroll to sync active tab (for swipe gestures)
const handleScroll = useCallback(
(event: { nativeEvent: { contentOffset: { x: number } } }) => {
+ // Ignore programmatic scrolls to prevent feedback loop with useEffect
+ if (isScrollingProgrammatically.current) {
+ return;
+ }
+
const offsetX = event.nativeEvent.contentOffset.x;
const index = Math.round(offsetX / containerWidth);
if (index >= 0 && index < tabsData.length) {
@@ -309,29 +239,34 @@ const PerpsMarketListView = ({
[containerWidth, tabsData, marketTypeFilter, setMarketTypeFilter],
);
- // Sync scroll position when active tab changes (e.g., from navigation param)
+ // Sync scroll position when active tab changes (e.g., from tab bar press or navigation param)
useEffect(() => {
if (
tabScrollViewRef.current &&
activeTabIndex >= 0 &&
tabsData.length > 0
) {
+ isScrollingProgrammatically.current = true;
tabScrollViewRef.current.scrollTo({
x: activeTabIndex * containerWidth,
animated: true,
});
+ // Clear flag after animation completes (~300ms animation + 50ms buffer)
+ setTimeout(() => {
+ isScrollingProgrammatically.current = false;
+ }, 350);
}
}, [activeTabIndex, containerWidth, tabsData.length]);
useEffect(() => {
- if (filteredMarkets.length > 0) {
+ if (displayMarkets.length > 0) {
Animated.timing(fadeAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
}
- }, [filteredMarkets.length, fadeAnimation]);
+ }, [displayMarkets.length, fadeAnimation]);
// Reset stocks/commodities filter to 'all' when switching tabs
// This ensures that when switching to the Stocks tab, it always shows both stocks and commodities
@@ -364,7 +299,7 @@ const PerpsMarketListView = ({
// Performance tracking: Measure screen load time until market data is displayed
usePerpsMeasurement({
traceName: TraceName.PerpsMarketListView,
- conditions: [filteredMarkets.length > 0],
+ conditions: [displayMarkets.length > 0],
});
// Track markets screen viewed event
@@ -372,7 +307,7 @@ const PerpsMarketListView = ({
route.params?.source || PerpsEventValues.SOURCE.MAIN_ACTION_BUTTON;
usePerpsEventTracking({
eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED,
- conditions: [filteredMarkets.length > 0],
+ conditions: [displayMarkets.length > 0],
properties: {
[PerpsEventProperties.SCREEN_TYPE]: PerpsEventValues.SCREEN_TYPE.MARKETS,
[PerpsEventProperties.SOURCE]: source,
@@ -397,7 +332,7 @@ const PerpsMarketListView = ({
}
// Error (Failed to load markets)
- if (error && filteredMarkets.length === 0) {
+ if (error && displayMarkets.length === 0) {
return (
({
key: tab.key,
label: tab.label,
- content: null, // Content is rendered separately in ScrollView
+ content: null,
isDisabled: false,
}))}
activeIndex={activeTabIndex}
@@ -527,7 +462,7 @@ const PerpsMarketListView = ({
/>
{/* Filter Bar - Between tabs and content */}
- {(filteredMarkets.length > 0 || showFavoritesOnly) && (
+ {(displayMarkets.length > 0 || showFavoritesOnly) && (
setIsSortFieldSheetVisible(true)}
@@ -542,7 +477,7 @@ const PerpsMarketListView = ({
/>
)}
- {/* Tab Content - Scrollable */}
+ {/* Tab Content - Swipeable */}
- {tabsToRender.map((tabContent, index) => (
+ {tabsData.map((tab) => (
- {tabContent}
+
+
+
))}
)}
- {/* Market list hidden when tabs are shown (tabs contain the list) */}
+ {/* Market list when no tabs shown (rare case) */}
{!isSearchVisible &&
!isLoadingMarkets &&
!error &&
- tabsToRender.length === 0 && (
+ tabsData.length === 0 && (
{renderMarketList()}
diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx
index eaa8e32e921..85765781939 100644
--- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx
@@ -94,7 +94,7 @@ describe('PerpsOrderTransactionView', () => {
mockUsePerpsOrderFees.mockReturnValue({
totalFee: 10.5,
protocolFee: 7.5,
- metamaskFee: 3.0,
+ metamaskFee: 3,
protocolFeeRate: 0.1,
metamaskFeeRate: 0.05,
isLoadingMetamaskFee: false,
@@ -162,7 +162,7 @@ describe('PerpsOrderTransactionView', () => {
expect(zeroFees).toHaveLength(3); // All three fees should be $0
});
- it('should handle small fees correctly', () => {
+ it('should show "< $0.01" for fees less than 0.01', () => {
mockUsePerpsOrderFees.mockReturnValue({
totalFee: 0.005,
protocolFee: 0.003,
@@ -173,11 +173,135 @@ describe('PerpsOrderTransactionView', () => {
error: null,
});
- const { getByText } = render();
+ const { getAllByText } = render();
+
+ // All three fees should show "< $0.01" since they're all less than 0.01
+ const smallFeeLabels = getAllByText('< $0.01');
+ expect(smallFeeLabels).toHaveLength(3);
+ });
+
+ it('should format fees normally when they are exactly 0.01', () => {
+ mockUsePerpsOrderFees.mockReturnValue({
+ totalFee: 0.03,
+ protocolFee: 0.01,
+ metamaskFee: 0.01,
+ protocolFeeRate: 0.1,
+ metamaskFeeRate: 0.05,
+ isLoadingMetamaskFee: false,
+ error: null,
+ });
+
+ const { getAllByText, queryByText, getByText } = render(
+ ,
+ );
+
+ // Fees at exactly 0.01 should be formatted normally, not show "< $0.01"
+ expect(queryByText('< $0.01')).toBeNull();
+ // Both metamask and protocol fees are 0.01
+ const fee01Labels = getAllByText('$0.01');
+ expect(fee01Labels.length).toBeGreaterThanOrEqual(2);
+ expect(getByText('$0.03')).toBeTruthy(); // Total fee
+ });
+
+ it('should format fees normally when they are greater than 0.01', () => {
+ mockUsePerpsOrderFees.mockReturnValue({
+ totalFee: 0.015,
+ protocolFee: 0.012,
+ metamaskFee: 0.003,
+ protocolFeeRate: 0.1,
+ metamaskFeeRate: 0.05,
+ isLoadingMetamaskFee: false,
+ error: null,
+ });
+
+ const { getByText, getAllByText } = render();
+
+ // Metamask fee is less than 0.01, should show "< $0.01"
+ expect(getAllByText('< $0.01')).toHaveLength(1);
+ // Protocol and total fees are >= 0.01, should be formatted normally
+ expect(getByText('$0.01')).toBeTruthy(); // Protocol fee formatted
+ expect(getByText('$0.02')).toBeTruthy(); // Total fee formatted (rounded)
+ });
+
+ it('should handle mixed small and large fees correctly', () => {
+ mockUsePerpsOrderFees.mockReturnValue({
+ totalFee: 0.025,
+ protocolFee: 0.02,
+ metamaskFee: 0.005,
+ protocolFeeRate: 0.1,
+ metamaskFeeRate: 0.05,
+ isLoadingMetamaskFee: false,
+ error: null,
+ });
+
+ const { getByText, getAllByText } = render();
+
+ // Metamask fee is less than 0.01
+ const smallFeeLabels = getAllByText('< $0.01');
+ expect(smallFeeLabels).toHaveLength(1);
+ // Protocol and total fees are >= 0.01, should be formatted
+ expect(getByText('$0.02')).toBeTruthy(); // Protocol fee
+ expect(getByText('$0.03')).toBeTruthy(); // Total fee (rounded)
+ });
+
+ it('should handle edge case: fee just below 0.01 threshold', () => {
+ mockUsePerpsOrderFees.mockReturnValue({
+ totalFee: 0.029,
+ protocolFee: 0.0099,
+ metamaskFee: 0.0099,
+ protocolFeeRate: 0.1,
+ metamaskFeeRate: 0.05,
+ isLoadingMetamaskFee: false,
+ error: null,
+ });
+
+ const { getAllByText } = render();
+
+ // Both metamask and protocol fees are just below 0.01
+ const smallFeeLabels = getAllByText('< $0.01');
+ expect(smallFeeLabels).toHaveLength(2);
+ // Total fee is >= 0.01, should be formatted
+ });
+
+ it('should handle edge case: fee just above 0.01 threshold', () => {
+ mockUsePerpsOrderFees.mockReturnValue({
+ totalFee: 0.0201,
+ protocolFee: 0.0101,
+ metamaskFee: 0.01,
+ protocolFeeRate: 0.1,
+ metamaskFeeRate: 0.05,
+ isLoadingMetamaskFee: false,
+ error: null,
+ });
+
+ const { queryByText, getAllByText, getByText } = render(
+ ,
+ );
+
+ // All fees are >= 0.01, should be formatted normally
+ expect(queryByText('< $0.01')).toBeNull();
+ // Metamask fee and protocol fee (rounded) both show $0.01
+ const fee01Labels = getAllByText('$0.01');
+ expect(fee01Labels.length).toBeGreaterThanOrEqual(2);
+ expect(getByText('$0.02')).toBeTruthy(); // Total fee (rounded)
+ });
+
+ it('should show "< $0.01" for all fees when all are below threshold', () => {
+ mockUsePerpsOrderFees.mockReturnValue({
+ totalFee: 0.008,
+ protocolFee: 0.005,
+ metamaskFee: 0.003,
+ protocolFeeRate: 0.1,
+ metamaskFeeRate: 0.05,
+ isLoadingMetamaskFee: false,
+ error: null,
+ });
+
+ const { getAllByText } = render();
- expect(getByText('$0.002')).toBeTruthy();
- expect(getByText('$0.003')).toBeTruthy();
- expect(getByText('$0.005')).toBeTruthy();
+ // All three fees are below 0.01
+ const smallFeeLabels = getAllByText('< $0.01');
+ expect(smallFeeLabels).toHaveLength(3);
});
it('should navigate to block explorer in browser tab when button is pressed', () => {
diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx
index c6eee742d05..b3c69c16497 100644
--- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx
+++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx
@@ -11,7 +11,6 @@ import Text, {
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
-import { BigNumber } from 'bignumber.js';
import { useSelector } from 'react-redux';
import { PerpsTransactionSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import Button, {
@@ -28,6 +27,7 @@ import { usePerpsBlockExplorerUrl, usePerpsOrderFees } from '../../hooks';
import { PerpsNavigationParamList } from '../../types/navigation';
import { PerpsOrderTransactionRouteProp } from '../../types/transactionHistory';
import {
+ formatFee,
formatPerpsFiat,
formatTransactionDate,
} from '../../utils/formatUtils';
@@ -103,44 +103,21 @@ const PerpsOrderTransactionView: React.FC = () => {
];
const isFilled = transaction.order?.text === 'Filled';
+
// Fee breakdown
const feeRows = [
{
label: strings('perps.transactions.order.metamask_fee'),
- value: `${
- isFilled
- ? `${
- BigNumber(metamaskFee).isLessThan(0.01)
- ? `$${metamaskFee}`
- : formatPerpsFiat(metamaskFee)
- }`
- : '$0'
- }`,
+ value: formatFee(isFilled ? metamaskFee : 0),
},
{
label: strings('perps.transactions.order.hyperliquid_fee'),
- value: `${
- isFilled
- ? `${
- BigNumber(protocolFee).isLessThan(0.01)
- ? `$${protocolFee}`
- : formatPerpsFiat(protocolFee)
- }`
- : '$0'
- }`,
+ value: formatFee(isFilled ? protocolFee : 0),
},
{
label: strings('perps.transactions.order.total_fee'),
- value: `${
- isFilled
- ? `${
- BigNumber(totalFee).isLessThan(0.01)
- ? `$${totalFee}`
- : formatPerpsFiat(totalFee)
- }`
- : '$0'
- }`,
+ value: formatFee(isFilled ? totalFee : 0),
},
];
diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx
index 05e12176fee..9776b363ee9 100644
--- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx
@@ -361,7 +361,7 @@ describe('PerpsPositionTransactionView', () => {
expect(getByText('$5')).toBeOnTheScreen();
});
- it('should display fees with $ prefix directly for amounts < 0.01', () => {
+ it('should display fees with < $0.01 label for amounts < 0.01', () => {
// Given a transaction with fee less than 0.01
const smallFeeTransaction = {
...mockTransaction,
@@ -379,9 +379,9 @@ describe('PerpsPositionTransactionView', () => {
state: mockInitialState,
});
- // Then fee should display with $ prefix directly (not formatted through formatPerpsFiat)
+ // Then fee should display with < $0.01 label (not the actual value)
expect(getByText('Total fees')).toBeOnTheScreen();
- expect(getByText('$0.005')).toBeOnTheScreen();
+ expect(getByText('< $0.01')).toBeOnTheScreen();
});
it('should not render points when not present', () => {
diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
index 42f1dd39602..6f9db04cfd6 100644
--- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
+++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
@@ -30,6 +30,7 @@ import {
PerpsTransaction,
} from '../../types/transactionHistory';
import {
+ formatFee,
formatPerpsFiat,
formatTransactionDate,
PRICE_RANGES_UNIVERSAL,
@@ -115,9 +116,7 @@ const PerpsPositionTransactionView: React.FC = () => {
transaction.fill?.fee !== undefined &&
transaction.fill?.fee !== null && {
label: strings('perps.transactions.position.fees'),
- value: BigNumber(transaction.fill.fee).isGreaterThan(0.01)
- ? formatPerpsFiat(transaction.fill.fee)
- : `$${transaction.fill.fee}`,
+ value: formatFee(transaction.fill.fee),
textColor: TextColor.Default,
},
].filter(Boolean);
diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx
index 6e4d46a17b1..4d7cdc5831c 100644
--- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx
@@ -390,21 +390,21 @@ describe('PerpsMarketRowItem', () => {
mockUsePerpsLivePrices.mockReturnValue({
BTC: { price: '50000', volume24h: 750000000 },
});
- rerender();
+ rerender();
expect(screen.getByText('$750.00M')).toBeOnTheScreen(); // M shows 2 decimals
// Test thousands (0 decimals with formatVolume)
mockUsePerpsLivePrices.mockReturnValue({
BTC: { price: '50000', volume24h: 50000 },
});
- rerender();
+ rerender();
expect(screen.getByText('$50K')).toBeOnTheScreen(); // K shows no decimals
// Test small values (2 decimals with formatVolume)
mockUsePerpsLivePrices.mockReturnValue({
BTC: { price: '50000', volume24h: 123.45 },
});
- rerender();
+ rerender();
expect(screen.getByText('$123.45')).toBeOnTheScreen(); // Shows 2 decimals
});
diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx
index f442069360a..22f810f4e46 100644
--- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx
@@ -211,4 +211,4 @@ const PerpsMarketRowItem = ({
);
};
-export default PerpsMarketRowItem;
+export default React.memo(PerpsMarketRowItem);
diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx
index 11d3a24b0bf..0c594c770ca 100644
--- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx
@@ -242,7 +242,7 @@ describe('PerpsMarketTypeSection', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_LIST,
params: {
- defaultMarketTypeFilter: 'all',
+ defaultMarketTypeFilter: 'crypto',
},
});
});
@@ -261,7 +261,7 @@ describe('PerpsMarketTypeSection', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_LIST,
params: {
- defaultMarketTypeFilter: 'all',
+ defaultMarketTypeFilter: 'equity',
},
});
});
@@ -280,7 +280,7 @@ describe('PerpsMarketTypeSection', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_LIST,
params: {
- defaultMarketTypeFilter: 'all',
+ defaultMarketTypeFilter: 'commodity',
},
});
});
diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx
index 6935946afbc..60ae3757572 100644
--- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx
@@ -23,7 +23,13 @@ export interface PerpsMarketTypeSectionProps {
/** Markets to display */
markets: PerpsMarketData[];
/** Market type for filtering when "See All" is pressed */
- marketType: 'crypto' | 'equity' | 'commodity' | 'forex' | 'all';
+ marketType:
+ | 'crypto'
+ | 'equity'
+ | 'commodity'
+ | 'forex'
+ | 'all'
+ | 'stocks_and_commodities';
/** Sort field for market list */
sortBy?: SortField;
/** Whether markets are loading */
@@ -58,7 +64,7 @@ export interface PerpsMarketTypeSectionProps {
const PerpsMarketTypeSection: React.FC = ({
title,
markets,
- marketType: _marketType, // Unused but kept for API compatibility
+ marketType,
sortBy = 'volume',
isLoading,
testID,
@@ -67,14 +73,14 @@ const PerpsMarketTypeSection: React.FC = ({
const navigation = useNavigation>();
const handleViewAll = useCallback(() => {
- // Always navigate to "All" tab when "See all" is pressed
+ // Navigate to the specific market type tab when "See all" is pressed
navigation.navigate(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_LIST,
params: {
- defaultMarketTypeFilter: 'all',
+ defaultMarketTypeFilter: marketType,
},
});
- }, [navigation]);
+ }, [navigation, marketType]);
const handleMarketPress = useCallback(
(market: PerpsMarketData) => {
diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts
index bd7bb37ffbe..437046430b2 100644
--- a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts
@@ -145,7 +145,7 @@ describe('usePerpsCloseAllCalculations', () => {
});
describe('Total Margin Calculation', () => {
- it('calculates total margin including P&L for single position', () => {
+ it('calculates total margin excluding P&L for single position', () => {
// Arrange
const positions = [
createMockPosition({
@@ -161,7 +161,7 @@ describe('usePerpsCloseAllCalculations', () => {
);
// Assert
- expect(result.current.totalMargin).toBe(1100); // 1000 + 100
+ expect(result.current.totalMargin).toBe(1000); // Only marginUsed, PnL excluded
});
it('calculates total margin for multiple positions', () => {
@@ -189,10 +189,10 @@ describe('usePerpsCloseAllCalculations', () => {
);
// Assert
- expect(result.current.totalMargin).toBe(1550); // (1000+100) + (500-50)
+ expect(result.current.totalMargin).toBe(1500); // 1000 + 500, PnL excluded
});
- it('handles negative P&L correctly', () => {
+ it('excludes negative P&L from margin calculation', () => {
// Arrange
const positions = [
createMockPosition({
@@ -208,7 +208,47 @@ describe('usePerpsCloseAllCalculations', () => {
);
// Assert
- expect(result.current.totalMargin).toBe(800); // 1000 - 200
+ expect(result.current.totalMargin).toBe(1000); // Only marginUsed, negative PnL excluded
+ });
+
+ it('excludes positive P&L from margin calculation', () => {
+ // Arrange
+ const positions = [
+ createMockPosition({
+ marginUsed: '500',
+ unrealizedPnl: '250',
+ }),
+ ];
+ const priceData = { BTC: { price: '52000' } };
+
+ // Act
+ const { result } = renderHook(() =>
+ usePerpsCloseAllCalculations({ positions, priceData }),
+ );
+
+ // Assert
+ expect(result.current.totalMargin).toBe(500); // Only marginUsed, positive PnL excluded
+ expect(result.current.totalPnl).toBe(250); // PnL is tracked separately
+ });
+
+ it('calculates total margin with zero P&L', () => {
+ // Arrange
+ const positions = [
+ createMockPosition({
+ marginUsed: '800',
+ unrealizedPnl: '0',
+ }),
+ ];
+ const priceData = { BTC: { price: '50000' } };
+
+ // Act
+ const { result } = renderHook(() =>
+ usePerpsCloseAllCalculations({ positions, priceData }),
+ );
+
+ // Assert
+ expect(result.current.totalMargin).toBe(800); // Only marginUsed
+ expect(result.current.totalPnl).toBe(0);
});
});
@@ -778,10 +818,12 @@ describe('usePerpsCloseAllCalculations', () => {
expect(result.current.isLoading).toBe(false);
});
// Total fee recalculated: 25 + 25 = 50
- expect(result.current.receiveAmount).toBe(1050); // (1000 + 100) - 50
+ // Receive amount: 1000 (margin only, PnL excluded) - 50 (fees) = 950
+ expect(result.current.receiveAmount).toBe(950);
+ expect(result.current.totalPnl).toBe(100); // PnL tracked separately
});
- it('handles negative receive amount when fees exceed margin', async () => {
+ it('returns zero receive amount when fees equal margin', async () => {
// Arrange
const positions = [
createMockPosition({
@@ -807,7 +849,53 @@ describe('usePerpsCloseAllCalculations', () => {
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
- expect(result.current.receiveAmount).toBe(-50); // (100 - 50) - 100
+ // Receive amount: 100 (margin only) - 100 (fees) = 0
+ expect(result.current.receiveAmount).toBe(0);
+ expect(result.current.totalPnl).toBe(-50); // PnL tracked separately
+ });
+
+ it('calculates receive amount excluding P&L for multiple positions', async () => {
+ // Arrange
+ const positions = [
+ createMockPosition({
+ coin: 'BTC',
+ marginUsed: '2000',
+ unrealizedPnl: '300',
+ }),
+ createMockPosition({
+ coin: 'ETH',
+ marginUsed: '1500',
+ unrealizedPnl: '-100',
+ }),
+ ];
+ const priceData = {
+ BTC: { price: '51000' },
+ ETH: { price: '3000' },
+ };
+ mockCalculateFees.mockResolvedValue(
+ createMockFeeResult({
+ feeAmount: 150,
+ metamaskFeeAmount: 100,
+ protocolFeeAmount: 50,
+ }),
+ );
+
+ // Act
+ const { result } = renderHook(() =>
+ usePerpsCloseAllCalculations({ positions, priceData }),
+ );
+
+ // Assert
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ // Total margin: 2000 + 1500 = 3500 (PnL excluded)
+ // Total fees: 150 + 150 = 300 (two positions)
+ // Receive amount: 3500 - 300 = 3200
+ expect(result.current.totalMargin).toBe(3500);
+ expect(result.current.totalPnl).toBe(200); // 300 - 100
+ expect(result.current.totalFees).toBe(300);
+ expect(result.current.receiveAmount).toBe(3200);
});
});
diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts
index b3ebca08f9d..a95f253d5d0 100644
--- a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts
+++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts
@@ -14,7 +14,7 @@ import { formatAccountToCaipAccountId } from '../utils/rewardsUtils';
* Aggregated calculations result for closing all positions
*/
export interface CloseAllCalculationsResult {
- /** Total margin across all positions (includes P&L) */
+ /** Total margin across all positions (excludes P&L) */
totalMargin: number;
/** Total unrealized P&L across all positions */
totalPnl: number;
@@ -120,13 +120,12 @@ export function usePerpsCloseAllCalculations({
const hasValidResultsRef = useRef(false);
const hasValidDiscountRef = useRef(false);
- // Calculate total margin (including P&L)
+ // Calculate total margin
const totalMargin = useMemo(
() =>
positions.reduce((sum, pos) => {
- const margin = parseFloat(pos.marginUsed) || 0;
- const pnl = parseFloat(pos.unrealizedPnl) || 0;
- return sum + margin + pnl;
+ const margin = Number.parseFloat(pos.marginUsed) || 0;
+ return sum + margin;
}, 0),
[positions],
);
@@ -135,7 +134,7 @@ export function usePerpsCloseAllCalculations({
const totalPnl = useMemo(
() =>
positions.reduce(
- (sum, pos) => sum + (parseFloat(pos.unrealizedPnl) || 0),
+ (sum, pos) => sum + (Number.parseFloat(pos.unrealizedPnl) || 0),
0,
),
[positions],
diff --git a/app/components/UI/Perps/utils/formatUtils.test.ts b/app/components/UI/Perps/utils/formatUtils.test.ts
index 8d9eafa47de..cf231c46565 100644
--- a/app/components/UI/Perps/utils/formatUtils.test.ts
+++ b/app/components/UI/Perps/utils/formatUtils.test.ts
@@ -15,6 +15,7 @@ import {
formatTransactionDate,
formatDateSection,
formatFundingRate,
+ formatFee,
PRICE_RANGES_UNIVERSAL,
PRICE_RANGES_MINIMAL_VIEW,
} from './formatUtils';
@@ -194,6 +195,140 @@ describe('formatUtils', () => {
});
});
+ describe('formatFee', () => {
+ it('returns "$0" when fee is exactly zero', () => {
+ // Given a fee of exactly zero
+ const fee = 0;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it returns "$0"
+ expect(result).toBe('$0');
+ });
+
+ it('returns "< $0.01" when fee is below threshold', () => {
+ // Given a fee below the 0.01 threshold
+ const fee = 0.005;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it returns "< $0.01"
+ expect(result).toBe('< $0.01');
+ });
+
+ it('formats fee normally when exactly at 0.01 threshold', () => {
+ // Given a fee at exactly the 0.01 threshold
+ const fee = 0.01;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it formats normally
+ expect(result).toBe('$0.01');
+ });
+
+ it('formats fee normally when above threshold', () => {
+ // Given a fee above the 0.01 threshold
+ const fee = 1.5;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it formats normally
+ expect(result).toBe('$1.50');
+ });
+
+ it('returns "< $0.01" for very small positive fees', () => {
+ // Given a very small positive fee
+ const fee = 0.0001;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it returns "< $0.01"
+ expect(result).toBe('< $0.01');
+ });
+
+ it('returns "< $0.01" for fee just below threshold', () => {
+ // Given a fee just below the 0.01 threshold
+ const fee = 0.0099;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it returns "< $0.01"
+ expect(result).toBe('< $0.01');
+ });
+
+ it('formats fee normally when just above threshold', () => {
+ // Given a fee just above the 0.01 threshold
+ const fee = 0.0101;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it formats normally (rounded to $0.01)
+ expect(result).toBe('$0.01');
+ });
+
+ it('formats large fees with proper decimals', () => {
+ // Given a large fee value
+ const fee = 123.45;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it formats with proper decimals
+ expect(result).toBe('$123.45');
+ });
+
+ it('strips trailing zeros for whole number fees', () => {
+ // Given a whole number fee
+ const fee = 100;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then trailing zeros are stripped
+ expect(result).toBe('$100');
+ });
+
+ it('handles fees with many decimal places', () => {
+ // Given a fee with many decimal places
+ const fee = 1.23456789;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it rounds appropriately
+ expect(result).toBe('$1.23');
+ });
+
+ it('returns "$0" for negative zero', () => {
+ // Given a negative zero value
+ const fee = -0;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it returns "$0"
+ expect(result).toBe('$0');
+ });
+
+ it('returns "< $0.01" for smallest representable positive fee', () => {
+ // Given the smallest positive fee
+ const fee = 0.00000001;
+
+ // When formatting the fee
+ const result = formatFee(fee);
+
+ // Then it returns "< $0.01"
+ expect(result).toBe('< $0.01');
+ });
+ });
+
describe('formatPerpsFiat', () => {
it('should format balance with default 2 decimal places (fiat-style stripping)', () => {
expect(formatPerpsFiat(1234.56)).toBe('$1,234.56'); // Has meaningful decimals: preserved
diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts
index 3470a1fab1b..8a965b150ab 100644
--- a/app/components/UI/Perps/utils/formatUtils.ts
+++ b/app/components/UI/Perps/utils/formatUtils.ts
@@ -1,6 +1,7 @@
/**
* Shared formatting utilities for Perps components
*/
+import { BigNumber } from 'bignumber.js';
import { formatWithThreshold } from '../../../../util/assets';
import {
FUNDING_RATE_CONFIG,
@@ -341,6 +342,26 @@ export const formatPerpsFiat = (
return formatted;
};
+/**
+ * Formats a fee value as USD currency with appropriate decimal places
+ * @param fee - Raw numeric or string fee value (e.g., 1234.56, not token minimal denomination)
+ * @returns Formatted currency string with variable decimals based on configured ranges
+ * @example formatFee(1234.56) => "$1,234.56"
+ * @example formatFee(0.005) => "< $0.01"
+ * @example formatFee(0) => "$0"
+ */
+export const formatFee = (fee: number | string): string => {
+ const smallFeeThreshold = 0.01;
+
+ if (BigNumber(fee).isEqualTo(0)) {
+ return '$0';
+ }
+ if (BigNumber(fee).isLessThan(smallFeeThreshold)) {
+ return '< $0.01';
+ }
+ return formatPerpsFiat(fee);
+};
+
/**
* Default price range configurations
* Applied in order - first matching condition wins
diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx
index 78230801149..f7a287385c8 100644
--- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx
+++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx
@@ -88,12 +88,11 @@ const renderComponent = (overrides?: Partial) => {
};
describe('PredictActivity', () => {
- it('renders BUY activity with title, market, detail, amount and percent', () => {
+ it('renders BUY activity with title, market, amount and percent', () => {
renderComponent();
expect(screen.getByText('Buy')).toBeOnTheScreen();
expect(screen.getByText(baseItem.marketTitle)).toBeOnTheScreen();
- expect(screen.getByText(baseItem.detail)).toBeOnTheScreen();
expect(screen.getByText('-$1,234.50')).toBeOnTheScreen();
expect(screen.getByText('+1.50%')).toBeOnTheScreen();
});
diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx
index e65429d3138..27f93385f4b 100644
--- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx
+++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx
@@ -32,7 +32,6 @@ const PredictActivity: React.FC = ({ item }) => {
const tw = useTailwind();
const navigation = useNavigation();
const isDebit = item.type === PredictActivityType.BUY;
- const isCredit = !isDebit;
const signedAmount = `${isDebit ? '-' : '+'}${formatPrice(
Math.abs(item.amountUsd),
{
@@ -41,7 +40,6 @@ const PredictActivity: React.FC = ({ item }) => {
},
)}`;
- const amountColor = isCredit ? 'text-success-default' : 'text-error-default';
const percentColor =
(item.percentChange ?? 0) >= 0
? 'text-success-default'
@@ -64,42 +62,31 @@ const PredictActivity: React.FC = ({ item }) => {
justifyContent={BoxJustifyContent.Between}
twClassName="w-full p-2"
>
-
- {item.icon ? (
-
- ) : (
-
- )}
+
+
+ {item.icon ? (
+
+ ) : (
+
+ )}
+
{activityTitleByType[item.type]}
-
+
{item.marketTitle}
- {item.type !== PredictActivityType.CLAIM ? (
-
- {item.detail}
-
- ) : null}
-
+
{signedAmount}
{item.percentChange !== undefined ? (
diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts
index 957b5b3a038..4c95fea1aad 100644
--- a/app/components/UI/Predict/controllers/PredictController.ts
+++ b/app/components/UI/Predict/controllers/PredictController.ts
@@ -1157,70 +1157,58 @@ export class PredictController extends BaseController<
// Track Predict Action Completed or Failed
const completionDuration = performance.now() - startTime;
- if (result.success) {
- const { spentAmount, receivedAmount } = result.response;
-
- const cachedBalance =
- this.state.balances[providerId]?.[signer.address]?.balance ?? 0;
- let realAmountUsd = amountUsd;
- let realSharePrice = sharePrice;
- try {
- if (preview.side === Side.BUY) {
- realAmountUsd = parseFloat(spentAmount);
- realSharePrice =
- parseFloat(spentAmount) / parseFloat(receivedAmount);
-
- // Optimistically update balance
- this.update((state) => {
- state.balances[providerId] = state.balances[providerId] || {};
- state.balances[providerId][signer.address] = {
- balance: cachedBalance - realAmountUsd,
- // valid for 5 seconds (since it takes some time to reflect balance on-chain)
- validUntil: Date.now() + 5000,
- };
- });
- } else {
- realAmountUsd = parseFloat(receivedAmount);
- realSharePrice =
- parseFloat(receivedAmount) / parseFloat(spentAmount);
-
- // Optimistically update balance
- this.update((state) => {
- state.balances[providerId] = state.balances[providerId] || {};
- state.balances[providerId][signer.address] = {
- balance: cachedBalance + realAmountUsd,
- // valid for 5 seconds (since it takes some time to reflect balance on-chain)
- validUntil: Date.now() + 5000,
- };
- });
- }
- } catch (_e) {
- // If we can't get real share price, continue without it
- }
-
- // Track Predict Action Completed (fire and forget)
- this.trackPredictOrderEvent({
- eventType: PredictEventType.COMPLETED,
- amountUsd: realAmountUsd,
- analyticsProperties,
- providerId,
- completionDuration,
- sharePrice: realSharePrice,
- });
- } else {
- // Track Predict Action Failed (fire and forget)
- this.trackPredictOrderEvent({
- eventType: PredictEventType.FAILED,
- amountUsd,
- analyticsProperties,
- providerId,
- sharePrice,
- completionDuration,
- failureReason: result.error || 'Unknown error',
- });
+ if (!result.success) {
throw new Error(result.error);
}
+ const { spentAmount, receivedAmount } = result.response;
+
+ const cachedBalance =
+ this.state.balances[providerId]?.[signer.address]?.balance ?? 0;
+ let realAmountUsd = amountUsd;
+ let realSharePrice = sharePrice;
+ try {
+ if (preview.side === Side.BUY) {
+ realAmountUsd = parseFloat(spentAmount);
+ realSharePrice = parseFloat(spentAmount) / parseFloat(receivedAmount);
+
+ // Optimistically update balance
+ this.update((state) => {
+ state.balances[providerId] = state.balances[providerId] || {};
+ state.balances[providerId][signer.address] = {
+ balance: cachedBalance - realAmountUsd,
+ // valid for 5 seconds (since it takes some time to reflect balance on-chain)
+ validUntil: Date.now() + 5000,
+ };
+ });
+ } else {
+ realAmountUsd = parseFloat(receivedAmount);
+ realSharePrice = parseFloat(receivedAmount) / parseFloat(spentAmount);
+
+ // Optimistically update balance
+ this.update((state) => {
+ state.balances[providerId] = state.balances[providerId] || {};
+ state.balances[providerId][signer.address] = {
+ balance: cachedBalance + realAmountUsd,
+ // valid for 5 seconds (since it takes some time to reflect balance on-chain)
+ validUntil: Date.now() + 5000,
+ };
+ });
+ }
+ } catch (_e) {
+ // If we can't get real share price, continue without it
+ }
+
+ // Track Predict Action Completed (fire and forget)
+ this.trackPredictOrderEvent({
+ eventType: PredictEventType.COMPLETED,
+ amountUsd: realAmountUsd,
+ analyticsProperties,
+ providerId,
+ completionDuration,
+ sharePrice: realSharePrice,
+ });
+
return result as unknown as Result;
} catch (error) {
const completionDuration = performance.now() - startTime;
@@ -1229,6 +1217,7 @@ export class PredictController extends BaseController<
? error.message
: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED;
+ // Track Predict Action Failed (fire and forget)
this.trackPredictOrderEvent({
eventType: PredictEventType.FAILED,
amountUsd,
diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
index 09a49af9e84..90bd9a40df3 100644
--- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
+++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
@@ -711,7 +711,10 @@ export class PolymarketProvider implements PredictProvider {
if (error.includes(`order couldn't be fully filled`)) {
throw new Error(PREDICT_ERROR_CODES.ORDER_NOT_FULLY_FILLED);
}
- if (error.includes(`not available in your region`)) {
+ if (
+ error.includes(`not available in your region`) ||
+ error.includes(`unable to access this provider`)
+ ) {
throw new Error(PREDICT_ERROR_CODES.NOT_ELIGIBLE);
}
throw new Error(error ?? PREDICT_ERROR_CODES.PLACE_ORDER_FAILED);
diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts
index 9af965eb9c0..5ff1b1f4fcd 100644
--- a/app/components/UI/Predict/providers/polymarket/utils.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts
@@ -2085,13 +2085,13 @@ describe('polymarket utils', () => {
}
});
- it('maps non-TRADE to claimWinnings entries and handles defaults', () => {
+ it('maps REDEEM with payout to claimWinnings entries', () => {
const input = [
{
type: 'REDEEM' as const,
side: '' as const,
timestamp: 3000,
- usdcSize: 1.23,
+ usdcSize: 1.23, // Winning claim with actual payout
price: 0,
conditionId: '',
outcomeIndex: 0,
@@ -2102,11 +2102,32 @@ describe('polymarket utils', () => {
},
];
const result = parsePolymarketActivity(input);
+ expect(result).toHaveLength(1);
expect(result[0].entry.type).toBe('claimWinnings');
expect(result[0].entry.amount).toBe(1.23);
expect(result[0].id).toBe('0xhash3');
});
+ it('filters out REDEEM activities with no payout (lost positions)', () => {
+ const input = [
+ {
+ type: 'REDEEM' as const,
+ side: '' as const,
+ timestamp: 3000,
+ usdcSize: 0, // No payout - lost position
+ price: 0,
+ conditionId: '',
+ outcomeIndex: 0,
+ title: 'Lost Market',
+ outcome: '' as const,
+ icon: '',
+ transactionHash: '0xhash3',
+ },
+ ];
+ const result = parsePolymarketActivity(input);
+ expect(result).toHaveLength(0);
+ });
+
it('generates fallback id and timestamp when missing', () => {
const input = [
{
diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts
index 119c9829a5c..798df38283a 100644
--- a/app/components/UI/Predict/providers/polymarket/utils.ts
+++ b/app/components/UI/Predict/providers/polymarket/utils.ts
@@ -450,6 +450,7 @@ export const parsePolymarketEvents = (
/**
* Normalizes Polymarket /activity entries to PredictActivity[]
* Keeps essential metadata used by UI (title/outcome/icon)
+ * Filters out claim activities with no payout (lost positions - technical clearing only)
*/
export const parsePolymarketActivity = (
activities: PolymarketApiActivity[],
@@ -458,53 +459,67 @@ export const parsePolymarketActivity = (
return [];
}
- const parsedActivities: PredictActivity[] = activities.map((activity) => {
- // Normalize entry type: TRADE with explicit side => buy/sell, otherwise claimWinnings
- const entryType: 'buy' | 'sell' | 'claimWinnings' =
- activity.type === 'TRADE'
- ? activity.side === 'BUY'
- ? 'buy'
- : activity.side === 'SELL'
- ? 'sell'
- : 'claimWinnings'
- : 'claimWinnings';
-
- const id =
- activity.transactionHash ?? String(activity.timestamp ?? Math.random());
- const timestamp = Number(activity.timestamp ?? Date.now());
-
- const price = Number(activity.price ?? 0);
- const amount = Number(activity.usdcSize ?? 0);
-
- const outcomeId = String(activity.conditionId ?? '');
- const marketId = String(activity.conditionId ?? '');
- const outcomeTokenId = Number(activity.outcomeIndex ?? 0);
- const title = String(activity.title ?? 'Market');
- const outcome = activity.outcome ? String(activity.outcome) : undefined;
- const icon = activity.icon as string | undefined;
-
- const parsedActivity: PredictActivity = {
- id,
- providerId: 'polymarket',
- entry:
- entryType === 'claimWinnings'
- ? { type: 'claimWinnings', timestamp, amount }
- : {
- type: entryType,
- timestamp,
- marketId,
- outcomeId,
- outcomeTokenId,
- amount,
- price,
- },
- title,
- outcome,
- icon,
- } as PredictActivity & { title?: string; outcome?: string; icon?: string };
-
- return parsedActivity;
- });
+ const parsedActivities: PredictActivity[] = activities
+ .map((activity) => {
+ // Normalize entry type: TRADE with explicit side => buy/sell, otherwise claimWinnings
+ const entryType: 'buy' | 'sell' | 'claimWinnings' =
+ activity.type === 'TRADE'
+ ? activity.side === 'BUY'
+ ? 'buy'
+ : activity.side === 'SELL'
+ ? 'sell'
+ : 'claimWinnings'
+ : 'claimWinnings';
+
+ const id =
+ activity.transactionHash ?? String(activity.timestamp ?? Math.random());
+ const timestamp = Number(activity.timestamp ?? Date.now());
+
+ const price = Number(activity.price ?? 0);
+ const amount = Number(activity.usdcSize ?? 0);
+
+ const outcomeId = String(activity.conditionId ?? '');
+ const marketId = String(activity.conditionId ?? '');
+ const outcomeTokenId = Number(activity.outcomeIndex ?? 0);
+ const title = String(activity.title ?? 'Market');
+ const outcome = activity.outcome ? String(activity.outcome) : undefined;
+ const icon = activity.icon as string | undefined;
+
+ const parsedActivity: PredictActivity = {
+ id,
+ providerId: 'polymarket',
+ entry:
+ entryType === 'claimWinnings'
+ ? { type: 'claimWinnings', timestamp, amount }
+ : {
+ type: entryType,
+ timestamp,
+ marketId,
+ outcomeId,
+ outcomeTokenId,
+ amount,
+ price,
+ },
+ title,
+ outcome,
+ icon,
+ } as PredictActivity & {
+ title?: string;
+ outcome?: string;
+ icon?: string;
+ };
+
+ return parsedActivity;
+ })
+ .filter((activity) => {
+ // Filter out claim activities with no actual payout
+ // These are lost positions being cleared - just technical operations with no transaction value
+ if (activity.entry.type === 'claimWinnings') {
+ return activity.entry.amount > 0;
+ }
+ return true;
+ });
+
return parsedActivities;
};
@@ -756,6 +771,9 @@ export async function calculateFees({
totalFee = (userBetAmount * FEE_PERCENTAGE) / 100;
+ // Round to 4 decimals
+ totalFee = Math.round(totalFee * 10000) / 10000;
+
// split total 50/50 between metamask and provider
const metamaskFee = totalFee / 2;
const providerFee = totalFee - metamaskFee;
diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx
index bf3ce236c19..6ea227a355a 100644
--- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx
+++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx
@@ -107,6 +107,8 @@ describe('PredictTransactionsView', () => {
});
it('renders list items mapped from activity entries', () => {
+ const mockTimestamp = Math.floor(Date.now() / 1000); // Current time in seconds
+
(usePredictActivity as jest.Mock).mockReturnValueOnce({
isLoading: false,
activity: [
@@ -115,28 +117,52 @@ describe('PredictTransactionsView', () => {
title: 'Market A',
outcome: 'Yes',
icon: 'https://example.com/a.png',
- entry: { type: 'buy', amount: 50, price: 0.34 },
+ entry: {
+ type: 'buy',
+ amount: 50,
+ price: 0.34,
+ timestamp: mockTimestamp,
+ marketId: 'market-a',
+ outcomeId: 'outcome-yes',
+ outcomeTokenId: 1,
+ },
},
{
id: 'b2',
title: 'Market B',
outcome: 'No',
icon: 'https://example.com/b.png',
- entry: { type: 'sell', amount: 12.3, price: 0.7 },
+ entry: {
+ type: 'sell',
+ amount: 12.3,
+ price: 0.7,
+ timestamp: mockTimestamp - 100,
+ marketId: 'market-b',
+ outcomeId: 'outcome-no',
+ outcomeTokenId: 2,
+ },
},
{
id: 'c3',
title: 'Market C',
outcome: 'Yes',
icon: 'https://example.com/c.png',
- entry: { type: 'claimWinnings', amount: 99.99 },
+ entry: {
+ type: 'claimWinnings',
+ amount: 99.99,
+ timestamp: mockTimestamp - 200,
+ },
},
{
id: 'd4',
title: 'Market D',
outcome: 'Yes',
icon: 'https://example.com/d.png',
- entry: { type: 'unknown', amount: 1.23 },
+ entry: {
+ type: 'unknown',
+ amount: 1.23,
+ timestamp: mockTimestamp - 300,
+ },
},
],
});
diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx
index aa03447a063..44286d1bd49 100644
--- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx
+++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx
@@ -1,5 +1,5 @@
-import React, { useMemo, useEffect } from 'react';
-import { ActivityIndicator, FlatList } from 'react-native';
+import React, { useMemo, useEffect, useCallback } from 'react';
+import { ActivityIndicator, SectionList } from 'react-native';
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import PredictActivity from '../../components/PredictActivity/PredictActivity';
@@ -16,6 +16,46 @@ interface PredictTransactionsViewProps {
isVisible?: boolean;
}
+interface ActivitySection {
+ title: string;
+ data: PredictActivityItem[];
+}
+
+/**
+ * Groups activities by individual day (Today, Yesterday, or specific date)
+ * Matches Perps date format: "Today", "Yesterday", or "Jan 15"
+ * @param timestamp Unix timestamp in seconds
+ * @param todayTime Start of today in milliseconds
+ * @param yesterdayTime Start of yesterday in milliseconds
+ */
+const getDateGroupLabel = (
+ timestamp: number,
+ todayTime: number,
+ yesterdayTime: number,
+): string => {
+ // Convert timestamp from seconds to milliseconds
+ const timestampMs = timestamp * 1000;
+ const activityDate = new Date(timestampMs);
+
+ // Reset time to start of day for accurate comparison
+ activityDate.setHours(0, 0, 0, 0);
+ const activityTime = activityDate.getTime();
+
+ if (activityTime === todayTime) {
+ return strings('predict.transactions.today');
+ } else if (activityTime === yesterdayTime) {
+ return strings('predict.transactions.yesterday');
+ }
+
+ // Format all other dates as "MMM D" (e.g., "Jan 15") to match Perps
+ const formatter = new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ });
+
+ return formatter.format(activityDate);
+};
+
const PredictTransactionsView: React.FC = ({
isVisible,
}) => {
@@ -31,89 +71,173 @@ const PredictTransactionsView: React.FC = ({
}
}, [isVisible, isLoading]);
- const items: PredictActivityItem[] = useMemo(
- () =>
- activity.map((activityEntry) => {
- const e = activityEntry.entry;
-
- switch (e.type) {
- case 'buy': {
- const amountUsd = e.amount;
- const priceCents = formatCents(e.price ?? 0);
- const outcome = activityEntry.outcome;
-
- return {
- id: activityEntry.id,
- type: PredictActivityType.BUY,
- marketTitle: activityEntry.title ?? '',
- detail: strings('predict.transactions.buy_detail', {
- amountUsd,
- outcome,
- priceCents,
- }),
+ const sections: ActivitySection[] = useMemo(() => {
+ // Cache today and yesterday timestamps for reuse
+ const now = Date.now();
+ const today = new Date(now);
+ const yesterday = new Date(now - 24 * 60 * 60 * 1000);
+ today.setHours(0, 0, 0, 0);
+ yesterday.setHours(0, 0, 0, 0);
+ const todayTime = today.getTime();
+ const yesterdayTime = yesterday.getTime();
+
+ // Pre-compute date order labels
+ const todayLabel = strings('predict.transactions.today');
+ const yesterdayLabel = strings('predict.transactions.yesterday');
+
+ // Map and group in a single pass for better performance
+ const groupedByDate: Record = {};
+ const sectionOrder: string[] = [];
+
+ activity.forEach((activityEntry) => {
+ const e = activityEntry.entry;
+
+ // Map activity to item
+ let item: PredictActivityItem;
+ switch (e.type) {
+ case 'buy': {
+ const amountUsd = e.amount;
+ const priceCents = formatCents(e.price ?? 0);
+ const outcome = activityEntry.outcome;
+
+ item = {
+ id: activityEntry.id,
+ type: PredictActivityType.BUY,
+ marketTitle: activityEntry.title ?? '',
+ detail: strings('predict.transactions.buy_detail', {
amountUsd,
- icon: activityEntry.icon,
outcome,
- providerId: activityEntry.providerId,
- entry: e,
- };
- }
- case 'sell': {
- const amountUsd = e.amount;
- const priceCents = formatCents(e.price ?? 0);
- return {
- id: activityEntry.id,
- type: PredictActivityType.SELL,
- marketTitle: activityEntry.title ?? '',
- detail: strings('predict.transactions.sell_detail', {
- priceCents,
- }),
- amountUsd,
- icon: activityEntry.icon,
- outcome: activityEntry.outcome,
- providerId: activityEntry.providerId,
- entry: e,
- };
- }
- case 'claimWinnings': {
- const amountUsd = e.amount;
- return {
- id: activityEntry.id,
- type: PredictActivityType.CLAIM,
- marketTitle: activityEntry.title ?? '',
- detail: strings('predict.transactions.claim_detail'),
- amountUsd,
- icon: activityEntry.icon,
- outcome: activityEntry.outcome,
- providerId: activityEntry.providerId,
- entry: e,
- };
- }
- default: {
- return {
- id: activityEntry.id,
- type: PredictActivityType.CLAIM,
- marketTitle: activityEntry.title ?? '',
- detail: strings('predict.transactions.claim_detail'),
- amountUsd: 0,
- icon: activityEntry.icon,
- outcome: activityEntry.outcome,
- providerId: activityEntry.providerId,
- entry: e,
- };
- }
+ priceCents,
+ }),
+ amountUsd,
+ icon: activityEntry.icon,
+ outcome,
+ providerId: activityEntry.providerId,
+ entry: e,
+ };
+ break;
+ }
+ case 'sell': {
+ const amountUsd = e.amount;
+ const priceCents = formatCents(e.price ?? 0);
+ item = {
+ id: activityEntry.id,
+ type: PredictActivityType.SELL,
+ marketTitle: activityEntry.title ?? '',
+ detail: strings('predict.transactions.sell_detail', {
+ priceCents,
+ }),
+ amountUsd,
+ icon: activityEntry.icon,
+ outcome: activityEntry.outcome,
+ providerId: activityEntry.providerId,
+ entry: e,
+ };
+ break;
}
- }),
- [activity],
+ case 'claimWinnings': {
+ const amountUsd = e.amount;
+ item = {
+ id: activityEntry.id,
+ type: PredictActivityType.CLAIM,
+ marketTitle: activityEntry.title ?? '',
+ detail: strings('predict.transactions.claim_detail'),
+ amountUsd,
+ icon: activityEntry.icon,
+ outcome: activityEntry.outcome,
+ providerId: activityEntry.providerId,
+ entry: e,
+ };
+ break;
+ }
+ default: {
+ item = {
+ id: activityEntry.id,
+ type: PredictActivityType.CLAIM,
+ marketTitle: activityEntry.title ?? '',
+ detail: strings('predict.transactions.claim_detail'),
+ amountUsd: 0,
+ icon: activityEntry.icon,
+ outcome: activityEntry.outcome,
+ providerId: activityEntry.providerId,
+ entry: e,
+ };
+ break;
+ }
+ }
+
+ // Group by date
+ const dateLabel = getDateGroupLabel(
+ item.entry.timestamp,
+ todayTime,
+ yesterdayTime,
+ );
+
+ if (!groupedByDate[dateLabel]) {
+ groupedByDate[dateLabel] = [];
+ sectionOrder.push(dateLabel);
+ }
+ groupedByDate[dateLabel].push(item);
+ });
+
+ // Convert to sections array, maintaining chronological order
+ const sections: ActivitySection[] = [];
+
+ // Add Today first if it exists
+ if (groupedByDate[todayLabel]) {
+ sections.push({ title: todayLabel, data: groupedByDate[todayLabel] });
+ }
+
+ // Add Yesterday second if it exists
+ if (groupedByDate[yesterdayLabel]) {
+ sections.push({
+ title: yesterdayLabel,
+ data: groupedByDate[yesterdayLabel],
+ });
+ }
+
+ // Add all other dates in chronological order
+ sectionOrder.forEach((label) => {
+ if (label !== todayLabel && label !== yesterdayLabel) {
+ sections.push({ title: label, data: groupedByDate[label] });
+ }
+ });
+
+ return sections;
+ }, [activity]);
+
+ const renderSectionHeader = useCallback(
+ ({ section }: { section: ActivitySection }) => (
+
+
+ {section.title}
+
+
+ ),
+ [],
+ );
+
+ const renderItem = useCallback(
+ ({ item }: { item: PredictActivityItem }) => (
+
+
+
+ ),
+ [],
);
+ const keyExtractor = useCallback((item: PredictActivityItem) => item.id, []);
+
return (
{isLoading ? (
- ) : items.length === 0 ? (
+ ) : sections.length === 0 ? (
= ({
) : (
// TODO: Improve loading state, pagination, consider FlashList for better performance, pull down to refresh, etc.
-
- data={items}
- keyExtractor={(item) => item.id}
- renderItem={({ item }) => (
-
-
-
- )}
+
+ sections={sections}
+ keyExtractor={keyExtractor}
+ renderItem={renderItem}
+ renderSectionHeader={renderSectionHeader}
contentContainerStyle={tw.style('p-2')}
showsVerticalScrollIndicator={false}
- nestedScrollEnabled
style={tw.style('flex-1')}
+ stickySectionHeadersEnabled
+ removeClippedSubviews
+ maxToRenderPerBatch={10}
+ updateCellsBatchingPeriod={50}
+ initialNumToRender={10}
+ windowSize={5}
/>
)}
diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx
index 13cb0aa9faf..98e17969992 100644
--- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx
@@ -100,6 +100,7 @@ import { trace, endTrace, TraceName } from '../../../../../../util/trace';
import { CHAIN_IDS } from '@metamask/transaction-controller';
import { createUnsupportedRegionModalNavigationDetails } from '../../components/UnsupportedRegionModal';
import { regex } from '../../../../../../util/regex';
+import { createBuySettingsModalNavigationDetails } from '../Modals/Settings/SettingsModal';
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -417,6 +418,10 @@ const BuildQuote = () => {
}
}, [screenLocation, isBuy, selectedAsset?.network?.chainId, trackEvent]);
+ const handleConfigurationPress = useCallback(() => {
+ navigation.navigate(...createBuySettingsModalNavigationDetails());
+ }, [navigation]);
+
useEffect(() => {
navigation.setOptions(
getDepositNavbarOptions(
@@ -426,12 +431,21 @@ const BuildQuote = () => {
? strings('fiat_on_ramp_aggregator.amount_to_buy')
: strings('fiat_on_ramp_aggregator.amount_to_sell'),
showBack: params.showBack,
+ showConfiguration: isBuy,
+ onConfigurationPress: handleConfigurationPress,
},
theme,
handleCancelPress,
),
);
- }, [navigation, theme, handleCancelPress, params.showBack, isBuy]);
+ }, [
+ navigation,
+ theme,
+ handleCancelPress,
+ params.showBack,
+ isBuy,
+ handleConfigurationPress,
+ ]);
/**
* * Keypad style, handlers and effects
diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx
new file mode 100644
index 00000000000..2f485b13143
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx
@@ -0,0 +1,150 @@
+// Third party dependencies.
+import { fireEvent } from '@testing-library/react-native';
+
+// Internal dependencies.
+import SettingsModal from './SettingsModal';
+import { renderScreen } from '../../../../../../../util/test/renderWithProvider';
+import { backgroundState } from '../../../../../../../util/test/initial-root-state';
+import Routes from '../../../../../../../constants/navigation/Routes';
+import { createDepositNavigationDetails } from '../../../../Deposit/routes/utils';
+
+const mockNavigate = jest.fn();
+const mockGoBack = jest.fn();
+const mockDangerouslyGetParent = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ ...actualReactNavigation.useNavigation(),
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ dangerouslyGetParent: mockDangerouslyGetParent,
+ }),
+ };
+});
+
+function render() {
+ return renderScreen(
+ SettingsModal,
+ {
+ name: 'SettingsModal',
+ },
+ {
+ state: {
+ engine: {
+ backgroundState,
+ },
+ },
+ },
+ );
+}
+
+describe('SettingsModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockDangerouslyGetParent.mockReturnValue({
+ dangerouslyGetParent: jest.fn().mockReturnValue({
+ goBack: jest.fn(),
+ }),
+ });
+ });
+
+ it('renders snapshot correctly', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('displays settings title in header', () => {
+ const { getByText } = render();
+
+ expect(getByText('Settings')).toBeTruthy();
+ });
+
+ it('displays view order history menu item', () => {
+ const { getByText } = render();
+
+ expect(getByText('View order history')).toBeTruthy();
+ });
+
+ it('displays use new buy experience menu item', () => {
+ const { getByText } = render();
+
+ expect(getByText('Use new buy experience')).toBeTruthy();
+ expect(getByText('Try new native on ramp')).toBeTruthy();
+ });
+
+ it('navigates to transactions view when view order history is pressed', () => {
+ const { getByText } = render();
+ const viewOrderHistoryButton = getByText('View order history');
+
+ fireEvent.press(viewOrderHistoryButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW, {
+ screen: Routes.TRANSACTIONS_VIEW,
+ params: {
+ redirectToOrders: true,
+ },
+ });
+ });
+
+ it('navigates to deposit when use new buy experience is pressed', () => {
+ const { getByText } = render();
+ const newBuyExperienceButton = getByText('Use new buy experience');
+
+ fireEvent.press(newBuyExperienceButton);
+
+ expect(mockDangerouslyGetParent).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(
+ ...createDepositNavigationDetails(),
+ );
+ });
+
+ it('navigates back through parent navigation when deposit is pressed', () => {
+ const mockParentGoBack = jest.fn();
+ mockDangerouslyGetParent.mockReturnValue({
+ dangerouslyGetParent: jest.fn().mockReturnValue({
+ goBack: mockParentGoBack,
+ }),
+ });
+
+ const { getByText } = render();
+ const newBuyExperienceButton = getByText('Use new buy experience');
+
+ fireEvent.press(newBuyExperienceButton);
+
+ expect(mockParentGoBack).toHaveBeenCalled();
+ });
+
+ describe('bottom sheet behavior', () => {
+ it('renders bottom sheet with settings content', () => {
+ const { getByText } = render();
+
+ expect(getByText('Settings')).toBeTruthy();
+ });
+ });
+
+ describe('menu item icons', () => {
+ it('renders clock icon for view order history', () => {
+ const { getByText } = render();
+
+ expect(getByText('View order history')).toBeTruthy();
+ });
+
+ it('renders add icon for new buy experience', () => {
+ const { getByText } = render();
+
+ expect(getByText('Use new buy experience')).toBeTruthy();
+ });
+ });
+
+ describe('callback functions', () => {
+ it('calls navigation callbacks only when menu items are pressed', () => {
+ render();
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockDangerouslyGetParent).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx
new file mode 100644
index 00000000000..83827590482
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx
@@ -0,0 +1,67 @@
+import React, { useCallback, useRef } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { strings } from '../../../../../../../../locales/i18n';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import { IconName } from '../../../../../../../component-library/components/Icons/Icon';
+import Routes from '../../../../../../../constants/navigation/Routes';
+import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils';
+import MenuItem from '../../../../components/MenuItem';
+import { createDepositNavigationDetails } from '../../../../Deposit/routes/utils';
+
+export const createBuySettingsModalNavigationDetails = createNavigationDetails(
+ Routes.RAMP.MODALS.ID,
+ Routes.RAMP.MODALS.SETTINGS,
+);
+
+function SettingsModal() {
+ const sheetRef = useRef(null);
+ const navigation = useNavigation();
+
+ const handleNavigateToOrderHistory = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet();
+ navigation.navigate(Routes.TRANSACTIONS_VIEW, {
+ screen: Routes.TRANSACTIONS_VIEW,
+ params: {
+ redirectToOrders: true,
+ },
+ });
+ }, [navigation]);
+
+ const handleDepositPress = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet();
+ navigation.dangerouslyGetParent()?.dangerouslyGetParent()?.goBack();
+ navigation.navigate(...createDepositNavigationDetails());
+ }, [navigation]);
+
+ const handleClosePress = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet();
+ }, []);
+
+ return (
+
+
+ {strings('fiat_on_ramp_aggregator.settings_modal.title')}
+
+
+
+
+ );
+}
+
+export default SettingsModal;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap
new file mode 100644
index 00000000000..8616d94bd89
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap
@@ -0,0 +1,728 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SettingsModal renders snapshot correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ SettingsModal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View order history
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use new buy experience
+
+
+ Try new native on ramp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/index.ts b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/index.ts
new file mode 100644
index 00000000000..a5bf953a756
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/index.ts
@@ -0,0 +1 @@
+export { default } from './SettingsModal';
diff --git a/app/components/UI/Ramp/Aggregator/routes/index.tsx b/app/components/UI/Ramp/Aggregator/routes/index.tsx
index 50f349e2d83..a8904721176 100644
--- a/app/components/UI/Ramp/Aggregator/routes/index.tsx
+++ b/app/components/UI/Ramp/Aggregator/routes/index.tsx
@@ -15,6 +15,7 @@ import { colors } from '../../../../../styles/common';
import IncompatibleAccountTokenModal from '../components/IncompatibleAccountTokenModal';
import RegionSelectorModal from '../components/RegionSelectorModal';
import UnsupportedRegionModal from '../components/UnsupportedRegionModal';
+import SettingsModal from '../Views/Modals/Settings';
const Stack = createStackNavigator();
const ModalsStack = createStackNavigator();
@@ -92,6 +93,10 @@ const RampModalsRoutes = () => (
name={Routes.RAMP.MODALS.UNSUPPORTED_REGION}
component={UnsupportedRegionModal}
/>
+
);
diff --git a/app/components/UI/Ramp/Aggregator/routes/utils.ts b/app/components/UI/Ramp/Aggregator/routes/utils.ts
index b160f44769c..4bfa48a5c5d 100644
--- a/app/components/UI/Ramp/Aggregator/routes/utils.ts
+++ b/app/components/UI/Ramp/Aggregator/routes/utils.ts
@@ -1,5 +1,6 @@
import { RampIntent, RampType } from '../types';
import Routes from '../../../../../constants/navigation/Routes';
+// import useRampsUnifiedV1Enabled from '../../hooks/useRampsUnifiedV1Enabled';
function createRampNavigationDetails(rampType: RampType, intent?: RampIntent) {
const route = rampType === RampType.BUY ? Routes.RAMP.BUY : Routes.RAMP.SELL;
@@ -19,6 +20,17 @@ function createRampNavigationDetails(rampType: RampType, intent?: RampIntent) {
}
export function createBuyNavigationDetails(intent?: RampIntent) {
+ // TODO: Use goToRamps hook for managing ramps navigation
+ // https://consensyssoftware.atlassian.net/browse/TRAM-2813
+ // const isRampsUnifiedV1Enabled = useRampsUnifiedV1Enabled();
+ // if (isRampsUnifiedV1Enabled) {
+ // return [
+ // Routes.RAMP.TOKEN_SELECTION,
+ // {
+ // rampType: 'BUY',
+ // },
+ // ];
+ // }
return createRampNavigationDetails(RampType.BUY, intent);
}
diff --git a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
index b93d4c2e455..4bdcce5f13d 100644
--- a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
@@ -183,7 +183,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
>
- StyleSheet.create({
- container: {
- paddingTop: 6,
- },
- });
-
-export default styleSheet;
diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx
index 451afb70e7e..e7f5cf2a242 100644
--- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx
@@ -7,6 +7,7 @@ import { fireEvent, waitFor } from '@testing-library/react-native';
import Routes from '../../../../../../../constants/navigation/Routes';
import { TRANSAK_SUPPORT_URL } from '../../../constants/constants';
import { ToastContext } from '../../../../../../../component-library/components/Toast';
+import { createBuyNavigationDetails } from '../../../../Aggregator/routes/utils';
const mockShowToast = jest.fn();
const mockToastRef = {
@@ -51,6 +52,7 @@ jest.mock('@react-navigation/native', () => {
return {
...actualReactNavigation,
useNavigation: () => ({
+ ...actualReactNavigation.useNavigation(),
navigate: mockNavigate,
goBack: mockGoBack,
setOptions: mockSetNavigationOptions.mockImplementation(
@@ -112,6 +114,13 @@ describe('ConfigurationModal', () => {
});
});
+ it('navigates to aggregator when more ways to buy is pressed', () => {
+ const { getByText } = renderWithProvider(ConfigurationModal);
+ const moreWaysToBuyButton = getByText('More ways to buy');
+ fireEvent.press(moreWaysToBuyButton);
+ expect(mockNavigate).toHaveBeenCalledWith(...createBuyNavigationDetails());
+ });
+
it('should open support URL when contact support is pressed', () => {
const { getByText } = renderWithProvider(ConfigurationModal);
const contactSupportButton = getByText('Contact support');
@@ -129,13 +138,13 @@ describe('ConfigurationModal', () => {
it('should display logout option', () => {
const { getByText } = renderWithProvider(ConfigurationModal);
- expect(getByText('Log out')).toBeTruthy();
+ expect(getByText('Log out of Transak')).toBeTruthy();
});
it('should clear auth token and show success toast when logout is successful', async () => {
mockClearAuthToken.mockResolvedValue(undefined);
const { getByText } = renderWithProvider(ConfigurationModal);
- const logoutButton = getByText('Log out');
+ const logoutButton = getByText('Log out of Transak');
fireEvent.press(logoutButton);
expect(mockClearAuthToken).toHaveBeenCalled();
@@ -155,7 +164,7 @@ describe('ConfigurationModal', () => {
const mockError = new Error('Logout failed');
mockClearAuthToken.mockRejectedValue(mockError);
const { getByText } = renderWithProvider(ConfigurationModal);
- const logoutButton = getByText('Log out');
+ const logoutButton = getByText('Log out of Transak');
fireEvent.press(logoutButton);
diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx
index 838cac9ab50..9d0b41720cd 100644
--- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx
@@ -1,25 +1,15 @@
import React, { useCallback, useRef, useContext } from 'react';
-import { Linking, View } from 'react-native';
-import Text, {
- TextVariant,
-} from '../../../../../../../component-library/components/Texts/Text';
+import { Linking } from 'react-native';
import BottomSheet, {
BottomSheetRef,
} from '../../../../../../../component-library/components/BottomSheets/BottomSheet';
-import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect';
-import ListItemColumn, {
- WidthType,
-} from '../../../../../../../component-library/components/List/ListItemColumn';
-import Icon, {
+import {
IconName,
- IconSize,
IconColor,
} from '../../../../../../../component-library/components/Icons/Icon';
-import { useStyles } from '../../../../../../hooks/useStyles';
-import styleSheet from './ConfigurationModal.styles';
-
import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils';
+import { createBuyNavigationDetails } from '../../../../Aggregator/routes/utils';
import Routes from '../../../../../../../constants/navigation/Routes';
import { strings } from '../../../../../../../../locales/i18n';
import { TRANSAK_SUPPORT_URL } from '../../../constants/constants';
@@ -30,6 +20,8 @@ import {
ToastVariants,
} from '../../../../../../../component-library/components/Toast';
import Logger from '../../../../../../../util/Logger';
+import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import MenuItem from '../../../../components/MenuItem';
export const createConfigurationModalNavigationDetails =
createNavigationDetails(
@@ -37,39 +29,8 @@ export const createConfigurationModalNavigationDetails =
Routes.DEPOSIT.MODALS.CONFIGURATION,
);
-interface MenuItemProps {
- iconName: IconName;
- title: string;
- onPress: () => void;
-}
-
-function MenuItem({ iconName, title, onPress }: MenuItemProps) {
- const { theme } = useStyles(styleSheet, {});
-
- return (
-
-
-
-
-
- {title}
-
-
- );
-}
-
function ConfigurationModal() {
const sheetRef = useRef(null);
- const { styles } = useStyles(styleSheet, {});
const navigation = useNavigation();
const { toastRef } = useContext(ToastContext);
@@ -90,6 +51,11 @@ function ConfigurationModal() {
Linking.openURL(TRANSAK_SUPPORT_URL);
}, []);
+ const handleNavigateToAggregator = useCallback(() => {
+ navigation.dangerouslyGetParent()?.dangerouslyGetParent()?.goBack();
+ navigation.navigate(...createBuyNavigationDetails());
+ }, [navigation]);
+
const handleLogOut = useCallback(async () => {
try {
await logoutFromProvider();
@@ -118,29 +84,43 @@ function ConfigurationModal() {
}
}, [logoutFromProvider, toastRef]);
+ const handleClosePress = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet();
+ }, []);
+
return (
-
-
-
+
+ {strings('deposit.configuration_modal.title')}
+
+
+
+
+
+ {isAuthenticated && (
+ )}
- {isAuthenticated && (
-
+
);
}
diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap
index aac0a38ecbf..f057f3d43ff 100644
--- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap
@@ -430,189 +430,375 @@ exports[`ConfigurationModal render matches snapshot 1`] = `
/>
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
+ View order history
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
- View order history
-
-
+ Contact support
+
-
-
+
+
+
-
-
-
-
+
+
+
+
-
+ More ways to buy
+
+
-
- Contact support
-
-
+ Use a different payment provider
+
-
-
+
+
diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx
index 8e678dc0ec1..1b19d74051c 100644
--- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx
@@ -5,6 +5,7 @@ import { CaipChainId } from '@metamask/utils';
import NetworksFilterBar from '../../../components/NetworksFilterBar';
import NetworksFilterSelector from '../../../components/NetworksFilterSelector/NetworksFilterSelector';
+import TokenListItem from '../../../../components/TokenListItem';
import Text, {
TextVariant,
@@ -14,15 +15,6 @@ import BottomSheet, {
} from '../../../../../../../component-library/components/BottomSheets/BottomSheet';
import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader';
import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect';
-import ListItemColumn, {
- WidthType,
-} from '../../../../../../../component-library/components/List/ListItemColumn';
-import AvatarToken from '../../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
-import { AvatarSize } from '../../../../../../../component-library/components/Avatars/Avatar';
-import BadgeNetwork from '../../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork';
-import BadgeWrapper, {
- BadgePosition,
-} from '../../../../../../../component-library/components/Badges/BadgeWrapper';
import TextFieldSearch from '../../../../../../../component-library/components/Form/TextFieldSearch';
import styleSheet from './TokenSelectorModal.styles';
@@ -35,11 +27,9 @@ import {
useParams,
} from '../../../../../../../util/navigation/navUtils';
import { useDepositCryptoCurrencyNetworkName } from '../../../hooks/useDepositCryptoCurrencyNetworkName';
-import { getNetworkImageSource } from '../../../../../../../util/networks';
import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk';
import Routes from '../../../../../../../constants/navigation/Routes';
import { strings } from '../../../../../../../../locales/i18n';
-import { DEPOSIT_NETWORKS_BY_CHAIN_ID } from '../../../constants/networks';
import { useTheme } from '../../../../../../../util/theme';
import useAnalytics from '../../../../hooks/useAnalytics';
@@ -138,48 +128,15 @@ function TokenSelectorModal() {
}, [handleSearchTextChange]);
const renderToken = useCallback(
- ({ item: token }: { item: DepositCryptoCurrency }) => {
- const networkName = getNetworkName(token.chainId);
- const networkImageSource = getNetworkImageSource({
- chainId: token.chainId,
- });
- const depositNetworkName =
- DEPOSIT_NETWORKS_BY_CHAIN_ID[token.chainId]?.name;
- return (
- handleSelectAssetIdCallback(token.assetId)}
- accessibilityRole="button"
- accessible
- >
-
-
- }
- >
-
-
-
-
- {token.symbol}
-
- {depositNetworkName ?? networkName}
-
-
-
- );
- },
+ ({ item: token }: { item: DepositCryptoCurrency }) => (
+ handleSelectAssetIdCallback(token.assetId)}
+ textColor={colors.text.alternative}
+ />
+ ),
[
- getNetworkName,
colors.text.alternative,
handleSelectAssetIdCallback,
selectedCryptoCurrency?.assetId,
diff --git a/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx b/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx
index 939e9b6d9bd..fdab9c544a2 100644
--- a/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx
+++ b/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx
@@ -1,5 +1,4 @@
import React, { useMemo } from 'react';
-import { useSelector } from 'react-redux';
import { CaipChainId } from '@metamask/utils';
import { ScrollView } from 'react-native-gesture-handler';
@@ -22,11 +21,9 @@ import Icon, {
import styleSheet from './NetworksFilterBar.styles';
-import { selectNetworkConfigurationsByCaipChainId } from '../../../../../../selectors/networkController';
import { useStyles } from '../../../../../hooks/useStyles';
-import { DEPOSIT_NETWORKS_BY_CHAIN_ID } from '../../constants/networks';
import { excludeFromArray } from '../../utils';
-import { getNetworkImageSource } from '../../../../../../util/networks';
+import { useTokenNetworkInfo } from '../../../hooks/useTokenNetworkInfo';
import { useTheme } from '../../../../../../util/theme';
import { strings } from '../../../../../../../locales/i18n';
@@ -45,29 +42,30 @@ function NetworksFilterBar({
}: Readonly) {
const { styles } = useStyles(styleSheet, {});
const { colors } = useTheme();
-
- const allNetworkConfigurations = useSelector(
- selectNetworkConfigurationsByCaipChainId,
- );
+ const getTokenNetworkInfo = useTokenNetworkInfo();
const allNetworksIcons = useMemo(() => {
const headSize = 3;
const reversedHead = networks.slice(0, headSize).reverse();
return (
- {reversedHead.map((chainId) => (
-
- ))}
+ {reversedHead.map((chainId) => {
+ const { depositNetworkName, networkName, networkImageSource } =
+ getTokenNetworkInfo(chainId);
+ return (
+
+ );
+ })}
);
- }, [allNetworkConfigurations, styles.overlappedNetworkIcon, networks]);
+ }, [getTokenNetworkInfo, styles.overlappedNetworkIcon, networks]);
return (
{networks.map((chainId) => {
const isSelected = networkFilter.includes(chainId);
- const networkName =
- DEPOSIT_NETWORKS_BY_CHAIN_ID[chainId]?.name ??
- allNetworkConfigurations[chainId]?.name;
+ const { depositNetworkName, networkName, networkImageSource } =
+ getTokenNetworkInfo(chainId);
+ const displayName = depositNetworkName ?? networkName;
return (
@@ -122,7 +120,7 @@ function NetworksFilterBar({
- {networkName}
+ {displayName}
>
}
diff --git a/app/components/UI/Ramp/Deposit/components/NetworksFilterSelector/NetworksFilterSelector.tsx b/app/components/UI/Ramp/Deposit/components/NetworksFilterSelector/NetworksFilterSelector.tsx
index d6de0fe186e..7dd1694a6a4 100644
--- a/app/components/UI/Ramp/Deposit/components/NetworksFilterSelector/NetworksFilterSelector.tsx
+++ b/app/components/UI/Ramp/Deposit/components/NetworksFilterSelector/NetworksFilterSelector.tsx
@@ -2,7 +2,6 @@ import { CaipChainId } from '@metamask/utils';
import React, { useCallback } from 'react';
import { View, useWindowDimensions } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
-import { useSelector } from 'react-redux';
import styleSheet from './NetworksFilterSelector.styles';
@@ -23,10 +22,8 @@ import Text, {
} from '../../../../../../component-library/components/Texts/Text';
import { useStyles } from '../../../../../hooks/useStyles';
-import { selectNetworkConfigurationsByCaipChainId } from '../../../../../../selectors/networkController';
import { strings } from '../../../../../../../locales/i18n';
-import { getNetworkImageSource } from '../../../../../../util/networks';
-import { DEPOSIT_NETWORKS_BY_CHAIN_ID } from '../../constants/networks';
+import { useTokenNetworkInfo } from '../../../hooks/useTokenNetworkInfo';
import { excludeFromArray } from '../../utils';
interface NetworksFilterSelectorProps {
@@ -47,9 +44,7 @@ function NetworksFilterSelector({
screenHeight,
});
- const allNetworkConfigurations = useSelector(
- selectNetworkConfigurationsByCaipChainId,
- );
+ const getTokenNetworkInfo = useTokenNetworkInfo();
const handleNetworkOnPress = useCallback(
(chainId: CaipChainId) => () => {
@@ -80,32 +75,31 @@ function NetworksFilterSelector({
(
-
-
-
-
-
-
-
-
-
- {DEPOSIT_NETWORKS_BY_CHAIN_ID[chainId]?.name ??
- allNetworkConfigurations[chainId]?.name}
-
-
-
- )}
+ renderItem={({ item: chainId }) => {
+ const { depositNetworkName, networkName, networkImageSource } =
+ getTokenNetworkInfo(chainId);
+ const displayName = depositNetworkName ?? networkName;
+ return (
+
+
+
+
+
+
+
+
+ {displayName}
+
+
+ );
+ }}
keyExtractor={(item) => item}
>
diff --git a/app/components/UI/Ramp/Deposit/constants/index.ts b/app/components/UI/Ramp/Deposit/constants/index.ts
index 63fdb9d5431..04d42d9fc17 100644
--- a/app/components/UI/Ramp/Deposit/constants/index.ts
+++ b/app/components/UI/Ramp/Deposit/constants/index.ts
@@ -1 +1,2 @@
export * from './constants.ts';
+export * from './mockCryptoCurrencies';
diff --git a/app/components/UI/Ramp/Deposit/constants/mockCryptoCurrencies.ts b/app/components/UI/Ramp/Deposit/constants/mockCryptoCurrencies.ts
new file mode 100644
index 00000000000..e43e85a7869
--- /dev/null
+++ b/app/components/UI/Ramp/Deposit/constants/mockCryptoCurrencies.ts
@@ -0,0 +1,59 @@
+import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk';
+
+export const MOCK_USDC_TOKEN: DepositCryptoCurrency = {
+ assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ chainId: 'eip155:1',
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.png',
+};
+
+export const MOCK_USDT_TOKEN: DepositCryptoCurrency = {
+ assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7',
+ chainId: 'eip155:1',
+ name: 'Tether USD',
+ symbol: 'USDT',
+ decimals: 6,
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xdAC17F958D2ee523a2206206994597C13D831ec7.png',
+};
+
+export const MOCK_BTC_TOKEN: DepositCryptoCurrency = {
+ assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
+ chainId: 'bip122:000000000019d6689c085ae165831e93',
+ name: 'Bitcoin',
+ symbol: 'BTC',
+ decimals: 8,
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/bip122/000000000019d6689c085ae165831e93/slip44/0.png',
+};
+
+export const MOCK_ETH_TOKEN: DepositCryptoCurrency = {
+ assetId: 'eip155:1/slip44:60',
+ chainId: 'eip155:1',
+ name: 'Ethereum',
+ symbol: 'ETH',
+ decimals: 18,
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png',
+};
+
+export const MOCK_USDC_SOLANA_TOKEN: DepositCryptoCurrency = {
+ assetId: 'solana:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
+ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.png',
+};
+
+export const MOCK_CRYPTOCURRENCIES: DepositCryptoCurrency[] = [
+ MOCK_USDC_TOKEN,
+ MOCK_USDT_TOKEN,
+ MOCK_BTC_TOKEN,
+ MOCK_ETH_TOKEN,
+ MOCK_USDC_SOLANA_TOKEN,
+];
diff --git a/app/components/UI/Ramp/Deposit/routes/utils.ts b/app/components/UI/Ramp/Deposit/routes/utils.ts
index 26a5755236f..04d3e12e0b7 100644
--- a/app/components/UI/Ramp/Deposit/routes/utils.ts
+++ b/app/components/UI/Ramp/Deposit/routes/utils.ts
@@ -1,12 +1,26 @@
import { DepositNavigationParams } from '../types';
import Routes from '../../../../../constants/navigation/Routes';
+// import useRampsUnifiedV1Enabled from '../../hooks/useRampsUnifiedV1Enabled';
export function createDepositNavigationDetails(
params?: DepositNavigationParams,
) {
+ // const isRampsUnifiedV1Enabled = useRampsUnifiedV1Enabled();
+ // // TODO: Use goToRamps hook for managing ramps navigation to the token selection screen
+ // // https://consensyssoftware.atlassian.net/browse/TRAM-2813
+ // if (isRampsUnifiedV1Enabled) {
+ // return [
+ // Routes.RAMP.TOKEN_SELECTION,
+ // {
+ // rampType: 'DEPOSIT',
+ // },
+ // ];
+ // }
+
const route = Routes.DEPOSIT.ID;
if (!params) {
return [route] as const;
}
+
return [route, { screen: route, params }] as const;
}
diff --git a/app/components/UI/Ramp/Deposit/testUtils/constants.ts b/app/components/UI/Ramp/Deposit/testUtils/constants.ts
index f98ab77f312..daa90e60c2c 100644
--- a/app/components/UI/Ramp/Deposit/testUtils/constants.ts
+++ b/app/components/UI/Ramp/Deposit/testUtils/constants.ts
@@ -4,7 +4,6 @@ import {
BuyQuote,
DepositOrder,
type DepositRegion,
- type DepositCryptoCurrency,
type DepositPaymentMethod,
DepositPaymentMethodDuration,
NativeTransakUserDetails,
@@ -12,6 +11,14 @@ import {
} from '@consensys/native-ramps-sdk';
import { IconName } from '../../../../../component-library/components/Icons/Icon';
import type { DepositSDK } from '../sdk';
+import {
+ MOCK_USDC_TOKEN,
+ MOCK_USDT_TOKEN,
+ MOCK_BTC_TOKEN,
+ MOCK_ETH_TOKEN,
+ MOCK_USDC_SOLANA_TOKEN,
+ MOCK_CRYPTOCURRENCIES,
+} from '../constants/mockCryptoCurrencies';
export const MOCK_US_REGION: DepositRegion = {
isoCode: 'US',
@@ -88,64 +95,15 @@ export const MOCK_REGIONS_EXTENDED: DepositRegion[] = [
];
// ====== CRYPTOCURRENCIES ======
-
-export const MOCK_USDC_TOKEN: DepositCryptoCurrency = {
- assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
- chainId: 'eip155:1',
- name: 'USD Coin',
- symbol: 'USDC',
- decimals: 6,
- iconUrl:
- 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.png',
-};
-
-export const MOCK_USDT_TOKEN: DepositCryptoCurrency = {
- assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7',
- chainId: 'eip155:1',
- name: 'Tether USD',
- symbol: 'USDT',
- decimals: 6,
- iconUrl:
- 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xdAC17F958D2ee523a2206206994597C13D831ec7.png',
-};
-
-export const MOCK_BTC_TOKEN: DepositCryptoCurrency = {
- assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
- chainId: 'bip122:000000000019d6689c085ae165831e93',
- name: 'Bitcoin',
- symbol: 'BTC',
- decimals: 8,
- iconUrl:
- 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/bip122/000000000019d6689c085ae165831e93/slip44/0.png',
-};
-
-export const MOCK_ETH_TOKEN: DepositCryptoCurrency = {
- assetId: 'eip155:1/slip44:60',
- chainId: 'eip155:1',
- name: 'Ethereum',
- symbol: 'ETH',
- decimals: 18,
- iconUrl:
- 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png',
-};
-
-export const MOCK_USDC_SOLANA_TOKEN: DepositCryptoCurrency = {
- assetId: 'solana:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
- chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
- name: 'USD Coin',
- symbol: 'USDC',
- decimals: 6,
- iconUrl:
- 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.png',
-};
-
-export const MOCK_CRYPTOCURRENCIES: DepositCryptoCurrency[] = [
+// Re-exported from constants/mockCryptoCurrencies.ts
+export {
MOCK_USDC_TOKEN,
MOCK_USDT_TOKEN,
MOCK_BTC_TOKEN,
MOCK_ETH_TOKEN,
MOCK_USDC_SOLANA_TOKEN,
-];
+ MOCK_CRYPTOCURRENCIES,
+};
export const MOCK_CREDIT_DEBIT_CARD: DepositPaymentMethod = {
id: 'credit_debit_card',
diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.styles.ts b/app/components/UI/Ramp/components/MenuItem/MenuItem.styles.ts
new file mode 100644
index 00000000000..1609dcc4cec
--- /dev/null
+++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.styles.ts
@@ -0,0 +1,10 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ listItem: {
+ paddingVertical: 8,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx b/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx
new file mode 100644
index 00000000000..6c300ee55ae
--- /dev/null
+++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx
@@ -0,0 +1,85 @@
+// Third party dependencies.
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+
+// Internal dependencies.
+import MenuItem from './MenuItem';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
+
+const createTestProps = (overrides = {}) => ({
+ iconName: IconName.Add,
+ title: 'Test Menu Item',
+ description: 'Test description',
+ onPress: jest.fn(),
+ ...overrides,
+});
+
+describe('MenuItem', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders snapshot correctly', () => {
+ const props = createTestProps();
+ const wrapper = render();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('renders with title only', () => {
+ const props = createTestProps({ description: undefined });
+ const wrapper = render();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('renders with different icon', () => {
+ const props = createTestProps({ iconName: IconName.Bank });
+ const wrapper = render();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('renders with empty description', () => {
+ const props = createTestProps({ description: '' });
+ const wrapper = render();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('displays the correct title', () => {
+ const customTitle = 'Custom Menu Title';
+ const props = createTestProps({ title: customTitle });
+ const { getByText } = render();
+
+ expect(getByText(customTitle)).toBeTruthy();
+ });
+
+ it('displays the correct description when provided', () => {
+ const customDescription = 'Custom description text';
+ const props = createTestProps({ description: customDescription });
+ const { getByText } = render();
+
+ expect(getByText(customDescription)).toBeTruthy();
+ });
+
+ it('hides description when not provided', () => {
+ const props = createTestProps({ description: undefined });
+ const { queryByText } = render();
+
+ expect(queryByText('Test description')).toBeNull();
+ });
+
+ it('calls onPress when pressed', () => {
+ const mockOnPress = jest.fn();
+ const props = createTestProps({ onPress: mockOnPress });
+ const { getByText } = render();
+
+ fireEvent.press(getByText('Test Menu Item'));
+
+ expect(mockOnPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders empty string title', () => {
+ const props = createTestProps({ title: '' });
+ const { getByText } = render();
+
+ expect(getByText('')).toBeTruthy();
+ });
+});
diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx b/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx
new file mode 100644
index 00000000000..6807f519064
--- /dev/null
+++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import Icon, {
+ IconName,
+ IconSize,
+} from '../../../../../component-library/components/Icons/Icon';
+import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect';
+import { useStyles } from '../../../../hooks/useStyles';
+import styleSheet from './MenuItem.styles';
+import ListItemColumn, {
+ WidthType,
+} from '../../../../../component-library/components/List/ListItemColumn';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+
+interface MenuItemProps {
+ iconName: IconName;
+ title: string;
+ description?: string;
+ onPress: () => void;
+}
+
+export default function MenuItem({
+ iconName,
+ title,
+ description,
+ onPress,
+}: MenuItemProps) {
+ const { theme, styles } = useStyles(styleSheet, {});
+
+ return (
+
+
+
+
+
+ {title}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ );
+}
diff --git a/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap b/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap
new file mode 100644
index 00000000000..5dfab8e7431
--- /dev/null
+++ b/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap
@@ -0,0 +1,389 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MenuItem renders snapshot correctly 1`] = `
+
+
+
+
+
+
+
+
+
+ Test Menu Item
+
+
+ Test description
+
+
+
+
+
+`;
+
+exports[`MenuItem renders with different icon 1`] = `
+
+
+
+
+
+
+
+
+
+ Test Menu Item
+
+
+ Test description
+
+
+
+
+
+`;
+
+exports[`MenuItem renders with empty description 1`] = `
+
+
+
+
+
+
+
+
+
+ Test Menu Item
+
+
+
+
+
+`;
+
+exports[`MenuItem renders with title only 1`] = `
+
+
+
+
+
+
+
+
+
+ Test Menu Item
+
+
+
+
+
+`;
diff --git a/app/components/UI/Ramp/components/MenuItem/index.ts b/app/components/UI/Ramp/components/MenuItem/index.ts
new file mode 100644
index 00000000000..01646402a4f
--- /dev/null
+++ b/app/components/UI/Ramp/components/MenuItem/index.ts
@@ -0,0 +1 @@
+export { default } from './MenuItem';
diff --git a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx
new file mode 100644
index 00000000000..4b645453363
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk';
+
+import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect';
+import ListItemColumn, {
+ WidthType,
+} from '../../../../../component-library/components/List/ListItemColumn';
+import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
+import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
+import BadgeNetwork from '../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork';
+import BadgeWrapper, {
+ BadgePosition,
+} from '../../../../../component-library/components/Badges/BadgeWrapper';
+import Text, {
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+
+import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo';
+
+interface TokenListItemProps {
+ token: DepositCryptoCurrency;
+ isSelected: boolean;
+ onPress: () => void;
+ textColor?: string;
+ isDisabled?: boolean;
+}
+
+function TokenListItem({
+ token,
+ isSelected,
+ onPress,
+ textColor,
+ isDisabled = false,
+}: Readonly) {
+ const getTokenNetworkInfo = useTokenNetworkInfo();
+ const { networkName, depositNetworkName, networkImageSource } =
+ getTokenNetworkInfo(token.chainId);
+
+ return (
+
+
+
+ }
+ >
+
+
+
+
+ {token.symbol}
+
+ {depositNetworkName ?? networkName}
+
+
+
+ );
+}
+
+export default TokenListItem;
diff --git a/app/components/UI/Ramp/components/TokenListItem/index.ts b/app/components/UI/Ramp/components/TokenListItem/index.ts
new file mode 100644
index 00000000000..2a92bde1b4f
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenListItem/index.ts
@@ -0,0 +1 @@
+export { default } from './TokenListItem';
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts
new file mode 100644
index 00000000000..d00e96a7337
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts
@@ -0,0 +1,15 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ networksContainer: {
+ paddingHorizontal: 16,
+ flexDirection: 'row',
+ gap: 8,
+ },
+ selectedNetworkIcon: {
+ marginRight: 8,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx
new file mode 100644
index 00000000000..a5e80abaa32
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import TokenNetworkFilterBar from './TokenNetworkFilterBar';
+import { CaipChainId } from '@metamask/utils';
+
+const mockNetworks: CaipChainId[] = [
+ 'eip155:1',
+ 'eip155:10',
+ 'eip155:137',
+] as CaipChainId[];
+
+const mockSetNetworkFilter = jest.fn();
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(() => ({
+ 'eip155:1': { name: 'Ethereum Mainnet' },
+ 'eip155:10': { name: 'Optimism' },
+ 'eip155:137': { name: 'Polygon' },
+ })),
+}));
+
+describe('TokenNetworkFilterBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly with all networks selected (null)', () => {
+ const { toJSON } = render(
+ ,
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders correctly with all networks selected (empty array)', () => {
+ const { toJSON } = render(
+ ,
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders correctly with single network selected', () => {
+ const { toJSON } = render(
+ ,
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders correctly with partial networks selected', () => {
+ const { toJSON } = render(
+ ,
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ describe('handleNetworkPress', () => {
+ it('sets single network when all networks are selected', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText('Ethereum'));
+
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']);
+ });
+
+ it('removes network from filter when network is currently selected', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText('Ethereum'));
+
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:10']);
+ });
+
+ it('sets filter to empty array when deselecting last selected network', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText('Ethereum'));
+
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith([]);
+ });
+
+ it('adds network to filter when network is not currently selected', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText('Optimism'));
+
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith([
+ 'eip155:1',
+ 'eip155:10',
+ ]);
+ });
+
+ it('sets filter to null when adding network results in all networks selected', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText('Polygon'));
+
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(null);
+ });
+ });
+});
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx
new file mode 100644
index 00000000000..5c04a6f53c4
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import { CaipChainId } from '@metamask/utils';
+import { ScrollView } from 'react-native-gesture-handler';
+
+import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
+import AvatarNetwork from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarNetwork';
+import Button, {
+ ButtonSize,
+ ButtonVariants,
+} from '../../../../../component-library/components/Buttons/Button';
+import Text, {
+ TextColor,
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+
+import styleSheet from './TokenNetworkFilterBar.styles';
+
+import { useStyles } from '../../../../hooks/useStyles';
+import { excludeFromArray } from '../../Deposit/utils';
+import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo';
+import { strings } from '../../../../../../locales/i18n';
+
+interface TokenNetworkFilterBarProps {
+ networks: CaipChainId[];
+ networkFilter: CaipChainId[] | null;
+ setNetworkFilter: React.Dispatch>;
+}
+
+function TokenNetworkFilterBar({
+ networks,
+ networkFilter,
+ setNetworkFilter,
+}: Readonly) {
+ const { styles } = useStyles(styleSheet, {});
+ const getTokenNetworkInfo = useTokenNetworkInfo();
+
+ const isAllSelected =
+ !networkFilter ||
+ networkFilter.length === 0 ||
+ networkFilter.length === networks.length;
+
+ const handleAllPress = () => {
+ setNetworkFilter(null);
+ };
+
+ const handleNetworkPress = (chainId: CaipChainId) => {
+ if (isAllSelected) {
+ setNetworkFilter([chainId]);
+ return;
+ }
+
+ const currentFilter = networkFilter || [];
+ const isSelected = currentFilter.includes(chainId);
+
+ if (isSelected) {
+ const newFilter = excludeFromArray(currentFilter, chainId);
+ setNetworkFilter(newFilter.length === networks.length ? null : newFilter);
+ } else {
+ const newFilter = [...currentFilter, chainId];
+ setNetworkFilter(newFilter.length === networks.length ? null : newFilter);
+ }
+ };
+
+ return (
+
+
+ );
+}
+
+export default TokenNetworkFilterBar;
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap b/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap
new file mode 100644
index 00000000000..c315efc8209
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap
@@ -0,0 +1,1011 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TokenNetworkFilterBar renders correctly with all networks selected (empty array) 1`] = `
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Optimism
+
+
+
+
+
+
+
+ Polygon
+
+
+
+
+`;
+
+exports[`TokenNetworkFilterBar renders correctly with all networks selected (null) 1`] = `
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Optimism
+
+
+
+
+
+
+
+ Polygon
+
+
+
+
+`;
+
+exports[`TokenNetworkFilterBar renders correctly with partial networks selected 1`] = `
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Optimism
+
+
+
+
+
+
+
+ Polygon
+
+
+
+
+`;
+
+exports[`TokenNetworkFilterBar renders correctly with single network selected 1`] = `
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Optimism
+
+
+
+
+
+
+
+ Polygon
+
+
+
+
+`;
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/index.ts b/app/components/UI/Ramp/components/TokenNetworkFilterBar/index.ts
new file mode 100644
index 00000000000..0b91bc169da
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/index.ts
@@ -0,0 +1 @@
+export { default } from './TokenNetworkFilterBar';
diff --git a/app/components/UI/Ramp/components/TokenSelection/TokenSelection.styles.ts b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.styles.ts
new file mode 100644
index 00000000000..59c953a0a0c
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.styles.ts
@@ -0,0 +1,22 @@
+import { StyleSheet } from 'react-native';
+import { Theme } from '../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: params.theme.colors.background.default,
+ },
+ filterBarContainer: {
+ paddingVertical: 8,
+ },
+ list: {
+ flex: 1,
+ },
+ searchContainer: {
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx
new file mode 100644
index 00000000000..9f08321fb8c
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+import TokenSelection from './TokenSelection';
+import { useParams } from '../../../../../util/navigation/navUtils';
+import useSearchTokenResults from '../../Deposit/hooks/useSearchTokenResults';
+import { renderScreen } from '../../../../../util/test/renderWithProvider';
+import { backgroundState } from '../../../../../util/test/initial-root-state';
+import { MOCK_CRYPTOCURRENCIES } from '../../Deposit/testUtils';
+
+const mockNavigate = jest.fn();
+const mockSetOptions = jest.fn();
+const mockGoBack = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ setOptions: mockSetOptions,
+ goBack: mockGoBack,
+ }),
+}));
+
+function renderWithProvider(component: React.ComponentType) {
+ return renderScreen(
+ component,
+ {
+ name: 'TokenSelection',
+ },
+ {
+ state: {
+ engine: {
+ backgroundState,
+ },
+ fiatOrders: {
+ detectedGeolocation: 'US',
+ },
+ },
+ },
+ );
+}
+
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ ...jest.requireActual('../../../../../util/navigation/navUtils'),
+ useParams: jest.fn(),
+}));
+
+jest.mock('../../Deposit/hooks/useSearchTokenResults', () => jest.fn());
+
+const mockTokens = MOCK_CRYPTOCURRENCIES;
+
+describe('TokenSelection Component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useParams as jest.Mock).mockReturnValue({
+ rampType: 'BUY',
+ selectedCryptoAssetId: undefined,
+ });
+ (useSearchTokenResults as jest.Mock).mockReturnValue(mockTokens);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders correctly and matches snapshot', () => {
+ const { toJSON } = renderWithProvider(TokenSelection);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('displays empty state when no tokens match search', async () => {
+ (useSearchTokenResults as jest.Mock).mockReturnValue([]);
+ const { getByPlaceholderText, getByText, toJSON } =
+ renderWithProvider(TokenSelection);
+
+ const searchInput = getByPlaceholderText('Search token by name or address');
+ fireEvent.changeText(searchInput, 'Nonexistent Token');
+
+ await waitFor(() => {
+ expect(getByText('No tokens match "Nonexistent Token"')).toBeTruthy();
+ });
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('marks token as selected when selectedCryptoAssetId matches', () => {
+ (useParams as jest.Mock).mockReturnValue({
+ rampType: 'BUY',
+ selectedCryptoAssetId: mockTokens[0].assetId,
+ });
+
+ const { toJSON } = renderWithProvider(TokenSelection);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('filters tokens by search string', async () => {
+ const { getByPlaceholderText } = renderWithProvider(TokenSelection);
+
+ const searchInput = getByPlaceholderText('Search token by name or address');
+ fireEvent.changeText(searchInput, 'USDC');
+
+ await waitFor(() => {
+ expect(useSearchTokenResults).toHaveBeenCalledWith(
+ expect.objectContaining({
+ searchString: 'USDC',
+ }),
+ );
+ });
+ });
+});
diff --git a/app/components/UI/Ramp/components/TokenSelection/TokenSelection.tsx b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.tsx
new file mode 100644
index 00000000000..8b93a614ff0
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.tsx
@@ -0,0 +1,190 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { View } from 'react-native';
+import { FlatList } from 'react-native-gesture-handler';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { CaipChainId } from '@metamask/utils';
+import { useNavigation } from '@react-navigation/native';
+
+import TokenNetworkFilterBar from '../TokenNetworkFilterBar';
+import TokenListItem from '../TokenListItem';
+
+import Text, {
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+import {
+ ButtonIcon,
+ ButtonIconSize,
+ IconName,
+} from '@metamask/design-system-react-native';
+import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect';
+import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch';
+
+import styleSheet from './TokenSelection.styles';
+import { useStyles } from '../../../../hooks/useStyles';
+import useSearchTokenResults from '../../Deposit/hooks/useSearchTokenResults';
+
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk';
+import { strings } from '../../../../../../locales/i18n';
+import { useTheme } from '../../../../../util/theme';
+import { MOCK_CRYPTOCURRENCIES } from '../../Deposit/constants/mockCryptoCurrencies';
+// TODO: Fetch these tokens from the API new enpoint for top 25 with supported status
+//https://consensyssoftware.atlassian.net/browse/TRAM-2816
+
+interface TokenSelectionParams {
+ rampType: 'BUY' | 'DEPOSIT';
+ selectedCryptoAssetId?: string;
+}
+
+function TokenSelection() {
+ const listRef = useRef(null);
+ const [searchString, setSearchString] = useState('');
+ const [networkFilter, setNetworkFilter] = useState(
+ null,
+ );
+ const { styles } = useStyles(styleSheet, {});
+
+ const { colors } = useTheme();
+ const theme = useTheme();
+ const navigation = useNavigation();
+
+ const { selectedCryptoAssetId } = useParams();
+
+ const supportedTokens = MOCK_CRYPTOCURRENCIES;
+
+ const searchTokenResults = useSearchTokenResults({
+ tokens: supportedTokens,
+ networkFilter,
+ searchString,
+ });
+
+ const handleSelectAssetIdCallback = useCallback((_assetId: string) => {
+ // TODO: Handle token by routing to the appropriate agg or deposit screen with asset id as param and pre-select it
+ // https://consensyssoftware.atlassian.net/browse/TRAM-2795
+ }, []);
+
+ const scrollToTop = useCallback(() => {
+ if (listRef?.current) {
+ listRef?.current.scrollToOffset({
+ animated: false,
+ offset: 0,
+ });
+ }
+ }, []);
+
+ const handleSearchTextChange = useCallback(
+ (text: string) => {
+ setSearchString(text);
+ scrollToTop();
+ },
+ [scrollToTop],
+ );
+
+ const clearSearchText = useCallback(() => {
+ handleSearchTextChange('');
+ }, [handleSearchTextChange]);
+
+ const renderToken = useCallback(
+ ({ item: token }: { item: DepositCryptoCurrency }) => (
+ handleSelectAssetIdCallback(token.assetId)}
+ textColor={colors.text.alternative}
+ />
+ ),
+ [
+ colors.text.alternative,
+ handleSelectAssetIdCallback,
+ selectedCryptoAssetId,
+ ],
+ );
+
+ const renderEmptyList = useCallback(
+ () => (
+
+
+ {strings('deposit.token_modal.no_tokens_found', {
+ searchString,
+ })}
+
+
+ ),
+ [searchString],
+ );
+
+ const uniqueNetworks = useMemo(() => {
+ const uniqueNetworksSet = new Set();
+ for (const token of supportedTokens) {
+ uniqueNetworksSet.add(token.chainId);
+ }
+ return Array.from(uniqueNetworksSet);
+ }, [supportedTokens]);
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerShown: true,
+ headerLeft: () => null,
+ headerTitle: () => (
+
+ {strings('deposit.token_modal.select_token')}
+
+ ),
+ headerRight: () => (
+ navigation.goBack()}
+ twClassName="mr-1"
+ testID="token-selection-close-button"
+ />
+ ),
+ headerStyle: {
+ backgroundColor: theme.colors.background.default,
+ shadowColor: 'transparent',
+ elevation: 0,
+ },
+ });
+ }, [navigation, theme.colors.background.default]);
+
+ return (
+
+
+
+
+
+ 0}
+ onPressClearButton={clearSearchText}
+ onFocus={scrollToTop}
+ onChangeText={handleSearchTextChange}
+ placeholder={strings('deposit.token_modal.search_by_name_or_address')}
+ />
+
+ item.assetId}
+ ListEmptyComponent={renderEmptyList}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="always"
+ />
+
+ );
+}
+
+export default TokenSelection;
diff --git a/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap b/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap
new file mode 100644
index 00000000000..84a346d1a21
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap
@@ -0,0 +1,4586 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TokenSelection Component displays empty state when no tokens match search 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ TokenSelection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Bitcoin
+
+
+
+
+
+
+
+ Solana
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No tokens match "Nonexistent Token"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TokenSelection Component marks token as selected when selectedCryptoAssetId matches 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ TokenSelection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Bitcoin
+
+
+
+
+
+
+
+ Solana
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ USDC
+
+
+ Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ USDT
+
+
+ Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BTC
+
+
+ Bitcoin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ETH
+
+
+ Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ USDC
+
+
+ Solana
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TokenSelection Component renders correctly and matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ TokenSelection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+
+
+
+
+ Bitcoin
+
+
+
+
+
+
+
+ Solana
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ USDC
+
+
+ Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ USDT
+
+
+ Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BTC
+
+
+ Bitcoin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ETH
+
+
+ Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ USDC
+
+
+ Solana
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Ramp/components/TokenSelection/index.ts b/app/components/UI/Ramp/components/TokenSelection/index.ts
new file mode 100644
index 00000000000..ca6ce16be1a
--- /dev/null
+++ b/app/components/UI/Ramp/components/TokenSelection/index.ts
@@ -0,0 +1 @@
+export { default } from './TokenSelection';
diff --git a/app/components/UI/Ramp/hooks/useTokenNetworkInfo.ts b/app/components/UI/Ramp/hooks/useTokenNetworkInfo.ts
new file mode 100644
index 00000000000..2d8bc59d99d
--- /dev/null
+++ b/app/components/UI/Ramp/hooks/useTokenNetworkInfo.ts
@@ -0,0 +1,39 @@
+import { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { CaipChainId } from '@metamask/utils';
+import { ImageSourcePropType } from 'react-native';
+
+import { selectNetworkConfigurationsByCaipChainId } from '../../../../selectors/networkController';
+import { getNetworkImageSource } from '../../../../util/networks';
+import { DEPOSIT_NETWORKS_BY_CHAIN_ID } from '../Deposit/constants/networks';
+
+interface TokenNetworkInfo {
+ networkName?: string;
+ depositNetworkName: string | undefined;
+ networkImageSource: ImageSourcePropType;
+}
+
+/**
+ * Hook to get network information for a given chain ID
+ * @returns Function that returns network name, deposit network name, and image source for a chain ID
+ */
+export function useTokenNetworkInfo() {
+ const allNetworkConfigurations = useSelector(
+ selectNetworkConfigurationsByCaipChainId,
+ );
+
+ return useCallback(
+ (chainId: CaipChainId): TokenNetworkInfo => {
+ const networkName = allNetworkConfigurations[chainId]?.name;
+ const depositNetworkName = DEPOSIT_NETWORKS_BY_CHAIN_ID[chainId]?.name;
+ const networkImageSource = getNetworkImageSource({ chainId });
+
+ return {
+ networkName,
+ depositNetworkName,
+ networkImageSource,
+ };
+ },
+ [allNetworkConfigurations],
+ );
+}
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index d8b089dfc4e..6be634af3b1 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -8,6 +8,7 @@ const Routes = {
ID: 'Ramp',
BUY: 'RampBuy',
SELL: 'RampSell',
+ TOKEN_SELECTION: 'RampTokenSelection',
GET_STARTED: 'GetStarted',
BUILD_QUOTE: 'BuildQuote',
BUILD_QUOTE_HAS_STARTED: 'BuildQuoteHasStarted',
@@ -25,6 +26,7 @@ const Routes = {
REGION_SELECTOR: 'RampRegionSelectorModal',
UNSUPPORTED_REGION: 'RampUnsupportedRegionModal',
PAYMENT_METHOD_SELECTOR: 'RampPaymentMethodSelectorModal',
+ SETTINGS: 'RampSettingsModal',
},
},
DEPOSIT: {
diff --git a/appwright/tests/performance/predict/predict-market-details.spec.js b/appwright/tests/performance/predict/predict-market-details.spec.js
index 16f1d19d463..0f05a85544d 100644
--- a/appwright/tests/performance/predict/predict-market-details.spec.js
+++ b/appwright/tests/performance/predict/predict-market-details.spec.js
@@ -22,7 +22,7 @@ import { login } from '../../../utils/Flows.js';
* 4. Time to load and verify About tab content
* 5. Time to load and verify Outcomes tab content
*/
-test.skip('Predict Market Details - Load Time Performance', async ({
+test('Predict Market Details - Load Time Performance', async ({
device,
performanceTracker,
}, testInfo) => {
diff --git a/e2e/specs/predict/predict-cash-out.spec.ts b/e2e/specs/predict/predict-cash-out.spec.ts
index fd95927db53..a82654e0497 100644
--- a/e2e/specs/predict/predict-cash-out.spec.ts
+++ b/e2e/specs/predict/predict-cash-out.spec.ts
@@ -47,7 +47,7 @@ const PredictionMarketFeature = async (mockServer: Mockttp) => {
};
describe(SmokePredictions('Predictions'), () => {
- it('should cash out on open position: Spurs vs. Pelicans', async () => {
+ it.skip('should cash out on open position: Spurs vs. Pelicans', async () => {
await withFixtures(
{
fixture: new FixtureBuilder().withPolygon().build(),
diff --git a/e2e/specs/swaps/bridge-action-smoke.spec.ts b/e2e/specs/swaps/bridge-action-smoke.spec.ts
index c8b50a14ba5..2f0f0cf0612 100644
--- a/e2e/specs/swaps/bridge-action-smoke.spec.ts
+++ b/e2e/specs/swaps/bridge-action-smoke.spec.ts
@@ -63,7 +63,7 @@ describe(SmokeTrade('Bridge functionality'), () => {
await device.disableSynchronization();
await QuoteView.tapDestinationToken();
await TestHelpers.delay(2000); // wait until tokens are displayed
- await QuoteView.swipeNetwork(destNetwork, 0.3);
+ await QuoteView.swipeNetwork('Ethereum', 0.8);
await TestHelpers.delay(2000); // allow scroll to take place
await QuoteView.selectNetwork(destNetwork);
await QuoteView.tapToken(destChainId, sourceSymbol);
diff --git a/locales/languages/en.json b/locales/languages/en.json
index bf645c0c2b1..5569a8c32f2 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -613,6 +613,11 @@
"invalid_address": "Invalid address",
"contractAddressError": "You are sending tokens to the token's contract address. This may result in the loss of these tokens."
},
+ "unified_ramp": {
+ "networks_filter_bar": {
+ "all_networks": "All"
+ }
+ },
"deposit": {
"title": "Deposit",
"selectRegion": "Select Region",
@@ -642,13 +647,15 @@
"apply": "Apply"
},
"configuration_modal": {
- "title": "Options",
+ "title": "Settings",
"view_order_history": "View order history",
"contact_support": "Contact support",
- "log_out": "Log out",
+ "log_out": "Log out of Transak",
"logged_out_success": "Successfully logged out",
"error_sdk_not_initialized": "SDK not initialized",
- "logged_out_error": "Error logging out"
+ "logged_out_error": "Error logging out",
+ "more_ways_to_buy": "More ways to buy",
+ "more_ways_to_buy_description": "Use a different payment provider"
},
"region_modal": {
"select_a_region": "Select a region",
@@ -1866,7 +1873,9 @@
"net_pnl": "Net P&L",
"total_net_pnl": "Total Net P&L",
"market_net_pnl": "Market Net P&L",
- "activity_details": "Activity details"
+ "activity_details": "Activity details",
+ "today": "Today",
+ "yesterday": "Yesterday"
},
"claim": {
"toasts": {
@@ -4432,6 +4441,11 @@
"order_status_cancelled": "Cancelled",
"webview_no_url_provided": "No URL was provided to continue",
"webview_error_no_address_provided": "No wallet address was provided to continue",
+ "settings_modal": {
+ "title": "Settings",
+ "use_new_buy_experience": "Use new buy experience",
+ "use_new_buy_experience_description": "Try new native on ramp"
+ },
"onboarding": {
"what_to_expect": "What to Expect",
"quotes": "Our buy crypto feature aggregates quotes from integrated vendors, providing quotes from those sources to get crypto directly into your wallet with no waiting period.",