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"