diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 263d61bf4cd..5b712170248 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -175,15 +175,6 @@ jobs:
uses: ./.github/workflows/build-android-e2e.yml
secrets: inherit
- build-ios-apps:
- name: "Build iOS Apps"
- if: ${{ github.event_name != 'merge_group' }}
- permissions:
- contents: read
- id-token: write
- uses: ./.github/workflows/build-ios-e2e.yml
- secrets: inherit
-
e2e-smoke-tests-android:
name: "Android E2E Smoke Tests"
permissions:
@@ -193,15 +184,6 @@ jobs:
uses: ./.github/workflows/run-e2e-smoke-tests-android.yml
secrets: inherit
- e2e-smoke-tests-ios:
- name: "iOS E2E Smoke Tests"
- permissions:
- contents: read
- id-token: write
- needs: [build-ios-apps]
- uses: ./.github/workflows/run-e2e-smoke-tests-ios.yml
- secrets: inherit
-
js-bundle-size-check:
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/nightly-temp-branch-sync.yml b/.github/workflows/nightly-temp-branch-sync.yml
new file mode 100644
index 00000000000..d3cbefbe67f
--- /dev/null
+++ b/.github/workflows/nightly-temp-branch-sync.yml
@@ -0,0 +1,47 @@
+name: Nightly Temp Branch Sync
+
+# Required permissions for the action to work
+permissions:
+ contents: write
+
+on:
+ schedule:
+ # Run at 11 PM UTC daily (adjust timezone as needed)
+ # NOTE: Scheduled workflows ALWAYS run from the default branch (main)
+ - cron: '0 23 * * *'
+
+ # Allow manual trigger for testing from ANY branch
+ workflow_dispatch:
+
+jobs:
+ sync-temp-nightly-branch:
+ name: Sync chore/temp-nightly with main
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ # Fetch all history so we can work with branches
+ fetch-depth: 0
+ # Use the default GITHUB_TOKEN which has write permissions
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Configure Git
+ run: |
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Make sync script executable
+ run: chmod +x scripts/create-temp-nightly-branch.sh
+
+ - name: Run temp-nightly branch sync
+ run: ./scripts/create-temp-nightly-branch.sh
+
+ - name: Report sync status
+ run: |
+ echo "✅ Successfully synced chore/temp-nightly branch with main"
+ echo "🌿 Workflow triggered from branch: ${{ github.ref_name }}"
+ echo "📍 Current branch after sync: $(git branch --show-current)"
+ echo "📄 Latest commit: $(git log --oneline -1)"
+ echo "🔄 Trigger event: ${{ github.event_name }}"
diff --git a/.github/workflows/temp-ios-workflow.yml b/.github/workflows/temp-ios-workflow.yml
new file mode 100644
index 00000000000..a0f03f1f5dc
--- /dev/null
+++ b/.github/workflows/temp-ios-workflow.yml
@@ -0,0 +1,34 @@
+# Temporary workflow to monitor iOS builds and E2E tests
+
+name: TEMPORARY iOS Workflow
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+ schedule:
+ - cron: '0 2-6 * * *'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ !(contains(github.ref, 'refs/heads/main') || contains(github.ref, 'refs/heads/stable')) }}
+
+jobs:
+ build-ios-apps:
+ name: "Build iOS Apps"
+ if: ${{ github.event_name != 'merge_group' }}
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/build-ios-e2e.yml
+ secrets: inherit
+
+ e2e-smoke-tests-ios:
+ name: "iOS E2E Smoke Tests"
+ permissions:
+ contents: read
+ id-token: write
+ needs: [build-ios-apps]
+ uses: ./.github/workflows/run-e2e-smoke-tests-ios.yml
+ secrets: inherit
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.tsx
index 55e36c4d63e..9bb70fac550 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useCallback, useState } from 'react';
+import React, { memo, useCallback, useState, useEffect } from 'react';
import { View, TouchableOpacity, InteractionManager } from 'react-native';
import { useSelector } from 'react-redux';
@@ -21,6 +21,12 @@ import { useWalletInfo } from '../../../../../components/Views/MultichainAccount
import { AccountWalletId } from '@metamask/account-api';
import createStyles from './AccountListFooter.styles';
import Engine from '../../../../../core/Engine';
+import {
+ TraceName,
+ TraceOperation,
+ endTrace,
+ trace,
+} from '../../../../../util/trace';
interface AccountListFooterProps {
walletId: AccountWalletId;
@@ -37,6 +43,13 @@ const AccountListFooter = memo(
const wallet = walletsMap?.[walletId];
const walletInfo = useWalletInfo(wallet);
+ // End trace when the loading finishes
+ useEffect(() => {
+ if (!isLoading) {
+ endTrace({ name: TraceName.CreateMultichainAccount });
+ }
+ }, [isLoading]);
+
const handleCreateAccount = useCallback(async () => {
if (!walletInfo?.keyringId) {
Logger.error(
@@ -44,7 +57,6 @@ const AccountListFooter = memo(
'Cannot create account without keyring ID',
);
setIsLoading(false);
-
return;
}
@@ -73,6 +85,12 @@ const AccountListFooter = memo(
}, [walletInfo?.keyringId, onAccountCreated]);
const handlePress = useCallback(() => {
+ // Start the trace before setting the loading state
+ trace({
+ name: TraceName.CreateMultichainAccount,
+ op: TraceOperation.AccountCreate,
+ });
+
// Force immediate state update
setIsLoading(true);
diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx
index a95ed87af92..8590ddc4b21 100644
--- a/app/components/Nav/App/App.tsx
+++ b/app/components/Nav/App/App.tsx
@@ -460,6 +460,7 @@ const RootModalFlow = (props: RootModalFlowProps) => (
{
- trace({
- name: TraceName.AccountList,
- tags: getTraceTags(store.getState()),
- op: TraceOperation.AccountList,
- });
navigation.navigate(...createAccountSelectorNavDetails({}));
}}
testID={WalletViewSelectorsIDs.ACCOUNT_ICON}
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
index 92d1bae76bd..1428ab18fe7 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
@@ -1028,6 +1028,45 @@ describe('PerpsMarketDetailsView', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
+ it('shows geo block modal when add funds button is pressed and user is not eligible', () => {
+ // Set user as not eligible
+ const { useSelector } = jest.requireMock('react-redux');
+ const mockSelectPerpsEligibility = jest.requireMock(
+ '../../selectors/perpsController',
+ ).selectPerpsEligibility;
+ useSelector.mockImplementation((selector: unknown) => {
+ if (selector === mockSelectPerpsEligibility) {
+ return false;
+ }
+ return undefined;
+ });
+
+ // Set zero balance to show add funds button
+ mockUsePerpsAccount.mockReturnValue({
+ availableBalance: '0.00',
+ totalBalance: '0.00',
+ marginUsed: '0.00',
+ unrealizedPnl: '0.00',
+ });
+
+ const { getByTestId, getByText } = renderWithProvider(
+
+
+ ,
+ {
+ state: initialState,
+ },
+ );
+
+ const addFundsButton = getByTestId(
+ PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON,
+ );
+ fireEvent.press(addFundsButton);
+
+ expect(getByText('Geo Block Tooltip')).toBeTruthy();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
it('closes geo block modal when onClose is called', () => {
const { useSelector } = jest.requireMock('react-redux');
const mockSelectPerpsEligibility = jest.requireMock(
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
index ad0a3241236..ddcbf8a0c60 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
@@ -263,6 +263,11 @@ const PerpsMarketDetailsView: React.FC = () => {
const handleAddFundsPress = async () => {
try {
+ if (!isEligible) {
+ setIsEligibilityModalVisible(true);
+ return;
+ }
+
// Ensure the network exists before proceeding
await ensureArbitrumNetworkExists();
diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx
index 1347a69c98b..f5e93f7067e 100644
--- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx
@@ -709,6 +709,60 @@ describe('PerpsOrderView', () => {
});
});
+ it('calculates liquidation price using market price for market orders', async () => {
+ // Set route params for market order
+ (useRoute as jest.Mock).mockReturnValue({
+ params: {
+ asset: 'BTC',
+ direction: 'long',
+ amount: '100',
+ leverage: 10,
+ },
+ });
+
+ render(, { wrapper: TestWrapper });
+
+ // Wait for component to render and liquidation price to be calculated
+ await waitFor(() => {
+ expect(screen.getByText('Liquidation price')).toBeDefined();
+ });
+
+ // Since the default order type is 'market' and no limit price is set,
+ // the hook should be called with the current market price (0 from mock data)
+ expect(usePerpsLiquidationPrice).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entryPrice: 0, // Current mock price from assetData
+ }),
+ );
+ });
+
+ it('calculates liquidation price using limit price for limit orders', async () => {
+ // We need to test the logic by examining what happens when the order context
+ // provides limit order data. Since the actual context logic is complex,
+ // we'll verify the memoized calculation logic instead.
+ // Set route params that would lead to a limit order
+ (useRoute as jest.Mock).mockReturnValue({
+ params: {
+ asset: 'BTC',
+ direction: 'long',
+ amount: '100',
+ leverage: 10,
+ },
+ });
+
+ render(, { wrapper: TestWrapper });
+
+ // Wait for initial render
+ await waitFor(() => {
+ expect(screen.getByText('Liquidation price')).toBeDefined();
+ });
+
+ // The liquidation price hook should be called - the exact parameters
+ // depend on the order form state. We verify it's being called which
+ // confirms our logic is reached.
+ expect(usePerpsLiquidationPrice).toHaveBeenCalled();
+ });
+
it('shows margin required', async () => {
render(, { wrapper: TestWrapper });
diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
index 093e07e0593..ac96a51e9ad 100644
--- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
@@ -437,15 +437,27 @@ const PerpsOrderViewContentBase: React.FC = () => {
const marginRequired = calculations.marginRequired;
// Memoize liquidation price params to prevent infinite recalculation
- const liquidationPriceParams = useMemo(
- () => ({
- entryPrice: assetData.price,
+ const liquidationPriceParams = useMemo(() => {
+ // Use limit price for limit orders, market price for market orders
+ const entryPrice =
+ orderForm.type === 'limit' && orderForm.limitPrice
+ ? parseFloat(orderForm.limitPrice)
+ : assetData.price;
+
+ return {
+ entryPrice,
leverage: orderForm.leverage,
direction: orderForm.direction,
asset: orderForm.asset,
- }),
- [assetData.price, orderForm.leverage, orderForm.direction, orderForm.asset],
- );
+ };
+ }, [
+ assetData.price,
+ orderForm.leverage,
+ orderForm.direction,
+ orderForm.asset,
+ orderForm.type,
+ orderForm.limitPrice,
+ ]);
// Real-time liquidation price calculation
const { liquidationPrice } = usePerpsLiquidationPrice(liquidationPriceParams);
@@ -1111,6 +1123,8 @@ const PerpsOrderViewContentBase: React.FC = () => {
currentPrice={assetData.price}
direction={orderForm.direction}
asset={orderForm.asset}
+ limitPrice={orderForm.limitPrice}
+ orderType={orderForm.type}
/>
{/* Limit Price Bottom Sheet */}
{
// Assert - Should cap at 100.0% even for actual liquidation price
expect(screen.getByText(/100\.0%/)).toBeOnTheScreen();
});
+
+ it('uses limit price for liquidation calculation when orderType is limit', () => {
+ // Arrange
+ const limitPrice = '2800';
+ const currentPrice = 3000;
+ const mockUsePerpsLiquidationPrice = jest.requireMock(
+ '../../hooks/usePerpsLiquidationPrice',
+ );
+
+ const propsWithLimitOrder = {
+ ...defaultProps,
+ currentPrice,
+ limitPrice,
+ orderType: 'limit' as const,
+ };
+
+ // Mock the liquidation price hook to track what entry price it receives
+ mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice = jest.fn(() => ({
+ liquidationPrice: '2520.00', // Mock calculated based on limit price
+ isCalculating: false,
+ error: null,
+ }));
+
+ // Act
+ render();
+
+ // Assert - Hook should be called with limit price as entry price
+ expect(
+ mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entryPrice: parseFloat(limitPrice), // Should use limit price, not current price
+ leverage: defaultProps.leverage,
+ direction: defaultProps.direction,
+ asset: defaultProps.asset,
+ }),
+ );
+ });
+
+ it('uses current price for liquidation calculation when orderType is market', () => {
+ // Arrange
+ const limitPrice = '2800';
+ const currentPrice = 3000;
+ const mockUsePerpsLiquidationPrice = jest.requireMock(
+ '../../hooks/usePerpsLiquidationPrice',
+ );
+
+ const propsWithMarketOrder = {
+ ...defaultProps,
+ currentPrice,
+ limitPrice, // Even if limit price is provided
+ orderType: 'market' as const,
+ };
+
+ // Mock the liquidation price hook to track what entry price it receives
+ mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice = jest.fn(() => ({
+ liquidationPrice: '2700.00', // Mock calculated based on current price
+ isCalculating: false,
+ error: null,
+ }));
+
+ // Act
+ render();
+
+ // Assert - Hook should be called with current price as entry price
+ expect(
+ mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entryPrice: currentPrice, // Should use current price, not limit price
+ leverage: defaultProps.leverage,
+ direction: defaultProps.direction,
+ asset: defaultProps.asset,
+ }),
+ );
+ });
});
describe('Price Information Display', () => {
diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx
index 06ead53cc48..0d1bfc5c06f 100644
--- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx
@@ -69,6 +69,8 @@ interface PerpsLeverageBottomSheetProps {
currentPrice: number;
direction: 'long' | 'short';
asset?: string;
+ limitPrice?: string;
+ orderType?: 'market' | 'limit';
}
/**
@@ -306,6 +308,8 @@ const PerpsLeverageBottomSheet: React.FC = ({
currentPrice,
direction,
asset = '',
+ limitPrice,
+ orderType = 'market',
}) => {
const { colors } = useTheme();
const styles = createStyles(colors);
@@ -316,9 +320,18 @@ const PerpsLeverageBottomSheet: React.FC = ({
const hasTrackedLeverageView = useRef(false);
// Dynamically calculate liquidation price based on tempLeverage
+ // Use limit price for limit orders, market price for market orders
+ const entryPrice = useMemo(
+ () =>
+ orderType === 'limit' && limitPrice
+ ? parseFloat(limitPrice)
+ : currentPrice,
+ [orderType, limitPrice, currentPrice],
+ );
+
const { liquidationPrice: calculatedLiquidationPrice } =
usePerpsLiquidationPrice({
- entryPrice: currentPrice,
+ entryPrice,
leverage: tempLeverage,
direction,
asset,
diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx
index 7a4abe50e00..fae3e0b9edc 100644
--- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx
+++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx
@@ -160,6 +160,22 @@ describe('PerpsTutorialCarousel', () => {
setParams: jest.fn(),
};
+ // Helper function to navigate through screens
+ const navigateToScreen = async (screenIndex: number) => {
+ for (let i = 0; i < screenIndex; i++) {
+ const continueButton = screen.getByText(
+ strings('perps.tutorial.continue'),
+ );
+ await act(async () => {
+ fireEvent.press(continueButton);
+ });
+ // Advance timers to clear the debounce
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+ }
+ };
+
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
@@ -212,14 +228,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens by pressing Continue 5 times (6 screens total)
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(5);
// Verify we're on the last screen
expect(
@@ -244,28 +253,28 @@ describe('PerpsTutorialCarousel', () => {
expect(mockDepositWithConfirmation).toHaveBeenCalled();
});
- it('should go back when pressing Skip on first screen', () => {
+ it('should navigate to markets list when pressing Skip on first screen', () => {
render();
act(() => {
fireEvent.press(screen.getByText(strings('perps.tutorial.skip')));
});
- expect(mockNavigation.goBack).toHaveBeenCalled();
+ expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith(
+ Routes.PERPS.ROOT,
+ {
+ screen: Routes.PERPS.MARKETS,
+ },
+ );
expect(mockMarkTutorialCompleted).not.toHaveBeenCalled();
expect(mockDepositWithConfirmation).not.toHaveBeenCalled();
});
- it('should mark tutorial as completed and go back when pressing Skip on last screen', async () => {
+ it('should mark tutorial as completed and navigate to markets list when pressing Skip on last screen', async () => {
render();
// Navigate to the last screen
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- fireEvent.press(continueButton);
- }
+ await navigateToScreen(5);
// Verify we're on the last screen
expect(
@@ -282,9 +291,14 @@ describe('PerpsTutorialCarousel', () => {
fireEvent.press(screen.getByText(strings('perps.tutorial.got_it')));
});
- // Should mark tutorial as completed and go back, but NOT initialize deposit
+ // Should mark tutorial as completed and navigate to markets list, but NOT initialize deposit
expect(mockMarkTutorialCompleted).toHaveBeenCalled();
- expect(mockNavigation.goBack).toHaveBeenCalled();
+ expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith(
+ Routes.PERPS.ROOT,
+ {
+ screen: Routes.PERPS.MARKETS,
+ },
+ );
expect(mockDepositWithConfirmation).not.toHaveBeenCalled();
});
@@ -292,14 +306,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens by pressing Continue 5 times to get to last screen
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(5);
// Press Add funds button on last screen
await act(async () => {
@@ -315,14 +322,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens by pressing Continue 5 times
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(5);
// Press Add funds button on last screen
await act(async () => {
@@ -380,12 +380,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate to the last screen
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- fireEvent.press(continueButton);
- }
+ await navigateToScreen(5);
// Press "Got it" button on last screen
act(() => {
@@ -442,7 +437,7 @@ describe('PerpsTutorialCarousel', () => {
});
});
- it('should use goBack when not from deeplink', () => {
+ it('should navigate to markets list when not from deeplink', () => {
// Default params (not from deeplink)
(useRoute as jest.Mock).mockReturnValue({
params: {},
@@ -455,8 +450,13 @@ describe('PerpsTutorialCarousel', () => {
fireEvent.press(screen.getByText(strings('perps.tutorial.skip')));
});
- // Should use goBack instead of navigate
- expect(mockNavigation.goBack).toHaveBeenCalled();
+ // Should navigate to markets list
+ expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith(
+ Routes.PERPS.ROOT,
+ {
+ screen: Routes.PERPS.MARKETS,
+ },
+ );
expect(mockNavigation.navigate).not.toHaveBeenCalled();
});
@@ -473,8 +473,13 @@ describe('PerpsTutorialCarousel', () => {
fireEvent.press(screen.getByText(strings('perps.tutorial.skip')));
});
- // Should default to goBack behavior
- expect(mockNavigation.goBack).toHaveBeenCalled();
+ // Should default to navigating to markets list
+ expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith(
+ Routes.PERPS.ROOT,
+ {
+ screen: Routes.PERPS.MARKETS,
+ },
+ );
expect(mockNavigation.navigate).not.toHaveBeenCalled();
});
@@ -488,14 +493,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate to last screen and press Add funds
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(5);
// Press Add funds button
await act(async () => {
@@ -555,6 +553,10 @@ describe('PerpsTutorialCarousel', () => {
await act(async () => {
fireEvent.press(continueButton);
});
+ // Advance timers to clear the debounce
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
// Check that the correct artboard is rendered for current screen
expect(screen.getByTestId('mock-rive-animation')).toBeOnTheScreen();
@@ -568,14 +570,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens to get to last screen
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(5);
// Verify we're on the ready_to_trade screen
expect(
@@ -592,14 +587,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens by pressing Continue 5 times
- for (let i = 0; i < 5; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(5);
// Press the "Add funds" button
await act(async () => {
@@ -660,6 +648,10 @@ describe('PerpsTutorialCarousel', () => {
await act(async () => {
fireEvent.press(continueButton);
});
+ // Advance timers to clear the debounce
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
// Check that the correct artboard is rendered for current screen
expect(screen.getByTestId('mock-rive-animation')).toBeOnTheScreen();
@@ -683,14 +675,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens to get to last screen (4 clicks for 5 screens)
- for (let i = 0; i < 4; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(4);
// Verify we're on the close_anytime screen (last for non-eligible)
expect(
@@ -713,14 +698,7 @@ describe('PerpsTutorialCarousel', () => {
render();
// Navigate through all screens by pressing Continue 4 times (5 screens total)
- for (let i = 0; i < 4; i++) {
- const continueButton = screen.getByText(
- strings('perps.tutorial.continue'),
- );
- await act(async () => {
- fireEvent.press(continueButton);
- });
- }
+ await navigateToScreen(4);
// Press the main "Got it" button (first one found, which is the main button)
await act(async () => {
@@ -730,9 +708,14 @@ describe('PerpsTutorialCarousel', () => {
fireEvent.press(gotItButtons[0]); // Main button is first
});
- // Should mark tutorial as completed and go back
+ // Should mark tutorial as completed and navigate to markets list
expect(mockMarkTutorialCompleted).toHaveBeenCalled();
- expect(mockNavigation.goBack).toHaveBeenCalled();
+ expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith(
+ Routes.PERPS.ROOT,
+ {
+ screen: Routes.PERPS.MARKETS,
+ },
+ );
// Should NOT navigate to deposit screen or call deposit
expect(mockNavigation.navigate).not.toHaveBeenCalled();
expect(mockDepositWithConfirmation).not.toHaveBeenCalled();
diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx
index 7ea5b018561..c5c8c993d42 100644
--- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx
+++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx
@@ -138,6 +138,7 @@ const PerpsTutorialCarousel: React.FC = () => {
const hasTrackedViewed = useRef(false);
const hasTrackedStarted = useRef(false);
const tutorialStartTime = useRef(Date.now());
+ const continueDebounceRef = useRef(null);
const isEligible = useSelector(selectPerpsEligibility);
@@ -151,8 +152,13 @@ const PerpsTutorialCarousel: React.FC = () => {
[currentTab, tutorialScreens.length],
);
+ const shouldShowSkipButton = useMemo(
+ () => !isLastScreen || isEligible,
+ [isLastScreen, isEligible],
+ );
+
const { styles } = useStyles(createStyles, {
- shouldShowSkipButton: !isLastScreen || isEligible,
+ shouldShowSkipButton,
});
// Track tutorial viewed on mount
@@ -167,6 +173,17 @@ const PerpsTutorialCarousel: React.FC = () => {
}
}, [track]);
+ // Cleanup timeout on unmount
+ useEffect(
+ () => () => {
+ if (continueDebounceRef.current) {
+ clearTimeout(continueDebounceRef.current);
+ continueDebounceRef.current = null;
+ }
+ },
+ [],
+ );
+
const handleTabChange = useCallback(
(obj: { i: number }) => {
setCurrentTab(obj.i);
@@ -184,7 +201,39 @@ const PerpsTutorialCarousel: React.FC = () => {
[track],
);
+ const navigateToWalletPerpsTab = useCallback(() => {
+ // Navigate to wallet home first (using global navigation service like deeplink handler)
+ NavigationService.navigation.navigate(Routes.WALLET.HOME);
+ // The timeout is REQUIRED - React Navigation needs time to:
+ // 1. Complete the navigation transition
+ // 2. Mount the Wallet component
+ // 3. Make navigation context available for setParams
+ // Without this delay, the tab selection will fail
+ setTimeout(() => {
+ NavigationService.navigation.setParams({
+ initialTab: 'perps',
+ shouldSelectPerpsTab: true,
+ });
+ }, PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS);
+ }, []);
+
+ const navigateToMarketsList = useCallback(() => {
+ NavigationService.navigation.navigate(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.MARKETS,
+ });
+ }, []);
+
const handleContinue = useCallback(async () => {
+ // Prevent double-tap on Android - if timeout exists, we're still debouncing
+ if (continueDebounceRef.current) {
+ return;
+ }
+
+ // Set debounce timeout
+ continueDebounceRef.current = setTimeout(() => {
+ continueDebounceRef.current = null;
+ }, 100);
+
if (isLastScreen) {
// Track tutorial completed
const completionDuration = Date.now() - tutorialStartTime.current;
@@ -220,7 +269,12 @@ const PerpsTutorialCarousel: React.FC = () => {
return;
}
- navigation.goBack();
+ if (isFromGTMModal) {
+ navigateToWalletPerpsTab();
+ return;
+ }
+
+ navigateToMarketsList();
} else {
// Go to next screen using the ref
const nextTab = Math.min(currentTab + 1, tutorialScreens.length - 1);
@@ -246,6 +300,9 @@ const PerpsTutorialCarousel: React.FC = () => {
depositWithConfirmation,
tutorialScreens.length,
ensureArbitrumNetworkExists,
+ isFromGTMModal,
+ navigateToWalletPerpsTab,
+ navigateToMarketsList,
]);
const handleSkip = useCallback(() => {
@@ -266,31 +323,19 @@ const PerpsTutorialCarousel: React.FC = () => {
// Navigate based on deeplink/gtm modal flag
if (isFromDeeplink || isFromGTMModal) {
- // Navigate to wallet home first (using global navigation service like deeplink handler)
- NavigationService.navigation.navigate(Routes.WALLET.HOME);
-
- // The timeout is REQUIRED - React Navigation needs time to:
- // 1. Complete the navigation transition
- // 2. Mount the Wallet component
- // 3. Make navigation context available for setParams
- // Without this delay, the tab selection will fail
- setTimeout(() => {
- NavigationService.navigation.setParams({
- initialTab: 'perps',
- shouldSelectPerpsTab: true,
- });
- }, PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS);
+ navigateToWalletPerpsTab();
} else {
- navigation.goBack();
+ navigateToMarketsList();
}
}, [
isLastScreen,
markTutorialCompleted,
- navigation,
currentTab,
track,
isFromGTMModal,
isFromDeeplink,
+ navigateToWalletPerpsTab,
+ navigateToMarketsList,
]);
const renderTabBar = () => ;
diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx
index c364e1340c6..40d85484ac3 100644
--- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx
+++ b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx
@@ -169,10 +169,14 @@ jest.mock('../SeasonTierImage', () => {
);
});
-// Mock lodash capitalize
-jest.mock('lodash', () => ({
- capitalize: jest.fn((str) => str?.charAt(0).toUpperCase() + str?.slice(1)),
-}));
+// Mock lodash capitalize but preserve the rest of lodash
+jest.mock('lodash', () => {
+ const actual = jest.requireActual('lodash');
+ return {
+ ...actual,
+ capitalize: jest.fn((str) => str?.charAt(0).toUpperCase() + str?.slice(1)),
+ };
+});
describe('SeasonStatus', () => {
// Default mock values
diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx
index 24c095e6457..794fd924200 100644
--- a/app/components/Views/AccountSelector/AccountSelector.tsx
+++ b/app/components/Views/AccountSelector/AccountSelector.tsx
@@ -171,8 +171,8 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
useEffect(() => {
if (isAccountSelector) {
trace({
- name: TraceName.AccountList,
- op: TraceOperation.AccountList,
+ name: TraceName.ShowAccountList,
+ op: TraceOperation.AccountUi,
tags: getTraceTags(store.getState()),
});
}
@@ -182,7 +182,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
const onOpen = useCallback(() => {
if (isAccountSelector) {
endTrace({
- name: TraceName.AccountList,
+ name: TraceName.ShowAccountList,
});
}
}, [isAccountSelector]);
diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx
index 29b341541be..4bdb574f05d 100644
--- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx
@@ -299,6 +299,7 @@ describe('AccountGroupDetails', () => {
expect(mockNavigate).toHaveBeenCalledWith(expect.any(String), {
groupId: mockAccountGroup.id,
title: `Addresses / ${mockAccountGroup.metadata.name}`,
+ onLoad: expect.any(Function),
});
});
diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx
index f954147b4fa..ab601f14e79 100644
--- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx
+++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx
@@ -42,6 +42,12 @@ import { selectInternalAccountsById } from '../../../../selectors/accountsContro
import { SecretRecoveryPhrase, Wallet, RemoveAccount } from './components';
import { createAddressListNavigationDetails } from '../AddressList';
import { createPrivateKeyListNavigationDetails } from '../PrivateKeyList/PrivateKeyList';
+import {
+ endTrace,
+ trace,
+ TraceName,
+ TraceOperation,
+} from '../../../../util/trace';
import Routes from '../../../../constants/navigation/Routes';
import { createMultichainAccountDetailActionsModalNavigationDetails } from '../sheets/MultichainAccountActions/MultichainAccountActions';
@@ -103,12 +109,22 @@ export const AccountGroupDetails = (props: AccountGroupDetailsProps) => {
);
const navigateToAddressList = useCallback(() => {
+ // Start the trace before navigating to the address list so that the
+ // navigation and render time are included in the trace.
+ trace({
+ name: TraceName.ShowAccountAddressList,
+ op: TraceOperation.AccountUi,
+ });
+
navigation.navigate(
...createAddressListNavigationDetails({
groupId: id,
title: `${strings('multichain_accounts.address_list.addresses')} / ${
metadata.name
}`,
+ onLoad: () => {
+ endTrace({ name: TraceName.ShowAccountAddressList });
+ },
}),
);
}, [id, metadata.name, navigation]);
diff --git a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx
index a816090be4b..80b6daa0885 100644
--- a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx
+++ b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx
@@ -43,7 +43,7 @@ export const AddressList = () => {
const navigation = useNavigation();
const { styles } = useStyles(styleSheet, {});
- const { groupId, title } = useParams();
+ const { groupId, title, onLoad } = useParams();
const selectInternalAccountsSpreadByScopes = useSelector(
selectInternalAccountListSpreadByScopesByGroupId,
@@ -114,6 +114,7 @@ export const AddressList = () => {
data={internalAccountsSpreadByScopes}
keyExtractor={(item) => item.scope}
renderItem={renderAddressItem}
+ onLoad={onLoad}
/>
diff --git a/app/components/Views/MultichainAccounts/AddressList/types.ts b/app/components/Views/MultichainAccounts/AddressList/types.ts
index cfb97531716..09305907c52 100644
--- a/app/components/Views/MultichainAccounts/AddressList/types.ts
+++ b/app/components/Views/MultichainAccounts/AddressList/types.ts
@@ -5,6 +5,7 @@ import { type InternalAccount } from '@metamask/keyring-internal-api';
export interface AddressListProps {
groupId: AccountGroupId;
title: string;
+ onLoad?: () => void;
}
export interface AddressItem {
diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx
index 32e8f3aeff4..cbba0f719c9 100644
--- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx
+++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx
@@ -48,6 +48,12 @@ import { PrivateKeyListIds } from '../../../../../e2e/selectors/MultichainAccoun
import styleSheet from './styles';
import type { Params as PrivateKeyListParams, AddressItem } from './types';
+import {
+ endTrace,
+ trace,
+ TraceName,
+ TraceOperation,
+} from '../../../../util/trace';
export const createPrivateKeyListNavigationDetails =
createNavigationDetails(
@@ -103,6 +109,17 @@ export const PrivateKeyList = () => {
[],
);
+ // Start tracing the private key list display only after the password is
+ // entered and verified.
+ useEffect(() => {
+ if (reveal) {
+ trace({
+ name: TraceName.ShowAccountPrivateKeyList,
+ op: TraceOperation.AccountUi,
+ });
+ }
+ }, [reveal]);
+
const onPasswordChange = useCallback((pswd: string) => {
setPassword(pswd);
}, []);
@@ -249,6 +266,9 @@ export const PrivateKeyList = () => {
keyExtractor={(item) => item.scope}
renderItem={renderAddressItem}
testID={PrivateKeyListIds.LIST}
+ onLoad={() => {
+ endTrace({ name: TraceName.ShowAccountPrivateKeyList });
+ }}
/>
),
diff --git a/app/components/Views/ReturnToAppModal/ReturnToAppModal.tsx b/app/components/Views/ReturnToAppModal/ReturnToAppModal.tsx
index 7e2992c20dd..fe363256e90 100644
--- a/app/components/Views/ReturnToAppModal/ReturnToAppModal.tsx
+++ b/app/components/Views/ReturnToAppModal/ReturnToAppModal.tsx
@@ -9,14 +9,29 @@ import { useStyles } from '../../../component-library/hooks';
import Text from '../../../component-library/components/Texts/Text';
import BottomSheet from '../../../component-library/components/BottomSheets/BottomSheet';
-const ReturnToAppModal = () => {
+interface ReturnToAppModalProps {
+ route: {
+ params: {
+ isPostNetworkSwitch: boolean;
+ };
+ };
+}
+
+const ReturnToAppModal = (props: ReturnToAppModalProps) => {
+ const { isPostNetworkSwitch } = props.route.params;
const { styles } = useStyles(styleSheet, {});
const sheetRef = useRef(null);
return (
-
+
{strings('sdk_return_to_app_modal.description')}
diff --git a/app/components/Views/ReturnToAppModal/__snapshots__/index.test.tsx.snap b/app/components/Views/ReturnToAppModal/__snapshots__/index.test.tsx.snap
index 8f29c4b79ba..0db0e252d2e 100644
--- a/app/components/Views/ReturnToAppModal/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/ReturnToAppModal/__snapshots__/index.test.tsx.snap
@@ -1,6 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`ReturnToAppModal renders correctly 1`] = `
+exports[`ReturnToAppModal renders correctly iwth default message 1`] = `
+
+`;
+
+exports[`ReturnToAppModal renders correctly with specific post network switch message 1`] = `
({
}));
describe('ReturnToAppModal', () => {
+ const mockRoute = {
+ params: {
+ isPostNetworkSwitch: false,
+ },
+ };
+
+ beforeEach(() => {
+ mockRoute.params.isPostNetworkSwitch = false;
+ });
+
it('renders without crashing', () => {
render(
-
+
+ ,
+ );
+ });
+
+ it('renders correctly iwth default message', () => {
+ const { toJSON } = render(
+
+
,
);
+
+ expect(toJSON()).toMatchSnapshot();
});
- it('renders correctly', () => {
+ it('renders correctly with specific post network switch message', () => {
+ mockRoute.params.isPostNetworkSwitch = true;
+
const { toJSON } = render(
-
+
,
);
diff --git a/app/components/Views/WalletActions/WalletActions.test.tsx b/app/components/Views/WalletActions/WalletActions.test.tsx
index bcc74a37089..6734eb6ff27 100644
--- a/app/components/Views/WalletActions/WalletActions.test.tsx
+++ b/app/components/Views/WalletActions/WalletActions.test.tsx
@@ -23,6 +23,7 @@ import {
import { EarnTokenDetails } from '../../UI/Earn/types/lending.types';
import WalletActions from './WalletActions';
import { selectPerpsEnabledFlag } from '../../UI/Perps';
+import { selectIsFirstTimePerpsUser } from '../../UI/Perps/selectors/perpsController';
jest.mock('react-native-device-info', () => ({
getVersion: jest.fn().mockReturnValue('1.0.0'),
@@ -32,6 +33,10 @@ jest.mock('../../UI/Perps', () => ({
selectPerpsEnabledFlag: jest.fn(),
}));
+jest.mock('../../UI/Perps/selectors/perpsController', () => ({
+ selectIsFirstTimePerpsUser: jest.fn(),
+}));
+
jest.mock('../../UI/Earn/selectors/featureFlags', () => ({
selectStablecoinLendingEnabledFlag: jest.fn(),
selectPooledStakingEnabledFlag: jest.fn(),
@@ -444,12 +449,17 @@ describe('WalletActions', () => {
).toBeDefined();
});
- it('should call the onPerps function when the Perpetuals button is pressed', () => {
+ it('should navigate to Perps markets when returning user presses Perpetuals button', async () => {
(
selectPerpsEnabledFlag as jest.MockedFunction<
typeof selectPerpsEnabledFlag
>
).mockReturnValue(true);
+ (
+ selectIsFirstTimePerpsUser as jest.MockedFunction<
+ typeof selectIsFirstTimePerpsUser
+ >
+ ).mockReturnValue(false);
const { getByTestId } = renderWithProvider(, {
state: mockInitialState,
@@ -459,11 +469,44 @@ describe('WalletActions', () => {
getByTestId(WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON),
);
+ // Wait for the bottom sheet close callback to execute
+ // closeBottomSheetAndNavigate wraps navigation in a callback
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
expect(mockNavigate).toHaveBeenCalledWith('Perps', {
screen: 'PerpsMarketListView',
});
});
+ it('should navigate to Perps tutorial when first-time user presses Perpetuals button', async () => {
+ (
+ selectPerpsEnabledFlag as jest.MockedFunction<
+ typeof selectPerpsEnabledFlag
+ >
+ ).mockReturnValue(true);
+ (
+ selectIsFirstTimePerpsUser as jest.MockedFunction<
+ typeof selectIsFirstTimePerpsUser
+ >
+ ).mockReturnValue(true);
+
+ const { getByTestId } = renderWithProvider(, {
+ state: mockInitialState,
+ });
+
+ fireEvent.press(
+ getByTestId(WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON),
+ );
+
+ // Wait for the bottom sheet close callback to execute
+ // closeBottomSheetAndNavigate wraps navigation in a callback
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(mockNavigate).toHaveBeenCalledWith('Perps', {
+ screen: 'PerpsTutorial',
+ });
+ });
+
it('disables action buttons when the account cannot sign transactions', () => {
(
selectStablecoinLendingEnabledFlag as jest.MockedFunction<
diff --git a/app/components/Views/WalletActions/WalletActions.tsx b/app/components/Views/WalletActions/WalletActions.tsx
index 6f065b7e449..0304d3b4f12 100644
--- a/app/components/Views/WalletActions/WalletActions.tsx
+++ b/app/components/Views/WalletActions/WalletActions.tsx
@@ -39,6 +39,7 @@ import {
import { RootState } from '../../../reducers';
import { selectIsSwapsLive } from '../../../core/redux/slices/bridge';
import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController';
+import { selectIsFirstTimePerpsUser } from '../../UI/Perps/selectors/perpsController';
const WalletActions = () => {
const { styles } = useStyles(styleSheet, {});
@@ -47,6 +48,7 @@ const WalletActions = () => {
const isPooledStakingEnabled = useSelector(selectPooledStakingEnabledFlag);
const { earnTokens } = useSelector(earnSelectors.selectEarnTokens);
+ const isFirstTimePerpsUser = useSelector(selectIsFirstTimePerpsUser);
const chainId = useSelector(selectChainId);
const swapsIsLive = useSelector((state: RootState) =>
selectIsSwapsLive(state, chainId),
@@ -127,12 +129,20 @@ const WalletActions = () => {
]);
const onPerps = useCallback(() => {
- closeBottomSheetAndNavigate(() => {
- navigate(Routes.PERPS.ROOT, {
+ let params: Record | null = null;
+ if (isFirstTimePerpsUser) {
+ params = {
+ screen: Routes.PERPS.TUTORIAL,
+ };
+ } else {
+ params = {
screen: Routes.PERPS.MARKETS,
- });
+ };
+ }
+ closeBottomSheetAndNavigate(() => {
+ navigate(Routes.PERPS.ROOT, params);
});
- }, [closeBottomSheetAndNavigate, navigate]);
+ }, [closeBottomSheetAndNavigate, navigate, isFirstTimePerpsUser]);
const isEarnWalletActionEnabled = useMemo(() => {
if (
diff --git a/app/core/SDKConnect/handlers/handleSendMessage.test.ts b/app/core/SDKConnect/handlers/handleSendMessage.test.ts
index de6b5ffb22a..57fe7e13b60 100644
--- a/app/core/SDKConnect/handlers/handleSendMessage.test.ts
+++ b/app/core/SDKConnect/handlers/handleSendMessage.test.ts
@@ -83,7 +83,7 @@ describe('handleSendMessage', () => {
isAnalyticsTrackedRpcMethod as jest.Mock;
beforeEach(() => {
- mockRpcQueueManagerGetId.mockReturnValue('eth_requestAccounts'); // Example tracked method
+ mockRpcQueueManagerGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS); // Example tracked method
mockIsAnalyticsTrackedRpcMethod.mockReturnValue(true);
mockConnection.originatorInfo = {
anonId: 'test-anon-id',
@@ -315,6 +315,7 @@ describe('handleSendMessage', () => {
});
expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', {
+ isPostNetworkSwitch: false,
screen: 'ReturnToDappModal',
});
});
@@ -341,4 +342,31 @@ describe('handleSendMessage', () => {
expect(mockConnection.trigger).toBe('resume');
});
});
+
+ describe('Confirmation popup message', () => {
+ beforeEach(() => {
+ mockRpcQueueManagerGetId.mockReturnValue(
+ RPC_METHODS.WALLET_SWITCHETHEREUMCHAIN,
+ );
+ // mockCanRedirect.mockReturnValue(true);
+ mockBatchRPCManagerGetById.mockReturnValue(undefined);
+ });
+ it('should handle specific behavior for network switch', async () => {
+ mockConnection.trigger = 'deeplink';
+
+ await handleSendMessage({
+ msg: {
+ data: {
+ id: 1,
+ },
+ },
+ connection: mockConnection,
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', {
+ isPostNetworkSwitch: true,
+ screen: 'ReturnToDappModal',
+ });
+ });
+ });
});
diff --git a/app/core/SDKConnect/handlers/handleSendMessage.ts b/app/core/SDKConnect/handlers/handleSendMessage.ts
index f74c6438912..3be8a91d026 100644
--- a/app/core/SDKConnect/handlers/handleSendMessage.ts
+++ b/app/core/SDKConnect/handlers/handleSendMessage.ts
@@ -162,6 +162,7 @@ export const handleSendMessage = async ({
connection.trigger = 'resume';
connection.navigation?.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
screen: Routes.SHEET.RETURN_TO_DAPP_MODAL,
+ isPostNetworkSwitch: method === RPC_METHODS.WALLET_SWITCHETHEREUMCHAIN,
});
} catch (err) {
Logger.log(
diff --git a/app/util/trace.ts b/app/util/trace.ts
index c7f87b7b6a1..52107024af1 100644
--- a/app/util/trace.ts
+++ b/app/util/trace.ts
@@ -41,7 +41,6 @@ export enum TraceName {
SwitchBuiltInNetwork = 'Switch to Built in Network',
SwitchCustomNetwork = 'Switch to Custom Network',
VaultCreation = 'Login Vault Creation',
- AccountList = 'Account List',
StoreInit = 'Store Initialization',
Tokens = 'Tokens List',
CreateHdAccount = 'Create HD Account',
@@ -118,6 +117,11 @@ export enum TraceName {
EarnTokenList = 'Earn Token List',
EarnClaimConfirmationScreen = 'Earn Claim Confirmation Screen',
EarnPooledStakingClaimTxConfirmed = 'Earn Pooled Staking Claim Tx Confirmed',
+ // Accounts
+ ShowAccountList = 'Show Account List',
+ ShowAccountAddressList = 'Show Account Address List',
+ ShowAccountPrivateKeyList = 'Show Account Private Key List',
+ CreateMultichainAccount = 'Create Multichain Account',
// Perps
PerpsOpenPosition = 'Perps Open Position',
PerpsClosePosition = 'Perps Close Position',
@@ -161,6 +165,9 @@ export enum TraceOperation {
OnboardingUserJourney = 'onboarding.user_journey',
OnboardingSecurityOp = 'onboarding.security_operation',
OnboardingError = 'onboarding.error',
+ // Accounts
+ AccountCreate = 'account.create',
+ AccountUi = 'account.ui',
// Perps
PerpsOperation = 'perps.operation',
PerpsMarketData = 'perps.market_data',
diff --git a/locales/languages/de.json b/locales/languages/de.json
index 05a99fc518f..d8bbf935fd6 100644
--- a/locales/languages/de.json
+++ b/locales/languages/de.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Zur App zurückkehren",
+ "postNetworkSwitchTitle": "Netzwerk erfolgreich gewechselt",
"description": "Bitte kehren Sie zur App zurück, um ihre Dienste weiter zu nutzen."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/el.json b/locales/languages/el.json
index 9e20596aaf0..daf3018fb84 100644
--- a/locales/languages/el.json
+++ b/locales/languages/el.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Επιστροφή στην εφαρμογή",
+ "postNetworkSwitchTitle": "Το δίκτυο άλλαξε με επιτυχία",
"description": "Επιστρέψτε στην εφαρμογή για να συνεχίσετε να χρησιμοποιείτε τις υπηρεσίες τους."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 692e0d2483f..60ac8959b33 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -2340,6 +2340,7 @@
},
"sdk_return_to_app_modal": {
"title": "Return to app",
+ "postNetworkSwitchTitle": "Network successfully switched",
"description": "Please return to the app to continue using their services."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/es.json b/locales/languages/es.json
index 8377d633b51..c70c817f200 100644
--- a/locales/languages/es.json
+++ b/locales/languages/es.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Volver a la aplicación",
+ "postNetworkSwitchTitle": "Red cambiada correctamente",
"description": "Regrese a la aplicación para continuar usando sus servicios."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/fr.json b/locales/languages/fr.json
index 1a2b415fd99..f2cdbf60d99 100644
--- a/locales/languages/fr.json
+++ b/locales/languages/fr.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Retourner à l’application",
+ "postNetworkSwitchTitle": "Réseau changé avec succès",
"description": "Veuillez retourner à l’application pour continuer à utiliser leurs services."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/hi.json b/locales/languages/hi.json
index be80c3387cd..8d8387fc617 100644
--- a/locales/languages/hi.json
+++ b/locales/languages/hi.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "ऐप पर लौटें",
+ "postNetworkSwitchTitle": "नेटवर्क सफलतापूर्वक बदल दिया गया",
"description": "कृपया उनकी सेवाओं का उपयोग जारी रखने के लिए ऐप पर लौटें।"
},
"sdk_feedback_modal": {
diff --git a/locales/languages/id.json b/locales/languages/id.json
index b4eaca1c0fb..5843f4b06b2 100644
--- a/locales/languages/id.json
+++ b/locales/languages/id.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Kembali ke aplikasi",
+ "postNetworkSwitchTitle": "Jaringan berhasil diubah",
"description": "Kembali ke aplikasi untuk terus menggunakan layanannya."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/ja.json b/locales/languages/ja.json
index 20c339c7733..e7827e07280 100644
--- a/locales/languages/ja.json
+++ b/locales/languages/ja.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "アプリに戻る",
+ "postNetworkSwitchTitle": "ネットワークが正常に切り替わりました",
"description": "サービスの使用を続けるには、アプリに戻ってください。"
},
"sdk_feedback_modal": {
diff --git a/locales/languages/ko.json b/locales/languages/ko.json
index 1cb981d4dbb..49fa9d83fff 100644
--- a/locales/languages/ko.json
+++ b/locales/languages/ko.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "앱으로 돌아가기",
+ "postNetworkSwitchTitle": "네트워크가 성공적으로 전환되었습니다",
"description": "서비스를 계속 이용하려면 앱으로 돌아가세요."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/pt.json b/locales/languages/pt.json
index 81bf53c93bb..188bf14851d 100644
--- a/locales/languages/pt.json
+++ b/locales/languages/pt.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Voltar ao app",
+ "postNetworkSwitchTitle": "Rede alternada com sucesso",
"description": "Por favor, volte ao app para continuar usando os serviços relevantes."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/ru.json b/locales/languages/ru.json
index eedb379b0e9..4b8a5c62d60 100644
--- a/locales/languages/ru.json
+++ b/locales/languages/ru.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Назад в приложение",
+ "postNetworkSwitchTitle": "Сеть успешно переключена",
"description": "Вернитесь в приложение, чтобы продолжить пользоваться его услугами."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/tl.json b/locales/languages/tl.json
index 1245aeca5f5..fdd1b1bd071 100644
--- a/locales/languages/tl.json
+++ b/locales/languages/tl.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Bumalik sa app",
+ "postNetworkSwitchTitle": "Matagumpay na na-switch ang network",
"description": "Bumalik sa app para magpatuloy sa paggamit ng kanilang mga serbisyo."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/tr.json b/locales/languages/tr.json
index de7ebdf9011..9552214ed40 100644
--- a/locales/languages/tr.json
+++ b/locales/languages/tr.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Uygulamaya geri dön",
+ "postNetworkSwitchTitle": "Ağ başarıyla değiştirildi",
"description": "Hizmetlerini kullanmaya devam etmek için lütfen uygulamaya geri dönün."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/vi.json b/locales/languages/vi.json
index 081eab8081b..e88bb0ed0a9 100644
--- a/locales/languages/vi.json
+++ b/locales/languages/vi.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "Quay lại ứng dụng",
+ "postNetworkSwitchTitle": "Đã chuyển mạng thành công",
"description": "Vui lòng quay lại ứng dụng để tiếp tục sử dụng các dịch vụ của họ."
},
"sdk_feedback_modal": {
diff --git a/locales/languages/zh.json b/locales/languages/zh.json
index cda0e2603ac..6e5db6c6c2f 100644
--- a/locales/languages/zh.json
+++ b/locales/languages/zh.json
@@ -2311,6 +2311,7 @@
},
"sdk_return_to_app_modal": {
"title": "返回应用程序",
+ "postNetworkSwitchTitle": "网络已成功切换",
"description": "请返回应用程序继续使用其服务。"
},
"sdk_feedback_modal": {
diff --git a/package.json b/package.json
index 6c665f36ab8..948e7465964 100644
--- a/package.json
+++ b/package.json
@@ -228,7 +228,7 @@
"@metamask/chain-agnostic-permission": "^1.1.0",
"@metamask/composable-controller": "^11.0.0",
"@metamask/controller-utils": "^11.11.0",
- "@metamask/design-system-react-native": "^0.3.0",
+ "@metamask/design-system-react-native": "^0.4.0",
"@metamask/design-system-twrnc-preset": "^0.2.1",
"@metamask/design-tokens": "^8.1.1",
"@metamask/earn-controller": "^7.0.0",
diff --git a/scripts/create-temp-nightly-branch.sh b/scripts/create-temp-nightly-branch.sh
new file mode 100755
index 00000000000..f99300586a1
--- /dev/null
+++ b/scripts/create-temp-nightly-branch.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+
+# Script to create and sync chore/temp-nightly branch with main
+# This script ALWAYS takes main's changes and discards any conflicts
+# Usage: ./scripts/create-temp-nightly-branch.sh
+
+set -e # Exit on any error
+
+BRANCH_NAME="chore/temp-nightly"
+MAIN_BRANCH="main"
+
+echo "🚀 Starting to create/sync $BRANCH_NAME branch..."
+
+# Check if we're in a git repository
+if ! git rev-parse --git-dir > /dev/null 2>&1; then
+ echo "❌ Error: Not in a git repository"
+ exit 1
+fi
+
+# Stash any uncommitted changes
+if ! git diff --quiet || ! git diff --staged --quiet; then
+ echo "📦 Stashing uncommitted changes..."
+ git stash push -m "Auto-stash before creating $BRANCH_NAME branch"
+ STASHED=true
+else
+ STASHED=false
+fi
+
+# Fetch latest changes from remote
+echo "📡 Fetching latest changes from remote..."
+git fetch origin
+
+# Switch to main branch
+echo "🔄 Switching to $MAIN_BRANCH branch..."
+git checkout $MAIN_BRANCH
+
+# Pull latest changes from main
+echo "⬇️ Pulling latest changes from $MAIN_BRANCH..."
+git pull origin $MAIN_BRANCH
+
+# Check if the temp-nightly branch already exists locally
+if git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then
+ echo "🔄 Branch $BRANCH_NAME already exists locally, switching to it..."
+ git checkout $BRANCH_NAME
+
+ # ALWAYS take main's changes - discard any local commits/conflicts
+ echo "🔄 Syncing $BRANCH_NAME with $MAIN_BRANCH (discarding any conflicts)..."
+ echo "⚠️ This will discard ANY commits in $BRANCH_NAME that aren't in $MAIN_BRANCH"
+ git reset --hard origin/$MAIN_BRANCH
+
+ # Clean any untracked files that might cause issues
+ git clean -fd
+else
+ # Create and switch to the new branch
+ echo "🌿 Creating new branch $BRANCH_NAME from $MAIN_BRANCH..."
+ git checkout -b $BRANCH_NAME
+fi
+
+# Check if branch exists on remote
+if git ls-remote --exit-code --heads origin $BRANCH_NAME > /dev/null 2>&1; then
+ echo "⬆️ Force pushing to remote $BRANCH_NAME (overriding any remote conflicts)..."
+ echo "⚠️ This will overwrite remote $BRANCH_NAME with main's content"
+ git push --force-with-lease origin $BRANCH_NAME
+else
+ echo "⬆️ Pushing new branch $BRANCH_NAME to remote..."
+ git push -u origin $BRANCH_NAME
+fi
+
+# Restore stashed changes if any
+if [ "$STASHED" = true ]; then
+ echo "📦 Restoring stashed changes..."
+ git stash pop
+fi
+
+echo "✅ Successfully synced branch $BRANCH_NAME with $MAIN_BRANCH"
+echo "📍 Current branch: $(git branch --show-current)"
+echo "🔗 Branch is tracking: $(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo 'No upstream set')"
+echo "✨ $BRANCH_NAME is now identical to $MAIN_BRANCH (any conflicts were resolved in favor of $MAIN_BRANCH)"
diff --git a/yarn.lock b/yarn.lock
index 1625a426e88..fa78d32d279 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5088,13 +5088,22 @@
fast-deep-equal "^3.1.3"
lodash "^4.17.21"
-"@metamask/design-system-react-native@^0.3.0":
- version "0.3.0"
- resolved "https://registry.npmjs.org/@metamask/design-system-react-native/-/design-system-react-native-0.3.0.tgz#cf2e7f4c20a73a02d9a9d64b51bfd3dbe7cf7d7c"
- integrity sha512-A5AT1VmnznMrteNt/r2zUno3jfwKshVPyAkcL9JwJ9LslgEuCR/M42P9+0koQK0z5od9lb3Tw1s6ZIwLQlXglQ==
+"@metamask/design-system-react-native@^0.4.0":
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/@metamask/design-system-react-native/-/design-system-react-native-0.4.0.tgz#38441ef7d321f507aa007a4bdcbc35a97045f260"
+ integrity sha512-w2CeiHoBuOHVPuskbl4siHfP96won/+mtw5R/TIP3tEvzIFg/nnMPvMGKTHK96L3lkOCXqXqGuy3qKy24Qg0xQ==
dependencies:
+ "@metamask/design-system-shared" "^0.1.0"
+ fast-text-encoding "^1.0.6"
react-native-jazzicon "^0.1.2"
+"@metamask/design-system-shared@^0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@metamask/design-system-shared/-/design-system-shared-0.1.0.tgz#9ebfcf784b0b86e3e183f3a5623e143ea9375de0"
+ integrity sha512-eCbzb5Vm7ZQCrPSi6NbasFtjSdUpkGshAnYq2nlZZRJwDdDNTKlJMWeA7RA5tOrV+vIsSDuYfvl+UcLp1fsOYw==
+ dependencies:
+ "@metamask/utils" "^11.7.0"
+
"@metamask/design-system-twrnc-preset@^0.2.1":
version "0.2.1"
resolved "https://registry.npmjs.org/@metamask/design-system-twrnc-preset/-/design-system-twrnc-preset-0.2.1.tgz#1d76c4272f67d41ead6ad8faef793b902a9644c9"
@@ -6281,18 +6290,19 @@
semver "^7.5.4"
uuid "^9.0.1"
-"@metamask/utils@^11.0.1", "@metamask/utils@^11.1.0", "@metamask/utils@^11.2.0", "@metamask/utils@^11.4.0", "@metamask/utils@^11.4.2":
- version "11.4.2"
- resolved "https://registry.yarnpkg.com/@metamask/utils/-/utils-11.4.2.tgz#8c83e1a962dbd1e451c4f607e8356dc2d189c24b"
- integrity sha512-TygCcGmUbhmpxjYMm+mx68kRiJ80jYV54/Aa8gUFBv4cTX7ulX2XZKr8CJoJAw3K3FN5ZvCRmU0IzWZFaonwhA==
+"@metamask/utils@^11.0.1", "@metamask/utils@^11.1.0", "@metamask/utils@^11.2.0", "@metamask/utils@^11.4.0", "@metamask/utils@^11.4.2", "@metamask/utils@^11.7.0":
+ version "11.7.0"
+ resolved "https://registry.yarnpkg.com/@metamask/utils/-/utils-11.7.0.tgz#d6b7dad510eef5d19e756fa8d3e43c1a95cf1c5e"
+ integrity sha512-IamqpZF8Lr4WeXJ84fD+Sy+v1Zo05SYuMPHHBrZWpzVbnHAmXQpL4ckn9s5dfA+zylp3WGypaBPb6SBZdOhuNQ==
dependencies:
"@ethereumjs/tx" "^4.2.0"
"@metamask/superstruct" "^3.1.0"
"@noble/hashes" "^1.3.1"
"@scure/base" "^1.1.3"
"@types/debug" "^4.1.7"
+ "@types/lodash" "^4.17.20"
debug "^4.3.4"
- lodash.memoize "^4.1.2"
+ lodash "^4.17.21"
pony-cause "^2.1.10"
semver "^7.5.4"
uuid "^9.0.1"
@@ -12151,7 +12161,7 @@
dependencies:
"@types/lodash" "*"
-"@types/lodash@*", "@types/lodash@^4.14.162", "@types/lodash@^4.14.167", "@types/lodash@^4.14.175", "@types/lodash@^4.14.184", "@types/lodash@^4.14.192":
+"@types/lodash@*", "@types/lodash@^4.14.162", "@types/lodash@^4.14.167", "@types/lodash@^4.14.175", "@types/lodash@^4.14.184", "@types/lodash@^4.14.192", "@types/lodash@^4.17.20":
version "4.17.20"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.20.tgz#1ca77361d7363432d29f5e55950d9ec1e1c6ea93"
integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==
@@ -20459,7 +20469,7 @@ fast-stable-stringify@^1.0.0:
resolved "https://registry.yarnpkg.com/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz#5c5543462b22aeeefd36d05b34e51c78cb86d313"
integrity sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==
-fast-text-encoding@1.0.6:
+fast-text-encoding@1.0.6, fast-text-encoding@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867"
integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==
@@ -24525,11 +24535,6 @@ lodash.isplainobject@^4.0.6:
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
-lodash.memoize@^4.1.2:
- version "4.1.2"
- resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
- integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==
-
lodash.merge@^4.6.1, lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"