diff --git a/.github/workflows/add-team-label.yml b/.github/workflows/add-team-label.yml index c88854d105a3..6bc187c55822 100644 --- a/.github/workflows/add-team-label.yml +++ b/.github/workflows/add-team-label.yml @@ -10,8 +10,29 @@ jobs: name: Add team label if: ${{ !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest + permissions: + id-token: write steps: + - name: Get planning token + id: planning-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + target-repository: MetaMask/MetaMask-planning + permissions: | + contents: read + + - name: Get label token + id: label-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + permissions: | + contents: read + pull_requests: write + - name: Add team label uses: MetaMask/github-tools/.github/actions/add-team-label@v1 with: - team-label-token: ${{ secrets.TEAM_LABEL_TOKEN }} + planning-token: ${{ steps.planning-token.outputs.token }} + team-label-token: ${{ steps.label-token.outputs.token }} diff --git a/.github/workflows/check-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml index 7d0c6b3e0f4b..bb8b3b5b767d 100644 --- a/.github/workflows/check-template-and-add-labels.yml +++ b/.github/workflows/check-template-and-add-labels.yml @@ -27,6 +27,9 @@ jobs: runs-on: ubuntu-latest needs: [is-fork-pull-request] if: ${{ always() && github.event_name != 'merge_group' && (github.event_name == 'issues' || (github.event_name == 'pull_request_target' && needs.is-fork-pull-request.outputs.IS_FORK == 'false')) }} + permissions: + contents: read + id-token: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -46,9 +49,20 @@ jobs: retry_wait_seconds: 30 command: cd .github/scripts && yarn --immutable + - name: Get access token + id: get-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + continue-on-error: true + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + permissions: | + issues: write + members: read + pull_requests: write + - name: Check template and add labels id: check-template-and-add-labels env: - LABEL_TOKEN: ${{ secrets.LABEL_TOKEN }} + LABEL_TOKEN: ${{ steps.get-token.outputs.token || secrets.LABEL_TOKEN }} run: npm run check-template-and-add-labels working-directory: '.github/scripts' diff --git a/.github/workflows/performance-test-runner.yml b/.github/workflows/performance-test-runner.yml index e71746ef6e44..da7634200cf4 100644 --- a/.github/workflows/performance-test-runner.yml +++ b/.github/workflows/performance-test-runner.yml @@ -44,7 +44,12 @@ on: required: false type: string default: e2e - description: 'Build variant for app artifacts (e2e, rc, or exp)' + description: 'Deprecated for env; kept for workflow_call compatibility. Use feature_flags_environment for client-config API.' + feature_flags_environment: + required: false + type: string + default: rc + description: 'client-config API environment (rc, exp, test). Independent of BrowserStack build_variant.' secrets: BROWSERSTACK_USERNAME: required: true @@ -58,6 +63,8 @@ on: required: true TEST_SRP_3: required: true + TEST_SRP_4: + required: true E2E_PASSWORD: required: true MM_SENTRY_DSN_TEST: @@ -170,10 +177,10 @@ jobs: SENTRY_REAL_DSN="${{ secrets.MM_SENTRY_DSN }}" SELECTED_SENTRY_DSN="" SENTRY_ENVIRONMENT="github-actions-performance-e2e" - BUILD_VARIANT="${{ inputs.build_variant }}" + FEATURE_FLAGS_ENV="${{ inputs.feature_flags_environment }}" - if [[ "$BUILD_VARIANT" != "rc" && "$BUILD_VARIANT" != "exp" && "$BUILD_VARIANT" != "e2e" ]]; then - echo "❌ Invalid build_variant '$BUILD_VARIANT'. Expected 'e2e', 'rc', or 'exp'." + if [[ "$FEATURE_FLAGS_ENV" != "rc" && "$FEATURE_FLAGS_ENV" != "exp" && "$FEATURE_FLAGS_ENV" != "test" && "$FEATURE_FLAGS_ENV" != "dev" && "$FEATURE_FLAGS_ENV" != "prod" ]]; then + echo "❌ Invalid feature_flags_environment '$FEATURE_FLAGS_ENV'. Expected rc, exp, test, dev, or prod." exit 1 fi @@ -219,11 +226,12 @@ jobs: echo "TEST_SRP_1=${{ secrets.TEST_SRP_1 }}" echo "TEST_SRP_2=${{ secrets.TEST_SRP_2 }}" echo "TEST_SRP_3=${{ secrets.TEST_SRP_3 }}" + echo "TEST_SRP_4=${{ secrets.TEST_SRP_4 }}" echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" echo "E2E_PERFORMANCE_SENTRY_DSN=$SELECTED_SENTRY_DSN" echo "E2E_PERFORMANCE_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT" echo "E2E_PERFORMANCE_SENTRY_RELEASE=${{ github.sha }}" - echo "E2E_PERFORMANCE_BUILD_VARIANT=$BUILD_VARIANT" + echo "E2E_PERFORMANCE_BUILD_VARIANT=$FEATURE_FLAGS_ENV" echo "DISABLE_VIDEO_DOWNLOAD=true" } >> "$GITHUB_ENV" diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index e9c282bc802c..f4ca6baa6954 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -72,7 +72,7 @@ on: required: false type: string build_variant: - description: 'Build variant (e2e = e2e environment, exp = experimental, rc = release)' + description: 'BrowserStack build profile (e2e = build-e2e, works on feature branches; rc = build-rc, release branches only; exp = experimental)' required: false type: string default: 'e2e' @@ -97,6 +97,7 @@ env: TEST_SRP_1: ${{ secrets.TEST_SRP_1 }} TEST_SRP_2: ${{ secrets.TEST_SRP_2 }} TEST_SRP_3: ${{ secrets.TEST_SRP_3 }} + TEST_SRP_4: ${{ secrets.TEST_SRP_4 }} E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} DISABLE_VIDEO_DOWNLOAD: true @@ -216,6 +217,7 @@ jobs: uses: ./.github/workflows/build-ios-upload-to-browserstack.yml needs: [determine-branch-name] if: false # temporarily disabled — iOS tests not yet active + # if: (!inputs.browserstack_app_url_ios_onboarding && !inputs.browserstack_app_url_ios_imported_wallet) with: branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} build_variant: ${{ inputs.build_variant || 'e2e' }} @@ -259,6 +261,7 @@ jobs: determine-branch-name, ] if: false # temporarily disabled — Android only + # if: always() && !failure() && !cancelled() && (needs.trigger-ios-dual-versions.result == 'skipped' || needs.trigger-ios-dual-versions.result == 'success') && (inputs.browserstack_app_url_ios_onboarding != '' || needs.trigger-ios-dual-versions.outputs.without-srp-browserstack-url != '') with: platform: ios build_type: onboarding @@ -322,6 +325,7 @@ jobs: determine-branch-name, ] if: false # temporarily disabled — Android only + # if: always() && !cancelled() && (needs.trigger-ios-dual-versions.result == 'skipped' || needs.trigger-ios-dual-versions.result == 'success') && (inputs.browserstack_app_url_ios_imported_wallet != '' || needs.trigger-ios-dual-versions.outputs.with-srp-browserstack-url != '') with: platform: ios build_type: imported-wallet diff --git a/.github/workflows/triage-forwarder.yml b/.github/workflows/triage-forwarder.yml index f137e5315fc2..779fe889eb9d 100644 --- a/.github/workflows/triage-forwarder.yml +++ b/.github/workflows/triage-forwarder.yml @@ -2,12 +2,12 @@ name: Triage Agent Forwarder on: issues: - types: [labeled] + types: [labeled, opened] jobs: - forward: + forward-labeled: runs-on: ubuntu-latest - if: github.event.label.name == 'ta-needs-triage' + if: github.event.action == 'labeled' && github.event.label.name == 'ta-needs-triage' permissions: id-token: write steps: @@ -49,3 +49,46 @@ jobs: --arg action "${{ github.event.action }}" \ --arg label "${{ github.event.label.name }}" \ '{event_type: "triage-issue", client_payload: {repo_owner: $owner, repo_name: $name, issue_number: $number, event_action: $action, event_label_name: $label, trigger_mode: "label"}}')" + + forward-opened: + runs-on: ubuntu-latest + if: github.event.action == 'opened' + permissions: + id-token: write + steps: + - name: Get OIDC Token + id: oidc + run: | + OIDC_TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api://token-exchange-service" | jq -r '.value') + echo "::add-mask::$OIDC_TOKEN" + echo "oidc_token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT" + + - name: Exchange for Installation Token + id: exchange + env: + OIDC_TOKEN: ${{ steps.oidc.outputs.oidc_token }} + run: | + RESPONSE=$(curl -sSf -X POST "${{ vars.TOKEN_EXCHANGE_URL }}/api/exchange/token" \ + -H "Content-Type: application/json" \ + -d "$(jq -cn \ + --arg oidcToken "$OIDC_TOKEN" \ + --arg targetRepo "MetaMask/triage-agent" \ + '{oidcToken: $oidcToken, targetRepo: $targetRepo, requested_permissions: {contents: "write", metadata: "read"}}')") + TOKEN=$(echo "$RESPONSE" | jq -r '.token') + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: Dispatch to triage-agent + env: + TOKEN: ${{ steps.exchange.outputs.token }} + run: | + curl -sSf -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/MetaMask/triage-agent/dispatches" \ + -d "$(jq -cn \ + --arg owner "${{ github.repository_owner }}" \ + --arg name "${{ github.event.repository.name }}" \ + --arg number "${{ github.event.issue.number }}" \ + '{event_type: "triage-issue", client_payload: {repo_owner: $owner, repo_name: $name, issue_number: $number, event_action: "opened", trigger_mode: "on_create"}}')" diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 7f1c7dbcbc39..c8fa81424c52 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -3552,6 +3552,172 @@ describe('PerpsMarketDetailsView', () => { expect(getAllByText('ETH-USD').length).toBeGreaterThanOrEqual(1); }); + it('enriches market data when route maxLeverage is unformatted', async () => { + mockRouteParams.market = { + symbol: 'xyz:SPCX', + name: 'SPCX', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + maxLeverage: '100', + }; + + mockUsePerpsMarketsImpl.mockImplementation(() => ({ + markets: [ + { + symbol: 'xyz:SPCX', + name: 'SPCX', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + maxLeverage: '5x', + volumeNumber: 0, + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + })); + + const { getByText, queryByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + await waitFor(() => { + expect(getByText('5x')).toBeOnTheScreen(); + }); + expect(queryByText('100')).toBeNull(); + }); + + it('passes enriched SPCX leverage defaults to order screen', async () => { + const { usePerpsMarketData } = jest.requireMock('../../hooks'); + mockRouteParams.market = { + symbol: 'xyz:SPCX', + name: 'SPCX', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + maxLeverage: '100', + }; + + mockUsePerpsMarketsImpl.mockImplementation(() => ({ + markets: [ + { + symbol: 'xyz:SPCX', + name: 'SPCX', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + maxLeverage: '5x', + volumeNumber: 0, + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + })); + usePerpsMarketData.mockReturnValue({ + marketData: { szDecimals: 2, maxLeverage: 5 }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + try { + const { getByTestId, getByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + await waitFor(() => { + expect(getByText('5x')).toBeOnTheScreen(); + }); + + await act(async () => { + fireEvent.press( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON), + ); + }); + + expect(mockNavigateToOrder).toHaveBeenCalledWith( + expect.objectContaining({ + asset: 'xyz:SPCX', + defaultMaxLeverage: 5, + defaultSzDecimals: 2, + direction: 'long', + source: 'perp_asset_screen', + }), + ); + } finally { + usePerpsMarketData.mockReturnValue({ + marketData: null, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + } + }); + + it('enriches unformatted route market without market source', async () => { + mockRouteParams.market = { + symbol: 'SPCX', + name: 'SPCX', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + maxLeverage: '100', + }; + + mockUsePerpsMarketsImpl.mockImplementation(() => ({ + markets: [ + { + symbol: 'SPCX', + name: 'SPCX', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + maxLeverage: '5x', + volumeNumber: 0, + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + })); + + const { getByText, queryByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + await waitFor(() => { + expect(getByText('5x')).toBeOnTheScreen(); + }); + expect(queryByText('100')).toBeNull(); + }); + it('enriches market data from usePerpsMarkets when route has minimal data', async () => { // Route has minimal market data (no maxLeverage) mockRouteParams.market = { diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 8fafbad9bd00..9ca184a9c135 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -1,9 +1,14 @@ import { Box, Button as DSButton, + ButtonSemantic, + ButtonSemanticSeverity, ButtonVariant, ButtonSize as ButtonSizeRNDesignSystem, IconName, + Text, + TextColor, + TextVariant, } from '@metamask/design-system-react-native'; import { useNavigation, @@ -34,14 +39,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import { setPerpsChartPreferredCandlePeriod } from '../../../../../actions/settings'; -import ButtonSemantic, { - ButtonSemanticSeverity, -} from '../../../../../component-library/components-temp/Buttons/ButtonSemantic'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import Routes from '../../../../../constants/navigation/Routes'; import Engine from '../../../../../core/Engine'; @@ -203,8 +201,11 @@ const PerpsMarketDetailsView: React.FC = () => { // Get full market data from stream to ensure all fields (including maxLeverage) are available // This handles cases where navigation passes minimal market data (e.g., from Recent Activity) - // Skip fetching if routeMarket already has maxLeverage (performance optimization) - const needsEnrichment = !routeMarket?.maxLeverage; + // Skip fetching if routeMarket already has a formatted maxLeverage. + const hasFormattedMaxLeverage = + typeof routeMarket?.maxLeverage === 'string' && + routeMarket.maxLeverage.endsWith('x'); + const needsEnrichment = !hasFormattedMaxLeverage; const { markets } = usePerpsMarkets({ skipInitialFetch: !needsEnrichment }); const market = useMemo(() => { // If route market already has all required fields, use it directly @@ -1188,7 +1189,7 @@ const PerpsMarketDetailsView: React.FC = () => { style={styles.errorContainer} testID={PerpsMarketDetailsViewSelectorsIDs.ERROR} > - + {strings('perps.market.details.error_message')} @@ -1410,7 +1411,7 @@ const PerpsMarketDetailsView: React.FC = () => { {/* Orders Section - Compact view (includes standalone TP/SL orders) */} {displayOrders.length > 0 && ( - + {strings('perps.market.orders')} {displayOrders.map((order, index) => ( @@ -1475,13 +1476,13 @@ const PerpsMarketDetailsView: React.FC = () => { {strings('perps.risk_disclaimer', riskDisclaimerParams)}{' '} TradingView. diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 5f95cb686fc8..ba36ae276aa2 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -498,6 +498,11 @@ describe('PerpsMarketListView', () => { >; const { useRoute } = jest.requireMock('@react-navigation/native'); const mockUseRoute = useRoute as jest.MockedFunction; + const { usePerpsMarketListView } = jest.requireMock('../../hooks'); + const mockUsePerpsMarketListView = + usePerpsMarketListView as jest.MockedFunction< + typeof usePerpsMarketListView + >; const mockMarketData: PerpsMarketData[] = [ { @@ -623,6 +628,26 @@ describe('PerpsMarketListView', () => { }); }); + it('passes default sort params from route to market list hook', () => { + mockUseRoute.mockReturnValue({ + key: 'PerpsMarketListView-123', + name: 'PerpsMarketListView', + params: { + defaultSortOptionId: 'priceChange', + defaultSortDirection: 'asc', + }, + }); + + renderWithProvider(, { state: mockState }); + + expect(mockUsePerpsMarketListView).toHaveBeenCalledWith( + expect.objectContaining({ + defaultSortOptionId: 'priceChange', + defaultSortDirection: 'asc', + }), + ); + }); + it('renders interactive elements', async () => { renderWithProvider(, { state: mockState }); @@ -717,6 +742,31 @@ describe('PerpsMarketListView', () => { expect(() => fireEvent.press(btcRows[0])).not.toThrow(); }); + it('navigates to SPCX details with market-list source when SPCX is pressed', () => { + const spcxMarket: PerpsMarketData = { + symbol: 'xyz:SPCX', + name: 'SPCX', + maxLeverage: '5x', + price: '$0.00', + change24h: '+$0.00', + change24hPercent: '+0.00%', + volume: '$0', + marketSource: 'xyz', + }; + mockMarketDataForHook.length = 0; + mockMarketDataForHook.push(spcxMarket); + + renderWithProvider(, { state: mockState }); + + fireEvent.press(screen.getByTestId('market-row-xyz:SPCX')); + + expect(mockNavigateToMarketDetails).toHaveBeenCalledWith( + spcxMarket, + 'perp_markets', + undefined, + ); + }); + it('carries route transactionActiveAbTests when a market opens market details', () => { const transactionActiveAbTests = [ createActiveABTestAssignment( @@ -824,12 +874,10 @@ describe('PerpsMarketListView', () => { describe('Edge Cases', () => { it('filters markets with whitespace-only query', async () => { - const { usePerpsMarketListView } = jest.requireMock('../../hooks'); - mockSearchQuery = ' '; // Mock to return empty results when search query is whitespace - usePerpsMarketListView.mockReturnValue({ + mockUsePerpsMarketListView.mockReturnValue({ markets: mockMarketData, // Whitespace is trimmed, so all markets show searchState: { searchQuery: ' ', diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 28450e8ba191..ac344c2441a3 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -68,6 +68,7 @@ const PerpsMarketListView = ({ const defaultMarketTypeFilter = route.params?.defaultMarketTypeFilter ?? 'all'; const defaultSortOptionId = route.params?.defaultSortOptionId; + const defaultSortDirection = route.params?.defaultSortDirection; const transactionActiveAbTests = route.params?.transactionActiveAbTests; const fadeAnimation = useRef(new Animated.Value(0)).current; @@ -87,6 +88,7 @@ const PerpsMarketListView = ({ showWatchlistOnly, defaultMarketTypeFilter, defaultSortOptionId, + defaultSortDirection, showZeroVolume: __DEV__, }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts index cbaca4762505..cd9f4fa791ba 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts @@ -327,6 +327,29 @@ describe('usePerpsMarketListView', () => { }); }); + it('defaultSortDirection overrides saved direction when provided', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + return { optionId: 'priceChange', direction: 'desc' }; + }); + + renderHook(() => + usePerpsMarketListView({ + defaultSortOptionId: 'priceChange', + defaultSortDirection: 'asc', + }), + ); + + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'priceChange', + initialDirection: 'asc', + }); + }); + it('preserves saved direction when defaultSortOptionId matches the saved option', () => { let selectorCallCount = 0; mockUseSelector.mockImplementation(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts index 8eeb0afff5c1..5c9f9a63a244 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -39,6 +39,11 @@ interface UsePerpsMarketListViewParams { * @default undefined (falls back to saved user preference) */ defaultSortOptionId?: SortOptionId; + /** + * Initial sort direction — overrides the persisted user preference when provided. + * @default undefined (falls back to saved user preference/default override behavior) + */ + defaultSortDirection?: SortDirection; /** * Show markets with $0.00 volume * @default false @@ -140,6 +145,7 @@ export const usePerpsMarketListView = ({ showWatchlistOnly = false, defaultMarketTypeFilter = 'all', defaultSortOptionId, + defaultSortDirection, showZeroVolume = false, }: UsePerpsMarketListViewParams = {}): UsePerpsMarketListViewReturn => { // Fetch markets data @@ -205,18 +211,21 @@ export const usePerpsMarketListView = ({ // Use sorting hook for sort state and sorting logic. // defaultSortOptionId (from navigation params) takes precedence over the saved user - // preference. When it overrides a *different* option, reset direction to the default - // so the market list opens sorted the same way the explore feed displayed it (always desc). - // When there is no override, or the override matches the saved option, carry the saved direction. + // preference. A route-provided direction also takes precedence so Explore can + // open the market list with the same ordering as the source section. + // Without an explicit direction, reset changed sort options to the default + // direction; otherwise carry the saved direction. const isOptionOverridden = defaultSortOptionId !== undefined && defaultSortOptionId !== savedSortPreference.optionId; const sortingHook = usePerpsSorting({ initialOptionId: (defaultSortOptionId ?? savedSortPreference.optionId) as SortOptionId, - initialDirection: isOptionOverridden - ? MARKET_SORTING_CONFIG.DefaultDirection - : savedSortPreference.direction, + initialDirection: + defaultSortDirection ?? + (isOptionOverridden + ? MARKET_SORTING_CONFIG.DefaultDirection + : savedSortPreference.direction), }); // Wrap handleOptionChange to save preference to PerpsController diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 909a3f5514e4..5e908b22e36d 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -5,6 +5,7 @@ import { type OrderType, type PerpsMarketData, type TPSLTrackingData, + type SortDirection, type SortOptionId, } from '@metamask/perps-controller'; import { PerpsTransaction } from './transactionHistory'; @@ -93,6 +94,7 @@ export interface PerpsNavigationParamList extends ParamListBase { | 'forex' | 'new'; defaultSortOptionId?: SortOptionId; + defaultSortDirection?: SortDirection; fromHome?: boolean; button_clicked?: string; button_location?: string; diff --git a/app/components/UI/Predict/Predict.testIds.ts b/app/components/UI/Predict/Predict.testIds.ts index 551f7dd74f4b..ec93b602944d 100644 --- a/app/components/UI/Predict/Predict.testIds.ts +++ b/app/components/UI/Predict/Predict.testIds.ts @@ -54,6 +54,50 @@ export const PredictFeedSelectorsIDs = { PAGER: 'predict-feed-pager', } as const; +// ======================================== +// PREDICT POSITIONS VIEW SELECTORS +// ======================================== + +export const PredictPositionsViewSelectorsIDs = { + AVAILABLE_BALANCE_LABEL: 'predict-positions-view-available-balance-label', + AVAILABLE_BALANCE_SKELETON: + 'predict-positions-view-available-balance-skeleton', + AVAILABLE_BALANCE_VALUE: 'predict-positions-view-available-balance-value', + BACK_BUTTON: 'predict-positions-view-back-button', + CLAIM_CTA: 'predict-positions-view-claim-cta', + CONTAINER: 'predict-positions-view-container', + HEADER: 'predict-positions-view-header', + HISTORY_TAB: 'predict-positions-view-history-tab', + HISTORY_TAB_CONTENT: 'predict-positions-view-history-tab-content', + POSITIONS_TAB: 'predict-positions-view-positions-tab', + POSITIONS_TAB_CONTENT: 'predict-positions-view-positions-tab-content', + SUMMARY: 'predict-positions-view-summary', + SUMMARY_CARD: 'predict-positions-view-summary-card', + TABS: 'predict-positions-view-tabs', + UNREALIZED_PNL_LABEL: 'predict-positions-view-unrealized-pnl-label', + UNREALIZED_PNL_ROW: 'predict-positions-view-unrealized-pnl-row', + UNREALIZED_PNL_SKELETON: 'predict-positions-view-unrealized-pnl-skeleton', + UNREALIZED_PNL_VALUE: 'predict-positions-view-unrealized-pnl-value', +} as const; + +export const PredictPositionsEmptySelectorsIDs = { + BROWSE_MARKETS_CTA: 'predict-positions-empty-browse-markets-cta', + CONTAINER: 'predict-positions-empty', + DESCRIPTION: 'predict-positions-empty-description', + ICON: 'predict-positions-empty-icon', +} as const; + +export const PredictPositionsListSelectorsIDs = { + CONTAINER: 'predict-positions-list', + LOADING_STATE: 'predict-positions-list-loading-state', + OPEN_POSITIONS_LIST: 'predict-positions-list-open-positions', + SKELETON_ROW: 'predict-positions-list-skeleton-row', +} as const; + +export const PredictPositionsHistoryListSelectorsIDs = { + CONTAINER: 'predict-positions-history-list', +} as const; + export const getPredictFeedSelector = { tab: (index: number) => `${PredictFeedSelectorsIDs.TABS}-tab-${index}`, tabPage: (key: string) => `predict-feed-tab-page-${key}`, diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx index 2c85e7bb4334..2b10aa47c1d2 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx @@ -24,6 +24,7 @@ import { POLYGON_MAINNET_CHAIN_ID } from '../../providers/polymarket/constants'; interface PredictActivityProps { item: PredictActivityItem; + containerStyle?: string; } const activityTitleByType: Record = { @@ -32,7 +33,10 @@ const activityTitleByType: Record = { [PredictActivityType.CLAIM]: strings('predict.transactions.claim_title'), }; -const PredictActivity: React.FC = ({ item }) => { +const PredictActivity: React.FC = ({ + item, + containerStyle, +}) => { const tw = useTailwind(); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -75,7 +79,10 @@ const PredictActivity: React.FC = ({ item }) => { return ( {item.icon ? ( diff --git a/app/components/UI/Predict/components/PredictPositionsEmpty/PredictPositionsEmpty.test.tsx b/app/components/UI/Predict/components/PredictPositionsEmpty/PredictPositionsEmpty.test.tsx new file mode 100644 index 000000000000..a6665aecaf9f --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsEmpty/PredictPositionsEmpty.test.tsx @@ -0,0 +1,87 @@ +import { StackActions, useNavigation } from '@react-navigation/native'; +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PredictPositionsEmptySelectorsIDs } from '../../Predict.testIds'; +import PredictPositionsEmpty from './PredictPositionsEmpty'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../../images/predictions-dark.svg', () => { + const ReactLib = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return (props: Record) => + ReactLib.createElement(View, props); +}); + +jest.mock('../../../../../images/predictions-light.svg', () => { + const ReactLib = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return (props: Record) => + ReactLib.createElement(View, props); +}); + +const mockNavigation = { + canGoBack: jest.fn(), + dispatch: jest.fn(), + navigate: jest.fn(), +}; + +const mockUseNavigation = useNavigation as jest.Mock; + +describe('PredictPositionsEmpty', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation.canGoBack.mockReturnValue(true); + mockUseNavigation.mockReturnValue(mockNavigation); + }); + + it('renders the Positions empty state content', () => { + render(); + + expect( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.ICON), + ).toBeOnTheScreen(); + expect( + screen.getByText( + 'Your predictions will appear here, showing your stake and market movements', + ), + ).toBeOnTheScreen(); + expect(screen.getByText('Browse markets')).toBeOnTheScreen(); + }); + + it('pops to the Predict market list when the stack can go back', () => { + render(); + + fireEvent.press( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.BROWSE_MARKETS_CTA), + ); + + expect(mockNavigation.dispatch).toHaveBeenCalledWith( + StackActions.popToTop(), + ); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('navigates to the Predict market list when there is no stack to pop', () => { + mockNavigation.canGoBack.mockReturnValue(false); + render(); + + fireEvent.press( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.BROWSE_MARKETS_CTA), + ); + + expect(mockNavigation.dispatch).not.toHaveBeenCalled(); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.PREDICT.MARKET_LIST, + ); + }); +}); diff --git a/app/components/UI/Predict/components/PredictPositionsEmpty/PredictPositionsEmpty.tsx b/app/components/UI/Predict/components/PredictPositionsEmpty/PredictPositionsEmpty.tsx new file mode 100644 index 000000000000..e9eb36aa6a2a --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsEmpty/PredictPositionsEmpty.tsx @@ -0,0 +1,83 @@ +import { + StackActions, + type NavigationProp, + useNavigation, +} from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import { + Box, + Button, + ButtonVariant, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { + Theme, + useTailwind, + useTheme as useDesignSystemTheme, +} from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import PredictionsEmptyDarkIcon from '../../../../../images/predictions-dark.svg'; +import PredictionsEmptyLightIcon from '../../../../../images/predictions-light.svg'; +import { PredictPositionsEmptySelectorsIDs } from '../../Predict.testIds'; +import type { PredictNavigationParamList } from '../../types/navigation'; + +interface PredictPositionsEmptyProps { + testID?: string; +} + +const PredictPositionsEmpty = ({ + testID = PredictPositionsEmptySelectorsIDs.CONTAINER, +}: PredictPositionsEmptyProps) => { + const navigation = + useNavigation>(); + const tw = useTailwind(); + const designSystemTheme = useDesignSystemTheme(); + const EmptyIcon = + designSystemTheme === Theme.Dark + ? PredictionsEmptyDarkIcon + : PredictionsEmptyLightIcon; + + const handleBrowseMarketsPress = useCallback(() => { + if (navigation.canGoBack()) { + navigation.dispatch(StackActions.popToTop()); + return; + } + + navigation.navigate(Routes.PREDICT.MARKET_LIST); + }, [navigation]); + + return ( + + + + + + {strings('predict.positions_empty.description')} + + + + ); +}; + +export default PredictPositionsEmpty; diff --git a/app/components/UI/Predict/components/PredictPositionsEmpty/index.ts b/app/components/UI/Predict/components/PredictPositionsEmpty/index.ts new file mode 100644 index 000000000000..b12bc067745a --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsEmpty/index.ts @@ -0,0 +1 @@ +export { default } from './PredictPositionsEmpty'; diff --git a/app/components/UI/Predict/components/PredictPositionsHistoryList/PredictPositionsHistoryList.test.tsx b/app/components/UI/Predict/components/PredictPositionsHistoryList/PredictPositionsHistoryList.test.tsx new file mode 100644 index 000000000000..2ab05f1b80bd --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsHistoryList/PredictPositionsHistoryList.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { + PredictPositionsEmptySelectorsIDs, + PredictPositionsHistoryListSelectorsIDs, +} from '../../Predict.testIds'; +import PredictPositionsHistoryList from './PredictPositionsHistoryList'; + +jest.mock('../../views/PredictTransactionsView', () => { + const ReactLib = jest.requireActual('react'); + const { Text, View } = jest.requireActual('react-native'); + + return function MockPredictTransactionsView({ + emptyState, + isVisible, + }: { + emptyState: React.ReactNode; + isVisible: boolean; + }) { + return ReactLib.createElement( + View, + { + testID: 'mock-predict-transactions-view', + }, + ReactLib.createElement(Text, null, `visible:${isVisible}`), + emptyState, + ); + }; +}); + +jest.mock('../PredictPositionsEmpty', () => { + const ReactLib = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const { PredictPositionsEmptySelectorsIDs: testIds } = jest.requireActual( + '../../Predict.testIds', + ); + + return function MockPredictPositionsEmpty() { + return ReactLib.createElement(View, { testID: testIds.CONTAINER }); + }; +}); + +describe('PredictPositionsHistoryList', () => { + it('wraps the transactions view with the shared empty state', () => { + render(); + + expect( + screen.getByTestId(PredictPositionsHistoryListSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('mock-predict-transactions-view'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('passes the visible state to transaction history', () => { + render(); + + expect(screen.getByText('visible:false')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Predict/components/PredictPositionsHistoryList/PredictPositionsHistoryList.tsx b/app/components/UI/Predict/components/PredictPositionsHistoryList/PredictPositionsHistoryList.tsx new file mode 100644 index 000000000000..2ce16b553251 --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsHistoryList/PredictPositionsHistoryList.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import { PredictPositionsHistoryListSelectorsIDs } from '../../Predict.testIds'; +import PredictTransactionsView from '../../views/PredictTransactionsView'; +import PredictPositionsEmpty from '../PredictPositionsEmpty'; + +interface PredictPositionsHistoryListProps { + isVisible: boolean; +} + +const PredictPositionsHistoryList = ({ + isVisible, +}: PredictPositionsHistoryListProps) => ( + + } + isVisible={isVisible} + containerStyle="p-0" + activityContainerStyle="px-0" + /> + +); + +export default PredictPositionsHistoryList; diff --git a/app/components/UI/Predict/components/PredictPositionsHistoryList/index.ts b/app/components/UI/Predict/components/PredictPositionsHistoryList/index.ts new file mode 100644 index 000000000000..f2c06b4befa4 --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsHistoryList/index.ts @@ -0,0 +1 @@ +export { default } from './PredictPositionsHistoryList'; diff --git a/app/components/UI/Predict/components/PredictPositionsList/PredictPositionsList.test.tsx b/app/components/UI/Predict/components/PredictPositionsList/PredictPositionsList.test.tsx new file mode 100644 index 000000000000..f9d9c68cf32b --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsList/PredictPositionsList.test.tsx @@ -0,0 +1,277 @@ +import { useNavigation } from '@react-navigation/native'; +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../constants/eventNames'; +import type { PredictPortfolioModel } from '../../hooks/usePredictPortfolio'; +import { + PredictPositionsEmptySelectorsIDs, + PredictPositionsListSelectorsIDs, +} from '../../Predict.testIds'; +import { PredictPosition, PredictPositionStatus } from '../../types'; +import PredictPositionsList from './PredictPositionsList'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../PredictPositionsEmpty', () => { + const ReactLib = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const { PredictPositionsEmptySelectorsIDs: testIds } = jest.requireActual( + '../../Predict.testIds', + ); + + return function MockPredictPositionsEmpty() { + return ReactLib.createElement(View, { testID: testIds.CONTAINER }); + }; +}); + +jest.mock('../PredictPosition/PredictPosition', () => { + const ReactLib = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + + return function MockPredictPosition({ + onPress, + position, + privacyMode, + }: { + onPress?: (position: { id: string }) => void; + position: { id: string; title: string }; + privacyMode: boolean; + }) { + return ReactLib.createElement( + Pressable, + { + accessibilityLabel: `open-${position.id}`, + onPress: () => onPress?.(position), + testID: 'mock-position-card', + }, + ReactLib.createElement( + Text, + null, + `open:${position.title}:${privacyMode ? 'private' : 'public'}`, + ), + ); + }; +}); + +const mockNavigation = { + navigate: jest.fn(), +}; + +const mockUseNavigation = useNavigation as jest.Mock; + +const createPosition = ( + id: string, + overrides: Partial = {}, +): PredictPosition => ({ + amount: 10, + avgPrice: 1, + cashPnl: 0, + claimable: false, + currentValue: 100, + endDate: '2026-01-01T00:00:00Z', + icon: 'https://example.com/icon.png', + id, + initialValue: 100, + marketId: `market-${id}`, + outcome: 'Yes', + outcomeId: `outcome-${id}`, + outcomeIndex: 0, + outcomeTokenId: `token-${id}`, + percentPnl: 0, + price: 1, + providerId: 'provider', + size: 10, + status: PredictPositionStatus.OPEN, + title: `Market ${id}`, + ...overrides, +}); + +const mockClaim = jest.fn(); +const createPortfolio = ( + overrides: Partial = {}, +): PredictPortfolioModel => ({ + accountStateError: null, + actionableClaimablePositions: [], + activePositions: [], + availableBalance: 0, + balanceError: null, + claim: mockClaim, + claimableAmount: 0, + claimablePositionCount: 0, + claimablePositions: [], + claimablePositionsError: null, + deposit: jest.fn(), + error: null, + hasClaimableWinnings: false, + isBalanceLoading: false, + isClaimPending: false, + isDepositPending: false, + isLoading: false, + isOpenPositionsLoading: false, + isPositionsLoading: false, + isRefreshing: false, + openPositionCount: 0, + openPositions: [], + openPositionsError: null, + openPositionsValue: 0, + portfolioValue: 0, + positionsBadgeCount: 0, + refetch: jest.fn(), + showPnlLine: false, + showUnrealizedPnl: false, + totalUnrealizedPnlAmount: 0, + totalUnrealizedPnlPercent: undefined, + walletType: undefined, + withdraw: jest.fn(), + withdrawTransaction: null, + ...overrides, +}); + +const renderList = ({ + isPrivacyMode = false, + portfolio = createPortfolio(), +}: { + isPrivacyMode?: boolean; + portfolio?: PredictPortfolioModel; +} = {}) => + render( + , + ); + +describe('PredictPositionsList', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue(mockNavigation); + }); + + it('renders only open positions', () => { + const wonPosition = createPosition('won', { + claimable: true, + status: PredictPositionStatus.WON, + title: 'Won position', + }); + const lostPosition = createPosition('lost', { + status: PredictPositionStatus.LOST, + title: 'Lost position', + }); + const redeemablePosition = createPosition('redeemable', { + status: PredictPositionStatus.REDEEMABLE, + title: 'Redeemable position', + }); + const openPosition = createPosition('open', { + title: 'Open position', + }); + + renderList({ + portfolio: createPortfolio({ + actionableClaimablePositions: [wonPosition], + openPositions: [ + wonPosition, + lostPosition, + redeemablePosition, + openPosition, + ], + }), + }); + + expect( + screen.getByTestId(PredictPositionsListSelectorsIDs.OPEN_POSITIONS_LIST), + ).toBeOnTheScreen(); + + const cards = screen.getAllByTestId('mock-position-card'); + expect(cards).toHaveLength(1); + expect(cards[0]).toHaveProp('accessibilityLabel', 'open-open'); + expect(screen.queryByText('open:Won position:public')).toBeNull(); + expect(screen.queryByText('open:Lost position:public')).toBeNull(); + expect(screen.queryByText('open:Redeemable position:public')).toBeNull(); + }); + + it('navigates to market details when a position is pressed', () => { + const openPosition = createPosition('open', { + marketId: 'market-open', + }); + + renderList({ + portfolio: createPortfolio({ + openPositions: [openPosition], + }), + }); + + fireEvent.press(screen.getByLabelText('open-open')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.PREDICT.MARKET_DETAILS, + { + entryPoint: PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS, + marketId: 'market-open', + }, + ); + }); + + it('renders the shared empty state when there are no positions', () => { + renderList(); + + expect( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId(PredictPositionsListSelectorsIDs.CONTAINER), + ).toBeNull(); + }); + + it('renders a loading state while positions are loading without cached positions', () => { + renderList({ + portfolio: createPortfolio({ + isOpenPositionsLoading: true, + }), + }); + + expect( + screen.getByTestId(PredictPositionsListSelectorsIDs.LOADING_STATE), + ).toBeOnTheScreen(); + expect( + screen.getAllByTestId(PredictPositionsListSelectorsIDs.SKELETON_ROW), + ).toHaveLength(3); + expect( + screen.queryByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeNull(); + }); + + it('renders the empty state when only claimable positions are loading', () => { + renderList({ + portfolio: createPortfolio({ + isPositionsLoading: true, + isOpenPositionsLoading: false, + }), + }); + + expect( + screen.queryByTestId(PredictPositionsListSelectorsIDs.LOADING_STATE), + ).toBeNull(); + expect( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('passes privacy mode to rendered position rows', () => { + renderList({ + isPrivacyMode: true, + portfolio: createPortfolio({ + openPositions: [ + createPosition('open', { + title: 'Open position', + }), + ], + }), + }); + + expect(screen.getByText('open:Open position:private')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Predict/components/PredictPositionsList/PredictPositionsList.tsx b/app/components/UI/Predict/components/PredictPositionsList/PredictPositionsList.tsx new file mode 100644 index 000000000000..b3c493c40597 --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsList/PredictPositionsList.tsx @@ -0,0 +1,103 @@ +import { type NavigationProp, useNavigation } from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import { ScrollView } from 'react-native'; +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../constants/eventNames'; +import type { PredictPortfolioModel } from '../../hooks/usePredictPortfolio'; +import { PredictPositionsListSelectorsIDs } from '../../Predict.testIds'; +import { PredictPositionStatus, type PredictPosition } from '../../types'; +import type { PredictNavigationParamList } from '../../types/navigation'; +import PredictPositionItem from '../PredictPosition/PredictPosition'; +import PredictPositionsEmpty from '../PredictPositionsEmpty'; + +const SKELETON_ROW_COUNT = 3; + +interface PredictPositionsListProps { + isPrivacyMode: boolean; + portfolio: PredictPortfolioModel; +} + +const PredictPositionsListSkeleton = () => { + const tw = useTailwind(); + + return ( + + {Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( + + + + + + + + + + + + ))} + + ); +}; + +const PredictPositionsList = ({ + isPrivacyMode, + portfolio, +}: PredictPositionsListProps) => { + const navigation = + useNavigation>(); + const tw = useTailwind(); + const openPositions = portfolio.openPositions.filter( + (position) => position.status === PredictPositionStatus.OPEN, + ); + const hasPositions = openPositions.length > 0; + + const handlePositionPress = useCallback( + (position: PredictPosition) => { + navigation.navigate(Routes.PREDICT.MARKET_DETAILS, { + marketId: position.marketId, + entryPoint: PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS, + }); + }, + [navigation], + ); + + if (portfolio.isOpenPositionsLoading && !hasPositions) { + return ; + } + + if (!hasPositions) { + return ; + } + + return ( + + + {openPositions.map((position) => ( + + ))} + + + ); +}; + +export default PredictPositionsList; diff --git a/app/components/UI/Predict/components/PredictPositionsList/index.ts b/app/components/UI/Predict/components/PredictPositionsList/index.ts new file mode 100644 index 000000000000..9e5c8a64465d --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsList/index.ts @@ -0,0 +1 @@ +export { default } from './PredictPositionsList'; diff --git a/app/components/UI/Predict/components/PredictPositionsViewHeader/PredictPositionsViewHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsViewHeader/PredictPositionsViewHeader.test.tsx new file mode 100644 index 000000000000..a33272006f77 --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsViewHeader/PredictPositionsViewHeader.test.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { PredictEventValues } from '../../constants/eventNames'; +import type { PredictPortfolioModel } from '../../hooks/usePredictPortfolio'; +import { PredictPositionsViewSelectorsIDs } from '../../Predict.testIds'; +import PredictPositionsViewHeader from './PredictPositionsViewHeader'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +const mockExecuteGuardedAction = jest.fn(); +jest.mock('../../hooks/usePredictActionGuard', () => ({ + usePredictActionGuard: () => ({ + executeGuardedAction: mockExecuteGuardedAction, + }), +})); + +const mockNavigation = { + navigate: jest.fn(), +}; + +const mockUseNavigation = useNavigation as jest.Mock; +const mockClaim = jest.fn(); + +const createPortfolio = ( + overrides: Partial = {}, +): PredictPortfolioModel => ({ + accountStateError: null, + actionableClaimablePositions: [], + activePositions: [], + availableBalance: 0, + balanceError: null, + claim: mockClaim, + claimableAmount: 0, + claimablePositionCount: 0, + claimablePositions: [], + claimablePositionsError: null, + deposit: jest.fn(), + error: null, + hasClaimableWinnings: false, + isBalanceLoading: false, + isClaimPending: false, + isDepositPending: false, + isLoading: false, + isOpenPositionsLoading: false, + isPositionsLoading: false, + isRefreshing: false, + openPositionCount: 0, + openPositions: [], + openPositionsError: null, + openPositionsValue: 0, + portfolioValue: 0, + positionsBadgeCount: 0, + refetch: jest.fn(), + showPnlLine: false, + showUnrealizedPnl: false, + totalUnrealizedPnlAmount: 0, + totalUnrealizedPnlPercent: undefined, + walletType: undefined, + withdraw: jest.fn(), + withdrawTransaction: null, + ...overrides, +}); + +const renderHeader = ({ + isPrivacyMode = false, + portfolio = createPortfolio(), +}: { + isPrivacyMode?: boolean; + portfolio?: PredictPortfolioModel; +} = {}) => + render( + , + ); + +describe('PredictPositionsViewHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue(mockNavigation); + mockExecuteGuardedAction.mockImplementation( + async (action: () => void | Promise) => { + await action(); + }, + ); + }); + + it('always renders the available balance row for first-time zero state', () => { + renderHeader(); + + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.SUMMARY), + ).toBeOnTheScreen(); + expect(screen.getByText('Available balance')).toBeOnTheScreen(); + expect(screen.getByText('$0.00')).toBeOnTheScreen(); + expect( + screen.queryByTestId(PredictPositionsViewSelectorsIDs.UNREALIZED_PNL_ROW), + ).toBeNull(); + expect( + screen.queryByTestId(PredictPositionsViewSelectorsIDs.CLAIM_CTA), + ).toBeNull(); + }); + + it('renders Unrealized P&L when the portfolio model says the line should show', () => { + renderHeader({ + portfolio: createPortfolio({ + availableBalance: 250, + showPnlLine: true, + showUnrealizedPnl: true, + totalUnrealizedPnlAmount: 46.35, + totalUnrealizedPnlPercent: 20.23, + }), + }); + + expect(screen.getByText('$250.00')).toBeOnTheScreen(); + expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); + expect(screen.getByText('+$46.35 (+20.23%)')).toBeOnTheScreen(); + }); + + it('renders a negative Unrealized P&L value', () => { + renderHeader({ + portfolio: createPortfolio({ + showPnlLine: true, + showUnrealizedPnl: true, + totalUnrealizedPnlAmount: -18.47, + totalUnrealizedPnlPercent: -2.1, + }), + }); + + expect(screen.getByText('-$18.47 (-2.1%)')).toBeOnTheScreen(); + }); + + it('wraps claim CTA presses in the Predict action guard', async () => { + renderHeader({ + portfolio: createPortfolio({ + claimableAmount: 46.35, + hasClaimableWinnings: true, + }), + }); + + fireEvent.press( + screen.getByTestId(PredictPositionsViewSelectorsIDs.CLAIM_CTA), + ); + + expect(screen.getByText('Claim $46.35')).toBeOnTheScreen(); + await waitFor(() => { + expect(mockExecuteGuardedAction).toHaveBeenCalledWith( + expect.any(Function), + { attemptedAction: PredictEventValues.ATTEMPTED_ACTION.CLAIM }, + ); + }); + expect(mockClaim).toHaveBeenCalledTimes(1); + }); + + it('supports privacy mode with SensitiveText masking', () => { + renderHeader({ + isPrivacyMode: true, + portfolio: createPortfolio({ + availableBalance: 250, + showPnlLine: true, + showUnrealizedPnl: true, + totalUnrealizedPnlAmount: 46.35, + totalUnrealizedPnlPercent: 20.23, + }), + }); + + expect(screen.getByText('•••••••••')).toBeOnTheScreen(); + expect(screen.getByText('••••••••••••')).toBeOnTheScreen(); + expect(screen.queryByText('$250.00')).toBeNull(); + expect(screen.queryByText('+$46.35 (+20.23%)')).toBeNull(); + }); + + it('renders loading skeletons for balance and P&L rows', () => { + renderHeader({ + portfolio: createPortfolio({ + isBalanceLoading: true, + isOpenPositionsLoading: true, + }), + }); + + expect( + screen.getByTestId( + PredictPositionsViewSelectorsIDs.AVAILABLE_BALANCE_SKELETON, + ), + ).toBeOnTheScreen(); + expect( + screen.getByTestId( + PredictPositionsViewSelectorsIDs.UNREALIZED_PNL_SKELETON, + ), + ).toBeOnTheScreen(); + }); + + it('does not render a P&L skeleton when only claimable positions are loading', () => { + renderHeader({ + portfolio: createPortfolio({ + isPositionsLoading: true, + isOpenPositionsLoading: false, + }), + }); + + expect( + screen.queryByTestId(PredictPositionsViewSelectorsIDs.UNREALIZED_PNL_ROW), + ).toBeNull(); + expect( + screen.queryByTestId( + PredictPositionsViewSelectorsIDs.UNREALIZED_PNL_SKELETON, + ), + ).toBeNull(); + }); + + it('renders fallback text for balance and P&L errors', () => { + renderHeader({ + portfolio: createPortfolio({ + balanceError: new Error('Balance failed'), + openPositionsError: new Error('Positions failed'), + }), + }); + + expect(screen.getAllByText('Unable to load')).toHaveLength(2); + }); +}); diff --git a/app/components/UI/Predict/components/PredictPositionsViewHeader/PredictPositionsViewHeader.tsx b/app/components/UI/Predict/components/PredictPositionsViewHeader/PredictPositionsViewHeader.tsx new file mode 100644 index 000000000000..ac004b178c9b --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsViewHeader/PredictPositionsViewHeader.tsx @@ -0,0 +1,189 @@ +import { type NavigationProp, useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo } from 'react'; +import { + Box, + FontWeight, + SensitiveText, + SensitiveTextLength, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../locales/i18n'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; +import { PredictEventValues } from '../../constants/eventNames'; +import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; +import type { PredictPortfolioModel } from '../../hooks/usePredictPortfolio'; +import { PredictPositionsViewSelectorsIDs } from '../../Predict.testIds'; +import type { PredictNavigationParamList } from '../../types/navigation'; +import { + formatPredictUnrealizedPnLStringParts, + formatPrice, +} from '../../utils/format'; +import PredictClaimButton from '../PredictActionButtons/PredictClaimButton'; + +interface PredictPositionsViewHeaderProps { + isPrivacyMode: boolean; + portfolio: PredictPortfolioModel; +} + +const formatUnrealizedPnl = (amount: number, percent: number | undefined) => { + const parts = formatPredictUnrealizedPnLStringParts({ + cashUpnl: amount, + percentUpnl: percent ?? 0, + }); + + if (percent === undefined) { + return parts.amount; + } + + return strings('predict.unrealized_pnl_value', { + amount: parts.amount, + percent: parts.percent, + }); +}; + +const PredictPositionsViewHeader = ({ + isPrivacyMode, + portfolio, +}: PredictPositionsViewHeaderProps) => { + const navigation = + useNavigation>(); + const tw = useTailwind(); + const { claim } = portfolio; + const { executeGuardedAction } = usePredictActionGuard({ navigation }); + const showUnrealizedPnlRow = + portfolio.showPnlLine || + portfolio.isOpenPositionsLoading || + Boolean(portfolio.openPositionsError); + const unrealizedPnlColor = + portfolio.totalUnrealizedPnlAmount >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + const unrealizedPnlValue = useMemo( + () => + formatUnrealizedPnl( + portfolio.totalUnrealizedPnlAmount, + portfolio.totalUnrealizedPnlPercent, + ), + [portfolio.totalUnrealizedPnlAmount, portfolio.totalUnrealizedPnlPercent], + ); + + const handleClaimPress = useCallback(async () => { + await executeGuardedAction( + async () => { + await claim(); + }, + { attemptedAction: PredictEventValues.ATTEMPTED_ACTION.CLAIM }, + ); + }, [claim, executeGuardedAction]); + + return ( + + + + + {strings('predict.available_balance')} + + {portfolio.isBalanceLoading ? ( + + ) : portfolio.balanceError ? ( + + {strings('predict.unrealized_pnl_error')} + + ) : ( + + {formatPrice(portfolio.availableBalance, { + minimumDecimals: 2, + maximumDecimals: 2, + })} + + )} + + + {showUnrealizedPnlRow && ( + <> + + + + {strings('predict.unrealized_pnl_label')} + + {portfolio.isOpenPositionsLoading ? ( + + ) : portfolio.openPositionsError ? ( + + {strings('predict.unrealized_pnl_error')} + + ) : ( + + {unrealizedPnlValue} + + )} + + + )} + + + {portfolio.hasClaimableWinnings && ( + + )} + + ); +}; + +export default PredictPositionsViewHeader; diff --git a/app/components/UI/Predict/components/PredictPositionsViewHeader/index.ts b/app/components/UI/Predict/components/PredictPositionsViewHeader/index.ts new file mode 100644 index 000000000000..b7e83a9fc4ea --- /dev/null +++ b/app/components/UI/Predict/components/PredictPositionsViewHeader/index.ts @@ -0,0 +1 @@ +export { default } from './PredictPositionsViewHeader'; diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 4f10afde7574..8b9af13a7e27 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -4492,6 +4492,39 @@ describe('PredictController', () => { }); }); + describe('clearPendingClaim', () => { + it('clears pending claim from state', () => { + withController(({ controller }) => { + const address = '0x1234567890123456789012345678901234567890'; + + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [address]: 'batch-id-123', + }; + }); + + expect(controller.state.pendingClaims[address]).toBe('batch-id-123'); + + controller.clearPendingClaim(); + + expect(controller.state.pendingClaims[address]).toBe(undefined); + }); + }); + + it('handles clearing empty pending claim state', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.pendingClaims = {}; + }); + + expect(() => controller.clearPendingClaim()).not.toThrow(); + + const address = '0x1234567890123456789012345678901234567890'; + expect(controller.state.pendingClaims[address]).toBe(undefined); + }); + }); + }); + describe('deposit transaction event handlers', () => { // Deposit transaction status updates are now handled by usePredictDepositToasts hook // rather than the controller's event handlers diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 27c96b6fbf7a..02339a28ff99 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -2209,6 +2209,11 @@ export class PredictController extends BaseController< this.clearPendingDepositForAddress({ address: selectedAddress }); } + public clearPendingClaim(): void { + const selectedAddress = this.getSigner().address; + this.clearPendingClaimForAddress({ address: selectedAddress }); + } + private clearPendingDepositForAddress({ address, }: { diff --git a/app/components/UI/Predict/hooks/usePredictPortfolio.test.ts b/app/components/UI/Predict/hooks/usePredictPortfolio.test.ts index 4f2b2b81e73a..94e39224f843 100644 --- a/app/components/UI/Predict/hooks/usePredictPortfolio.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPortfolio.test.ts @@ -308,6 +308,24 @@ describe('usePredictPortfolio', () => { const { result } = renderHook(() => usePredictPortfolio()); expect(result.current.isLoading).toBe(true); + expect(result.current.isOpenPositionsLoading).toBe(true); + expect(result.current.isPositionsLoading).toBe(true); + }); + + it('keeps open-position loading separate from claimable-position loading', () => { + mockUsePredictPositions.mockImplementation( + ({ claimable }: { claimable?: boolean }) => + createQuery({ + data: [], + isLoading: Boolean(claimable), + }), + ); + + const { result } = renderHook(() => usePredictPortfolio()); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isOpenPositionsLoading).toBe(false); + expect(result.current.isPositionsLoading).toBe(true); }); it('aggregates loading, error, and refetch state', async () => { diff --git a/app/components/UI/Predict/hooks/usePredictPortfolio.ts b/app/components/UI/Predict/hooks/usePredictPortfolio.ts index 11335448e227..def4f4336d7a 100644 --- a/app/components/UI/Predict/hooks/usePredictPortfolio.ts +++ b/app/components/UI/Predict/hooks/usePredictPortfolio.ts @@ -35,6 +35,7 @@ export interface PredictPortfolioModel { isClaimPending: boolean; isDepositPending: boolean; isLoading: boolean; + isOpenPositionsLoading: boolean; isPositionsLoading: boolean; isRefreshing: boolean; openPositionCount: number; @@ -143,8 +144,9 @@ export function usePredictPortfolio(): PredictPortfolioModel { const openPositionCount = openPositions.length; const claimablePositionCount = actionableClaimablePositions.length; const positionsBadgeCount = openPositionCount + claimablePositionCount; + const isOpenPositionsLoading = activePositionsQuery.isLoading; const isPositionsLoading = - activePositionsQuery.isLoading || claimablePositionsQuery.isLoading; + isOpenPositionsLoading || claimablePositionsQuery.isLoading; const showUnrealizedPnl = Math.abs(totalUnrealizedPnlAmount) >= PNL_DISPLAY_THRESHOLD; @@ -184,6 +186,7 @@ export function usePredictPortfolio(): PredictPortfolioModel { isClaimPending, isDepositPending, isLoading: isBalanceLoading || isPositionsLoading, + isOpenPositionsLoading, isPositionsLoading, isRefreshing: isBalanceRefetching || diff --git a/app/components/UI/Predict/routes/index.test.tsx b/app/components/UI/Predict/routes/index.test.tsx index 749c0d8d6d3a..c40d7e422542 100644 --- a/app/components/UI/Predict/routes/index.test.tsx +++ b/app/components/UI/Predict/routes/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, act } from '@testing-library/react-native'; +import { render, screen, act, waitFor } from '@testing-library/react-native'; import { NavigationContainer, NavigationContainerRef, @@ -8,19 +8,25 @@ import Routes from '../../../../constants/navigation/Routes'; import PredictScreenStack, { PredictModalStack } from './index'; let mockPayWithAnyTokenEnabled = false; +let mockPredictPortfolioEnabled = true; const mockSelectPredictWithAnyTokenEnabledFlag = jest.fn( () => mockPayWithAnyTokenEnabled, ); +const mockSelectPredictPortfolioEnabledFlag = jest.fn( + () => mockPredictPortfolioEnabled, +); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest.fn(() => mockPayWithAnyTokenEnabled), + useSelector: jest.fn((selector: (state: unknown) => unknown) => selector({})), })); jest.mock('../selectors/featureFlags', () => ({ selectPredictWithAnyTokenEnabledFlag: () => mockSelectPredictWithAnyTokenEnabledFlag(), + selectPredictPortfolioEnabledFlag: () => + mockSelectPredictPortfolioEnabledFlag(), })); jest.mock('../contexts', () => { @@ -53,6 +59,11 @@ jest.mock('../views/PredictMarketDetails', () => { return () => ; }); +jest.mock('../views/PredictPositionsView', () => { + const { View } = jest.requireActual('react-native'); + return () => ; +}); + jest.mock('../views/PredictBuyPreview/PredictBuyPreview', () => { const { View } = jest.requireActual('react-native'); return () => ; @@ -123,6 +134,7 @@ describe('PredictScreenStack', () => { beforeEach(() => { jest.clearAllMocks(); mockPayWithAnyTokenEnabled = false; + mockPredictPortfolioEnabled = true; navigationRef = React.createRef(); }); @@ -158,6 +170,33 @@ describe('PredictScreenStack', () => { expect(screen.getByTestId('predict-world-cup')).toBeOnTheScreen(); }); + it('navigates to POSITIONS screen when portfolio flag is enabled', async () => { + mockPredictPortfolioEnabled = true; + renderWithNavigation(); + + await act(async () => { + navigationRef.current?.navigate(Routes.PREDICT.POSITIONS); + }); + + expect(screen.getByTestId('predict-positions-view')).toBeOnTheScreen(); + }); + + it('returns to market list when POSITIONS screen is disabled', async () => { + mockPredictPortfolioEnabled = false; + renderWithNavigation(); + + await act(async () => { + navigationRef.current?.navigate(Routes.PREDICT.POSITIONS); + }); + + await waitFor(() => { + expect(navigationRef.current?.getCurrentRoute()?.name).toBe( + Routes.PREDICT.MARKET_LIST, + ); + }); + expect(screen.queryByTestId('predict-positions-view')).toBeNull(); + }); + it('navigates to BUY_PREVIEW with PredictBuyPreview when payWithAnyToken is off', async () => { mockPayWithAnyTokenEnabled = false; diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx index ccab9aaaee2e..adc40f402736 100644 --- a/app/components/UI/Predict/routes/index.tsx +++ b/app/components/UI/Predict/routes/index.tsx @@ -1,5 +1,6 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import React from 'react'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import React, { useEffect } from 'react'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; import { @@ -16,6 +17,7 @@ import PredictActivityDetail from '../components/PredictActivityDetail/PredictAc import { PredictNavigationParamList } from '../types/navigation'; import PredictAddFundsModal from '../views/PredictAddFundsModal/PredictAddFundsModal'; import PredictFeed from '../views/PredictFeed'; +import PredictPositionsView from '../views/PredictPositionsView'; import PredictWorldCup from '../views/PredictWorldCup'; import PredictGTMModal from '../components/PredictGTMModal'; import { useSelector } from 'react-redux'; @@ -23,11 +25,41 @@ import { PredictPreviewSheetProvider } from '../contexts'; import PredictBuyPreview from '../views/PredictBuyPreview/PredictBuyPreview'; import PredictBuyWithAnyToken from '../views/PredictBuyWithAnyToken'; import PredictSellPreview from '../views/PredictSellPreview/PredictSellPreview'; -import { selectPredictWithAnyTokenEnabledFlag } from '../selectors/featureFlags'; +import { + selectPredictPortfolioEnabledFlag, + selectPredictWithAnyTokenEnabledFlag, +} from '../selectors/featureFlags'; const Stack = createNativeStackNavigator(); const ModalStack = createNativeStackNavigator(); +const PredictPositionsRoute = () => { + const navigation = + useNavigation>(); + const predictPortfolioEnabled = useSelector( + selectPredictPortfolioEnabledFlag, + ); + + useEffect(() => { + if (predictPortfolioEnabled) { + return; + } + + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate(Routes.PREDICT.MARKET_LIST); + }, [navigation, predictPortfolioEnabled]); + + if (!predictPortfolioEnabled) { + return null; + } + + return ; +}; + const PredictModalStack = () => { const emptyNavHeaderOptions = useEmptyNavHeaderForConfirmations(); @@ -97,6 +129,11 @@ const PredictScreenStack = () => { component={PredictWorldCup} /> + + { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.createAnimatedComponent = ( + Component: React.ComponentType, + ) => Component; + return Reanimated; +}); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), + useRoute: jest.fn(), +})); + +const mockUsePredictPortfolio = jest.fn(); +jest.mock('../../hooks/usePredictPortfolio', () => ({ + usePredictPortfolio: () => mockUsePredictPortfolio(), +})); + +jest.mock('../../hooks/usePredictActionGuard', () => ({ + usePredictActionGuard: () => ({ + executeGuardedAction: (action: () => void | Promise) => action(), + }), +})); + +jest.mock('../../components/PredictPositionsHistoryList', () => { + const ReactLib = jest.requireActual('react'); + const { Text, View } = jest.requireActual('react-native'); + const { PredictPositionsHistoryListSelectorsIDs: testIds } = + jest.requireActual('../../Predict.testIds'); + + return function MockPredictPositionsHistoryList({ + isVisible, + }: { + isVisible: boolean; + }) { + return ReactLib.createElement( + View, + { testID: testIds.CONTAINER }, + ReactLib.createElement(Text, null, `history-visible:${isVisible}`), + ); + }; +}); + +let mockPrivacyMode = false; +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => mockPrivacyMode), +})); + +const mockNavigation = { + canGoBack: jest.fn(), + goBack: jest.fn(), + navigate: jest.fn(), +}; + +const mockUseNavigation = useNavigation as jest.Mock; +const mockUseRoute = useRoute as jest.Mock; +const mockClaim = jest.fn(); + +const createPortfolio = ( + overrides: Partial = {}, +): PredictPortfolioModel => ({ + accountStateError: null, + actionableClaimablePositions: [], + activePositions: [], + availableBalance: 0, + balanceError: null, + claim: mockClaim, + claimableAmount: 0, + claimablePositionCount: 0, + claimablePositions: [], + claimablePositionsError: null, + deposit: jest.fn(), + error: null, + hasClaimableWinnings: false, + isBalanceLoading: false, + isClaimPending: false, + isDepositPending: false, + isLoading: false, + isOpenPositionsLoading: false, + isPositionsLoading: false, + isRefreshing: false, + openPositionCount: 0, + openPositions: [], + openPositionsError: null, + openPositionsValue: 0, + portfolioValue: 0, + positionsBadgeCount: 0, + refetch: jest.fn(), + showPnlLine: false, + showUnrealizedPnl: false, + totalUnrealizedPnlAmount: 0, + totalUnrealizedPnlPercent: undefined, + walletType: undefined, + withdraw: jest.fn(), + withdrawTransaction: null, + ...overrides, +}); + +const renderScreen = (initialTab?: 'positions' | 'history') => { + mockUseRoute.mockReturnValue({ + params: initialTab ? { initialTab } : undefined, + }); + + return render(); +}; + +const getMountedByTestId = (testID: string) => + screen.UNSAFE_getByProps({ testID }); + +const getMountedHistoryVisibilityText = (isVisible: boolean) => + screen.UNSAFE_getByProps({ children: `history-visible:${isVisible}` }); + +describe('PredictPositionsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrivacyMode = false; + mockNavigation.canGoBack.mockReturnValue(true); + mockUseNavigation.mockReturnValue(mockNavigation); + mockUsePredictPortfolio.mockReturnValue(createPortfolio()); + }); + + it('renders the fixed header, summary placeholder, tabs, and positions tab by default', () => { + renderScreen(); + + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.HEADER), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.SUMMARY), + ).toBeOnTheScreen(); + expect(screen.getByText('$0.00')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.TABS), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.POSITIONS_TAB), + ).toBeOnTheScreen(); + expect(screen.getByText('Active positions')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.HISTORY_TAB), + ).toBeOnTheScreen(); + expect( + screen.getByTestId( + PredictPositionsViewSelectorsIDs.POSITIONS_TAB_CONTENT, + ), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + getMountedByTestId(PredictPositionsViewSelectorsIDs.HISTORY_TAB_CONTENT), + ).toBeTruthy(); + expect(getMountedHistoryVisibilityText(false)).toBeTruthy(); + }); + + it('uses the initial history tab from route params', () => { + renderScreen('history'); + + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.HISTORY_TAB_CONTENT), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsHistoryListSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(screen.getByText('history-visible:true')).toBeOnTheScreen(); + expect( + getMountedByTestId( + PredictPositionsViewSelectorsIDs.POSITIONS_TAB_CONTENT, + ), + ).toBeTruthy(); + }); + + it('switches between Positions and History tabs', () => { + renderScreen(); + + fireEvent.press( + screen.getByTestId(PredictPositionsViewSelectorsIDs.HISTORY_TAB), + ); + + expect( + screen.getByTestId(PredictPositionsViewSelectorsIDs.HISTORY_TAB_CONTENT), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictPositionsHistoryListSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(screen.getByText('history-visible:true')).toBeOnTheScreen(); + + fireEvent.press( + screen.getByTestId(PredictPositionsViewSelectorsIDs.POSITIONS_TAB), + ); + + expect( + screen.getByTestId( + PredictPositionsViewSelectorsIDs.POSITIONS_TAB_CONTENT, + ), + ).toBeOnTheScreen(); + expect(getMountedHistoryVisibilityText(false)).toBeTruthy(); + }); + + it('navigates back when the back button is pressed and the stack can go back', () => { + renderScreen(); + + fireEvent.press( + screen.getByTestId(PredictPositionsViewSelectorsIDs.BACK_BUTTON), + ); + + expect(mockNavigation.goBack).toHaveBeenCalledTimes(1); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('returns to the Predict market list when the stack cannot go back', () => { + mockNavigation.canGoBack.mockReturnValue(false); + renderScreen(); + + fireEvent.press( + screen.getByTestId(PredictPositionsViewSelectorsIDs.BACK_BUTTON), + ); + + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.PREDICT.MARKET_LIST, + ); + }); +}); diff --git a/app/components/UI/Predict/views/PredictPositionsView/PredictPositionsView.tsx b/app/components/UI/Predict/views/PredictPositionsView/PredictPositionsView.tsx new file mode 100644 index 000000000000..4b1a368db7ef --- /dev/null +++ b/app/components/UI/Predict/views/PredictPositionsView/PredictPositionsView.tsx @@ -0,0 +1,196 @@ +import { + type NavigationProp, + type RouteProp, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Box, + FontWeight, + HeaderStandard, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; +import PredictPositionsHistoryList from '../../components/PredictPositionsHistoryList'; +import PredictPositionsList from '../../components/PredictPositionsList'; +import PredictPositionsViewHeader from '../../components/PredictPositionsViewHeader'; +import { usePredictPortfolio } from '../../hooks/usePredictPortfolio'; +import { PredictPositionsViewSelectorsIDs } from '../../Predict.testIds'; +import type { + PredictNavigationParamList, + PredictPositionsTabKey, +} from '../../types/navigation'; + +interface PredictPositionsTabItem { + key: PredictPositionsTabKey; + label: string; + testID: string; +} + +interface PredictPositionsTabsProps { + activeTab: PredictPositionsTabKey; + onTabPress: (tab: PredictPositionsTabKey) => void; + tabs: PredictPositionsTabItem[]; +} + +const PredictPositionsTabs = ({ + activeTab, + onTabPress, + tabs, +}: PredictPositionsTabsProps) => { + const tw = useTailwind(); + + return ( + + {tabs.map((tab) => { + const isActive = tab.key === activeTab; + + return ( + onTabPress(tab.key)} + style={tw.style('flex-1')} + testID={tab.testID} + > + + + {tab.label} + + + + + ); + })} + + ); +}; + +const PredictPositionsView = () => { + const navigation = + useNavigation>(); + const route = + useRoute>(); + const tw = useTailwind(); + const portfolio = usePredictPortfolio(); + const privacyMode = useSelector(selectPrivacyMode); + const [activeTab, setActiveTab] = useState( + route.params?.initialTab ?? 'positions', + ); + + const tabs = useMemo( + () => [ + { + key: 'positions', + label: strings('predict.tabs.active_positions'), + testID: PredictPositionsViewSelectorsIDs.POSITIONS_TAB, + }, + { + key: 'history', + label: strings('predict.tabs.history'), + testID: PredictPositionsViewSelectorsIDs.HISTORY_TAB, + }, + ], + [], + ); + + useEffect(() => { + setActiveTab(route.params?.initialTab ?? 'positions'); + }, [route.params?.initialTab]); + + const handleBackPress = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate(Routes.PREDICT.MARKET_LIST); + }, [navigation]); + + const handleTabPress = useCallback((tab: PredictPositionsTabKey) => { + setActiveTab(tab); + }, []); + + const isPositionsTabActive = activeTab === 'positions'; + const isHistoryTabActive = activeTab === 'history'; + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PredictPositionsView; diff --git a/app/components/UI/Predict/views/PredictPositionsView/PredictPositionsView.view.test.tsx b/app/components/UI/Predict/views/PredictPositionsView/PredictPositionsView.view.test.tsx new file mode 100644 index 000000000000..cc19ed63cea1 --- /dev/null +++ b/app/components/UI/Predict/views/PredictPositionsView/PredictPositionsView.view.test.tsx @@ -0,0 +1,228 @@ +/** + * Component view tests for PredictPositionsView. + * + * Run with: yarn jest -c jest.config.view.js PredictPositionsView.view.test --runInBand --silent --coverage=false + */ +import '../../../../../../tests/component-view/mocks'; +import Engine from '../../../../../../app/core/Engine'; +import { + renderPredictPositionsView, + renderPredictPositionsViewWithRoutes, +} from '../../../../../../tests/component-view/renderers/predictPositions'; +import { getRouteProbeTestId } from '../../../../../../tests/component-view/render'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { + PredictPositionSelectorsIDs, + PredictPositionsEmptySelectorsIDs, + PredictPositionsViewSelectorsIDs, +} from '../../Predict.testIds'; +import { + PredictActivity, + PredictPosition, + PredictPositionStatus, +} from '../../types'; +import { PredictEventValues } from '../../constants/eventNames'; + +const createPosition = ( + id: string, + overrides: Partial = {}, +): PredictPosition => ({ + amount: 10, + avgPrice: 1, + cashPnl: 0, + claimable: false, + currentValue: 20, + endDate: '2026-01-01T00:00:00Z', + icon: 'https://example.com/icon.png', + id, + initialValue: 20, + marketId: `market-${id}`, + outcome: 'Yes', + outcomeId: `outcome-${id}`, + outcomeIndex: 0, + outcomeTokenId: `token-${id}`, + percentPnl: 0, + price: 1, + providerId: 'polymarket', + size: 10, + status: PredictPositionStatus.OPEN, + title: `Market ${id}`, + ...overrides, +}); + +const OPEN_GAIN_POSITION = createPosition('france', { + currentValue: 24, + initialValue: 20, + percentPnl: 20, + title: 'Will France win the 2026 FIFA World Cup?', +}); + +const OPEN_LOSS_POSITION = createPosition('europe', { + currentValue: 9, + initialValue: 10, + percentPnl: -10, + title: 'Will Europe win the 2026 FIFA World Cup?', +}); + +const CLAIMABLE_POSITION = createPosition('claimable', { + claimable: true, + currentValue: 4.5, + initialValue: 3, + percentPnl: 50, + status: PredictPositionStatus.WON, + title: 'Will Finland win Eurovision?', +}); + +const CLOSED_LOST_POSITION = createPosition('lost', { + currentValue: 0, + initialValue: 1, + percentPnl: -100, + status: PredictPositionStatus.LOST, + title: 'Bitcoin Up or Down on May 1?', +}); + +const ACTIVITY: PredictActivity = { + id: 'activity-1', + providerId: 'polymarket', + title: 'Prediction lost', + outcome: 'USA', + icon: 'https://example.com/activity.png', + entry: { + type: 'buy', + amount: 40, + price: 0.4, + timestamp: Math.floor(Date.now() / 1000), + marketId: 'history-market', + outcomeId: 'history-outcome', + outcomeTokenId: 1, + }, +}; + +const mockPredictData = ({ + activity = [], + balance = 0, + positions = [], +}: { + activity?: PredictActivity[]; + balance?: number; + positions?: PredictPosition[]; +}) => { + (Engine.context.PredictController.getActivity as jest.Mock).mockResolvedValue( + activity, + ); + (Engine.context.PredictController.getBalance as jest.Mock).mockResolvedValue( + balance, + ); + ( + Engine.context.PredictController.getPositions as jest.Mock + ).mockResolvedValue(positions); +}; + +describe('PredictPositionsView component view', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPredictData({}); + }); + + it('shows active positions with rewards after portfolio data loads', async () => { + mockPredictData({ + balance: 250, + positions: [OPEN_GAIN_POSITION, CLAIMABLE_POSITION, CLOSED_LOST_POSITION], + }); + const { findByText, findByTestId, queryByText } = + renderPredictPositionsView(); + + expect(await findByText(OPEN_GAIN_POSITION.title)).toBeOnTheScreen(); + + expect(await findByText('$250.00')).toBeOnTheScreen(); + expect(await findByText('+$4.00 (+20%)')).toBeOnTheScreen(); + expect(await findByText('Claim $4.50')).toBeOnTheScreen(); + expect( + await findByTestId(PredictPositionSelectorsIDs.CURRENT_POSITION_CARD), + ).toBeOnTheScreen(); + expect(queryByText(CLAIMABLE_POSITION.title)).not.toBeOnTheScreen(); + expect(queryByText(CLOSED_LOST_POSITION.title)).not.toBeOnTheScreen(); + }); + + it('shows active positions without rewards when there are no claimable winnings', async () => { + mockPredictData({ + balance: 100, + positions: [OPEN_LOSS_POSITION], + }); + const { findByText, queryByTestId } = renderPredictPositionsView(); + + expect(await findByText(OPEN_LOSS_POSITION.title)).toBeOnTheScreen(); + + expect(await findByText('$100.00')).toBeOnTheScreen(); + expect(await findByText('-$1.00 (-10%)')).toBeOnTheScreen(); + expect( + queryByTestId(PredictPositionsViewSelectorsIDs.CLAIM_CTA), + ).not.toBeOnTheScreen(); + }); + + it('navigates from the active empty state back to browse markets', async () => { + mockPredictData({}); + const { findByTestId } = renderPredictPositionsViewWithRoutes({ + extraRoutes: [{ name: Routes.PREDICT.MARKET_LIST }], + }); + + expect( + await findByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + + fireEvent.press( + await findByTestId(PredictPositionsEmptySelectorsIDs.BROWSE_MARKETS_CTA), + ); + + expect( + await findByTestId(getRouteProbeTestId(Routes.PREDICT.MARKET_LIST)), + ).toBeOnTheScreen(); + }); + + it('switches to history and renders activity through the history wrapper', async () => { + const trackActivityViewedSpy = jest.spyOn( + Engine.context.PredictController, + 'trackActivityViewed', + ); + mockPredictData({ + activity: [ACTIVITY], + positions: [OPEN_GAIN_POSITION], + }); + const { findByText, findByTestId } = renderPredictPositionsView(); + + expect(await findByText(OPEN_GAIN_POSITION.title)).toBeOnTheScreen(); + + fireEvent.press( + await findByTestId(PredictPositionsViewSelectorsIDs.HISTORY_TAB), + ); + + expect(await findByText('Predicted')).toBeOnTheScreen(); + expect(await findByText(ACTIVITY.title as string)).toBeOnTheScreen(); + await waitFor(() => { + expect(trackActivityViewedSpy).toHaveBeenCalledWith({ + activityType: PredictEventValues.ACTIVITY_TYPE.ACTIVITY_LIST, + }); + }); + }); + + it('uses the shared empty state for empty history and navigates to browse markets', async () => { + mockPredictData({}); + const { findByTestId } = renderPredictPositionsViewWithRoutes({ + initialParams: { initialTab: 'history' }, + extraRoutes: [{ name: Routes.PREDICT.MARKET_LIST }], + }); + + expect( + await findByTestId(PredictPositionsEmptySelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + + fireEvent.press( + await findByTestId(PredictPositionsEmptySelectorsIDs.BROWSE_MARKETS_CTA), + ); + + expect( + await findByTestId(getRouteProbeTestId(Routes.PREDICT.MARKET_LIST)), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Predict/views/PredictPositionsView/index.tsx b/app/components/UI/Predict/views/PredictPositionsView/index.tsx new file mode 100644 index 000000000000..f60468fe27c3 --- /dev/null +++ b/app/components/UI/Predict/views/PredictPositionsView/index.tsx @@ -0,0 +1 @@ +export { default } from './PredictPositionsView'; diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx index 3a4117e6692c..57603943a042 100644 --- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx +++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { SectionList } from 'react-native'; +import { SectionList, Text } from 'react-native'; import { act, render, screen, fireEvent } from '@testing-library/react-native'; import PredictTransactionsView from './PredictTransactionsView'; import { PredictActivityType } from '../../types'; @@ -126,6 +126,24 @@ describe('PredictTransactionsView', () => { expect(screen.getByText('No recent activity')).toBeOnTheScreen(); }); + it('displays a custom empty state when provided', () => { + (usePredictActivity as jest.Mock).mockReturnValueOnce( + createUsePredictActivityValue({ + data: [], + isLoading: false, + }), + ); + + render( + Custom empty} + />, + ); + + expect(screen.getByTestId('custom-empty-state')).toBeOnTheScreen(); + expect(screen.queryByText('No recent activity')).toBeNull(); + }); + it('displays all activity items from the activity list', () => { const mockTimestamp = Math.floor(Date.now() / 1000); (usePredictActivity as jest.Mock).mockReturnValueOnce( diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx index cca1a0b2937e..cbee77e2eac5 100644 --- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx +++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx @@ -1,5 +1,10 @@ import React, { useMemo, useEffect, useCallback } from 'react'; -import { ActivityIndicator, SectionList } from 'react-native'; +import { + ActivityIndicator, + SectionList, + ViewStyle, + StyleProp, +} from 'react-native'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import PredictActivity from '../../components/PredictActivity/PredictActivity'; @@ -14,9 +19,12 @@ import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { TabEmptyState } from '../../../../../component-library/components-temp/TabEmptyState'; import { PREDICT_TRANSACTIONS_VIEW_TEST_IDS } from './PredictTransactionsView.testIds'; interface PredictTransactionsViewProps { + emptyState?: React.ReactNode; transactions?: unknown[]; tabLabel?: string; isVisible?: boolean; + containerStyle?: string; + activityContainerStyle?: string; } interface ActivitySection { @@ -60,7 +68,10 @@ const getDateGroupLabel = ( }; const PredictTransactionsView: React.FC = ({ + emptyState, isVisible, + containerStyle, + activityContainerStyle, }) => { const tw = useTailwind(); const { @@ -245,10 +256,10 @@ const PredictTransactionsView: React.FC = ({ const renderItem = useCallback( ({ item }: { item: PredictActivityItem }) => ( - + ), - [], + [activityContainerStyle], ); const keyExtractor = useCallback((item: PredictActivityItem) => item.id, []); @@ -263,18 +274,20 @@ const PredictTransactionsView: React.FC = ({ /> ) : sections.length === 0 ? ( - - - + (emptyState ?? ( + + + + )) ) : ( sections={sections} keyExtractor={keyExtractor} renderItem={renderItem} renderSectionHeader={renderSectionHeader} - contentContainerStyle={tw.style('p-2')} + contentContainerStyle={tw.style('p-2', containerStyle)} showsVerticalScrollIndicator={false} style={tw.style('flex-1')} stickySectionHeadersEnabled diff --git a/app/components/UI/SecurityTrust/utils/securityUtils.test.ts b/app/components/UI/SecurityTrust/utils/securityUtils.test.ts index 0ed51e7a9bfe..065c8faaad45 100644 --- a/app/components/UI/SecurityTrust/utils/securityUtils.test.ts +++ b/app/components/UI/SecurityTrust/utils/securityUtils.test.ts @@ -399,6 +399,58 @@ describe('securityUtils', () => { }); }); + describe('getSheetDescription in getResultTypeConfig', () => { + it('returns risky description with symbol for Warning type', () => { + const config = getResultTypeConfig('Warning'); + const { getSheetDescription } = config; + expect(getSheetDescription?.('ETH')).toBe( + strings('security_trust.risky_token_description', { symbol: 'ETH' }), + ); + }); + + it('returns risky description without symbol for Warning type', () => { + const config = getResultTypeConfig('Warning'); + const { getSheetDescription } = config; + expect(getSheetDescription?.('')).toBe( + strings('security_trust.risky_token_description_no_symbol'), + ); + }); + + it('returns risky description without symbol when undefined for Warning type', () => { + const config = getResultTypeConfig('Warning'); + const { getSheetDescription } = config; + expect(getSheetDescription?.(undefined)).toBe( + strings('security_trust.risky_token_description_no_symbol'), + ); + }); + + it('returns malicious sheet description with symbol for Malicious type', () => { + const config = getResultTypeConfig('Malicious'); + const { getSheetDescription } = config; + expect(getSheetDescription?.('SCAM')).toBe( + strings('security_trust.malicious_token_sheet_description', { + symbol: 'SCAM', + }), + ); + }); + + it('returns malicious sheet description without symbol for Malicious type', () => { + const config = getResultTypeConfig('Malicious'); + const { getSheetDescription } = config; + expect(getSheetDescription?.('')).toBe( + strings('security_trust.malicious_token_sheet_description_no_symbol'), + ); + }); + + it('returns malicious sheet description without symbol when undefined for Malicious type', () => { + const config = getResultTypeConfig('Malicious'); + const { getSheetDescription } = config; + expect(getSheetDescription?.(undefined)).toBe( + strings('security_trust.malicious_token_sheet_description_no_symbol'), + ); + }); + }); + describe('badge property in getResultTypeConfig', () => { it('returns badge config for Verified result type', () => { const config = getResultTypeConfig('Verified'); diff --git a/app/components/UI/SecurityTrust/utils/securityUtils.ts b/app/components/UI/SecurityTrust/utils/securityUtils.ts index 8c901d39176c..ea5d9d77f1a9 100644 --- a/app/components/UI/SecurityTrust/utils/securityUtils.ts +++ b/app/components/UI/SecurityTrust/utils/securityUtils.ts @@ -32,7 +32,7 @@ export interface ResultTypeConfig { /** Title for bottom sheet display */ sheetTitle?: string; /** Description for bottom sheet display (may include token symbol placeholder) */ - getSheetDescription?: (tokenSymbol: string) => string; + getSheetDescription?: (tokenSymbol: string | undefined) => string; } export const getResultTypeConfig = ( @@ -88,7 +88,9 @@ export const getResultTypeConfig = ( }, sheetTitle: strings('security_trust.risky_token_title'), getSheetDescription: (symbol) => - strings('security_trust.risky_token_description', { symbol }), + symbol + ? strings('security_trust.risky_token_description', { symbol }) + : strings('security_trust.risky_token_description_no_symbol'), }; case 'Malicious': return { @@ -108,9 +110,13 @@ export const getResultTypeConfig = ( }, sheetTitle: strings('security_trust.malicious_token_title'), getSheetDescription: (symbol) => - strings('security_trust.malicious_token_sheet_description', { - symbol, - }), + symbol + ? strings('security_trust.malicious_token_sheet_description', { + symbol, + }) + : strings( + 'security_trust.malicious_token_sheet_description_no_symbol', + ), }; default: return { diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx index 91f9a861fa6f..219dbc895fc3 100644 --- a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx @@ -95,6 +95,14 @@ describe('SitesSearchFooter', () => { expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); }); + it('detects localhost URLs', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + it('detects URLs with path', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx index fcf3fce7f545..724c8ac18cd3 100644 --- a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx @@ -15,6 +15,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { selectSearchEngine } from '../../../../../reducers/browser/selectors'; import { SEARCH_ENGINE_URLS, SearchEngine } from '../../../../../util/browser'; import AppConstants from '../../../../../core/AppConstants'; +import isUrlFn from 'is-url'; // TODO: @MetaMask/design-system-engineers // Use the concrete Box component props here instead of BoxProps. @@ -39,11 +40,14 @@ export interface SitesSearchFooterProps { containerStyle?: BoxComponentProps['style']; } +// Note: This regex intentionally does not require a fully valid URL +const URL_REGEX = /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/; + /** * Checks if a string looks like a URL */ function looksLikeUrl(str: string): boolean { - return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str); + return isUrlFn(str) || URL_REGEX.test(str); } export const useSearchFooterBrowserNavigation = () => { diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 7b06ad6450b9..c3c0ec972724 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -248,6 +248,7 @@ const AssetOverviewContent: React.FC = ({ const navigation = useNavigation(); const resetNavigationLockRef = useRef<(() => void) | null>(null); const { isTokenTradingOpen, isStockToken } = useRWAToken(); + const { trackEvent, createEventBuilder } = useAnalytics(); const tronNativeToken = isTronNativeToken(token) ? token : null; @@ -397,11 +398,13 @@ const AssetOverviewContent: React.FC = ({ icon: displayIcon, iconColor: displayIconColor, title: securityConfig.sheetTitle, - description: securityConfig.getSheetDescription(token.symbol), + description: securityConfig.getSheetDescription( + token.symbol || token.name, + ), source: 'badge', severity: securityData.resultType, tokenAddress: token.address, - tokenSymbol: token.symbol, + tokenSymbol: token.symbol || token.name, chainId: token.chainId, features: securityData.features, }, @@ -410,6 +413,7 @@ const AssetOverviewContent: React.FC = ({ securityData, securityConfig, token.symbol, + token.name, token.address, token.chainId, navigation, @@ -581,6 +585,22 @@ const AssetOverviewContent: React.FC = ({ Boolean(marketInsightsCaip19Id) && (Boolean(marketInsightsReport) || isMarketInsightsLoading); + const tokenDisplaySymbol = token.symbol || token.name; + const securityBadgeDescription = (() => { + if (securityData?.resultType === 'Malicious') { + return tokenDisplaySymbol + ? strings('security_trust.malicious_token_description', { + symbol: tokenDisplaySymbol, + }) + : strings('security_trust.malicious_token_description_no_symbol'); + } + return tokenDisplaySymbol + ? strings('security_trust.suspicious_token_description', { + symbol: tokenDisplaySymbol, + }) + : strings('security_trust.suspicious_token_description_no_symbol'); + })(); + return ( {token.hasBalanceError ? ( @@ -613,15 +633,7 @@ const AssetOverviewContent: React.FC = ({ ? strings('security_trust.malicious_token_title') : undefined } - description={ - securityData.resultType === 'Malicious' - ? strings('security_trust.malicious_token_description', { - symbol: token.symbol, - }) - : strings('security_trust.suspicious_token_description', { - symbol: token.symbol, - }) - } + description={securityBadgeDescription} className="mx-4 mb-3 gap-4" onPress={handleSecurityBadgePress} /> diff --git a/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.test.tsx b/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.test.tsx index 8b63c83c7828..e0205166a39d 100644 --- a/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.test.tsx +++ b/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.test.tsx @@ -325,6 +325,25 @@ describe('SecurityBadgeBottomSheet', () => { expect(getByText(bannerText)).toBeOnTheScreen(); }); + it('renders malicious banner with no_symbol fallback when tokenSymbol is empty', () => { + mockUseRouteImpl = jest.fn(() => ({ + params: { + ...mockRouteParams, + severity: 'Malicious', + tokenSymbol: '', + description: 'This should not appear', + }, + })); + + const { getByText, queryByText } = render(); + + const bannerText = strings( + 'security_trust.malicious_token_banner_description_no_symbol', + ); + expect(getByText(bannerText)).toBeOnTheScreen(); + expect(queryByText('This should not appear')).not.toBeOnTheScreen(); + }); + it('does not render malicious banner for Warning severity', () => { mockUseRouteImpl = jest.fn(() => ({ params: { diff --git a/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx b/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx index e1a5944d5b89..a9fea37eff9f 100644 --- a/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx +++ b/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx @@ -208,12 +208,16 @@ const SecurityBadgeBottomSheet = () => { titleFontWeight={FontWeight.Bold} testID="security-banner-malicious" className="mb-1 mt-3 gap-4" - description={strings( - 'security_trust.malicious_token_banner_description', - { - symbol: tokenSymbol, - }, - )} + description={ + tokenSymbol + ? strings( + 'security_trust.malicious_token_banner_description', + { symbol: tokenSymbol }, + ) + : strings( + 'security_trust.malicious_token_banner_description_no_symbol', + ) + } /> )} diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx index 06f3b0813e97..694987b23557 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx @@ -509,6 +509,67 @@ describe('TokenDetailsStickyFooter', () => { }); }); + describe('security interception - token.symbol fallback to token.name', () => { + it('passes token.name as tokenSymbol when symbol is missing', () => { + const tokenWithoutSymbol = { + ...mockToken, + symbol: '', + name: 'FakeToken', + } as unknown as TokenDetailsRouteParams; + + const maliciousSecurityData = { + resultType: 'Malicious', + features: [], + } as unknown as TokenSecurityData; + + const { getByText } = render( + , + ); + + fireEvent.press(getByText('Buy')); + + expect(mockNavigate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: expect.objectContaining({ + tokenSymbol: 'FakeToken', + description: expect.any(String), + }), + }), + ); + }); + + it('passes token.symbol as tokenSymbol when symbol is present', () => { + const warningSecurityData = { + resultType: 'Warning', + features: [], + } as unknown as TokenSecurityData; + + const { getByText } = render( + , + ); + + fireEvent.press(getByText('Buy')); + + expect(mockNavigate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: expect.objectContaining({ + tokenSymbol: 'ETH', + description: expect.any(String), + }), + }), + ); + }); + }); + describe('RWA geo-restriction', () => { it('blocks the buy action when token is a geo-restricted stock', () => { mockIsStockToken.mockReturnValue(true); diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx index fc6cb1bf5039..b1e0e39ba05e 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx @@ -215,12 +215,12 @@ const TokenDetailsStickyFooter: React.FC = ({ icon: config.icon, iconColor: config.iconColor, title: config.sheetTitle, - description: config.getSheetDescription(token.symbol), + description: config.getSheetDescription(token.symbol || token.name), onProceed: action, source, severity: securityData?.resultType, tokenAddress: token.address, - tokenSymbol: token.symbol, + tokenSymbol: token.symbol || token.name, chainId: token.chainId, features: securityData?.features, }, @@ -231,6 +231,7 @@ const TokenDetailsStickyFooter: React.FC = ({ navigation, securityData, token.symbol, + token.name, token.address, token.chainId, ], diff --git a/app/components/UI/WalletHomeOnboardingSteps/fundRampPriorityAssets.test.ts b/app/components/UI/WalletHomeOnboardingSteps/fundRampPriorityAssets.test.ts new file mode 100644 index 000000000000..2ebdc665e836 --- /dev/null +++ b/app/components/UI/WalletHomeOnboardingSteps/fundRampPriorityAssets.test.ts @@ -0,0 +1,49 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { + createMainnetEthBuyabilityToken, + createMainnetMusdBuyabilityToken, + MAINNET_ETH_RAMP_ASSET_ID, + MAINNET_MUSD_RAMP_ASSET_ID, + MAINNET_MUSD_TOKEN_ADDRESS, + WALLET_HOME_ONBOARDING_FUND_RAMP_PRIORITY, +} from './fundRampPriorityAssets'; +import { getTokenBuyabilityKey } from '../Ramp/hooks/useTokenBuyability'; + +describe('fundRampPriorityAssets', () => { + it('defines mainnet mUSD and ETH asset ids', () => { + expect(MAINNET_MUSD_RAMP_ASSET_ID).toBe( + 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + ); + expect(MAINNET_ETH_RAMP_ASSET_ID).toBe('eip155:1/slip44:60'); + }); + + it('orders mUSD before ETH in the priority list', () => { + expect( + WALLET_HOME_ONBOARDING_FUND_RAMP_PRIORITY.map((c) => c.assetId), + ).toEqual([MAINNET_MUSD_RAMP_ASSET_ID, MAINNET_ETH_RAMP_ASSET_ID]); + }); + + it('builds distinct buyability token keys for mUSD and ETH', () => { + const musdKey = getTokenBuyabilityKey(createMainnetMusdBuyabilityToken()); + const ethKey = getTokenBuyabilityKey(createMainnetEthBuyabilityToken()); + + expect(musdKey).not.toBe(ethKey); + expect(musdKey).toContain('eip155:1'); + expect(ethKey).toContain('eip155:1'); + }); + + it('uses mainnet hex chain id and erc20 address for mUSD buyability stub', () => { + const musdToken = createMainnetMusdBuyabilityToken(); + + expect(musdToken.chainId).toBe(CHAIN_IDS.MAINNET); + expect(musdToken.isNative).toBe(false); + expect(musdToken.address).toBe(MAINNET_MUSD_TOKEN_ADDRESS); + }); + + it('marks ETH buyability stub as native on mainnet', () => { + const ethToken = createMainnetEthBuyabilityToken(); + + expect(ethToken.chainId).toBe(CHAIN_IDS.MAINNET); + expect(ethToken.isNative).toBe(true); + }); +}); diff --git a/app/components/UI/WalletHomeOnboardingSteps/fundRampPriorityAssets.ts b/app/components/UI/WalletHomeOnboardingSteps/fundRampPriorityAssets.ts new file mode 100644 index 000000000000..30440d4701e2 --- /dev/null +++ b/app/components/UI/WalletHomeOnboardingSteps/fundRampPriorityAssets.ts @@ -0,0 +1,87 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { TokenI } from '../Tokens/types'; + +/** Native mainnet ETH CAIP-19 asset id for unified ramp buy flows. */ +export const MAINNET_ETH_RAMP_ASSET_ID = 'eip155:1/slip44:60'; + +/** Mainnet mUSD CAIP-19 asset id (TMCU-681 fund-step preselection). */ +export const MAINNET_MUSD_RAMP_ASSET_ID = + 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; + +/** mUSD contract address on mainnet (same on supported ramp chains). */ +export const MAINNET_MUSD_TOKEN_ADDRESS: Hex = + '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + +const MUSD_SYMBOL = 'mUSD'; +const MUSD_NAME = 'MetaMask USD'; +const MUSD_DECIMALS = 6; + +export interface WalletHomeOnboardingFundRampPriorityCandidate { + assetId: string; + token: TokenI; +} + +const EMPTY_TOKEN_IMAGE = ''; + +function createBuyabilityTokenStub( + overrides: Pick< + TokenI, + 'address' | 'chainId' | 'symbol' | 'name' | 'decimals' + > & + Partial>, +): TokenI { + return { + address: overrides.address, + chainId: overrides.chainId, + symbol: overrides.symbol, + name: overrides.name, + decimals: overrides.decimals, + image: EMPTY_TOKEN_IMAGE, + logo: undefined, + balance: '0', + isETH: overrides.isETH ?? false, + isNative: overrides.isNative ?? false, + }; +} + +/** + * Minimal {@link TokenI} for ramp buyability checks on mainnet mUSD. + */ +export function createMainnetMusdBuyabilityToken(): TokenI { + return createBuyabilityTokenStub({ + address: MAINNET_MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MAINNET, + symbol: MUSD_SYMBOL, + name: MUSD_NAME, + decimals: MUSD_DECIMALS, + }); +} + +/** + * Minimal {@link TokenI} for ramp buyability checks on mainnet native ETH. + */ +export function createMainnetEthBuyabilityToken(): TokenI { + return createBuyabilityTokenStub({ + address: '0x0000000000000000000000000000000000000000', + chainId: CHAIN_IDS.MAINNET, + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + isETH: true, + isNative: true, + }); +} + +/** Fund-step ramp asset priority: mainnet mUSD, then mainnet native ETH. */ +export const WALLET_HOME_ONBOARDING_FUND_RAMP_PRIORITY: WalletHomeOnboardingFundRampPriorityCandidate[] = + [ + { + assetId: MAINNET_MUSD_RAMP_ASSET_ID, + token: createMainnetMusdBuyabilityToken(), + }, + { + assetId: MAINNET_ETH_RAMP_ASSET_ID, + token: createMainnetEthBuyabilityToken(), + }, + ]; diff --git a/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.test.ts b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.test.ts index fb9695dce564..4a2e45549970 100644 --- a/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.test.ts +++ b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.test.ts @@ -2,6 +2,13 @@ import { renderHook, act } from '@testing-library/react-native'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { ActionLocation } from '../../../util/analytics/actionButtonTracking'; import { useWalletHomeOnboardingChecklistFundPress } from './useWalletHomeOnboardingChecklistFundPress'; +import { MAINNET_MUSD_RAMP_ASSET_ID } from './fundRampPriorityAssets'; + +const mockUseWalletHomeOnboardingFundRampIntent = jest.fn(); +jest.mock('./useWalletHomeOnboardingFundRampIntent', () => ({ + useWalletHomeOnboardingFundRampIntent: () => + mockUseWalletHomeOnboardingFundRampIntent(), +})); const mockTrackEvent = jest.fn(); const mockAddProperties = jest.fn().mockReturnThis(); @@ -64,6 +71,10 @@ describe('useWalletHomeOnboardingChecklistFundPress', () => { mockUseRampsButtonClickData.mockReturnValue(defaultButtonClickData); mockUseRampsUnifiedV1Enabled.mockReturnValue(false); mockUseRampsUnifiedV2Enabled.mockReturnValue(false); + mockUseWalletHomeOnboardingFundRampIntent.mockReturnValue({ + rampIntent: undefined, + isLoading: false, + }); }); it('fires RAMPS_BUTTON_CLICKED with location onboarding_checklist then calls goToBuy', () => { @@ -89,7 +100,26 @@ describe('useWalletHomeOnboardingChecklistFundPress', () => { order_count: 2, }); expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'built' }); - expect(goToBuy).toHaveBeenCalledTimes(1); + expect(goToBuy).toHaveBeenCalledWith(undefined); + }); + + it('passes resolved ramp intent to goToBuy when available', () => { + mockUseWalletHomeOnboardingFundRampIntent.mockReturnValue({ + rampIntent: { assetId: MAINNET_MUSD_RAMP_ASSET_ID }, + isLoading: false, + }); + + const { result } = renderHook(() => + useWalletHomeOnboardingChecklistFundPress(goToBuy), + ); + + act(() => { + result.current(); + }); + + expect(goToBuy).toHaveBeenCalledWith({ + assetId: MAINNET_MUSD_RAMP_ASSET_ID, + }); }); it('uses UNIFIED_BUY_2 ramp_type when V2 unified is enabled', () => { diff --git a/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.ts b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.ts index 648a51ecdec4..95ad67a30cf0 100644 --- a/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.ts +++ b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingChecklistFundPress.ts @@ -8,6 +8,7 @@ import useRampsUnifiedV1Enabled from '../Ramp/hooks/useRampsUnifiedV1Enabled'; import useRampsUnifiedV2Enabled from '../Ramp/hooks/useRampsUnifiedV2Enabled'; import { useRampsButtonClickData } from '../Ramp/hooks/useRampsButtonClickData'; import { walletHomeOnboardingPrimaryLabelForStep } from './walletHomeOnboardingStepsStrings'; +import { useWalletHomeOnboardingFundRampIntent } from './useWalletHomeOnboardingFundRampIntent'; type GoToBuyFromRampNavigation = ReturnType< typeof import('../Ramp/hooks/useRampNavigation').useRampNavigation @@ -25,6 +26,7 @@ export function useWalletHomeOnboardingChecklistFundPress( const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); const isV2UnifiedEnabled = useRampsUnifiedV2Enabled(); const region = useSelector(getDetectedGeolocation); + const { rampIntent } = useWalletHomeOnboardingFundRampIntent(); return useCallback(() => { const rampType = isV2UnifiedEnabled @@ -48,7 +50,7 @@ export function useWalletHomeOnboardingChecklistFundPress( .build(), ); - goToBuy(); + goToBuy(rampIntent); }, [ buttonClickData.is_authenticated, buttonClickData.order_count, @@ -57,6 +59,7 @@ export function useWalletHomeOnboardingChecklistFundPress( createEventBuilder, goToBuy, isV2UnifiedEnabled, + rampIntent, rampUnifiedV1Enabled, region, trackEvent, diff --git a/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingFundRampIntent.test.ts b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingFundRampIntent.test.ts new file mode 100644 index 000000000000..1ba79269f9c2 --- /dev/null +++ b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingFundRampIntent.test.ts @@ -0,0 +1,104 @@ +import { renderHook } from '@testing-library/react-native'; +import { + createMainnetEthBuyabilityToken, + createMainnetMusdBuyabilityToken, + MAINNET_ETH_RAMP_ASSET_ID, + MAINNET_MUSD_RAMP_ASSET_ID, +} from './fundRampPriorityAssets'; +import { useWalletHomeOnboardingFundRampIntent } from './useWalletHomeOnboardingFundRampIntent'; +import { + getTokenBuyabilityKey, + useTokensBuyability, +} from '../Ramp/hooks/useTokenBuyability'; + +jest.mock('../Ramp/hooks/useTokenBuyability', () => { + const actual = jest.requireActual('../Ramp/hooks/useTokenBuyability'); + return { + ...actual, + useTokensBuyability: jest.fn(), + }; +}); + +const mockUseTokensBuyability = useTokensBuyability as jest.MockedFunction< + typeof useTokensBuyability +>; + +describe('useWalletHomeOnboardingFundRampIntent', () => { + const musdKey = getTokenBuyabilityKey(createMainnetMusdBuyabilityToken()); + const ethKey = getTokenBuyabilityKey(createMainnetEthBuyabilityToken()); + + const setBuyability = ( + buyabilityByTokenKey: Record = {}, + isLoading = false, + ) => { + mockUseTokensBuyability.mockReturnValue({ + buyabilityByTokenKey, + isLoading, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + setBuyability({ + [musdKey]: true, + [ethKey]: true, + }); + }); + + it('returns undefined rampIntent while buyability is loading', () => { + setBuyability({}, true); + + const { result } = renderHook(() => + useWalletHomeOnboardingFundRampIntent(), + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.rampIntent).toBeUndefined(); + }); + + it('returns mUSD asset id when mainnet mUSD is buyable', () => { + setBuyability({ [musdKey]: true, [ethKey]: false }); + + const { result } = renderHook(() => + useWalletHomeOnboardingFundRampIntent(), + ); + + expect(result.current.rampIntent).toEqual({ + assetId: MAINNET_MUSD_RAMP_ASSET_ID, + }); + }); + + it('returns ETH asset id when mUSD is not buyable but ETH is', () => { + setBuyability({ [musdKey]: false, [ethKey]: true }); + + const { result } = renderHook(() => + useWalletHomeOnboardingFundRampIntent(), + ); + + expect(result.current.rampIntent).toEqual({ + assetId: MAINNET_ETH_RAMP_ASSET_ID, + }); + }); + + it('prefers mUSD when both mUSD and ETH are buyable', () => { + setBuyability({ [musdKey]: true, [ethKey]: true }); + + const { result } = renderHook(() => + useWalletHomeOnboardingFundRampIntent(), + ); + + expect(result.current.rampIntent).toEqual({ + assetId: MAINNET_MUSD_RAMP_ASSET_ID, + }); + }); + + it('returns undefined rampIntent when neither asset is buyable', () => { + setBuyability({ [musdKey]: false, [ethKey]: false }); + + const { result } = renderHook(() => + useWalletHomeOnboardingFundRampIntent(), + ); + + expect(result.current.rampIntent).toBeUndefined(); + }); +}); diff --git a/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingFundRampIntent.ts b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingFundRampIntent.ts new file mode 100644 index 000000000000..8e9baa5dc38e --- /dev/null +++ b/app/components/UI/WalletHomeOnboardingSteps/useWalletHomeOnboardingFundRampIntent.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import type { RampIntent } from '../Ramp/types'; +import { + getTokenBuyabilityKey, + useTokensBuyability, +} from '../Ramp/hooks/useTokenBuyability'; +import { WALLET_HOME_ONBOARDING_FUND_RAMP_PRIORITY } from './fundRampPriorityAssets'; + +export interface UseWalletHomeOnboardingFundRampIntentResult { + rampIntent: RampIntent | undefined; + isLoading: boolean; +} + +/** + * Resolves the ramp buy intent for the wallet home onboarding fund step (TMCU-681). + * Prefers mainnet mUSD when buyable, otherwise mainnet ETH; undefined while loading + * or when neither asset is supported for the user's region. + */ +export function useWalletHomeOnboardingFundRampIntent(): UseWalletHomeOnboardingFundRampIntentResult { + const priorityTokens = useMemo( + () => + WALLET_HOME_ONBOARDING_FUND_RAMP_PRIORITY.map( + (candidate) => candidate.token, + ), + [], + ); + + const { buyabilityByTokenKey, isLoading } = + useTokensBuyability(priorityTokens); + + const rampIntent = useMemo((): RampIntent | undefined => { + if (isLoading) { + return undefined; + } + + const selected = WALLET_HOME_ONBOARDING_FUND_RAMP_PRIORITY.find( + (candidate) => + buyabilityByTokenKey[getTokenBuyabilityKey(candidate.token)] === true, + ); + + return selected ? { assetId: selected.assetId } : undefined; + }, [buyabilityByTokenKey, isLoading]); + + return { rampIntent, isLoading }; +} diff --git a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx index 590ad35fb825..774b93d96287 100644 --- a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx +++ b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx @@ -95,6 +95,20 @@ describe('WhatsHappeningSection', () => { ).toBeNull(); }); + it('renders null (not ErrorState) when items are empty but there is no error', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: null, + refresh: jest.fn(), + }); + renderWithProvider(); + expect(screen.queryByText(/unable to load/i)).toBeNull(); + expect( + screen.queryByTestId(WhatsHappeningSelectorsIDs.CAROUSEL), + ).toBeNull(); + }); + it('renders skeleton cards while loading', () => { mockUseWhatsHappening.mockReturnValue({ items: [], diff --git a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx index 5caf37d85833..38fcd0fcb938 100644 --- a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx +++ b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx @@ -94,7 +94,11 @@ const WhatsHappeningAssetPill: React.FC = ({ paddingVertical={1} twClassName="rounded-full" > - + { expect(screen.getByText('+1.23%')).toBeOnTheScreen(); }); + it('renders pills for perps assets that have no sourceAssetId', () => { + const assetWithoutSourceId = { + symbol: 'SOL', + name: 'Solana', + caip19: [], + hlPerpsMarket: ['SOL'], + // sourceAssetId intentionally absent + }; + renderWithProvider( + , + ); + expect(screen.getByText('SOL')).toBeOnTheScreen(); + }); + it('shows negative change in red and hides change text when undefined', () => { mockUseWhatsHappeningAssetPrices.mockReturnValue({ perpsPriceBySymbol: { diff --git a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx index e7389835f679..94b6088612b2 100644 --- a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx +++ b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx @@ -40,9 +40,9 @@ const WhatsHappeningAssetSlider: React.FC = ({ contentContainerStyle={tw.style('flex-row gap-2 mt-2')} nestedScrollEnabled > - {perpsAssets.map((asset) => ( + {perpsAssets.map((asset, index) => ( { await waitFor(() => expect(result.current.items).toHaveLength(2)); }); + + it('returns items and no error when an asset is missing sourceAssetId and name', async () => { + const assetWithoutOptionalFields = { + symbol: 'ETH', + caip19: ['eip155:1/slip44:60'], + // sourceAssetId intentionally absent + // name intentionally absent + }; + mockFetchMarketOverview.mockResolvedValue({ + ...mockOverview, + trends: [ + { + ...mockTrend, + relatedAssets: [assetWithoutOptionalFields], + }, + ], + }); + + const { result } = renderHook(() => useWhatsHappening()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.items).toHaveLength(1); + expect(result.current.error).toBeNull(); + expect(result.current.items[0].relatedAssets[0].symbol).toBe('ETH'); + expect( + result.current.items[0].relatedAssets[0].sourceAssetId, + ).toBeUndefined(); + expect(result.current.items[0].relatedAssets[0].name).toBeUndefined(); + }); + + it('returns empty items and no error when API returns overview with empty trends', async () => { + mockFetchMarketOverview.mockResolvedValue({ + ...mockOverview, + trends: [], + }); + + const { result } = renderHook(() => useWhatsHappening()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.items).toHaveLength(0); + expect(result.current.error).toBeNull(); + }); }); diff --git a/app/components/Views/AccountBackupStep1/index.js b/app/components/Views/AccountBackupStep1/index.js index 50c75b039298..98a15390f755 100644 --- a/app/components/Views/AccountBackupStep1/index.js +++ b/app/components/Views/AccountBackupStep1/index.js @@ -37,7 +37,7 @@ import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder import SRPDesignLight from '../../../images/secure_wallet_light.png'; import SRPDesignDark from '../../../images/secure_wallet_dark.png'; import { CommonActions, useNavigation } from '@react-navigation/native'; -import { useMetrics } from '../../hooks/useMetrics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { AccountType, ONBOARDING_SUCCESS_FLOW, @@ -48,7 +48,7 @@ import { AppThemeKey } from '../../../util/theme/models'; const AccountBackupStep1 = (props) => { const [hasFunds, setHasFunds] = useState(false); const { themeAppearance } = useTheme(); - const { isEnabled: isMetricsEnabled } = useMetrics(); + const { isEnabled: isAnalyticsEnabled } = useAnalytics(); const tw = useTailwind(); const track = (event, properties) => { @@ -100,7 +100,7 @@ const AccountBackupStep1 = (props) => { endTrace({ name: TraceName.OnboardingNewSrpCreateWallet }); endTrace({ name: TraceName.OnboardingJourneyOverall }); - if (isMetricsEnabled()) { + if (isAnalyticsEnabled()) { navigation.dispatch(resetAction); } else { navigation.navigate('OptinMetrics', { diff --git a/app/components/Views/AccountBackupStep1/index.test.tsx b/app/components/Views/AccountBackupStep1/index.test.tsx index b1bb0bc1ff56..86ac14eebe53 100644 --- a/app/components/Views/AccountBackupStep1/index.test.tsx +++ b/app/components/Views/AccountBackupStep1/index.test.tsx @@ -15,6 +15,8 @@ import { InteractionManager, Platform } from 'react-native'; import { AccountType } from '../../../constants/onboarding'; import SRPDesignDark from '../../../images/secure_wallet_dark.png'; import SRPDesignLight from '../../../images/secure_wallet_light.png'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; // Use fake timers to resolve reanimated issues. jest.useFakeTimers(); @@ -32,18 +34,9 @@ jest.mock('../../../store/storage-wrapper', () => ({ getItem: jest.fn(), })); -const mockIsEnabled = jest.fn().mockReturnValue(true); +const mockIsAnalyticsEnabled = jest.fn().mockReturnValue(true); -jest.mock('../../hooks/useMetrics', () => { - const actualUseMetrics = jest.requireActual('../../hooks/useMetrics'); - return { - ...actualUseMetrics, - useMetrics: jest.fn().mockReturnValue({ - ...actualUseMetrics.useMetrics, - isEnabled: () => mockIsEnabled(), - }), - }; -}); +jest.mock('../../hooks/useAnalytics/useAnalytics'); // Mock useTheme hook - default to dark theme const mockUseTheme = jest.fn().mockReturnValue({ @@ -106,6 +99,15 @@ jest.doMock('react-native', () => { }); describe('AccountBackupStep1', () => { + beforeEach(() => { + mockIsAnalyticsEnabled.mockReturnValue(true); + jest + .mocked(useAnalytics) + .mockReturnValue( + createMockUseAnalyticsHook({ isEnabled: mockIsAnalyticsEnabled }), + ); + }); + afterEach(() => { jest.useFakeTimers({ legacyFakeTimers: true }); jest.clearAllMocks(); @@ -339,7 +341,7 @@ describe('AccountBackupStep1', () => { }); it('handle skip when metrics is disabled', async () => { - mockIsEnabled.mockReturnValue(false); + mockIsAnalyticsEnabled.mockReturnValue(false); (Engine.hasFunds as jest.Mock).mockReturnValue(false); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); @@ -377,7 +379,7 @@ describe('AccountBackupStep1', () => { }); it('handle secure now button to goNext step when metrics is disabled', async () => { - mockIsEnabled.mockReturnValue(false); + mockIsAnalyticsEnabled.mockReturnValue(false); (Engine.hasFunds as jest.Mock).mockReturnValue(false); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); diff --git a/app/components/Views/ChoosePassword/index.test.tsx b/app/components/Views/ChoosePassword/index.test.tsx index 64f97a883672..baa05fb3a484 100644 --- a/app/components/Views/ChoosePassword/index.test.tsx +++ b/app/components/Views/ChoosePassword/index.test.tsx @@ -232,7 +232,6 @@ const mockMetrics = { trackEvent: mockTrackEvent, enable: mockEnable, identify: jest.fn().mockResolvedValue(undefined), - addTraitsToUser: jest.fn().mockResolvedValue(undefined), createEventBuilder: jest.fn(() => ({ addProperties: jest.fn().mockReturnThis(), build: jest.fn(() => ({ name: 'Analytics Preference Selected' })), @@ -710,7 +709,7 @@ describe('ChoosePassword', () => { ], }); expect(mockTrackEvent).toHaveBeenCalled(); - expect(mockMetrics.addTraitsToUser).toHaveBeenCalled(); + expect(mockMetrics.identify).toHaveBeenCalled(); }); mockNewWalletAndKeychain.mockRestore(); @@ -1029,7 +1028,7 @@ describe('ChoosePassword', () => { expect(mockNewWalletAndKeychain).toHaveBeenCalledTimes(1); expect(spyUpdateMarketingOptInStatus).toHaveBeenCalledWith(true); expect(mockTrackEvent).toHaveBeenCalled(); - expect(mockMetrics.addTraitsToUser).toHaveBeenCalled(); + expect(mockMetrics.identify).toHaveBeenCalled(); }); mockNewWalletAndKeychain.mockRestore(); @@ -1066,7 +1065,7 @@ describe('ChoosePassword', () => { expect(mockNewWalletAndKeychain).toHaveBeenCalledTimes(1); expect(spyUpdateMarketingOptInStatus).toHaveBeenCalledWith(false); expect(mockTrackEvent).toHaveBeenCalled(); - expect(mockMetrics.addTraitsToUser).toHaveBeenCalled(); + expect(mockMetrics.identify).toHaveBeenCalled(); }); mockNewWalletAndKeychain.mockRestore(); diff --git a/app/components/Views/ChoosePassword/index.tsx b/app/components/Views/ChoosePassword/index.tsx index 6014541d70f9..3ffe569a4105 100644 --- a/app/components/Views/ChoosePassword/index.tsx +++ b/app/components/Views/ChoosePassword/index.tsx @@ -350,7 +350,7 @@ const ChoosePassword = () => { .build(), ); - await metrics.addTraitsToUser({ + await metrics.identify({ ...generateDeviceAnalyticsMetaData(), ...generateUserSettingsAnalyticsMetaData(), }); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx index 0de689d05ad4..a2eba4299dd0 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx @@ -376,29 +376,29 @@ const PerpsSectionMain = forwardRef( /> )} - {showSkeleton || pendingTrending ? ( + {showSkeleton || pendingTrending || hasItems ? ( - - - ) : hasItems ? ( - - - {displayPositions.map((position) => ( - handlePositionPress(position)} - testID={`perps-position-row-${position.symbol}`} - /> - ))} - {displayOrders.map((order) => ( - - ))} - + {showSkeleton || pendingTrending ? ( + + ) : ( + + {displayPositions.map((position) => ( + handlePositionPress(position)} + testID={`perps-position-row-${position.symbol}`} + /> + ))} + {displayOrders.map((order) => ( + + ))} + + )} ) : shouldShowPillsEmptyState ? ( data={data} isLoading={isLoading} - wrapperTwClassName="bg-transparent" + wrapperTwClassName="bg-transparent py-3" renderItem={(item) => ( )} diff --git a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx index 9dd8f94728be..f41cc3ca1afc 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx @@ -30,7 +30,7 @@ const PerpsTrendingCarousel = ({ {markets.map((market) => ( diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx index 527fe15a3dcf..0acc051ce24b 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx @@ -53,7 +53,7 @@ const HomepagePredictTrendingCarousel = ({ {isLoadingMarkets ? ( CAROUSEL_SKELETON_KEYS.map((key) => ( diff --git a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx index 1f50976e7621..0149066bb1ed 100644 --- a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx @@ -239,45 +239,47 @@ const TokensSectionMain = forwardRef( } return ( - - - {showTokensError ? ( - + + - ) : isZeroBalanceAccount ? ( - - - - ) : ( - - {displayTokenKeys.length === 0 && sortedTokenKeys.length === 0 ? ( - - ) : ( - displayTokenKeys.map((tokenKey, index) => ( - - )) - )} - - )} + ) : isZeroBalanceAccount ? ( + + + + ) : ( + + {displayTokenKeys.length === 0 && sortedTokenKeys.length === 0 ? ( + + ) : ( + displayTokenKeys.map((tokenKey, index) => ( + + )) + )} + + )} + {showSkeletons diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx index 4944c7dd37be..ab8aaf09b83f 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx @@ -4,81 +4,31 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import QuickBuyBanners from './QuickBuyBanners'; jest.mock('../../../../../../../locales/i18n', () => ({ - strings: (key: string, params?: Record) => { - if (!params) return key; - return Object.entries(params).reduce( - (acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), - `${key}:{{${Object.keys(params).join(',')}}}`, - ); - }, + strings: (key: string) => key, })); -const renderBanners = ( - overrides: Partial> = {}, -) => - renderWithProvider( - , - ); - describe('QuickBuyBanners', () => { it('renders nothing when no warnings are active', () => { - const { toJSON } = renderBanners(); + const { toJSON } = renderWithProvider( + , + ); expect(toJSON()).toBeNull(); }); it('renders the hardware-wallet + Solana banner when blocked', () => { - renderBanners({ isHardwareSolanaBlocked: true }); + renderWithProvider(); expect( screen.getByText('bridge.hardware_wallet_not_supported_solana'), ).toBeOnTheScreen(); }); - it('renders the price-impact error banner with formatted percentage', () => { - renderBanners({ - isPriceImpactError: true, - formattedPriceImpact: '30.00%', - }); - + it('does not render price-impact banners (handled by modal and pill)', () => { + renderWithProvider(); expect( - screen.getByText('bridge.price_impact_error_title'), - ).toBeOnTheScreen(); + screen.queryByText('bridge.price_impact_error_title'), + ).not.toBeOnTheScreen(); expect( - screen.getByText(/bridge\.price_impact_error_description.*30\.00%/), - ).toBeOnTheScreen(); - }); - - it('renders the price-impact warning banner with formatted percentage', () => { - renderBanners({ - isPriceImpactWarning: true, - formattedPriceImpact: '7.50%', - }); - - expect( - screen.getByText('bridge.price_impact_warning_title'), - ).toBeOnTheScreen(); - expect( - screen.getByText(/bridge\.price_impact_warning_description.*7\.50%/), - ).toBeOnTheScreen(); - }); - - it('stacks multiple banners when several conditions fire', () => { - renderBanners({ - isHardwareSolanaBlocked: true, - isPriceImpactWarning: true, - formattedPriceImpact: '7.50%', - }); - - expect( - screen.getByText('bridge.hardware_wallet_not_supported_solana'), - ).toBeOnTheScreen(); - expect( - screen.getByText('bridge.price_impact_warning_title'), - ).toBeOnTheScreen(); + screen.queryByText('bridge.price_impact_warning_title'), + ).not.toBeOnTheScreen(); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx index f162056eb9b8..d245aecf6701 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx @@ -1,27 +1,19 @@ import React from 'react'; -import { Box } from '@metamask/design-system-react-native'; -import BannerAlert from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert'; -import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import { + BannerAlert, + BannerAlertSeverity, + Box, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; export interface QuickBuyBannersProps { isHardwareSolanaBlocked: boolean; - isPriceImpactError: boolean; - isPriceImpactWarning: boolean; - formattedPriceImpact: string; } const QuickBuyBanners: React.FC = ({ isHardwareSolanaBlocked, - isPriceImpactError, - isPriceImpactWarning, - formattedPriceImpact, }) => { - if ( - !isHardwareSolanaBlocked && - !isPriceImpactError && - !isPriceImpactWarning - ) { + if (!isHardwareSolanaBlocked) { return null; } @@ -29,30 +21,10 @@ const QuickBuyBanners: React.FC = ({ {isHardwareSolanaBlocked && ( )} - - {isPriceImpactError && ( - - )} - - {isPriceImpactWarning && ( - - )} ); }; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx new file mode 100644 index 000000000000..4127bec6f485 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx @@ -0,0 +1,235 @@ +import React, { useContext } from 'react'; +import { act, render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import { + QuickBuyContext, + QuickBuyProvider, + type QuickBuyContextValue, +} from './QuickBuyContext'; +import { + useQuickBuyController, + type UseQuickBuyControllerResult, +} from './hooks/useQuickBuyController'; +import type { QuickBuyFeatures, QuickBuyTarget } from './types'; + +jest.mock('./hooks/useQuickBuyController', () => ({ + useQuickBuyController: jest.fn(), +})); + +const mockTarget: QuickBuyTarget = { + tokenAddress: '0x1234567890123456789012345678901234567890', + tokenSymbol: 'PEPE', + tokenName: 'Pepe', + chain: 'base', +}; + +const featuresWithModal: QuickBuyFeatures = { + tradeModes: ['buy'], + quoteDetails: false, + selectQuote: false, + payWithSheet: true, + highPriceImpactModal: true, + fiatCryptoToggle: true, +}; + +const featuresWithoutModal: QuickBuyFeatures = { + ...featuresWithModal, + highPriceImpactModal: false, +}; + +const buildController = ( + overrides: Partial = {}, +): UseQuickBuyControllerResult => ({ + hiddenInputRef: { current: null } as never, + activeQuote: undefined, + destToken: undefined, + isSetupLoading: false, + isUnsupportedChain: false, + sourceToken: undefined, + sourceChainId: '0x1', + sourceTokenOptions: [], + selectedSourceToken: undefined, + isSourcePickerOpen: false, + setIsSourcePickerOpen: jest.fn(), + setSelectedSourceToken: jest.fn(), + currentCurrency: 'USD', + amountDisplayMode: 'fiat', + usdAmount: '', + sliderPercent: 0, + maxSpendUsd: 0, + formattedExchangeRate: undefined, + metamaskFeePercent: 0, + estimatedReceiveAmount: undefined, + sourceBalanceFiat: '$0.00', + sourceBalanceDisplay: undefined, + formattedNetworkFee: '-', + formattedSlippage: '-', + formattedMinimumReceived: '-', + formattedMinimumReceivedFiat: undefined, + formattedPriceImpact: '-', + formattedRate: undefined, + totalAmountUsd: '$0', + isQuoteLoading: false, + isSubmittingTx: false, + isTotalLoading: false, + sortedQuotes: [], + selectedQuoteRequestId: undefined, + setSelectedQuoteRequestId: jest.fn(), + quotesLastFetchedAt: null, + refreshCount: 0, + quoteRefreshRateMs: 30000, + maxRefreshCount: 5, + refetchQuotes: jest.fn(), + isHardwareSolanaBlocked: false, + priceImpactViewData: { + textColor: TextColor.TextAlternative, + icon: undefined, + title: 'bridge.price_impact_info_title', + description: 'bridge.price_impact_info_description', + }, + isPriceImpactError: false, + buttonError: null, + hasValidAmount: false, + isConfirmDisabled: false, + confirmButtonState: 'idle', + getButtonLabel: () => 'Buy', + handleClose: jest.fn(), + handleSliderChange: jest.fn(), + handleAmountAreaPress: jest.fn(), + handleAmountChange: jest.fn(), + handleToggleAmountDisplay: jest.fn(), + handleSelectSourceToken: jest.fn(), + handleConfirm: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +/** + * Renders a QuickBuyProvider and exposes the context value via a ref so tests + * can inspect or invoke context methods directly. + */ +function renderProvider( + features: QuickBuyFeatures, + setActiveScreen: jest.Mock = jest.fn(), +) { + const contextRef: { current: QuickBuyContextValue } = { + current: null as never, + }; + + const Consumer = () => { + contextRef.current = useContext(QuickBuyContext) as never; + return null; + }; + + render( + + + , + ); + + return contextRef; +} + +describe('QuickBuyProvider — handleBuy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls handleConfirm directly when there is no price impact error', async () => { + const handleConfirm = jest.fn().mockResolvedValue(undefined); + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isPriceImpactError: false, handleConfirm }), + ); + + const setActiveScreen = jest.fn(); + const ctx = renderProvider(featuresWithModal, setActiveScreen); + + await act(async () => { + await ctx.current.handleBuy(); + }); + + expect(handleConfirm).toHaveBeenCalledTimes(1); + expect(setActiveScreen).not.toHaveBeenCalled(); + }); + + it('navigates to priceImpactConfirm when isPriceImpactError=true and highPriceImpactModal=true', async () => { + const handleConfirm = jest.fn(); + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isPriceImpactError: true, handleConfirm }), + ); + + const setActiveScreen = jest.fn(); + const ctx = renderProvider(featuresWithModal, setActiveScreen); + + await act(async () => { + await ctx.current.handleBuy(); + }); + + expect(setActiveScreen).toHaveBeenCalledWith('priceImpactConfirm'); + expect(handleConfirm).not.toHaveBeenCalled(); + }); + + it('does not navigate and does not confirm when isPriceImpactError=true and highPriceImpactModal=false', async () => { + const handleConfirm = jest.fn(); + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isPriceImpactError: true, handleConfirm }), + ); + + const setActiveScreen = jest.fn(); + const ctx = renderProvider(featuresWithoutModal, setActiveScreen); + + await act(async () => { + await ctx.current.handleBuy(); + }); + + expect(setActiveScreen).not.toHaveBeenCalled(); + expect(handleConfirm).not.toHaveBeenCalled(); + }); +}); + +describe('QuickBuyProvider — isConfirmDisabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is false when controller says false and there is no price impact error', () => { + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isConfirmDisabled: false, isPriceImpactError: false }), + ); + + const ctx = renderProvider(featuresWithModal); + expect(ctx.current.isConfirmDisabled).toBe(false); + }); + + it('is true when isPriceImpactError=true and highPriceImpactModal=false', () => { + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isConfirmDisabled: false, isPriceImpactError: true }), + ); + + const ctx = renderProvider(featuresWithoutModal); + expect(ctx.current.isConfirmDisabled).toBe(true); + }); + + it('is false (modal handles it) when isPriceImpactError=true and highPriceImpactModal=true', () => { + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isConfirmDisabled: false, isPriceImpactError: true }), + ); + + const ctx = renderProvider(featuresWithModal); + expect(ctx.current.isConfirmDisabled).toBe(false); + }); + + it('respects the controller isConfirmDisabled flag independently', () => { + (useQuickBuyController as jest.Mock).mockReturnValue( + buildController({ isConfirmDisabled: true, isPriceImpactError: false }), + ); + + const ctx = renderProvider(featuresWithModal); + expect(ctx.current.isConfirmDisabled).toBe(true); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx index bdbeb1a4da66..bd4c9bc51890 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext } from 'react'; +import React, { createContext, useCallback } from 'react'; import { useQuickBuyController, type UseQuickBuyControllerResult, @@ -17,6 +17,13 @@ export interface QuickBuyContextValue extends UseQuickBuyControllerResult { onClose: () => void; activeScreen: QuickBuyScreen; setActiveScreen: React.Dispatch>; + /** + * Called by the Buy button. When the high-price-impact modal feature is + * enabled and the active quote exceeds the error threshold, this navigates + * to the `priceImpactConfirm` screen instead of submitting immediately. + * Otherwise it delegates directly to `handleConfirm`. + */ + handleBuy: () => Promise; } export const QuickBuyContext = createContext(null); @@ -41,15 +48,40 @@ export const QuickBuyProvider: React.FC = ({ children, }) => { const controller = useQuickBuyController(target, onClose, analyticsContext); + const { isPriceImpactError, handleConfirm } = controller; + + const handleBuy = useCallback(async () => { + if (isPriceImpactError) { + // We guard here to ensure no high-impact trade ever silently proceeds. + if (features.highPriceImpactModal) { + setActiveScreen('priceImpactConfirm'); + } + return; + } + await handleConfirm(); + }, [ + features.highPriceImpactModal, + isPriceImpactError, + handleConfirm, + setActiveScreen, + ]); + + // When the modal feature is off the button must be disabled for any + // high-impact quote, since there is no other safeguard in place. + const isConfirmDisabled = + controller.isConfirmDisabled || + (isPriceImpactError && !features.highPriceImpactModal); const value: QuickBuyContextValue = { ...controller, + isConfirmDisabled, target, features, analyticsContext, onClose, activeScreen, setActiveScreen, + handleBuy, }; return ( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.test.tsx index 6db59dfa942e..d35a55a75e73 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; import type { BridgeToken } from '../../../../../UI/Bridge/types'; import QuickBuyPayWithScreen from './QuickBuyPayWithScreen'; import { useQuickBuyContext } from './useQuickBuyContext'; @@ -169,4 +170,13 @@ describe('QuickBuyPayWithScreen', () => { expect(screen.getByTestId(getRowTestId(usdcToken))).toBeOnTheScreen(); expect(screen.getByTestId(getRowTestId(usdtToken))).toBeOnTheScreen(); }); + + it('fills the available height so the token list scrolls within a fixed area', () => { + render(); + + const scrollView = screen.getByTestId('quick-buy-pay-with-scroll'); + const flattenedStyle = StyleSheet.flatten(scrollView.props.style); + + expect(flattenedStyle.flexGrow).toBe(1); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.tsx index fec0721f8f59..e748de67ab3b 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPayWithScreen.tsx @@ -4,10 +4,12 @@ import { BottomSheetHeader, Box, BoxAlignItems, + BoxJustifyContent, Text, TextColor, TextVariant, } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { strings } from '../../../../../../../locales/i18n'; import QuickBuyPayWithChainFilter from './components/QuickBuyPayWithChainFilter'; import QuickBuyPayWithRow from './components/QuickBuyPayWithRow'; @@ -16,6 +18,7 @@ import { getTokenKey } from './sourceTokenCandidates'; import { useQuickBuyContext } from './useQuickBuyContext'; const QuickBuyPayWithScreen: React.FC = () => { + const tw = useTailwind(); const { sourceTokenOptions, selectedSourceToken, @@ -84,7 +87,11 @@ const QuickBuyPayWithScreen: React.FC = () => { {sourceTokenOptions.length === 0 ? ( - + {strings('social_leaderboard.quick_buy.pay_with_no_tokens')} @@ -100,7 +107,11 @@ const QuickBuyPayWithScreen: React.FC = () => { ) : null} {filteredTokens.length === 0 ? ( - + { ) : ( ({ + useQuickBuyContext: jest.fn(), +})); + +jest.mock( + '../../../../../UI/Bridge/components/PriceImpactModal/PriceImpactHeader', + () => ({ + PriceImpactHeader: jest.fn( + ({ onClose, content }: { onClose: () => void; content: string }) => { + const { View, TouchableOpacity, Text } = + jest.requireActual('react-native'); + return ( + + {content} + + Close + + + ); + }, + ), + }), +); + +jest.mock( + '../../../../../UI/Bridge/components/PriceImpactModal/PriceImpactFooter', + () => ({ + PriceImpactFooter: jest.fn( + ({ + onConfirm, + onCancel, + loading, + }: { + type: string; + onConfirm: () => void; + onCancel: () => Promise; + loading: boolean; + }) => { + const { View, TouchableOpacity, Text } = + jest.requireActual('react-native'); + return ( + + + Cancel + + + Proceed + + + ); + }, + ), + }), +); + +jest.mock('../../../../../UI/Bridge/hooks/usePriceImpactFiat', () => ({ + usePriceImpactFiat: jest.fn(), +})); + +import { usePriceImpactFiat } from '../../../../../UI/Bridge/hooks/usePriceImpactFiat'; + +const buildContext = (overrides = {}) => ({ + activeQuote: undefined as unknown, + formattedPriceImpact: '25.00%', + setActiveScreen: jest.fn(), + handleConfirm: jest.fn(), + isSubmittingTx: false, + ...overrides, +}); + +describe('QuickBuyPriceImpactConfirmScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useQuickBuyContext as jest.Mock).mockReturnValue(buildContext()); + (usePriceImpactFiat as jest.Mock).mockReturnValue(undefined); + }); + + it('renders the header with the error title', () => { + render(); + expect(screen.getByTestId('price-impact-header-content')).toHaveTextContent( + 'bridge.price_impact_error_title', + ); + }); + + it('renders the description text with formatted price impact', () => { + render(); + expect(screen.getByTestId('price-impact-description')).toBeOnTheScreen(); + }); + + it('shows the fiat loss banner when fiat value is available', () => { + (usePriceImpactFiat as jest.Mock).mockReturnValue('$19,997.62'); + render(); + expect(screen.getByTestId('price-impact-fiat-banner')).toBeOnTheScreen(); + expect(screen.getByTestId('price-impact-fiat-text')).toBeOnTheScreen(); + }); + + it('hides the fiat loss banner when fiat value is unavailable', () => { + (usePriceImpactFiat as jest.Mock).mockReturnValue(undefined); + render(); + expect( + screen.queryByTestId('price-impact-fiat-banner'), + ).not.toBeOnTheScreen(); + }); + + it('calls setActiveScreen("amount") when the close/cancel button is pressed', () => { + const setActiveScreen = jest.fn(); + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ setActiveScreen }), + ); + render(); + fireEvent.press(screen.getByTestId('price-impact-header-close')); + expect(setActiveScreen).toHaveBeenCalledWith('amount'); + }); + + it('calls setActiveScreen("amount") when the footer Cancel button is pressed', () => { + const setActiveScreen = jest.fn(); + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ setActiveScreen }), + ); + render(); + fireEvent.press(screen.getByTestId('footer-cancel')); + expect(setActiveScreen).toHaveBeenCalledWith('amount'); + }); + + it('calls handleConfirm when the footer Proceed button is pressed', async () => { + const handleConfirm = jest.fn().mockResolvedValue(undefined); + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ handleConfirm }), + ); + render(); + fireEvent.press(screen.getByTestId('footer-proceed')); + await waitFor(() => expect(handleConfirm).toHaveBeenCalledTimes(1)); + }); + + it('passes loading=true to the footer while handleConfirm is in-flight', async () => { + let resolveConfirm!: () => void; + const handleConfirm = jest.fn( + () => + new Promise((res) => { + resolveConfirm = res; + }), + ); + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ handleConfirm }), + ); + + const { PriceImpactFooter } = jest.requireMock( + '../../../../../UI/Bridge/components/PriceImpactModal/PriceImpactFooter', + ) as { PriceImpactFooter: jest.Mock }; + + render(); + fireEvent.press(screen.getByTestId('footer-proceed')); + + await waitFor(() => { + const lastCall = PriceImpactFooter.mock.calls.at(-1)?.[0]; + expect(lastCall?.loading).toBe(true); + }); + + await act(async () => { + resolveConfirm(); + }); + + await waitFor(() => { + const lastCall = PriceImpactFooter.mock.calls.at(-1)?.[0]; + expect(lastCall?.loading).toBe(false); + }); + }); + + it('passes loading=true to the footer when isSubmittingTx is true', () => { + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ isSubmittingTx: true }), + ); + + const { PriceImpactFooter } = jest.requireMock( + '../../../../../UI/Bridge/components/PriceImpactModal/PriceImpactFooter', + ) as { PriceImpactFooter: jest.Mock }; + + render(); + + const lastCall = PriceImpactFooter.mock.calls.at(-1)?.[0]; + expect(lastCall?.loading).toBe(true); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPriceImpactConfirmScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPriceImpactConfirmScreen.tsx new file mode 100644 index 000000000000..81a207745281 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyPriceImpactConfirmScreen.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import { PriceImpactHeader } from '../../../../../UI/Bridge/components/PriceImpactModal/PriceImpactHeader'; +import { PriceImpactFooter } from '../../../../../UI/Bridge/components/PriceImpactModal/PriceImpactFooter'; +import { PriceImpactModalType } from '../../../../../UI/Bridge/components/PriceImpactModal/constants'; +import { usePriceImpactFiat } from '../../../../../UI/Bridge/hooks/usePriceImpactFiat'; +import { useQuickBuyContext } from './useQuickBuyContext'; + +// This screen is only ever mounted at the error case for high-price-impact swaps +const QuickBuyPriceImpactConfirmScreen: React.FC = () => { + const { + activeQuote, + formattedPriceImpact, + setActiveScreen, + handleConfirm, + isSubmittingTx, + } = useQuickBuyContext(); + + const [loading, setLoading] = useState(false); + + const formattedPriceImpactFiat = usePriceImpactFiat(activeQuote); + + const handleClose = useCallback(() => { + setActiveScreen('amount'); + }, [setActiveScreen]); + + const handleProceed = useCallback(async () => { + setLoading(true); + try { + await handleConfirm(); + } finally { + setLoading(false); + } + }, [handleConfirm]); + + return ( + <> + + + + + {strings('bridge.price_impact_error_description', { + priceImpact: formattedPriceImpact ?? '0%', + })} + + + {formattedPriceImpactFiat && ( + + + + {strings('bridge.price_impact_fiat_alert', { + priceImpactFiat: formattedPriceImpactFiat, + })} + + + )} + + + + + ); +}; + +export default QuickBuyPriceImpactConfirmScreen; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.test.tsx index edb91dc9c57c..9ca8ece26378 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.test.tsx @@ -92,6 +92,8 @@ const buildContext = (overrides = {}) => ({ formattedMinimumReceived: '0.99 ETH', formattedMinimumReceivedFiat: '$3,200', formattedRate: '1 USDC = 0.0003 ETH', + formattedPriceImpact: '-', + isPriceImpactError: false, quotesLastFetchedAt: Date.now(), quoteRefreshRateMs: 30000, onClose: jest.fn(), @@ -170,4 +172,28 @@ describe('QuickBuyQuoteDetailsScreen', () => { screen.queryByTestId('quick-buy-get-new-quote'), ).not.toBeOnTheScreen(); }); + + it('does not render the price impact row when isPriceImpactError is false', () => { + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ isPriceImpactError: false }), + ); + render(); + expect( + screen.queryByText('bridge.price_impact_info_title'), + ).not.toBeOnTheScreen(); + }); + + it('renders the price impact row with formatted value when isPriceImpactError is true', () => { + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ + isPriceImpactError: true, + formattedPriceImpact: '25.00%', + }), + ); + render(); + expect( + screen.getByText('bridge.price_impact_info_title'), + ).toBeOnTheScreen(); + expect(screen.getByText('25.00%')).toBeOnTheScreen(); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.tsx index c4b969910538..62529f9fab03 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyQuoteDetailsScreen.tsx @@ -3,7 +3,10 @@ import { Box, BoxAlignItems, BoxFlexDirection, + Icon, + IconColor, IconName, + IconSize, Text, TextColor, TextVariant, @@ -36,6 +39,8 @@ const QuickBuyQuoteDetailsScreen: React.FC = () => { formattedMinimumReceived, formattedMinimumReceivedFiat, formattedRate, + formattedPriceImpact, + isPriceImpactError, quotesLastFetchedAt, quoteRefreshRateMs, onClose, @@ -125,6 +130,33 @@ const QuickBuyQuoteDetailsScreen: React.FC = () => { } /> + {isPriceImpactError && ( + + + + {formattedPriceImpact} + + + } + /> + )} + { }; }); +jest.mock('./QuickBuyPriceImpactConfirmScreen', () => { + const ReactMock = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement( + Text, + { testID: 'mock-price-impact-confirm' }, + 'price-impact-confirm', + ), + }; +}); + jest.mock('./QuickBuyBottomSheetSkeleton', () => { const ReactMock = jest.requireActual('react'); const { Text } = jest.requireActual('react-native'); @@ -234,6 +249,36 @@ describe('QuickBuyRoot', () => { expect(screen.getByTestId('mock-action-footer')).toBeOnTheScreen(); }); + it('renders the price impact confirm screen via the children override', () => { + const MockPriceImpactConfirmScreen = () => { + const React2 = jest.requireActual('react'); + const { Text: RNText } = jest.requireActual('react-native'); + return React2.createElement( + RNText, + { testID: 'mock-price-impact-confirm' }, + 'price-impact-confirm', + ); + }; + + renderWithProvider( + + + , + ); + + act(() => { + storedOnOpenCallback?.(); + }); + + // children override is rendered regardless of activeScreen value + expect(screen.getByTestId('mock-price-impact-confirm')).toBeOnTheScreen(); + }); + it('shows unsupported chain message without amount flow', () => { (useQuickBuyController as jest.Mock).mockReturnValue( buildHookResult({ isUnsupportedChain: true }), @@ -257,6 +302,85 @@ describe('QuickBuyRoot', () => { ).toBeOnTheScreen(); expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen(); }); + + it('renders nothing when isVisible is false', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders nothing when target is null', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('locks the content container height after the first layout', () => { + renderWithProvider( + , + ); + act(() => { + storedOnOpenCallback?.(); + }); + + const container = screen.getByTestId('quick-buy-content-container'); + act(() => { + fireEvent(container, 'layout', { + nativeEvent: { layout: { height: 480 } }, + }); + }); + + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + height: 480, + }); + }); + + it('keeps the locked height when a later layout reports a different height', () => { + renderWithProvider( + , + ); + act(() => { + storedOnOpenCallback?.(); + }); + + const container = screen.getByTestId('quick-buy-content-container'); + act(() => { + fireEvent(container, 'layout', { + nativeEvent: { layout: { height: 480 } }, + }); + }); + act(() => { + fireEvent(container, 'layout', { + nativeEvent: { layout: { height: 300 } }, + }); + }); + + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + height: 480, + }); + }); }); describe('useQuickBuyContext guard', () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx index 6975feea18e4..0cc707d95754 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx @@ -1,15 +1,18 @@ import { BottomSheet, type BottomSheetRef, + Box, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler'; import Animated from 'react-native-reanimated'; import { useSelector } from 'react-redux'; import { selectIsSubmittingTx } from '../../../../../../core/redux/slices/bridge'; import QuickBuyAmountScreen from './QuickBuyAmountScreen'; import QuickBuyPayWithScreen from './QuickBuyPayWithScreen'; +import QuickBuyPriceImpactConfirmScreen from './QuickBuyPriceImpactConfirmScreen'; import QuickBuyQuoteDetailsScreen from './QuickBuyQuoteDetailsScreen'; import QuickBuySelectQuoteScreen from './QuickBuySelectQuoteScreen'; import { QuickBuyProvider } from './QuickBuyContext'; @@ -45,6 +48,8 @@ function renderActiveScreen( return ; case 'selectQuote': return ; + case 'priceImpactConfirm': + return ; case 'amount': default: return ; @@ -70,6 +75,7 @@ const QuickBuyRootInner: React.FC = ({ const bottomSheetRef = useRef(null); const [isContentReady, setIsContentReady] = useState(false); const [activeScreen, setActiveScreen] = useState('amount'); + const [lockedHeight, setLockedHeight] = useState(null); const isSubmittingTx = useSelector(selectIsSubmittingTx); const surfaceClass = useElevatedSurface(); @@ -79,6 +85,19 @@ const QuickBuyRootInner: React.FC = ({ }); }, []); + const handleContentLayout = useCallback( + (event: LayoutChangeEvent) => { + if (lockedHeight !== null) { + return; + } + const { height } = event.nativeEvent.layout; + if (height > 0) { + setLockedHeight(height); + } + }, + [lockedHeight], + ); + return ( = ({ activeScreen={activeScreen} setActiveScreen={setActiveScreen} > - {renderActiveScreen(activeScreen, children)} + + {renderActiveScreen(activeScreen, children)} + ) : ( { getButtonLabel, hasValidAmount, isConfirmDisabled, - handleConfirm, + handleBuy, metamaskFeePercent, isHardwareSolanaBlocked, - isPriceImpactError, - priceImpactViewData, - formattedPriceImpact, sourceToken, sourceChainId, sourceBalanceFiat, @@ -48,9 +45,6 @@ const QuickBuyActionFooter: React.FC = () => { setActiveScreen, } = useQuickBuyContext(); - const isPriceImpactWarning = - !isPriceImpactError && !!priceImpactViewData.icon; - const networkImage = sourceChainId ? getNetworkImageSource({ chainId: sourceChainId }) : undefined; @@ -128,19 +122,14 @@ const QuickBuyActionFooter: React.FC = () => { - + diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx index 584faac15433..bdcc994934db 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx @@ -2,13 +2,17 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react-native'; import QuickBuyRateTag from './QuickBuyRateTag'; +jest.mock('../../../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + describe('QuickBuyRateTag', () => { - it('renders nothing when label is undefined', () => { + it('renders nothing when label is undefined and isHighPriceImpact is false', () => { render(); expect(screen.queryByTestId('quick-buy-rate-tag')).not.toBeOnTheScreen(); }); - it('renders nothing when label is empty', () => { + it('renders nothing when label is empty and isHighPriceImpact is false', () => { render(); expect(screen.queryByTestId('quick-buy-rate-tag')).not.toBeOnTheScreen(); }); @@ -35,4 +39,43 @@ describe('QuickBuyRateTag', () => { fireEvent.press(screen.getByTestId('quick-buy-rate-tag-pressable')); expect(onPress).toHaveBeenCalledTimes(1); }); + + describe('isHighPriceImpact variant', () => { + it('renders even when label is undefined', () => { + render( + , + ); + expect(screen.getByTestId('quick-buy-rate-tag')).toBeOnTheScreen(); + }); + + it('shows the bridge.price_impact_warning_title i18n key as text', () => { + render( + , + ); + expect( + screen.getByText('bridge.price_impact_warning_title'), + ).toBeOnTheScreen(); + }); + + it('still calls onPress when the pill is tapped', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('quick-buy-rate-tag-pressable')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx index a5dcfaa2008a..3553ee12b726 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx @@ -12,17 +12,32 @@ import { IconName, IconSize, } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../../locales/i18n'; interface QuickBuyRateTagProps { label: string | undefined; onPress?: () => void; + isHighPriceImpact?: boolean; } const QuickBuyRateTag: React.FC = ({ label, onPress, + isHighPriceImpact = false, }) => { - if (!label) return null; + if (!label && !isHighPriceImpact) return null; + + const displayLabel = isHighPriceImpact + ? strings('bridge.price_impact_warning_title') + : label; + + const textColor = isHighPriceImpact + ? TextColor.ErrorDefault + : TextColor.TextAlternative; + + const iconColor = isHighPriceImpact + ? IconColor.ErrorDefault + : IconColor.IconAlternative; const content = ( = ({ alignItems={BoxAlignItems.Center} gap={1} > - - {label} + + {displayLabel} - + ); return ( {onPress ? ( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx index b3acba28f9e8..fb53a0c4a710 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx @@ -13,8 +13,12 @@ import QuickBuyRateTag from './QuickBuyRateTag'; import { useQuickBuyContext } from '../useQuickBuyContext'; const QuickBuyToolbar: React.FC = () => { - const { formattedRate, formattedExchangeRate, setActiveScreen } = - useQuickBuyContext(); + const { + formattedRate, + formattedExchangeRate, + setActiveScreen, + isPriceImpactError, + } = useQuickBuyContext(); // Prefer the quote-derived rate (available once a quote is fetched), // fall back to the price-metadata rate for the pre-quote state. @@ -39,6 +43,7 @@ const QuickBuyToolbar: React.FC = () => { setActiveScreen('quoteDetails')} + isHighPriceImpact={isPriceImpactError} /> ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts index 965180819b59..193a625a893f 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts @@ -6,6 +6,6 @@ export const TOP_TRADERS_QUICK_BUY_FEATURES: QuickBuyFeatures = { quoteDetails: false, selectQuote: false, payWithSheet: true, - highPriceImpactModal: false, + highPriceImpactModal: true, fiatCryptoToggle: true, }; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index e450ea5e5f3b..f2b28904e14c 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -764,7 +764,6 @@ export function useQuickBuyController( isSubmittingTx || hasError || isHardwareSolanaBlocked || - isPriceImpactError || !walletAddress; const isTotalLoading = diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts index 35fcbc247429..66a6640ad5d5 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts @@ -19,7 +19,8 @@ export type QuickBuyScreen = | 'amount' | 'quoteDetails' | 'selectQuote' - | 'payWith'; + | 'payWith' + | 'priceImpactConfirm'; /** Feature flags for optional flow pieces (enabled per consumer). */ export interface QuickBuyFeatures { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index ac26a6f0d280..8f67a10fdbce 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -909,12 +909,12 @@ describe('useQuickBuyController', () => { isHardwareAccount.mockReturnValue(false); }); - it('is disabled when the price impact exceeds the error threshold', () => { - (useQuickBuyQuotes as jest.Mock).mockImplementation(() => ({ - activeQuote: { - quote: { priceData: { priceImpact: '0.30' } }, - }, - destTokenAmount: '1', + it('sets isPriceImpactError when price impact exceeds error threshold, but does NOT disable the button (intercept handled by context)', () => { + // Use the same settle cycle as the hardware-Solana test so that + // settledSourceTokenAmountRef is properly updated and isPendingQuoteRefresh = false. + const quoteState: UseQuickBuyQuotesResult = { + activeQuote: undefined, + destTokenAmount: undefined, isQuoteLoading: false, isNoQuotesAvailable: false, quoteFetchError: null, @@ -926,13 +926,14 @@ describe('useQuickBuyController', () => { quoteRefreshRateMs: 30000, maxRefreshCount: 5, refetchQuotes: jest.fn(), - })); + }; + (useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState); const props = { target: positionToQuickBuyTarget(createPosition()), onClose: jest.fn(), }; - const { result } = renderHook( + const { result, rerender } = renderHook( ({ target, onClose }) => useQuickBuyController(target, onClose), { initialProps: props }, ); @@ -941,8 +942,22 @@ describe('useQuickBuyController', () => { result.current.handleAmountChange('20'); }); + // Simulate quote loading cycle so settledSourceTokenAmountRef is settled. + quoteState.isQuoteLoading = true; + rerender(props); + quoteState.isQuoteLoading = false; + // Inject a high-price-impact active quote. + quoteState.activeQuote = { + ...createActiveQuote(), + quote: { priceData: { priceImpact: '0.30' } }, + } as never; + rerender(props); + rerender(props); + expect(result.current.isPriceImpactError).toBe(true); - expect(result.current.isConfirmDisabled).toBe(true); + // The Buy button is ENABLED at error tier — the intercept lives in + // QuickBuyContext.handleBuy which routes to priceImpactConfirm instead. + expect(result.current.isConfirmDisabled).toBe(false); }); }); diff --git a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx index 70aa1145ab32..08e39ab63044 100644 --- a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx +++ b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx @@ -277,6 +277,8 @@ function TradeWalletActions() { {visible && ( diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts index 0ff8db5e422a..1a7aec078c51 100644 --- a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts @@ -64,12 +64,9 @@ describe('navigateToPerpsMarketList', () => { navigate, } as unknown as NavigationProp; - navigateToPerpsMarketList( - navigation, - 'all', - 'priceChange', - PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, - ); + navigateToPerpsMarketList(navigation, 'all', 'priceChange', { + source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, + }); expect(navigate).toHaveBeenCalledWith( Routes.PERPS.ROOT, @@ -88,12 +85,9 @@ describe('navigateToPerpsMarketList', () => { navigate, } as unknown as NavigationProp; - navigateToPerpsMarketList( - navigation, - 'all', - undefined, - PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, - ); + navigateToPerpsMarketList(navigation, 'all', undefined, { + source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, + }); expect(navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, @@ -103,4 +97,24 @@ describe('navigateToPerpsMarketList', () => { }, }); }); + + it('passes a custom sort direction', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation, 'all', 'priceChange', { + sortDirection: 'asc', + }); + + expect(navigate).toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.objectContaining({ + params: expect.objectContaining({ + defaultSortDirection: 'asc', + }), + }), + ); + }); }); diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts index ebfdd182dc8b..9b9d3e60ad9a 100644 --- a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts @@ -1,6 +1,7 @@ import { NavigationProp } from '@react-navigation/native'; import { PERPS_EVENT_VALUE, + type SortDirection, type SortOptionId, } from '@metamask/perps-controller'; import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; @@ -9,12 +10,20 @@ import Routes from '../../../../../constants/navigation/Routes'; type PerpsNavigationSource = (typeof PERPS_EVENT_VALUE.SOURCE)[keyof typeof PERPS_EVENT_VALUE.SOURCE]; +interface NavigateToPerpsMarketListOptions { + source?: PerpsNavigationSource; + sortDirection?: SortDirection; +} + /** Navigate to the perps market list, optionally pre-filtering by market type and pre-sorting by a sort option. */ export const navigateToPerpsMarketList = ( navigation: NavigationProp, filter: string = 'all', sortOptionId?: SortOptionId, - source: PerpsNavigationSource = PERPS_EVENT_VALUE.SOURCE.EXPLORE, + { + source = PERPS_EVENT_VALUE.SOURCE.EXPLORE, + sortDirection, + }: NavigateToPerpsMarketListOptions = {}, ): void => { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, @@ -22,6 +31,9 @@ export const navigateToPerpsMarketList = ( defaultMarketTypeFilter: filter, source, ...(sortOptionId !== undefined && { defaultSortOptionId: sortOptionId }), + ...(sortDirection !== undefined && { + defaultSortDirection: sortDirection, + }), }, }); }; diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts index 8e2408a3202a..558fb2bb06bb 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts @@ -10,7 +10,11 @@ import { renderHook } from '@testing-library/react-hooks'; import type { PerpsMarketData } from '@metamask/perps-controller'; -import { usePerpsFeed, PERPS_VARIANT_SORT_OPTION } from './usePerpsFeed'; +import { + filterAndSortByPriceChangeDirection, + usePerpsFeed, + PERPS_VARIANT_SORT_OPTION, +} from './usePerpsFeed'; // --------------------------------------------------------------------------- // Core dependency mocks @@ -140,6 +144,36 @@ describe('usePerpsFeed', () => { }); }); + describe('price-change mover filtering', () => { + it('filters gainers to positive price changes sorted descending', () => { + const markets = [ + makeMarket('LOSER', '-3', 100), + makeMarket('HIGH_GAINER', '5', 50), + makeMarket('LOW_GAINER', '1', 75), + ]; + + expect( + filterAndSortByPriceChangeDirection(markets, 'gainers').map( + (market) => market.symbol, + ), + ).toEqual(['HIGH_GAINER', 'LOW_GAINER']); + }); + + it('filters losers to negative price changes sorted ascending', () => { + const markets = [ + makeMarket('GAINER', '3', 100), + makeMarket('SMALL_LOSER', '-1', 50), + makeMarket('BIG_LOSER', '-7', 75), + ]; + + expect( + filterAndSortByPriceChangeDirection(markets, 'losers').map( + (market) => market.symbol, + ), + ).toEqual(['BIG_LOSER', 'SMALL_LOSER']); + }); + }); + describe('query path', () => { it('preserves Fuse.js relevance order for non-macro variants', () => { const markets = [ diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts index e5fc7caa5d6b..d2abc6458670 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { filterMarketsByQuery, type PerpsMarketData, + type SortDirection, type SortOptionId, } from '@metamask/perps-controller'; import { usePerpsMarkets } from '../../../../UI/Perps/hooks'; @@ -22,6 +23,7 @@ export type { PerpsFeedItem } from '../../../../UI/Perps/types/perpsFeedTypes'; const EMPTY_WATCHLIST_SYMBOLS: string[] = []; export type PerpsVariant = 'all' | 'crypto' | 'rwa' | 'macro'; +export type PerpsPriceChangeDirection = 'gainers' | 'losers'; interface UsePerpsFeedOptions { /** @default 'all' */ @@ -57,6 +59,14 @@ export const PERPS_VARIANT_SORT_OPTION: Record = { macro: 'volume', }; +export const PERPS_PRICE_CHANGE_SORT_DIRECTION: Record< + PerpsPriceChangeDirection, + SortDirection +> = { + gainers: 'desc', + losers: 'asc', +}; + const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => { const av = (a as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0; const bv = (b as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0; @@ -66,6 +76,9 @@ const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => { const sortByChange24hDesc = (a: PerpsMarketData, b: PerpsMarketData) => (parseFloat(b.change24hPercent) || 0) - (parseFloat(a.change24hPercent) || 0); +const sortByChange24hAsc = (a: PerpsMarketData, b: PerpsMarketData) => + (parseFloat(a.change24hPercent) || 0) - (parseFloat(b.change24hPercent) || 0); + /** Maps each SortOptionId to the comparator used inside the feed. */ const SORT_FNS: Record< SortOptionId, @@ -101,6 +114,22 @@ const filterByVariant = ( } }; +export const filterAndSortByPriceChangeDirection = ( + markets: PerpsMarketData[], + priceChangeDirection: PerpsPriceChangeDirection, +) => { + switch (priceChangeDirection) { + case 'gainers': + return markets + .filter((market) => parseFloat(market.change24hPercent) > 0) + .sort(sortByChange24hDesc); + case 'losers': + return markets + .filter((market) => parseFloat(market.change24hPercent) < 0) + .sort(sortByChange24hAsc); + } +}; + /** * Perps markets feed. Returns enriched items (market + optional sparkline + * watchlist flag) so consumers don't have to stitch data themselves. diff --git a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts index 14985935efd1..af4f33d431e8 100644 --- a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts +++ b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts @@ -1,13 +1,15 @@ import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; import Routes from '../../../../../constants/navigation/Routes'; import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames'; +import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../UI/Predict/constants/worldCupTabs'; import { navigateToExplorePredictionsList, + navigateToExploreWorldCupPredictions, navigateToPredictionsList, } from './predictionsNavigation'; describe('navigateToPredictionsList', () => { - it('navigates with an explicit entryPoint and no tab for trending variant', () => { + it('navigates with an explicit entryPoint and trending tab for trending variant', () => { const navigate = jest.fn(); const navigation = { navigate } as unknown as AppNavigationProp; @@ -19,7 +21,10 @@ describe('navigateToPredictionsList', () => { expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, - params: { entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE }, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + tab: 'trending', + }, }); }); @@ -73,7 +78,10 @@ describe('navigateToPredictionsList', () => { expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, - params: { entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED }, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, + tab: 'trending', + }, }); }); @@ -85,7 +93,27 @@ describe('navigateToPredictionsList', () => { expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, - params: { entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE }, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + tab: 'trending', + }, + }); + }); +}); + +describe('navigateToExploreWorldCupPredictions', () => { + it('navigates to the dedicated World Cup screen', () => { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as AppNavigationProp; + + navigateToExploreWorldCupPredictions(navigation); + + expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.WORLD_CUP, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + initialTab: PREDICT_WORLD_CUP_TAB_KEYS.ALL, + }, }); }); }); diff --git a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts index 0d46f15377d1..19c8fa933c56 100644 --- a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts +++ b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts @@ -1,11 +1,12 @@ import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; import Routes from '../../../../../constants/navigation/Routes'; import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames'; +import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../UI/Predict/constants/worldCupTabs'; import type { PredictEntryPoint } from '../../../../UI/Predict/types/navigation'; import type { PredictionsVariant } from './usePredictionsFeed'; -const VARIANT_TO_TAB: Record = { - trending: undefined, +const VARIANT_TO_TAB: Record = { + trending: 'trending', sports: 'sports', crypto: 'crypto', politics: 'politics', @@ -22,7 +23,7 @@ export const navigateToPredictionsList = ( screen: Routes.PREDICT.MARKET_LIST, params: { entryPoint, - ...(tab && { tab }), + tab, }, }); }; @@ -38,3 +39,16 @@ export const navigateToExplorePredictionsList = ( PredictEventValues.ENTRY_POINT.EXPLORE, ); }; + +/** Navigate from Explore World Cup prediction sections to the dedicated Predict World Cup screen. */ +export const navigateToExploreWorldCupPredictions = ( + navigation: AppNavigationProp, +): void => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.WORLD_CUP, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + initialTab: PREDICT_WORLD_CUP_TAB_KEYS.ALL, + }, + }); +}; diff --git a/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts b/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts index 580996410015..7cbc88c5d99c 100644 --- a/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts +++ b/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts @@ -13,6 +13,7 @@ interface UsePredictionsFeedOptions { variant?: PredictionsVariant; query?: string; refresh?: RefreshConfig; + enabled?: boolean; /** * Number of markets to fetch per page. Applies to both the no-query trending * fetch and the search fetch. Defaults to 6 for home-tab previews. @@ -36,23 +37,24 @@ export const usePredictionsFeed = ({ variant = 'trending', query, refresh, + enabled = true, pageSize = 6, }: UsePredictionsFeedOptions = {}): UsePredictionsFeedResult => { const hasQuery = Boolean(query?.trim()); const feed = usePredictMarketData({ category: variant, pageSize, - enabled: !hasQuery, + enabled: enabled && !hasQuery, }); const search = usePredictSearchMarketData({ q: query ?? '', pageSize, - enabled: hasQuery, + enabled: enabled && hasQuery, }); const activeResult = hasQuery ? search : feed; - useFeedRefresh(refresh, activeResult.refetch); + useFeedRefresh(enabled ? refresh : undefined, activeResult.refetch); // When a search query is active, results are already server-ranked by // relevance — skip Fuse re-ranking to preserve server order across pages. diff --git a/app/components/Views/TrendingView/feeds/predictions/useWorldCupPredictionsFeed.ts b/app/components/Views/TrendingView/feeds/predictions/useWorldCupPredictionsFeed.ts new file mode 100644 index 000000000000..88eb2e8fd5c9 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/useWorldCupPredictionsFeed.ts @@ -0,0 +1,50 @@ +import { useSelector } from 'react-redux'; +import { + selectPredictWorldCupConfig, + selectPredictWorldCupScreenEnabledFlag, +} from '../../../../UI/Predict/selectors/featureFlags'; +import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../UI/Predict/constants/worldCupTabs'; +import { usePredictWorldCupMarkets } from '../../../../UI/Predict/hooks/usePredictWorldCup'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import type { UsePredictionsFeedResult } from './usePredictionsFeed'; + +interface UseWorldCupPredictionsFeedOptions { + enabled?: boolean; + refresh?: RefreshConfig; + pageSize?: number; +} + +export interface UseWorldCupPredictionsFeedResult + extends UsePredictionsFeedResult { + isEnabled: boolean; +} + +export const useWorldCupPredictionsFeed = ({ + enabled = true, + refresh, + pageSize = 6, +}: UseWorldCupPredictionsFeedOptions = {}): UseWorldCupPredictionsFeedResult => { + const config = useSelector(selectPredictWorldCupConfig); + const isScreenEnabled = useSelector(selectPredictWorldCupScreenEnabledFlag); + const isEnabled = enabled && isScreenEnabled; + + const worldCupMarkets = usePredictWorldCupMarkets({ + tabKey: PREDICT_WORLD_CUP_TAB_KEYS.ALL, + config, + enabled: isEnabled, + pageSize, + }); + + useFeedRefresh(isEnabled ? refresh : undefined, worldCupMarkets.refetch); + + return { + data: worldCupMarkets.marketData, + isLoading: worldCupMarkets.isFetching, + refetch: worldCupMarkets.refetch, + fetchMore: worldCupMarkets.fetchMore, + isFetchingMore: worldCupMarkets.isFetchingMore, + hasMore: worldCupMarkets.hasMore, + isEnabled, + }; +}; diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx index 45c730e4dfe3..c4d86bc2abfe 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -50,16 +50,34 @@ const mockUsePerpsFeed = jest.fn(() => ({ })); jest.mock('../feeds/perps/usePerpsFeed', () => ({ + ...jest.requireActual('../feeds/perps/usePerpsFeed'), usePerpsFeed: () => mockUsePerpsFeed(), })); +jest.mock('../feeds/perps/PerpsPillItem', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createElement } = require('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Text } = require('react-native'); + return { + __esModule: true, + default: ({ item }: { item: { market: { symbol: string } } }) => + createElement( + Text, + { testID: `perps-pill-${item.market.symbol}` }, + item.market.symbol, + ), + }; +}); + const mockNavigateToPerpsMarketList = jest.fn(); jest.mock('../feeds/perps/perpsNavigation', () => ({ navigateToPerpsMarketList: ( nav: unknown, filter: unknown, sortOptionId: unknown, - ) => mockNavigateToPerpsMarketList(nav, filter, sortOptionId), + options: unknown, + ) => mockNavigateToPerpsMarketList(nav, filter, sortOptionId, options), })); interface MockPredictionMarket { @@ -77,6 +95,18 @@ jest.mock('../feeds/predictions/usePredictionsFeed', () => ({ usePredictionsFeed: () => mockUsePredictionsFeed(), })); +const mockUseWorldCupPredictionsFeed = jest.fn< + { data: MockPredictionMarket[]; isLoading: boolean; isEnabled: boolean }, + [] +>(() => ({ + data: [], + isLoading: false, + isEnabled: false, +})); +jest.mock('../feeds/predictions/useWorldCupPredictionsFeed', () => ({ + useWorldCupPredictionsFeed: () => mockUseWorldCupPredictionsFeed(), +})); + jest.mock('../feeds/predictions/PredictionRowItem', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { createElement } = require('react'); @@ -139,6 +169,7 @@ import NowTab from './NowTab'; import type { RefreshConfig } from '../hooks/useExploreRefresh'; import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; import Routes from '../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../../UI/Predict/constants/eventNames'; const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true }; const defaultTabProps = { @@ -216,6 +247,11 @@ beforeEach(() => { defaultSortOptionId: 'priceChange' as const, }); mockUsePredictionsFeed.mockReturnValue({ data: [], isLoading: false }); + mockUseWorldCupPredictionsFeed.mockReturnValue({ + data: [], + isLoading: false, + isEnabled: false, + }); mockWhatsHappeningImpl.mockReturnValue(null); }); @@ -351,10 +387,10 @@ describe('NowTab — Perps Movers "View All" navigation', () => { mockWhatsHappeningImpl.mockReturnValue(null); }); - it('calls navigateToPerpsMarketList with "all" filter and the defaultSortOptionId from usePerpsFeed', () => { + it('calls navigateToPerpsMarketList with "all" filter, price change sort, and gainers direction by default', () => { // Return one market so PerpsBlock does not bail out with an early null return. mockUsePerpsFeed.mockReturnValue({ - data: [{ market: { symbol: 'BTC' } }] as never, + data: [{ market: { symbol: 'BTC', change24hPercent: '5' } }] as never, isLoading: false, refetch: jest.fn(), defaultSortOptionId: 'priceChange' as const, @@ -369,6 +405,104 @@ describe('NowTab — Perps Movers "View All" navigation', () => { expect.anything(), // navigation object 'all', 'priceChange', + { sortDirection: 'desc' }, + ); + }); + + it('renders Gainers by default and filters out negative price changes', () => { + mockUsePerpsFeed.mockReturnValue({ + data: [ + { market: { symbol: 'BTC', change24hPercent: '5' } }, + { market: { symbol: 'ETH', change24hPercent: '-3' } }, + ] as never, + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + expect(screen.getByTestId('perps-movers-pill-gainers')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-pill-BTC')).toBeOnTheScreen(); + expect(screen.queryByTestId('perps-pill-ETH')).toBeNull(); + }); + + it('renders pill skeletons while Perps Movers are loading', () => { + mockUsePerpsFeed.mockReturnValue({ + data: [], + isLoading: true, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + expect( + screen.getAllByTestId('section-pills-skeleton').length, + ).toBeGreaterThan(0); + }); + + it('renders placeholder perps when price change data is unavailable after loading', () => { + mockUsePerpsFeed.mockReturnValue({ + data: [ + { market: { symbol: 'BTC', change24hPercent: '' } }, + { market: { symbol: 'ETH', change24hPercent: undefined } }, + ] as never, + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + expect(screen.queryByTestId('section-pills-skeleton')).toBeNull(); + expect(screen.getByTestId('perps-pill-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-pill-ETH')).toBeOnTheScreen(); + }); + + it('does not render pill skeletons when price change data is valid but filtered out', () => { + mockUsePerpsFeed.mockReturnValue({ + data: [ + { market: { symbol: 'BTC', change24hPercent: '0%' } }, + { market: { symbol: 'ETH', change24hPercent: '0.00%' } }, + ] as never, + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + expect(screen.queryByTestId('section-pills-skeleton')).toBeNull(); + }); + + it('renders Losers sorted by biggest negative move and passes ascending sort direction to the market list', () => { + mockUsePerpsFeed.mockReturnValue({ + data: [ + { market: { symbol: 'BTC', change24hPercent: '5' } }, + { market: { symbol: 'ETH', change24hPercent: '-3' } }, + { market: { symbol: 'SOL', change24hPercent: '-8' } }, + ] as never, + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + fireEvent.press(screen.getByTestId('perps-movers-pill-losers')); + + expect(screen.getByTestId('perps-pill-SOL')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-pill-ETH')).toBeOnTheScreen(); + expect(screen.queryByTestId('perps-pill-BTC')).toBeNull(); + + fireEvent.press(screen.getByTestId('section-header-view-all-perps')); + + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledWith( + expect.anything(), + 'all', + 'priceChange', + { sortDirection: 'asc' }, ); }); @@ -385,6 +519,63 @@ describe('NowTab — Perps Movers "View All" navigation', () => { }); }); +describe('NowTab — Predictions navigation', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectPredictEnabledFlag) return true; + if (selector === selectWhatsHappeningEnabled) return false; + return undefined; + }); + mockControlAbTest(); + mockUsePredictionsFeed.mockReturnValue({ + data: [{ id: 'market-1' }], + isLoading: false, + }); + }); + + it('opens the Predict trending tab from the Predictions section title', () => { + renderNowTab(); + + fireEvent.press(screen.getByTestId(predictSectionTestId)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + tab: 'trending', + }, + }); + }); + + it('opens the World Cup screen from the Predictions section title when World Cup predictions are enabled', () => { + mockUseWorldCupPredictionsFeed.mockReturnValue({ + data: [{ id: 'world-cup-market-1' }], + isLoading: false, + isEnabled: true, + }); + + renderNowTab(); + + expect(screen.getByText('World Cup predictions')).toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId(predictSectionTestId)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.WORLD_CUP, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + initialTab: 'all', + }, + }); + }); +}); + describe('NowTab — Crypto Movers', () => { const mockUseSelector = useSelector as jest.MockedFunction< typeof useSelector diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index e71a0aab52d7..fc2d98b95bf6 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -18,18 +24,29 @@ import CryptoMoversPillItem from '../feeds/tokens/CryptoMoversPillItem'; import CryptoMoversSkeleton from '../feeds/tokens/CryptoMoversSkeleton'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { TimeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; -import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed'; +import { + filterAndSortByPriceChangeDirection, + PERPS_PRICE_CHANGE_SORT_DIRECTION, + usePerpsFeed, + type PerpsFeedItem, + type PerpsPriceChangeDirection, +} from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; import PerpsPillItem from '../feeds/perps/PerpsPillItem'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection'; -import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation'; +import { + navigateToExplorePredictionsList, + navigateToExploreWorldCupPredictions, +} from '../feeds/predictions/predictionsNavigation'; +import { useWorldCupPredictionsFeed } from '../feeds/predictions/useWorldCupPredictionsFeed'; import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; import PillScrollList from '../components/PillScrollList'; +import PillRow, { type PillOption } from '../components/PillRow'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted } from '../search/analytics'; @@ -50,12 +67,52 @@ interface PerpsBlockProps { } const PerpsBlock: React.FC = ({ refresh, navigation }) => { + const [activeMoverDirection, setActiveMoverDirection] = + useState('gainers'); const perps = usePerpsFeed({ variant: 'all', refresh, withTileExtras: false, }); + const moverPills: PillOption[] = [ + { + key: 'gainers', + name: strings('trending.perps_movers_pill_gainers'), + }, + { + key: 'losers', + name: strings('trending.perps_movers_pill_losers'), + }, + ]; + + const handleMoverPillSelect = (key: string) => { + if (key === 'gainers' || key === 'losers') { + setActiveMoverDirection(key); + } + }; + + const data = useMemo(() => { + const feedItemsBySymbol = new Map( + perps.data.map((item) => [item.market.symbol, item]), + ); + const markets = filterAndSortByPriceChangeDirection( + perps.data.map((item) => item.market), + activeMoverDirection, + ); + return markets + .map((market) => feedItemsBySymbol.get(market.symbol)) + .filter((item): item is PerpsFeedItem => item !== undefined); + }, [activeMoverDirection, perps.data]); + const pillData = + data.length === 0 && + perps.data.length > 0 && + perps.data.every(({ market }) => + Number.isNaN(parseFloat(market.change24hPercent)), + ) + ? perps.data + : data; + if (!perps.isLoading && perps.data.length === 0) return null; return ( @@ -67,14 +124,24 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { navigation, 'all', perps.defaultSortOptionId, + { + sortDirection: + PERPS_PRICE_CHANGE_SORT_DIRECTION[activeMoverDirection], + }, ) } testID="section-header-view-all-perps" tabName="Now" sectionName="perps_movers" /> + - data={perps.data} + data={pillData} isLoading={perps.isLoading} renderItem={(item, index) => ( = ({ refresh, refreshing, onRefresh }) => { whatsHappeningRef.current?.refresh(); }, [refresh.trigger]); - const predictions = usePredictionsFeed({ refresh }); + const worldCupPredictions = useWorldCupPredictionsFeed({ + enabled: isPredictEnabled, + refresh, + }); + const predictions = usePredictionsFeed({ + refresh, + enabled: !worldCupPredictions.isEnabled, + }); + const displayedPredictions = worldCupPredictions.isEnabled + ? worldCupPredictions + : predictions; const cryptoMovers = useTokensFeed({ refresh, hideRiskyTokens: true, @@ -169,13 +246,21 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const predictionsSection = ( navigateToExplorePredictionsList(navigation, 'trending')} + onViewAll={() => + worldCupPredictions.isEnabled + ? navigateToExploreWorldCupPredictions(navigation) + : navigateToExplorePredictionsList(navigation, 'trending') + } isEnabled={isPredictEnabled} /> ); diff --git a/app/components/Views/TrendingView/tabs/SportsTab.test.tsx b/app/components/Views/TrendingView/tabs/SportsTab.test.tsx new file mode 100644 index 000000000000..e6ca0dfc9b0f --- /dev/null +++ b/app/components/Views/TrendingView/tabs/SportsTab.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../../UI/Predict/constants/eventNames'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import SportsTab from './SportsTab'; +import type { RefreshConfig } from '../hooks/useExploreRefresh'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + icon: { default: 'black' }, + primary: { default: 'blue' }, + }, + }), +})); + +interface MockPredictionMarket { + id: string; +} + +const mockUsePredictionsFeed = jest.fn< + { data: MockPredictionMarket[]; isLoading: boolean }, + [] +>(() => ({ + data: [], + isLoading: false, +})); +jest.mock('../feeds/predictions/usePredictionsFeed', () => ({ + usePredictionsFeed: () => mockUsePredictionsFeed(), +})); + +const mockUseWorldCupPredictionsFeed = jest.fn< + { data: MockPredictionMarket[]; isLoading: boolean; isEnabled: boolean }, + [] +>(() => ({ + data: [], + isLoading: false, + isEnabled: false, +})); +jest.mock('../feeds/predictions/useWorldCupPredictionsFeed', () => ({ + useWorldCupPredictionsFeed: () => mockUseWorldCupPredictionsFeed(), +})); + +const mockUseSportsMarketsFeed = jest.fn(() => ({ + pills: [], + activeKey: 'soccer', + select: jest.fn(), + active: { + marketData: [], + isFetching: false, + isFetchingMore: false, + hasMore: false, + fetchMore: jest.fn(), + }, +})); +jest.mock('../feeds/predictions/useSportsMarketsFeed', () => ({ + useSportsMarketsFeed: () => mockUseSportsMarketsFeed(), +})); + +jest.mock('../components/HorizontalCarousel', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createElement } = require('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + return { + __esModule: true, + default: ({ idPrefix }: { idPrefix: string }) => + createElement(View, { testID: `${idPrefix}-flash-list` }), + }; +}); + +const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true }; +const defaultTabProps = { + refresh: defaultRefresh, + refreshing: false, + onRefresh: jest.fn(), +}; + +const renderSportsTab = () => + render( + + + , + ); + +describe('SportsTab — Predictions carousel', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPredictEnabledFlag) return true; + return undefined; + }); + mockUsePredictionsFeed.mockReturnValue({ + data: [{ id: 'sports-market-1' }], + isLoading: false, + }); + mockUseWorldCupPredictionsFeed.mockReturnValue({ + data: [], + isLoading: false, + isEnabled: false, + }); + }); + + it('opens the sports predictions tab when World Cup predictions are disabled', () => { + renderSportsTab(); + + fireEvent.press( + screen.getByTestId('section-header-view-all-sports_predictions'), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + tab: 'sports', + }, + }); + }); + + it('shows World Cup predictions and opens the World Cup screen when enabled', () => { + mockUseWorldCupPredictionsFeed.mockReturnValue({ + data: [{ id: 'world-cup-market-1' }], + isLoading: false, + isEnabled: true, + }); + + renderSportsTab(); + + expect(screen.getByText('World Cup predictions')).toBeOnTheScreen(); + + fireEvent.press( + screen.getByTestId('section-header-view-all-sports_predictions'), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.WORLD_CUP, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE, + initialTab: 'all', + }, + }); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx index 31f76d369081..41fb0dcaa160 100644 --- a/app/components/Views/TrendingView/tabs/SportsTab.tsx +++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx @@ -25,13 +25,17 @@ import PredictMarket from '../../../UI/Predict/components/PredictMarket'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; import { strings } from '../../../../../locales/i18n'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { useWorldCupPredictionsFeed } from '../feeds/predictions/useWorldCupPredictionsFeed'; import { useSportsMarketsFeed, type UseSportsMarketsFeedResult, } from '../feeds/predictions/useSportsMarketsFeed'; import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection'; import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; -import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation'; +import { + navigateToExplorePredictionsList, + navigateToExploreWorldCupPredictions, +} from '../feeds/predictions/predictionsNavigation'; import PillRow from '../components/PillRow'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; @@ -51,6 +55,7 @@ interface SportsListHeaderProps { showSportsPredictions: boolean; sportsPredictionsData: PredictMarketType[]; sportsPredictionsLoading: boolean; + showWorldCupPredictions: boolean; sportsMarkets: UseSportsMarketsFeedResult; showAllSportsSkeleton: boolean; showAllSportsEmpty: boolean; @@ -61,6 +66,7 @@ const SportsListHeader: React.FC = ({ showSportsPredictions, sportsPredictionsData, sportsPredictionsLoading, + showWorldCupPredictions, sportsMarkets, showAllSportsSkeleton, showAllSportsEmpty, @@ -74,10 +80,18 @@ const SportsListHeader: React.FC = ({ }} tabName="Sports" sectionName="predictions_sports" - title={strings('trending.predictions')} + title={ + showWorldCupPredictions + ? strings('predict.world_cup.predictions_title') + : strings('trending.predictions') + } testIdPrefix="predict-sports-market-row-item" idPrefix="sports_predictions" - onViewAll={() => navigateToExplorePredictionsList(navigation, 'sports')} + onViewAll={() => + showWorldCupPredictions + ? navigateToExploreWorldCupPredictions(navigation) + : navigateToExplorePredictionsList(navigation, 'sports') + } isEnabled={showSportsPredictions} /> @@ -125,7 +139,18 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const isPredictEnabled = useSelector(selectPredictEnabledFlag); const { colors } = useTheme(); - const sportsPredictions = usePredictionsFeed({ variant: 'sports', refresh }); + const worldCupPredictions = useWorldCupPredictionsFeed({ + enabled: isPredictEnabled, + refresh, + }); + const sportsPredictions = usePredictionsFeed({ + variant: 'sports', + refresh, + enabled: !worldCupPredictions.isEnabled, + }); + const displayedSportsPredictions = worldCupPredictions.isEnabled + ? worldCupPredictions + : sportsPredictions; const sportsMarkets = useSportsMarketsFeed({ refresh }); const { active, activeKey } = sportsMarkets; @@ -166,7 +191,8 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const showSportsPredictions = isPredictEnabled && - (sportsPredictions.isLoading || sportsPredictions.data.length > 0); + (displayedSportsPredictions.isLoading || + displayedSportsPredictions.data.length > 0); const showAllSportsSkeleton = active.isFetching && active.marketData.length === 0; const showAllSportsEmpty = @@ -175,8 +201,9 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const listHeader = ( = ({ gap={3} twClassName="py-3" > - + = ({ {strings('homepage.sections.related_assets')} - {item.relatedAssets.map((asset) => ( + {item.relatedAssets.map((asset, index) => ( { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { - market: { symbol: hlPerpsMarket, name: asset.name }, + market: { symbol: hlPerpsMarket, name: asset.name || asset.symbol }, source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, }, }); - }, [navigation, hlPerpsMarket, asset.name]); + }, [navigation, hlPerpsMarket, asset.name, asset.symbol]); return { handleTrade, canTrade: Boolean(hlPerpsMarket) }; }; diff --git a/app/components/Views/confirmations/components/UI/token/token.tsx b/app/components/Views/confirmations/components/UI/token/token.tsx index cba9e1d4d767..5b5bdd027118 100644 --- a/app/components/Views/confirmations/components/UI/token/token.tsx +++ b/app/components/Views/confirmations/components/UI/token/token.tsx @@ -20,6 +20,7 @@ import { BadgeVariant } from '../../../../../../component-library/components/Bad import { BadgePosition } from '../../../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.types'; import { AccountTypeLabel } from '../account-type-label'; import { AssetType } from '../../../types/token'; +import { getAssetTestId } from '../../../../../../../tests/selectors/Wallet/WalletView.selectors'; import { formatAmount } from '../../../../../../components/UI/SimulationDetails/formatAmount'; import { ACCOUNT_TYPE_LABELS } from '../../../../../../constants/account-type-labels'; import AssetLogo from '../../../../../UI/Assets/components/AssetLogo/AssetLogo'; @@ -41,6 +42,7 @@ export function Token({ asset, onPress }: TokenProps) { return ( tw.style( diff --git a/app/components/Views/confirmations/components/footer/footer.test.tsx b/app/components/Views/confirmations/components/footer/footer.test.tsx index 73e5e102e7e1..1fa28fa54f17 100644 --- a/app/components/Views/confirmations/components/footer/footer.test.tsx +++ b/app/components/Views/confirmations/components/footer/footer.test.tsx @@ -108,11 +108,13 @@ describe('Footer', () => { mockUseConfirmationContext.mockReturnValue({ headlessBuyError: undefined, isFooterVisible: true, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: false, isTransactionValueUpdating: false, setHeadlessBuyError: jest.fn(), setIsFooterVisible: jest.fn(), + setIsConfirmationSubmitting: jest.fn(), setIsHeadlessBuyInProgress: jest.fn(), setIsTransactionDataUpdating: jest.fn(), setIsTransactionValueUpdating: jest.fn(), @@ -222,11 +224,13 @@ describe('Footer', () => { mockUseConfirmationContext.mockReturnValue({ headlessBuyError: undefined, isFooterVisible: true, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: true, isTransactionValueUpdating: true, setHeadlessBuyError: jest.fn(), setIsFooterVisible: jest.fn(), + setIsConfirmationSubmitting: jest.fn(), setIsHeadlessBuyInProgress: jest.fn(), setIsTransactionDataUpdating: jest.fn(), setIsTransactionValueUpdating: jest.fn(), @@ -285,11 +289,13 @@ describe('Footer', () => { mockUseConfirmationContext.mockReturnValue({ headlessBuyError: undefined, isFooterVisible: undefined, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: false, isTransactionValueUpdating: false, setHeadlessBuyError: jest.fn(), setIsFooterVisible: jest.fn(), + setIsConfirmationSubmitting: jest.fn(), setIsHeadlessBuyInProgress: jest.fn(), setIsTransactionDataUpdating: jest.fn(), setIsTransactionValueUpdating: jest.fn(), @@ -321,11 +327,13 @@ describe('Footer', () => { mockUseConfirmationContext.mockReturnValue({ headlessBuyError: undefined, isFooterVisible: undefined, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: false, isTransactionValueUpdating: false, setHeadlessBuyError: jest.fn(), setIsFooterVisible: jest.fn(), + setIsConfirmationSubmitting: jest.fn(), setIsHeadlessBuyInProgress: jest.fn(), setIsTransactionDataUpdating: jest.fn(), setIsTransactionValueUpdating: jest.fn(), @@ -357,11 +365,13 @@ describe('Footer', () => { mockUseConfirmationContext.mockReturnValue({ headlessBuyError: undefined, isFooterVisible: false, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: false, isTransactionValueUpdating: false, setHeadlessBuyError: jest.fn(), setIsFooterVisible: jest.fn(), + setIsConfirmationSubmitting: jest.fn(), setIsHeadlessBuyInProgress: jest.fn(), setIsTransactionDataUpdating: jest.fn(), setIsTransactionValueUpdating: jest.fn(), diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 93d8f1592925..09e3f571378b 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -23,6 +23,7 @@ import { SetPayTokenRequest, useAutomaticTransactionPayToken, } from '../../../hooks/pay/useAutomaticTransactionPayToken'; +import { useIsFiatPaymentAvailable } from '../../../hooks/pay/useIsFiatPaymentAvailable'; import { useTransactionPayPostQuote } from '../../../hooks/pay/useTransactionPayPostQuote'; import { useTransactionPayWithdraw } from '../../../hooks/pay/useTransactionPayWithdraw'; import { AlertMessage } from '../../alerts/alert-message'; @@ -135,9 +136,12 @@ export const CustomAmountInfo: React.FC = memo( const { isNative: isNativePayToken } = useTransactionPayToken(); const { styles } = useStyles(styleSheet, {}); const [isKeyboardVisible, setIsKeyboardVisible] = useState(true); - const { hasTokens } = useTransactionPayAvailableTokens(); + const { hasTokens: hasAvailableTokens } = + useTransactionPayAvailableTokens(); const fiatPayment = useTransactionPayFiatPayment(); const selectedFiatPaymentMethodId = fiatPayment?.selectedPaymentMethodId; + const isFiatAvailable = useIsFiatPaymentAvailable(); + const hasPaymentOption = hasAvailableTokens || isFiatAvailable; const fiatEverSelectedRef = useRef(false); if (selectedFiatPaymentMethodId) { fiatEverSelectedRef.current = true; @@ -228,7 +232,7 @@ export const CustomAmountInfo: React.FC = memo( currency={currency} hasAlert={Boolean(alertMessage)} onPress={handleAmountPress} - disabled={!hasTokens} + disabled={!hasPaymentOption} /> {!hidePayTokenAmount && disablePay !== true && @@ -237,7 +241,7 @@ export const CustomAmountInfo: React.FC = memo( ) : ( ))} {!hidePayTokenAmount && children} @@ -255,7 +259,7 @@ export const CustomAmountInfo: React.FC = memo( !shouldHideAccountSelector && ( )} - {disablePay !== true && hasTokens && } + {disablePay !== true && hasPaymentOption && } )} {isResultReady && ( @@ -263,7 +267,7 @@ export const CustomAmountInfo: React.FC = memo( {supportAccountSelection && !selectedFiatPaymentMethodId && !shouldHideAccountSelector && } - {disablePay !== true && hasTokens && } + {disablePay !== true && hasPaymentOption && } {showPaymentDetails && ( <> @@ -287,7 +291,7 @@ export const CustomAmountInfo: React.FC = memo( {footerText} )} - {isKeyboardVisible && hasTokens && ( + {isKeyboardVisible && hasPaymentOption && ( = memo( hasMax={hasMax && (isWithdraw || !isNativePayToken)} /> )} - {!hasTokens && } + {!hasPaymentOption && } {!isKeyboardVisible && ( ({ }), })); +jest.mock('../../../../../../core/Engine', () => ({ + context: { + PredictController: { + clearPendingClaim: jest.fn(), + }, + }, +})); + jest.mock('../../predict-confirmations/predict-claim-amount', () => ({ PredictClaimAmount: () => null, PredictClaimAmountSkeleton: () => null, @@ -50,11 +59,26 @@ jest.mock('../../../../../../component-library/hooks', () => ({ describe('PredictClaimInfo', () => { beforeEach(() => { jest.clearAllMocks(); + (useClearConfirmationOnBackSwipe as jest.Mock).mockReturnValue(jest.fn()); }); it('clears the confirmation when dismissed with a back gesture', () => { render(); expect(useClearConfirmationOnBackSwipe).toHaveBeenCalledTimes(1); + expect(useClearConfirmationOnBackSwipe).toHaveBeenCalledWith({ + rejectOnBeforeRemove: true, + skipNavigationOnGestureEnd: false, + rejectOnBeforeRemoveWithoutGesture: true, + onBeforeReject: expect.any(Function), + }); + const options = (useClearConfirmationOnBackSwipe as jest.Mock).mock + .calls[0][0]; + + options.onBeforeReject(); + + expect( + Engine.context.PredictController.clearPendingClaim, + ).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/Views/confirmations/components/info/predict-claim-info/predict-claim-info.tsx b/app/components/Views/confirmations/components/info/predict-claim-info/predict-claim-info.tsx index 837b54c1dbd2..f63e28deabfe 100644 --- a/app/components/Views/confirmations/components/info/predict-claim-info/predict-claim-info.tsx +++ b/app/components/Views/confirmations/components/info/predict-claim-info/predict-claim-info.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { PredictClaimAmount, PredictClaimAmountSkeleton, @@ -10,19 +10,28 @@ import ButtonIcon, { ButtonIconSizes, } from '../../../../../../component-library/components/Buttons/ButtonIcon'; import { IconName } from '../../../../../../component-library/components/Icons/Icon'; -import { useConfirmActions } from '../../../hooks/useConfirmActions'; import { useStyles } from '../../../../../../component-library/hooks'; import styleSheet from './predict-claim-info.styles'; import useClearConfirmationOnBackSwipe from '../../../hooks/ui/useClearConfirmationOnBackSwipe'; +import Engine from '../../../../../../core/Engine'; export function PredictClaimInfo() { useModalNavbar(); usePredictClaimConfirmationMetrics(); - useClearConfirmationOnBackSwipe(); + const clearPendingClaim = useCallback(() => { + Engine.context.PredictController.clearPendingClaim(); + }, []); + + const rejectConfirmation = useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + rejectOnBeforeRemoveWithoutGesture: true, + skipNavigationOnGestureEnd: false, + onBeforeReject: clearPendingClaim, + }); return ( <> - + @@ -41,15 +50,18 @@ export function PredictClaimInfoSkeleton() { /** * Intentionally not using navigation header as `headerTransparent` not rendering buttons on Android. */ -function BackButton() { +function BackButton({ onReject }: { onReject: () => void }) { const { styles } = useStyles(styleSheet, {}); - const { onReject } = useConfirmActions(); + + const handleReject = useCallback(() => { + onReject(); + }, [onReject]); return ( onReject()} + onPress={handleReject} style={styles.backButton} /> ); diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx index ba6d30405f0f..3d18a9d4ca9a 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../locales/i18n'; import Avatar, { @@ -22,9 +22,10 @@ import { BigNumber } from 'bignumber.js'; import ButtonHero from '../../../../../../component-library/components-temp/Buttons/ButtonHero'; import { ButtonBaseSize } from '@metamask/design-system-react-native'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { useConfirmationContext } from '../../../context/confirmation-context'; export interface PredictClaimFooterProps { - onPress: () => void; + onPress: () => void | Promise; onError: (error?: Error) => void; } @@ -34,6 +35,7 @@ export function PredictClaimFooter({ }: PredictClaimFooterProps) { const transactionMetadata = useTransactionMetadataRequest(); const { styles } = useStyles(styleSheet, {}); + const { setIsConfirmationSubmitting } = useConfirmationContext(); const address = transactionMetadata?.txParams.from; @@ -51,6 +53,17 @@ export function PredictClaimFooter({ } }, [hasNoPositions, onError]); + const handlePress = useCallback(async () => { + setIsConfirmationSubmitting(true); + + try { + await onPress(); + } catch (error) { + setIsConfirmationSubmitting(false); + throw error; + } + }, [onPress, setIsConfirmationSubmitting]); + if (hasNoPositions) { return null; } @@ -64,7 +77,7 @@ export function PredictClaimFooter({ )} diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx index d91a1ce1a236..ae8817d78e7b 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx @@ -329,6 +329,39 @@ describe('RecipientInput', () => { jest.advanceTimersByTime(100); }); + it('clears pastedRecipient when clear button is pressed', () => { + mockUseSendContext.mockReturnValue({ + to: '0x1234567890123456789012345678901234567890', + updateTo: mockUpdateTo, + asset: undefined, + chainId: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fromAccount: {} as any, + from: '', + maxValueMode: false, + updateAsset: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const mockSetPastedRecipient = jest.fn(); + const { getByText } = renderWithProvider( + , + ); + + const clearButton = getByText('Clear'); + fireEvent.press(clearButton); + + expect(mockUpdateTo).toHaveBeenCalledWith(''); + expect(mockSetPastedRecipient).toHaveBeenCalledWith(undefined); + + jest.advanceTimersByTime(100); + }); + it('maintains correct button state based on isRecipientSelectedFromList prop', () => { mockUseSendContext.mockReturnValue({ to: '0x123...', diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx index 293eac231974..3420ad21cf50 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx @@ -49,10 +49,11 @@ export const RecipientInput = ({ const handleClearInput = useCallback(() => { updateTo(''); + setPastedRecipient(undefined); setTimeout(() => { inputRef.current?.blur(); }, 100); - }, [updateTo, inputRef]); + }, [updateTo, inputRef, setPastedRecipient]); const handleTextChange = useCallback( async (toAddress: string) => { diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx index 74ea3e9163b6..edcdfaae1b96 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx @@ -770,6 +770,44 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { expect(mockHandleSubmitPressLocal).not.toHaveBeenCalled(); }); + it('does not submit when recipient address is empty', () => { + mockUseToAddressValidation.mockReturnValue({ + loading: false, + resolvedAddress: undefined, + toAddressError: undefined, + toAddressValidated: undefined, + toAddressWarning: undefined, + }); + + mockUseSendAlerts.mockReturnValue({ + alerts: [], + hasUnacknowledgedAlerts: false, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + }); + + mockUseSendContext.mockReturnValue({ + to: '', + updateTo: jest.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + asset: {} as any, + chainId: '0x1', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fromAccount: {} as any, + from: '', + maxValueMode: false, + updateAsset: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('set-pasted')); + + expect(mockHandleSubmitPressLocal).not.toHaveBeenCalled(); + }); + it('handles reviews exits early if toAddressError is defined', () => { // Given: valid to address, no errors/warnings/loading, but missing asset mockUseToAddressValidation.mockReturnValue({ diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx index f001f979c55a..ca89de8d8ad9 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx @@ -86,6 +86,10 @@ export const Recipient = () => { if (!asset || !chainId) { return; } + const recipientAddress = resolvedAddress || to; + if (!recipientAddress) { + return; + } setIsSubmittingTransaction(true); setPastedRecipient(undefined); captureRecipientSelected( diff --git a/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx index fa95899a8c1b..44dff7dc7817 100644 --- a/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx +++ b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx @@ -16,11 +16,13 @@ describe('ConfirmationContext', () => { expect(result.current).toStrictEqual({ headlessBuyError: undefined, isFooterVisible: undefined, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: false, isTransactionValueUpdating: false, setHeadlessBuyError: expect.any(Function), setIsFooterVisible: expect.any(Function), + setIsConfirmationSubmitting: expect.any(Function), setIsHeadlessBuyInProgress: expect.any(Function), setIsTransactionDataUpdating: expect.any(Function), setIsTransactionValueUpdating: expect.any(Function), @@ -84,4 +86,20 @@ describe('ConfirmationContext', () => { expect(result.current.isTransactionDataUpdating).toBe(false); }); + + it('updates isConfirmationSubmitting state when calling setIsConfirmationSubmitting', () => { + const { result } = renderHook(() => useConfirmationContext(), { wrapper }); + + act(() => { + result.current.setIsConfirmationSubmitting(true); + }); + + expect(result.current.isConfirmationSubmitting).toBe(true); + + act(() => { + result.current.setIsConfirmationSubmitting(false); + }); + + expect(result.current.isConfirmationSubmitting).toBe(false); + }); }); diff --git a/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx index 910e40b5b8a3..a91dc67f951f 100644 --- a/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx +++ b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx @@ -4,10 +4,12 @@ import React, { useContext, useMemo, useState } from 'react'; export interface ConfirmationContextParams { headlessBuyError: string | undefined; isFooterVisible?: boolean; + isConfirmationSubmitting: boolean; isHeadlessBuyInProgress: boolean; isTransactionValueUpdating: boolean; isTransactionDataUpdating: boolean; setHeadlessBuyError: (error: string | undefined) => void; + setIsConfirmationSubmitting: (isConfirmationSubmitting: boolean) => void; setIsFooterVisible: (isFooterVisible: boolean) => void; setIsHeadlessBuyInProgress: (isHeadlessBuyInProgress: boolean) => void; setIsTransactionValueUpdating: (isTransactionValueUpdating: boolean) => void; @@ -19,10 +21,12 @@ export interface ConfirmationContextParams { const ConfirmationContext = React.createContext({ headlessBuyError: undefined, isFooterVisible: true, + isConfirmationSubmitting: false, isHeadlessBuyInProgress: false, isTransactionDataUpdating: false, isTransactionValueUpdating: false, setHeadlessBuyError: noop, + setIsConfirmationSubmitting: noop, setIsFooterVisible: noop, setIsHeadlessBuyInProgress: noop, setIsTransactionDataUpdating: noop, @@ -50,6 +54,9 @@ export const ConfirmationContextProvider: React.FC< const [isTransactionDataUpdating, setIsTransactionDataUpdating] = useState(false); + const [isConfirmationSubmitting, setIsConfirmationSubmitting] = + useState(false); + const contextValue = useMemo( () => ({ headlessBuyError, @@ -57,11 +64,13 @@ export const ConfirmationContextProvider: React.FC< isHeadlessBuyInProgress, isTransactionDataUpdating, isTransactionValueUpdating, + isConfirmationSubmitting, setHeadlessBuyError, setIsFooterVisible, setIsHeadlessBuyInProgress, setIsTransactionDataUpdating, setIsTransactionValueUpdating, + setIsConfirmationSubmitting, }), [ headlessBuyError, @@ -69,11 +78,13 @@ export const ConfirmationContextProvider: React.FC< isHeadlessBuyInProgress, isTransactionDataUpdating, isTransactionValueUpdating, + isConfirmationSubmitting, setHeadlessBuyError, setIsFooterVisible, setIsHeadlessBuyInProgress, setIsTransactionDataUpdating, setIsTransactionValueUpdating, + setIsConfirmationSubmitting, ], ); diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts index 7abd5e6d30c3..d87bbad22d1b 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts @@ -13,7 +13,6 @@ import { transactionApprovalControllerMock } from '../../__mocks__/controllers/a import { MetaMaskPayTokensFlags, selectMetaMaskPayTokensFlags, - selectMetaMaskPayFiatFlags, } from '../../../../../selectors/featureFlagController/confirmations'; import { isHardwareAccount, @@ -38,6 +37,8 @@ import { useTransactionAccountOverride } from '../transactions/useTransactionAcc import { MUSD_TOKEN_ADDRESS } from '../../../../UI/Earn/constants/musd'; import { selectLastWithdrawTokenByType } from '../../../../../selectors/transactionController'; import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/transactionPayController'; +import { useIsFiatPaymentAvailable } from './useIsFiatPaymentAvailable'; +import { useMMPayFiatConfig } from './useMMPayFiatConfig'; jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('../transactions/useTransactionAccountOverride'); @@ -48,6 +49,8 @@ jest.mock('./useTransactionPayData'); jest.mock('./useTransactionPayAvailableTokens'); jest.mock('./useWithdrawTokenFilter'); jest.mock('../../../../UI/Ramp/hooks/useRampsPaymentMethods'); +jest.mock('./useIsFiatPaymentAvailable'); +jest.mock('./useMMPayFiatConfig'); jest.mock('../../../../../selectors/transactionController', () => ({ ...jest.requireActual('../../../../../selectors/transactionController'), selectLastWithdrawTokenByType: jest.fn(), @@ -59,7 +62,6 @@ jest.mock( '../../../../../selectors/featureFlagController/confirmations', ), selectMetaMaskPayTokensFlags: jest.fn(), - selectMetaMaskPayFiatFlags: jest.fn(), }), ); @@ -122,9 +124,6 @@ describe('useAutomaticTransactionPayToken', () => { const selectMetaMaskPayTokensFlagsMock = jest.mocked( selectMetaMaskPayTokensFlags, ); - const selectMetaMaskPayFiatFlagsMock = jest.mocked( - selectMetaMaskPayFiatFlags, - ); const useTransactionMetadataRequestMock = jest.mocked( useTransactionMetadataRequest, ); @@ -187,7 +186,8 @@ describe('useAutomaticTransactionPayToken', () => { error: null, }); - selectMetaMaskPayFiatFlagsMock.mockReturnValue({ + jest.mocked(useIsFiatPaymentAvailable).mockReturnValue(false); + jest.mocked(useMMPayFiatConfig).mockReturnValue({ enabledTransactionTypes: [], maxDelayMinutesForPaymentMethods: 10, }); @@ -220,7 +220,7 @@ describe('useAutomaticTransactionPayToken', () => { }); }); - it('selects target token if no tokens with balance', () => { + it('does not select token when no tokens with balance and fiat unavailable', () => { useTransactionPayAvailableTokensMock.mockReturnValue({ availableTokens: [] as AssetType[], hasTokens: false, @@ -228,10 +228,7 @@ describe('useAutomaticTransactionPayToken', () => { runHook(); - expect(setPayTokenMock).toHaveBeenCalledWith({ - address: TOKEN_ADDRESS_1_MOCK, - chainId: CHAIN_ID_1_MOCK, - }); + expect(setPayTokenMock).not.toHaveBeenCalled(); }); it('does nothing if no required tokens', () => { @@ -418,7 +415,7 @@ describe('useAutomaticTransactionPayToken', () => { }); }); - it('selects target token when preferred payment token provided but no tokens available', () => { + it('does not select token when preferred payment token provided but no tokens available and fiat unavailable', () => { useTransactionPayAvailableTokensMock.mockReturnValue({ availableTokens: [] as AssetType[], hasTokens: false, @@ -431,10 +428,7 @@ describe('useAutomaticTransactionPayToken', () => { }, }); - expect(setPayTokenMock).toHaveBeenCalledWith({ - address: TOKEN_ADDRESS_1_MOCK, - chainId: CHAIN_ID_1_MOCK, - }); + expect(setPayTokenMock).not.toHaveBeenCalled(); }); it('selects first available token when preferred token not in available tokens', () => { diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts index e4bd814a15b5..044c3fde4eef 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts @@ -28,10 +28,11 @@ import { import { useSelector } from 'react-redux'; import { selectMetaMaskPayTokensFlags, - selectMetaMaskPayFiatFlags, PreferredToken, getPreferredTokensForTransactionType, } from '../../../../../selectors/featureFlagController/confirmations'; +import { useIsFiatPaymentAvailable } from './useIsFiatPaymentAvailable'; +import { useMMPayFiatConfig } from './useMMPayFiatConfig'; import { RootState } from '../../../../../reducers'; import { selectLastWithdrawTokenByType } from '../../../../../selectors/transactionController'; import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/transactionPayController'; @@ -160,11 +161,8 @@ export function useAutomaticTransactionPayToken({ const automaticToken = useMemo(() => selectBestToken(), [selectBestToken]); const { paymentMethods } = useRampsPaymentMethods(); - const fiatFlags = useSelector(selectMetaMaskPayFiatFlags); - const isFiatEnabled = hasTransactionType( - transactionMeta, - fiatFlags.enabledTransactionTypes, - ); + const { maxDelayMinutesForPaymentMethods } = useMMPayFiatConfig(); + const isFiatEnabled = useIsFiatPaymentAvailable(); useEffect(() => { if ( @@ -177,20 +175,13 @@ export function useAutomaticTransactionPayToken({ return; } - if (!automaticToken) { - log('No automatic pay token found'); - return; - } - - if (autoSelectFiatPayment) { + if (autoSelectFiatPayment || tokens.length === 0) { if (!isFiatEnabled || paymentMethods.length === 0) { return; } const eligibleMethod = paymentMethods.find( - (pm) => - !pm.delay || - pm.delay[1] <= fiatFlags.maxDelayMinutesForPaymentMethods, + (pm) => !pm.delay || pm.delay[1] <= maxDelayMinutesForPaymentMethods, ); if (eligibleMethod) { @@ -207,6 +198,11 @@ export function useAutomaticTransactionPayToken({ return; } + if (!automaticToken) { + log('No automatic pay token found'); + return; + } + setPayToken({ address: automaticToken.address, chainId: automaticToken.chainId, @@ -219,9 +215,9 @@ export function useAutomaticTransactionPayToken({ autoSelectFiatPayment, automaticToken, disable, - fiatFlags, hasFiatPaymentSelected, isFiatEnabled, + maxDelayMinutesForPaymentMethods, payToken, paymentMethods, requiredTokens, diff --git a/app/components/Views/confirmations/hooks/pay/useFiatPaymentHighlightedActions.ts b/app/components/Views/confirmations/hooks/pay/useFiatPaymentHighlightedActions.ts index e4349444deab..5e88d7ae6f15 100644 --- a/app/components/Views/confirmations/hooks/pay/useFiatPaymentHighlightedActions.ts +++ b/app/components/Views/confirmations/hooks/pay/useFiatPaymentHighlightedActions.ts @@ -4,10 +4,10 @@ import Engine from '../../../../../core/Engine'; import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods'; import { formatDelayFromArray } from '../../../../UI/Ramp/Aggregator/utils'; import { useMMPayFiatConfig } from './useMMPayFiatConfig'; +import { useIsFiatPaymentAvailable } from './useIsFiatPaymentAvailable'; import { useTransactionPayFiatPayment } from './useTransactionPayData'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { HighlightedItem } from '../../types/token'; -import { hasTransactionType } from '../../utils/transaction'; /** * Converts available Ramps payment methods into {@link HighlightedItem}s for @@ -16,20 +16,16 @@ import { hasTransactionType } from '../../utils/transaction'; * `fiatPayment.selectedPaymentMethodId` on the current transaction. */ export function useFiatPaymentHighlightedActions(): HighlightedItem[] { - const { enabledTransactionTypes, maxDelayMinutesForPaymentMethods } = - useMMPayFiatConfig(); + const { maxDelayMinutesForPaymentMethods } = useMMPayFiatConfig(); const { paymentMethods } = useRampsPaymentMethods(); const fiatPayment = useTransactionPayFiatPayment(); const transactionMeta = useTransactionMetadataRequest(); const transactionId = transactionMeta?.id ?? ''; const selectedPaymentMethodId = fiatPayment?.selectedPaymentMethodId; - const isFiatEnabled = hasTransactionType( - transactionMeta, - enabledTransactionTypes, - ); + const isFiatAvailable = useIsFiatPaymentAvailable(); return useMemo(() => { - if (!isFiatEnabled || paymentMethods.length === 0) { + if (!isFiatAvailable) { return []; } @@ -39,7 +35,7 @@ export function useFiatPaymentHighlightedActions(): HighlightedItem[] { toHighlightedItem(pm, transactionId, selectedPaymentMethodId), ); }, [ - isFiatEnabled, + isFiatAvailable, maxDelayMinutesForPaymentMethods, paymentMethods, transactionId, diff --git a/app/components/Views/confirmations/hooks/pay/useIsFiatPaymentAvailable.test.ts b/app/components/Views/confirmations/hooks/pay/useIsFiatPaymentAvailable.test.ts new file mode 100644 index 000000000000..c648150835d2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useIsFiatPaymentAvailable.test.ts @@ -0,0 +1,90 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useIsFiatPaymentAvailable } from './useIsFiatPaymentAvailable'; +import { useMMPayFiatConfig } from './useMMPayFiatConfig'; +import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; + +jest.mock('./useMMPayFiatConfig'); +jest.mock('../../../../UI/Ramp/hooks/useRampsPaymentMethods'); +jest.mock('../transactions/useTransactionMetadataRequest'); + +describe('useIsFiatPaymentAvailable', () => { + const useMMPayFiatConfigMock = jest.mocked(useMMPayFiatConfig); + const useRampsPaymentMethodsMock = jest.mocked(useRampsPaymentMethods); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + + beforeEach(() => { + jest.resetAllMocks(); + + useMMPayFiatConfigMock.mockReturnValue({ + enabledTransactionTypes: [TransactionType.perpsDeposit], + maxDelayMinutesForPaymentMethods: 10, + }); + + useRampsPaymentMethodsMock.mockReturnValue({ + paymentMethods: [{ id: 'apple-pay' }], + } as unknown as ReturnType); + + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDeposit, + } as ReturnType); + }); + + it('returns true when transaction type is enabled and payment methods exist', () => { + const { result } = renderHook(() => useIsFiatPaymentAvailable()); + expect(result.current).toBe(true); + }); + + it('returns false when transaction type is not in enabledTransactionTypes', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.simpleSend, + } as ReturnType); + + const { result } = renderHook(() => useIsFiatPaymentAvailable()); + expect(result.current).toBe(false); + }); + + it('returns false when enabledTransactionTypes is empty', () => { + useMMPayFiatConfigMock.mockReturnValue({ + enabledTransactionTypes: [], + maxDelayMinutesForPaymentMethods: 10, + }); + + const { result } = renderHook(() => useIsFiatPaymentAvailable()); + expect(result.current).toBe(false); + }); + + it('returns false when no payment methods exist', () => { + useRampsPaymentMethodsMock.mockReturnValue({ + paymentMethods: [], + } as unknown as ReturnType); + + const { result } = renderHook(() => useIsFiatPaymentAvailable()); + expect(result.current).toBe(false); + }); + + it('returns false when transaction metadata is undefined', () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useIsFiatPaymentAvailable()); + expect(result.current).toBe(false); + }); + + it('returns true for batch transaction with matching nested type', () => { + useMMPayFiatConfigMock.mockReturnValue({ + enabledTransactionTypes: [TransactionType.predictDeposit], + maxDelayMinutesForPaymentMethods: 10, + }); + + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.predictDeposit }], + } as ReturnType); + + const { result } = renderHook(() => useIsFiatPaymentAvailable()); + expect(result.current).toBe(true); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/useIsFiatPaymentAvailable.ts b/app/components/Views/confirmations/hooks/pay/useIsFiatPaymentAvailable.ts new file mode 100644 index 000000000000..ebeef24f14ad --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useIsFiatPaymentAvailable.ts @@ -0,0 +1,20 @@ +import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods'; +import { hasTransactionType } from '../../utils/transaction'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useMMPayFiatConfig } from './useMMPayFiatConfig'; + +/** + * Returns whether fiat payment is available for the current transaction. + * True when the transaction type is fiat-enabled via remote feature flags + * AND at least one Ramps payment method exists. + */ +export function useIsFiatPaymentAvailable(): boolean { + const transactionMeta = useTransactionMetadataRequest(); + const { enabledTransactionTypes } = useMMPayFiatConfig(); + const { paymentMethods } = useRampsPaymentMethods(); + + return ( + hasTransactionType(transactionMeta, enabledTransactionTypes) && + paymentMethods.length > 0 + ); +} diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts index 1efe5e4048f0..63508787365e 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts @@ -118,6 +118,32 @@ describe('useTransactionPayAvailableTokens', () => { ); }); + it('returns hasTokens false when all tokens are disabled', () => { + const disabledToken = { ...TOKEN_MOCK, disabled: true }; + jest.mocked(getAvailableTokens).mockReturnValue([disabledToken]); + + const { result } = renderHookWithProvider(useTransactionPayAvailableTokens); + + expect(result.current.availableTokens).toHaveLength(1); + expect(result.current.hasTokens).toBe(false); + }); + + it('returns hasTokens true when at least one token is not disabled', () => { + const disabledToken = { ...TOKEN_MOCK, disabled: true }; + const enabledToken = { + ...TOKEN_MOCK, + address: '0xEnabled', + disabled: false, + }; + jest + .mocked(getAvailableTokens) + .mockReturnValue([disabledToken, enabledToken]); + + const { result } = renderHookWithProvider(useTransactionPayAvailableTokens); + + expect(result.current.hasTokens).toBe(true); + }); + it('passes default blocklist when transaction type has no override', () => { const defaultBlocked = { chainIds: ['0x1'], diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts index 6ffe0b2c7532..174327dde395 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts @@ -22,8 +22,10 @@ export function useTransactionPayAvailableTokens() { ); // For post-quote transactions, tokens are always available - // (the supported destination tokens from the bridge API) - const hasTokens = isPostQuote || availableTokens.length > 0; + // (the supported destination tokens from the bridge API). + // Disabled tokens (e.g. required tokens with no gas) are excluded + // so the UI can correctly fall back to fiat payment or BuySection. + const hasTokens = isPostQuote || availableTokens.some((t) => !t.disabled); return { availableTokens, hasTokens }; } diff --git a/app/components/Views/confirmations/hooks/tokens/useAddToken.test.ts b/app/components/Views/confirmations/hooks/tokens/useAddToken.test.ts index f05976bbeeaa..b5ef8a763f4e 100644 --- a/app/components/Views/confirmations/hooks/tokens/useAddToken.test.ts +++ b/app/components/Views/confirmations/hooks/tokens/useAddToken.test.ts @@ -8,6 +8,8 @@ import { otherControllersMock, } from '../../__mocks__/controllers/other-controllers-mock'; import { Token } from '@metamask/assets-controllers'; +import { selectIsAssetsUnifyStateEnabled } from '../../../../../selectors/featureFlagController/assetsUnifyState'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../../selectors/multichainAccounts/accountTreeController'; jest.mock('../../../../../core/Engine', () => ({ context: { @@ -17,9 +19,30 @@ jest.mock('../../../../../core/Engine', () => ({ TokensController: { addToken: jest.fn(), }, + AssetsController: { + addCustomAsset: jest.fn(), + }, }, })); +jest.mock( + '../../../../../selectors/featureFlagController/assetsUnifyState', + () => ({ + selectIsAssetsUnifyStateEnabled: jest.fn(() => false), + }), +); + +jest.mock( + '../../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + ...jest.requireActual( + '../../../../../selectors/multichainAccounts/accountTreeController', + ), + selectSelectedAccountGroupEvmInternalAccount: jest.fn(() => null), + }), +); + +const ACCOUNT_ID_MOCK = 'mock-account-id'; const TOKEN_ADDRESS_MOCK = '0x1234' as const; const CHAIN_ID_MOCK = '0x1' as const; const NETWORK_CLIENT_ID = 'mockNetworkClientId'; @@ -65,6 +88,9 @@ async function runHook({ describe('useAddToken', () => { const mockAddToken = jest.mocked(Engine.context.TokensController.addToken); + const mockAddCustomAsset = jest.mocked( + Engine.context.AssetsController.addCustomAsset, + ); const mockFindNetworkClientIdByChainId = jest.mocked( Engine.context.NetworkController.findNetworkClientIdByChainId, @@ -75,6 +101,17 @@ describe('useAddToken', () => { mockFindNetworkClientIdByChainId.mockReturnValue(NETWORK_CLIENT_ID); mockAddToken.mockResolvedValue([]); + mockAddCustomAsset.mockResolvedValue(undefined); + (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue( + false, + ); + ( + selectSelectedAccountGroupEvmInternalAccount as unknown as jest.Mock + ).mockReturnValue({ + id: ACCOUNT_ID_MOCK, + address: accountMock, + type: 'eip155:eoa', + }); }); it('adds token if not present', async () => { @@ -100,4 +137,45 @@ describe('useAddToken', () => { expect(mockAddToken).not.toHaveBeenCalled(); }); + + it('does not call addCustomAsset when assetsUnifyState is disabled', async () => { + await runHook(); + + expect(mockAddToken).toHaveBeenCalled(); + expect(mockAddCustomAsset).not.toHaveBeenCalled(); + }); + + it('calls addCustomAsset when assetsUnifyState is enabled', async () => { + (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue( + true, + ); + + await runHook(); + + expect(mockAddToken).toHaveBeenCalled(); + expect(mockAddCustomAsset).toHaveBeenCalledWith( + ACCOUNT_ID_MOCK, + expect.stringContaining('erc20'), + { + address: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + decimals: DECIMALS_MOCK, + name: NAME_MOCK, + symbol: SYMBOL_MOCK, + }, + ); + }); + + it('does not add token if already present with assetsUnifyState enabled', async () => { + (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue( + false, + ); + + await runHook({ + existingTokens: [{ address: TOKEN_ADDRESS_MOCK }], + }); + + expect(mockAddToken).not.toHaveBeenCalled(); + expect(mockAddCustomAsset).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/confirmations/hooks/tokens/useAddToken.ts b/app/components/Views/confirmations/hooks/tokens/useAddToken.ts index 08c806217660..3c5173a9b646 100644 --- a/app/components/Views/confirmations/hooks/tokens/useAddToken.ts +++ b/app/components/Views/confirmations/hooks/tokens/useAddToken.ts @@ -1,7 +1,11 @@ import Engine from '../../../../../core/Engine'; import { useSelector } from 'react-redux'; import { selectTokensByChainIdAndAddress } from '../../../../../selectors/tokensController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../../selectors/multichainAccounts/accountTreeController'; +import { selectIsAssetsUnifyStateEnabled } from '../../../../../selectors/featureFlagController/assetsUnifyState'; import { useAsyncResult } from '../../../../hooks/useAsyncResult'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import { toAssetId } from '../../../../UI/Bridge/hooks/useAssetMetadata/utils'; import { Hex, createProjectLogger } from '@metamask/utils'; const log = createProjectLogger('add-token'); @@ -19,7 +23,8 @@ export function useAddToken({ symbol: string; tokenAddress: Hex; }) { - const { NetworkController, TokensController } = Engine.context; + const { NetworkController, TokensController, AssetsController } = + Engine.context; const addedTokens = useSelector((state) => selectTokensByChainIdAndAddress(state, chainId), @@ -29,6 +34,12 @@ export function useAddToken({ (t) => t.address.toLowerCase() === tokenAddress.toLowerCase(), ); + const isAssetsUnifyStateEnabled = useSelector( + selectIsAssetsUnifyStateEnabled, + ); + const evmAccount = useSelector(selectSelectedAccountGroupEvmInternalAccount); + const accountId = evmAccount?.id; + const { error } = useAsyncResult(async () => { if (hasToken) { return; @@ -45,8 +56,23 @@ export function useAddToken({ symbol, }); + if (isAssetsUnifyStateEnabled && accountId) { + const caipChainId = toEvmCaipChainId(chainId); + const caipAssetType = toAssetId(tokenAddress, caipChainId); + + if (caipAssetType) { + await AssetsController.addCustomAsset(accountId, caipAssetType, { + address: tokenAddress, + chainId, + decimals, + name, + symbol, + }); + } + } + log('Added token', { tokenAddress, chainId }); - }, [hasToken]); + }, [hasToken, isAssetsUnifyStateEnabled, accountId]); if (error) { log('Failed', { tokenAddress, chainId, error }); diff --git a/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.test.ts b/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.test.ts index 898bd5cd1b84..90e2c2310009 100644 --- a/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.test.ts +++ b/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.test.ts @@ -2,9 +2,11 @@ import { renderHook } from '@testing-library/react-hooks'; import { useNavigation } from '@react-navigation/native'; import { BackHandler } from 'react-native'; import Device from '../../../../../util/device'; +import Logger from '../../../../../util/Logger'; import { useConfirmActions } from '../useConfirmActions'; import { useFullScreenConfirmation } from './useFullScreenConfirmation'; import useClearConfirmationOnBackSwipe from './useClearConfirmationOnBackSwipe'; +import { useConfirmationContext } from '../../context/confirmation-context'; jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), @@ -23,6 +25,17 @@ jest.mock('./useFullScreenConfirmation', () => ({ useFullScreenConfirmation: jest.fn(), })); +jest.mock('../../context/confirmation-context', () => ({ + useConfirmationContext: jest.fn(), +})); + +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})); + describe('useClearConfirmationOnBackSwipe', () => { const mockUnsubscribe = jest.fn(); const mockAddListener = jest.fn().mockReturnValue(mockUnsubscribe); @@ -40,6 +53,10 @@ describe('useClearConfirmationOnBackSwipe', () => { onReject: mockOnReject, }); + (useConfirmationContext as jest.Mock).mockReturnValue({ + isConfirmationSubmitting: false, + }); + jest.spyOn(BackHandler, 'addEventListener').mockReturnValue({ remove: mockBackHandlerRemove, }); @@ -81,6 +98,223 @@ describe('useClearConfirmationOnBackSwipe', () => { gestureEndCallback(); expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(); + }); + + it('calls onReject with skipNavigation when beforeRemove follows a gestureStart event', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ rejectOnBeforeRemove: true }), + ); + const gestureStartCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureStart', + )?.[1]; + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + gestureStartCallback(); + beforeRemoveCallback(); + + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(undefined, true); + }); + + it('does not reject on beforeRemove without a gestureStart event', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ rejectOnBeforeRemove: true }), + ); + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + beforeRemoveCallback(); + + expect(mockOnReject).not.toHaveBeenCalled(); + }); + + it('calls onReject with skipNavigation on beforeRemove without gesture when configured', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + rejectOnBeforeRemoveWithoutGesture: true, + }), + ); + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + beforeRemoveCallback(); + + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(undefined, true); + }); + + it('does not reject on beforeRemove when confirmation is submitting', () => { + (useConfirmationContext as jest.Mock).mockReturnValue({ + isConfirmationSubmitting: true, + }); + renderHook(() => + useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + rejectOnBeforeRemoveWithoutGesture: true, + }), + ); + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + beforeRemoveCallback(); + + expect(mockOnReject).not.toHaveBeenCalled(); + }); + + it('does not reject on gestureEnd when confirmation is submitting', () => { + const mockOnBeforeReject = jest.fn(); + (useConfirmationContext as jest.Mock).mockReturnValue({ + isConfirmationSubmitting: true, + }); + renderHook(() => + useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + onBeforeReject: mockOnBeforeReject, + }), + ); + const gestureEndCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureEnd', + )?.[1]; + + gestureEndCallback(); + + expect(mockOnBeforeReject).not.toHaveBeenCalled(); + expect(mockOnReject).not.toHaveBeenCalled(); + }); + + it('does not reject on beforeRemove after a gestureCancel event', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ rejectOnBeforeRemove: true }), + ); + const gestureStartCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureStart', + )?.[1]; + const gestureCancelCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureCancel', + )?.[1]; + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + gestureStartCallback(); + gestureCancelCallback(); + beforeRemoveCallback(); + + expect(mockOnReject).not.toHaveBeenCalled(); + }); + + it('does not reject twice when multiple dismissal events are triggered', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ rejectOnBeforeRemove: true }), + ); + const gestureStartCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureStart', + )?.[1]; + const gestureEndCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureEnd', + )?.[1]; + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + gestureStartCallback(); + beforeRemoveCallback(); + gestureEndCallback(); + + expect(mockOnReject).toHaveBeenCalledTimes(1); + }); + + it('calls onReject without skipNavigation when gestureEnd is triggered for rejectOnBeforeRemove by default', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ rejectOnBeforeRemove: true }), + ); + const gestureEndCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureEnd', + )?.[1]; + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + gestureEndCallback(); + beforeRemoveCallback(); + + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(undefined, false); + }); + + it('calls onReject with skipNavigation when gestureEnd is configured to skip navigation', () => { + renderHook(() => + useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + skipNavigationOnGestureEnd: true, + }), + ); + const gestureEndCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureEnd', + )?.[1]; + const beforeRemoveCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'beforeRemove', + )?.[1]; + + gestureEndCallback(); + beforeRemoveCallback(); + + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(undefined, true); + }); + + it('logs onBeforeReject errors and still rejects the confirmation', () => { + const mockError = new Error('cleanup failed'); + const mockOnBeforeReject = jest.fn(() => { + throw mockError; + }); + renderHook(() => + useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + onBeforeReject: mockOnBeforeReject, + }), + ); + const gestureEndCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureEnd', + )?.[1]; + + gestureEndCallback(); + + expect(Logger.error).toHaveBeenCalledTimes(1); + expect(Logger.error).toHaveBeenCalledWith( + mockError, + 'useClearConfirmationOnBackSwipe: onBeforeReject failed', + ); + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(undefined, false); + }); + + it('calls onBeforeReject before rejecting the confirmation', () => { + const mockOnBeforeReject = jest.fn(); + renderHook(() => + useClearConfirmationOnBackSwipe({ + rejectOnBeforeRemove: true, + onBeforeReject: mockOnBeforeReject, + }), + ); + const gestureEndCallback = mockAddListener.mock.calls.find( + ([eventName]) => eventName === 'gestureEnd', + )?.[1]; + + gestureEndCallback(); + + expect(mockOnBeforeReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnBeforeReject.mock.invocationCallOrder[0]).toBeLessThan( + mockOnReject.mock.invocationCallOrder[0], + ); }); it('calls unsubscribe when unmounted', () => { @@ -122,6 +356,7 @@ describe('useClearConfirmationOnBackSwipe', () => { const result = backHandlerCallback(); expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(); expect(result).toBe(true); }); @@ -148,6 +383,7 @@ describe('useClearConfirmationOnBackSwipe', () => { gestureEndCallback(); expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockOnReject).toHaveBeenCalledWith(); }); }); }); diff --git a/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.ts b/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.ts index e372ab36da2c..971caa8d182a 100644 --- a/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.ts +++ b/app/components/Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe.ts @@ -1,33 +1,152 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { BackHandler } from 'react-native'; import Device from '../../../../../util/device'; +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../../../../util/errorUtils'; import { useConfirmActions } from '../useConfirmActions'; import { useFullScreenConfirmation } from './useFullScreenConfirmation'; import type { RootStackParamList } from '../../../../../core/NavigationService/types'; +import { useConfirmationContext } from '../../context/confirmation-context'; -const useClearConfirmationOnBackSwipe = () => { +interface UseClearConfirmationOnBackSwipeOptions { + rejectOnBeforeRemove?: boolean; + rejectOnBeforeRemoveWithoutGesture?: boolean; + skipNavigationOnGestureEnd?: boolean; + onBeforeReject?: () => void; +} + +const useClearConfirmationOnBackSwipe = ({ + rejectOnBeforeRemove = false, + rejectOnBeforeRemoveWithoutGesture = false, + skipNavigationOnGestureEnd = false, + onBeforeReject, +}: UseClearConfirmationOnBackSwipeOptions = {}) => { const navigation = useNavigation>(); const { isFullScreenConfirmation } = useFullScreenConfirmation(); const { onReject } = useConfirmActions(); + const { isConfirmationSubmitting } = useConfirmationContext(); + const hasRejectedRef = useRef(false); + const isGestureInProgressRef = useRef(false); + const isConfirmationSubmittingRef = useRef(isConfirmationSubmitting); + + useEffect(() => { + isConfirmationSubmittingRef.current = isConfirmationSubmitting; + }, [isConfirmationSubmitting]); + + const rejectConfirmation = useCallback( + (skipNavigation = false) => { + if (hasRejectedRef.current || isConfirmationSubmittingRef.current) { + return; + } + + try { + onBeforeReject?.(); + } catch (error) { + Logger.error( + ensureError(error), + 'useClearConfirmationOnBackSwipe: onBeforeReject failed', + ); + } + + hasRejectedRef.current = true; + onReject(undefined, skipNavigation); + }, + [onBeforeReject, onReject], + ); useEffect(() => { if (isFullScreenConfirmation) { const unsubscribe = navigation.addListener('gestureEnd', () => { + isGestureInProgressRef.current = false; + if (rejectOnBeforeRemove) { + rejectConfirmation(skipNavigationOnGestureEnd); + return; + } + onReject(); }); - return unsubscribe; + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + } + }, [ + isFullScreenConfirmation, + navigation, + onReject, + rejectConfirmation, + rejectOnBeforeRemove, + skipNavigationOnGestureEnd, + ]); + + useEffect(() => { + if (isFullScreenConfirmation && rejectOnBeforeRemove) { + const unsubscribeGestureStart = navigation.addListener( + 'gestureStart', + () => { + isGestureInProgressRef.current = true; + }, + ); + const unsubscribeGestureCancel = navigation.addListener( + 'gestureCancel', + () => { + isGestureInProgressRef.current = false; + }, + ); + const unsubscribeBeforeRemove = navigation.addListener( + 'beforeRemove', + () => { + const shouldRejectBeforeRemove = + isGestureInProgressRef.current || + rejectOnBeforeRemoveWithoutGesture; + + if ( + isConfirmationSubmittingRef.current || + !shouldRejectBeforeRemove + ) { + return; + } + + isGestureInProgressRef.current = false; + rejectConfirmation(true); + }, + ); + + return () => { + if (typeof unsubscribeGestureStart === 'function') { + unsubscribeGestureStart(); + } + if (typeof unsubscribeGestureCancel === 'function') { + unsubscribeGestureCancel(); + } + if (typeof unsubscribeBeforeRemove === 'function') { + unsubscribeBeforeRemove(); + } + }; } - }, [isFullScreenConfirmation, navigation, onReject]); + }, [ + isFullScreenConfirmation, + navigation, + rejectConfirmation, + rejectOnBeforeRemove, + rejectOnBeforeRemoveWithoutGesture, + ]); useEffect(() => { if (isFullScreenConfirmation && Device.isAndroid()) { const backHandlerSubscription = BackHandler.addEventListener( 'hardwareBackPress', () => { - onReject(); + if (rejectOnBeforeRemove) { + rejectConfirmation(); + } else { + onReject(); + } + return true; }, ); @@ -36,7 +155,14 @@ const useClearConfirmationOnBackSwipe = () => { backHandlerSubscription.remove(); }; } - }, [isFullScreenConfirmation, onReject]); + }, [ + isFullScreenConfirmation, + onReject, + rejectConfirmation, + rejectOnBeforeRemove, + ]); + + return rejectConfirmation; }; export default useClearConfirmationOnBackSwipe; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index c73aca978b25..e7504df02f0d 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -385,6 +385,7 @@ const Routes = { ROOT: 'Predict', MARKET_LIST: 'PredictMarketList', MARKET_DETAILS: 'PredictMarketDetails', + POSITIONS: 'PredictPositions', ACTIVITY_DETAIL: 'PredictActivityDetail', WORLD_CUP: 'PredictWorldCup', MODALS: { diff --git a/app/core/AgenticService/AgentStepHud.test.tsx b/app/core/AgenticService/AgentStepHud.test.tsx index 45e3d4cad3dd..7ea873053b2a 100644 --- a/app/core/AgenticService/AgentStepHud.test.tsx +++ b/app/core/AgenticService/AgentStepHud.test.tsx @@ -52,11 +52,59 @@ describe('AgentStepHud', () => { const callback = getLatestCallback(); act(() => { - callback({ id: 'open-pos', description: 'Open BTC position' }); + callback({ id: 'run 2/10', description: 'Open BTC position' }); }); - expect(getByText('open-pos')).toBeOnTheScreen(); - expect(getByText('Open BTC position')).toBeOnTheScreen(); + expect(getByText('RUN 2/10')).toBeOnTheScreen(); + expect(getByText(/Open BTC position/)).toBeOnTheScreen(); + }); + + it('renders failed status in red instead of success green', () => { + const { getByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ id: 'fail 9/19', description: 'Close position failed' }); + }); + + expect(getByText('FAIL 9/19')).toHaveStyle({ color: '#FF4D4F' }); + }); + + it('shows one intent line and hides unmarked metadata lines', () => { + const { getAllByText, queryByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ + id: 'run 1/2', + description: 'Prepare clean state\nPrepare clean state\nperps setup', + }); + }); + + expect(getAllByText(/Prepare clean state/)).toHaveLength(1); + expect(queryByText('perps setup')).toBeNull(); + }); + + it('shows explicit subflow and error lines only', () => { + const { getByText, queryByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ + id: 'fail 1/2', + description: + 'Complete the validation checkpoint\nDuplicate metadata line should stay hidden\nsubflow: Prepare scenario\nerror: Timed out waiting for checkpoint', + }); + }); + + expect(getByText(/Complete the validation checkpoint/)).toBeOnTheScreen(); + expect(getByText('Prepare scenario')).toBeOnTheScreen(); + expect( + getByText('error: Timed out waiting for checkpoint'), + ).toBeOnTheScreen(); + expect( + queryByText('Duplicate metadata line should stay hidden'), + ).toBeNull(); }); it('hides overlay when callback fires with null', () => { diff --git a/app/core/AgenticService/AgentStepHud.tsx b/app/core/AgenticService/AgentStepHud.tsx index 14995b2bcfc2..ad4c8f762e6f 100644 --- a/app/core/AgenticService/AgentStepHud.tsx +++ b/app/core/AgenticService/AgentStepHud.tsx @@ -6,6 +6,65 @@ import { registerStepHudCallback } from './AgenticService'; interface Step { id: string; description: string; + status?: string; +} + +function statusForStep(step: Step) { + return String(step.status ?? step.id.split(/\s+/)[0] ?? '').toLowerCase(); +} + +function progressForStep(step: Step) { + const progressPattern = /\b\d+\s*\/\s*\d+\b/; + const match = progressPattern.exec(step.id); + return match ? match[0].replace(/\s+/g, '') : null; +} + +function badgeTextForStep(step: Step) { + const status = statusForStep(step); + const progress = progressForStep(step); + return [status || 'run', progress].filter(Boolean).join(' ').toUpperCase(); +} + +function statusToneForStep(step: Step) { + const status = statusForStep(step); + if (status === 'fail' || status === 'failed' || status === 'error') { + return 'fail'; + } + if (status === 'pass' || status === 'passed' || status === 'success') { + return 'pass'; + } + return 'running'; +} + +function secondaryDisplayText(part: string) { + const errorPrefix = 'error:'; + const subflowPrefix = 'subflow:'; + const detailPrefix = 'detail:'; + const normalized = part.toLowerCase(); + + if (normalized.startsWith(errorPrefix)) { + return part; + } + if (normalized.startsWith(subflowPrefix)) { + return part.slice(subflowPrefix.length).trim(); + } + if (normalized.startsWith(detailPrefix)) { + return part.slice(detailPrefix.length).trim(); + } + return null; +} + +function parseDescription(description: string) { + const parts = description + .split('\n') + .map((part) => part.trim()) + .filter(Boolean); + const deduped = parts.filter((part, index) => parts.indexOf(part) === index); + const [intent = '', ...secondaryCandidates] = deduped; + const secondary = secondaryCandidates + .map(secondaryDisplayText) + .filter((part): part is string => Boolean(part)); + return { intent, secondary }; } // Debug-only overlay — intentionally uses hardcoded colors for guaranteed @@ -14,23 +73,37 @@ interface Step { const styles = StyleSheet.create({ container: { position: 'absolute', - bottom: 0, left: 0, right: 0, zIndex: 9999, - backgroundColor: 'rgba(0, 0, 0, 0.75)', - paddingVertical: 6, + backgroundColor: 'rgba(0, 0, 0, 0.58)', + paddingVertical: 3, }, - stepId: { - color: '#00FF88', - fontFamily: 'Courier', - fontSize: 12, + line: { + color: '#FFFFFF', + fontSize: 11, fontWeight: '700', + lineHeight: 14, }, - description: { - color: '#FFFFFF', - fontSize: 12, - fontWeight: '500', + badgeText: { + fontFamily: 'Courier', + fontSize: 9, + fontWeight: '800', + }, + badgeTextRunning: { + color: '#00FF88', + }, + badgeTextPass: { + color: '#00FF88', + }, + badgeTextFail: { + color: '#FF4D4F', + }, + secondary: { + color: '#E6E6E6', + fontSize: 10, + fontWeight: '400', + lineHeight: 12, }, }); /* eslint-enable react-native/no-color-literals, @metamask/design-tokens/color-no-hex */ @@ -44,9 +117,9 @@ const AgentStepHudInner = () => { () => [ styles.container, { + bottom: Math.max(insets.bottom, 0), paddingLeft: Math.max(insets.left, 10), paddingRight: Math.max(insets.right, 10), - paddingBottom: insets.bottom > 0 ? insets.bottom : 6, }, ], [insets.left, insets.right, insets.bottom], @@ -61,12 +134,27 @@ const AgentStepHudInner = () => { if (!step) return null; + const { intent, secondary } = parseDescription(step.description); + const badge = badgeTextForStep(step); + const tone = statusToneForStep(step); + const badgeTextStyle = + tone === 'fail' + ? styles.badgeTextFail + : tone === 'pass' + ? styles.badgeTextPass + : styles.badgeTextRunning; + return ( - - {step.id} - {` ${step.description}`} + + {badge} + {intent ? ` ${intent}` : ''} + {secondary.map((detail) => ( + + {detail} + + ))} ); }; diff --git a/app/core/AgenticService/AgenticService.test.ts b/app/core/AgenticService/AgenticService.test.ts index 85b96e2655c6..a29dead227f9 100644 --- a/app/core/AgenticService/AgenticService.test.ts +++ b/app/core/AgenticService/AgenticService.test.ts @@ -5,10 +5,13 @@ import AgenticService, { tryScroll, toAccountSummary, registerStepHudCallback, + getFixtureMnemonicCount, + getFixtureAccountNames, type FiberNode, type ReactDevToolsHook, } from './AgenticService'; import Engine from '../Engine'; +import { Platform } from 'react-native'; import type { NavigationContainerRef, ParamListBase, @@ -38,6 +41,10 @@ jest.mock('../Engine', () => ({ }, }, }, + AccountTreeController: { + state: { accountTree: { wallets: {} } }, + setAccountGroupName: jest.fn(), + }, MultichainAccountService: { createMultichainAccountWallet: (...args: unknown[]) => mockCreateWallet(...args), @@ -45,13 +52,60 @@ jest.mock('../Engine', () => ({ }, KeyringController: { importAccountWithStrategy: (...args: unknown[]) => - mockImportAccount(...args), + mockImportAccount(...(args as [string, string[]])), }, PerpsController: { markTutorialCompleted: jest.fn(), + getPositions: jest.fn().mockResolvedValue([]), }, }, setSelectedAddress: jest.fn(), + setAccountLabel: jest.fn(), +})); + +// AgenticService imports the Engine *class* (for the disableAutomaticVaultBackup +// static) separately from the ../Engine facade. Stub it so the test does not +// pull in the full Engine/RewardsController/SecureKeychain stack. +jest.mock('../Engine/Engine', () => ({ + Engine: class { + static disableAutomaticVaultBackup = false; + }, +})); + +const mockEnsureConnected = jest.fn().mockResolvedValue(undefined); +const mockClearAllChannels = jest.fn(); + +jest.mock('../../components/UI/Perps/services/PerpsConnectionManager', () => ({ + __esModule: true, + default: { + ensureConnected: (...args: unknown[]) => mockEnsureConnected(...args), + }, +})); + +jest.mock('../../components/UI/Perps/providers/PerpsStreamManager', () => ({ + getStreamManagerInstance: () => ({ + clearAllChannels: (...args: unknown[]) => mockClearAllChannels(...args), + }), +})); + +// Authentication pulls in the full auth/keychain stack; stub the singleton. +jest.mock('../Authentication', () => ({ + __esModule: true, + default: { + unlockWallet: jest.fn().mockResolvedValue(undefined), + }, +})); + +// addNewHdAccount/importNewSecretRecoveryPhrase pull in a sentry/selector chain +// that cannot load in the unit-test env; stub them directly. +const mockAddNewHdAccount = jest.fn().mockResolvedValue(undefined); +const mockImportNewSecretRecoveryPhrase = jest + .fn() + .mockResolvedValue(undefined); +jest.mock('../../actions/multiSrp', () => ({ + addNewHdAccount: (...args: unknown[]) => mockAddNewHdAccount(...args), + importNewSecretRecoveryPhrase: (...args: unknown[]) => + mockImportNewSecretRecoveryPhrase(...args), })); const mockDispatch = jest.fn(); @@ -88,9 +142,18 @@ jest.mock('../../actions/settings', () => ({ jest.mock('@metamask/key-tree', () => ({ mnemonicPhraseToBytes: jest.fn((s: string) => new Uint8Array(s.length)), })); -jest.mock('../../store/storage-wrapper', () => ({ - setItem: jest.fn().mockResolvedValue(undefined), -})); +jest.mock('../../store/storage-wrapper', () => { + const storageWrapper = { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + }; + return { + __esModule: true, + default: storageWrapper, + getItem: storageWrapper.getItem, + setItem: storageWrapper.setItem, + }; +}); jest.mock('../../constants/storage', () => ({ OPTIN_META_METRICS_UI_SEEN: 'optin_meta_metrics_ui_seen', PERPS_GTM_MODAL_SHOWN: 'perps_gtm', @@ -273,6 +336,49 @@ describe('toAccountSummary', () => { }); }); +describe('getFixtureMnemonicCount', () => { + it('defaults to 1 when no count is provided', () => { + expect(getFixtureMnemonicCount(undefined)).toBe(1); + expect(getFixtureMnemonicCount({})).toBe(1); + }); + + it('prefers count, falls back to numberOfAccounts', () => { + expect(getFixtureMnemonicCount({ count: 3 })).toBe(3); + expect(getFixtureMnemonicCount({ numberOfAccounts: 2 })).toBe(2); + expect(getFixtureMnemonicCount({ count: 5, numberOfAccounts: 2 })).toBe(5); + }); + + it('throws on out-of-range or non-integer counts', () => { + expect(() => getFixtureMnemonicCount({ count: 0 })).toThrow(); + expect(() => getFixtureMnemonicCount({ count: 101 })).toThrow(); + expect(() => getFixtureMnemonicCount({ count: 1.5 })).toThrow(); + }); +}); + +describe('getFixtureAccountNames', () => { + it('uses explicit names by index when present', () => { + expect(getFixtureAccountNames({ names: ['One', 'Two'] }, 2)).toEqual([ + 'One', + 'Two', + ]); + }); + + it('uses name only for the first account', () => { + expect(getFixtureAccountNames({ name: 'Primary' }, 2)).toEqual([ + 'Primary', + 'Account 2', + ]); + }); + + it('falls back to Account N when nothing is provided', () => { + expect(getFixtureAccountNames(undefined, 3)).toEqual([ + 'Account 1', + 'Account 2', + 'Account 3', + ]); + }); +}); + describe('tryScroll', () => { it('returns false for null start', () => { expect(tryScroll(null, 100, false)).toBe(false); @@ -392,6 +498,24 @@ describe('AgenticService.install', () => { expect(mockDeferredNav.goBack).toHaveBeenCalled(); }); + it('refreshPerpsStreams reconnects streams and reports position count', async () => { + mockEnsureConnected.mockClear(); + mockClearAllChannels.mockClear(); + ( + MockEngine.context.PerpsController.getPositions as jest.Mock + ).mockResolvedValue([{ coin: 'ETH' }, { coin: 'BTC' }]); + + await expect(bridge().refreshPerpsStreams()).resolves.toEqual({ + ok: true, + positions: 2, + }); + expect(mockEnsureConnected).toHaveBeenCalledWith({ + source: 'agentic_refresh_perps_streams', + suppressError: true, + }); + expect(mockClearAllChannels).toHaveBeenCalledTimes(1); + }); + it('listAccounts returns mapped accounts', () => { ( MockEngine.context.AccountsController.listAccounts as jest.Mock @@ -641,43 +765,6 @@ describe('AgenticService.install', () => { mockDispatch.mockClear(); }); - it('creates wallet from mnemonic and returns accounts', async () => { - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [{ type: 'mnemonic', value: 'word1 word2 word3' }], - }); - expect(result.ok).toBe(true); - expect(result.accounts).toEqual([ - { id: 'a1', address: '0xABC', name: 'Account 1' }, - ]); - expect(mockCreateWallet).toHaveBeenCalledWith( - expect.objectContaining({ type: 'restore', password: 'test123' }), - ); - }); - - it('creates wallet without mnemonic when no mnemonic account', async () => { - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [{ type: 'privateKey', value: '0xkey' }], - }); - expect(result.ok).toBe(true); - expect(mockCreateWallet).toHaveBeenCalledWith( - expect.objectContaining({ type: 'create', password: 'test123' }), - ); - expect(mockImportAccount).toHaveBeenCalled(); - }); - - it('imports private key accounts', async () => { - await bridge().setupWallet({ - password: 'test123', - accounts: [ - { type: 'mnemonic', value: 'word1 word2' }, - { type: 'privateKey', value: '0xkey1' }, - ], - }); - expect(mockImportAccount).toHaveBeenCalledWith('privateKey', ['0xkey1']); - }); - it('dispatches all onboarding flags', async () => { await bridge().setupWallet({ password: 'test123', @@ -700,18 +787,6 @@ describe('AgenticService.install', () => { expect(result.error).toBe('boom'); }); - it('handles failed private key import gracefully', async () => { - mockImportAccount.mockRejectedValueOnce(new Error('bad key')); - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [ - { type: 'mnemonic', value: 'words' }, - { type: 'privateKey', value: '0xbad' }, - ], - }); - expect(result.ok).toBe(true); - }); - it('opts out of metametrics when specified', async () => { const { analytics } = jest.requireMock('../../util/analytics/analytics'); await bridge().setupWallet({ @@ -807,16 +882,43 @@ describe('AgenticService.install', () => { ); }); - it('dispatches setOsAuthEnabled(true) when deviceAuthEnabled is true', async () => { + it('dispatches setOsAuthEnabled(true) on Android when deviceAuthEnabled is true', async () => { mockDispatch.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { deviceAuthEnabled: true }, - }); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED', enabled: true }), - ); + const originalOS = Platform.OS; + Platform.OS = 'android'; + try { + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { deviceAuthEnabled: true }, + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SET_OS_AUTH_ENABLED', + enabled: true, + }), + ); + } finally { + Platform.OS = originalOS; + } + }); + + it('does not dispatch setOsAuthEnabled on iOS even when deviceAuthEnabled is true', async () => { + mockDispatch.mockClear(); + const originalOS = Platform.OS; + Platform.OS = 'ios'; + try { + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { deviceAuthEnabled: true }, + }); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED' }), + ); + } finally { + Platform.OS = originalOS; + } }); it('does not dispatch setOsAuthEnabled when deviceAuthEnabled is not set', async () => { diff --git a/app/core/AgenticService/AgenticService.ts b/app/core/AgenticService/AgenticService.ts index 8f98502fff3e..733ff9f880c2 100644 --- a/app/core/AgenticService/AgenticService.ts +++ b/app/core/AgenticService/AgenticService.ts @@ -1,21 +1,20 @@ /** - * AgenticService — __DEV__-only bridge for AI coding agents. + * AgenticService — __DEV__-only bridge for Farmslot recipe runners. * - * This file is NEVER bundled in production builds (guarded by __DEV__). - * It intentionally uses loose types, inline casts, and minimal abstractions - * because it is throwaway dev tooling — not shared library code. Do not - * apply production code standards (strict types, full error handling, - * abstraction layers) here; keep it pragmatic and easy to change. + * This file is not bundled in production builds. It exposes a stable control + * surface for deterministic local recipes while keeping user-visible proof + * flows on the real app path. */ import { NavigationContainerRef, ParamListBase, } from '@react-navigation/native'; -import { Platform } from 'react-native'; +import { Dimensions, Platform } from 'react-native'; import Logger from '../../util/Logger'; import ReduxService from '../redux'; import { persistor } from '../../store'; import Engine from '../Engine'; +import { Engine as EngineClass } from '../Engine/Engine'; import { passwordSet, setExistingUser, @@ -26,6 +25,7 @@ import { import { setCompletedOnboarding } from '../../actions/onboarding'; import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import { AccountImportStrategy } from '@metamask/keyring-controller'; +import type { AccountGroupId } from '@metamask/account-api'; import StorageWrapper from '../../store/storage-wrapper'; import { OPTIN_META_METRICS_UI_SEEN, @@ -45,6 +45,15 @@ import Routes from '../../constants/navigation/Routes'; import SecureKeychain from '../SecureKeychain'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; import DevLogger from '../SDKConnect/utils/DevLogger'; +import { + addNewHdAccount, + importNewSecretRecoveryPhrase, +} from '../../actions/multiSrp'; +import { bufferToHex, privateToAddress } from 'ethereumjs-util'; +import Authentication from '../Authentication'; +import { Wallet as EthersWallet } from 'ethers'; +import PerpsConnectionManager from '../../components/UI/Perps/services/PerpsConnectionManager'; +import { getStreamManagerInstance } from '../../components/UI/Perps/providers/PerpsStreamManager'; // ─── Fiber tree types ────────────────────────────────────────────────────── @@ -65,6 +74,19 @@ interface FiberNode { stateNode: { scrollTo?: (opts: { y: number; animated: boolean }) => void; scrollToOffset?: (opts: { offset: number; animated: boolean }) => void; + measure?: ( + callback: ( + x: number, + y: number, + width: number, + height: number, + pageX: number, + pageY: number, + ) => void, + ) => void; + measureInWindow?: ( + callback: (x: number, y: number, width: number, height: number) => void, + ) => void; [key: string]: unknown; } | null; } @@ -81,6 +103,7 @@ interface ReactDevToolsHook { /** Shape of the __DEV__-only agentic bridge on globalThis. */ interface AgenticBridge { platform: string; + replayHarnessPatch?: string; navigate: (name: string, params?: object) => void; getRoute: () => unknown; getState: () => unknown; @@ -146,6 +169,9 @@ interface AgenticBridge { type: 'mnemonic' | 'privateKey'; value: string; name?: string; + count?: number; + numberOfAccounts?: number; + names?: string[]; }[]; settings?: { metametrics?: boolean; @@ -155,15 +181,41 @@ interface AgenticBridge { deviceAuthEnabled?: boolean; }; }) => Promise<{ + ok: boolean; + error?: string; + step?: string; + accounts?: { address: string; name: string }[]; + }>; + applyWalletFixture: ( + fixture: Parameters[0], + ) => Promise<{ ok: boolean; error?: string; accounts?: { address: string; name: string }[]; }>; showStep: (step: { id: string; description: string }) => void; hideStep: () => void; + refreshPerpsStreams: () => Promise<{ ok: boolean; positions: number }>; findFiberByTestId: (testId: string) => boolean; + queryUiTarget: (options: { + testId?: string; + textContains?: string; + visibility?: 'tree' | 'viewport'; + }) => Promise<{ + present: boolean; + visible: boolean; + visibility: 'tree' | 'viewport'; + testId?: string; + textContains?: string; + textMatched?: boolean; + rect?: { x: number; y: number; width: number; height: number }; + viewport?: { width: number; height: number }; + error?: string; + }>; } +type WalletFixture = Parameters[0]; + declare global { // eslint-disable-next-line no-var var __AGENTIC__: AgenticBridge | undefined; @@ -242,6 +294,334 @@ function toAccountSummary(a: { return { id: a.id, address: a.address, name: a.metadata.name }; } +export function getFixtureMnemonicCount(account?: { + count?: number; + numberOfAccounts?: number; +}): number { + const raw = account?.count ?? account?.numberOfAccounts ?? 1; + const count = Number(raw); + if (!Number.isInteger(count) || count < 1 || count > 100) { + throw new Error(`Invalid mnemonic account count: ${raw}`); + } + return count; +} + +export function getFixtureAccountNames( + account: { name?: string; names?: string[] } | undefined, + count: number, +): string[] { + return Array.from({ length: count }, (_unused, index) => { + const explicitName = account?.names?.[index]; + if (typeof explicitName === 'string' && explicitName.trim()) { + return explicitName.trim(); + } + if ( + index === 0 && + typeof account?.name === 'string' && + account.name.trim() + ) { + return account.name.trim(); + } + return `Account ${index + 1}`; + }); +} + +interface FixtureEvmAccount { + id: string; + address: string; + metadata: { name: string; keyring?: { type?: string } }; +} + +function findEvmAccounts(accounts: Record) { + return (Object.values(accounts) as FixtureEvmAccount[]).filter((account) => + account.address?.startsWith('0x'), + ); +} + +function isHdFixtureAccount(account: FixtureEvmAccount) { + return account.metadata?.keyring?.type === 'HD Key Tree'; +} + +function normalizePrivateKey(value: string) { + return value.startsWith('0x') ? value.slice(2) : value; +} + +function getPrivateKeyAddress(value: string) { + return bufferToHex( + privateToAddress(Buffer.from(normalizePrivateKey(value), 'hex')), + ).toLowerCase(); +} + +function isExpectedLegacyAccountTreeInitError(error: unknown) { + const message = String((error as Error).message || error); + return ( + message.includes('Money Keyring') || + message.includes('No keyringBuilder found') + ); +} + +async function initializeFixtureAccountTree( + options: { + allowLegacyAccountTreeInitFailure?: boolean; + } = {}, +) { + try { + await AccountTreeInitService.initializeAccountTree(); + } catch (error) { + if ( + !options.allowLegacyAccountTreeInitFailure || + !isExpectedLegacyAccountTreeInitError(error) + ) { + throw error; + } + // Historical replay vaults can lack the multichain keyring builder. In that + // mode AccountsController remains the source of truth for fixture validation + // and account labels; group renames are skipped when no account-tree group + // exists. + Logger.log( + '[AgenticService] Skipping fixture account-tree refresh for historical replay fixture setup', + ); + } +} + +function getMnemonicFirstAddress(value: string) { + return EthersWallet.fromMnemonic( + value, + "m/44'/60'/0'/0/0", + ).address.toLowerCase(); +} + +interface FixtureHdWallet { + keyringId?: string; + accounts: FixtureEvmAccount[]; +} + +function getHdFixtureWallets( + accountsController: { + state: { internalAccounts: { accounts: Record } }; + }, + accountTreeController: { + state: { accountTree?: { wallets?: Record } }; + }, +): FixtureHdWallet[] { + const evmById = new Map( + findEvmAccounts(accountsController.state.internalAccounts.accounts).map( + (account) => [account.id, account], + ), + ); + const wallets = accountTreeController.state.accountTree?.wallets ?? {}; + const result: FixtureHdWallet[] = []; + for (const wallet of Object.values(wallets) as { + metadata?: { entropy?: { id?: string } }; + groups?: Record< + string, + { accounts?: string[]; metadata?: { entropy?: { groupIndex?: number } } } + >; + }[]) { + const entropyId = wallet.metadata?.entropy?.id; + if (!entropyId) continue; + const accounts = Object.values(wallet.groups ?? {}) + .map((group) => ({ + index: group.metadata?.entropy?.groupIndex ?? Number.MAX_SAFE_INTEGER, + account: group.accounts?.[0] + ? evmById.get(group.accounts[0]) + : undefined, + })) + .filter((entry): entry is { index: number; account: FixtureEvmAccount } => + Boolean(entry.account), + ) + .sort((left, right) => left.index - right.index) + .map((entry) => entry.account); + if (accounts.length > 0) { + result.push({ keyringId: entropyId, accounts }); + } + } + if (result.length > 0) { + return result; + } + const legacyHdAccounts = findEvmAccounts( + accountsController.state.internalAccounts.accounts, + ).filter(isHdFixtureAccount); + return legacyHdAccounts.length > 0 ? [{ accounts: legacyHdAccounts }] : []; +} + +async function ensureFixtureMnemonicAccounts( + mnemonicAccount: WalletFixture['accounts'][number], + mnemonicIndex: number, + controllers: { + AccountsController: { + state: { internalAccounts: { accounts: Record } }; + }; + AccountTreeController: { + state: { accountTree?: { wallets?: Record } }; + setAccountGroupName: ( + accountGroupId: AccountGroupId, + accountGroupName: string, + ) => void; + }; + }, + options: { allowLegacyAccountTreeInitFailure?: boolean } = {}, +) { + const { AccountsController, AccountTreeController } = controllers; + const count = getFixtureMnemonicCount(mnemonicAccount); + const names = getFixtureAccountNames(mnemonicAccount, count); + const firstAddress = getMnemonicFirstAddress(mnemonicAccount.value); + const findWallet = () => + getHdFixtureWallets(AccountsController, AccountTreeController).find( + (hdWallet) => + hdWallet.accounts[0]?.address.toLowerCase() === firstAddress, + ); + + let wallet = findWallet(); + if (!wallet) { + await importNewSecretRecoveryPhrase(mnemonicAccount.value, { + shouldSelectAccount: false, + }); + await initializeFixtureAccountTree(options); + wallet = findWallet(); + } + + if (!wallet) { + throw new Error( + `No HD wallet found for fixture mnemonic ${mnemonicIndex + 1}`, + ); + } + + for ( + let accountIndex = wallet.accounts.length; + accountIndex < count; + accountIndex += 1 + ) { + await addNewHdAccount(wallet.keyringId, names[accountIndex]); + await initializeFixtureAccountTree(options); + wallet = findWallet(); + if (!wallet) { + throw new Error( + `No HD wallet found after adding fixture account ${accountIndex + 1}`, + ); + } + } + + wallet.accounts.slice(0, count).forEach((account, index) => { + setFixtureAccountName(AccountTreeController, account, names[index]); + }); +} + +function findAccountGroupIdByAccountId( + accountTreeController: { + state: { accountTree?: { wallets?: Record } }; + }, + accountId: string, +): string | undefined { + const wallets = accountTreeController.state.accountTree?.wallets ?? {}; + for (const wallet of Object.values(wallets) as { + groups?: Record; + }[]) { + for (const [groupId, group] of Object.entries(wallet.groups ?? {})) { + if (group.accounts?.includes(accountId)) { + return groupId; + } + } + } + return undefined; +} + +function setFixtureAccountName( + accountTreeController: { + state: { accountTree?: { wallets?: Record } }; + setAccountGroupName: ( + accountGroupId: AccountGroupId, + accountGroupName: string, + ) => void; + }, + account: { id: string; address: string }, + name: string, +) { + Engine.setAccountLabel(account.address, name); + const groupId = findAccountGroupIdByAccountId( + accountTreeController, + account.id, + ); + // Legacy vault fallback skips account-tree init, so no group exists yet. + // The account label set above is sufficient; skip the group rename instead + // of throwing and crashing fixture setup. + if (!groupId) { + DevLogger.log( + `[AgenticService] No account group for fixture account ${account.address}; skipped group rename`, + ); + return; + } + accountTreeController.setAccountGroupName(groupId as AccountGroupId, name); +} + +async function materializeFixtureAccounts( + fixture: WalletFixture, + controllers: { + KeyringController: { + importAccountWithStrategy: ( + strategy: AccountImportStrategy, + args: string[], + ) => Promise; + }; + AccountsController: { + state: { internalAccounts: { accounts: Record } }; + }; + AccountTreeController: { + state: { accountTree?: { wallets?: Record } }; + setAccountGroupName: ( + accountGroupId: AccountGroupId, + accountGroupName: string, + ) => void; + }; + }, + options: { allowLegacyAccountTreeInitFailure?: boolean } = {}, +) { + const { KeyringController, AccountsController, AccountTreeController } = + controllers; + const mnemonicAccounts = fixture.accounts.filter( + (account) => account.type === 'mnemonic', + ); + + for (const [mnemonicIndex, mnemonicAccount] of mnemonicAccounts.entries()) { + await ensureFixtureMnemonicAccounts( + mnemonicAccount, + mnemonicIndex, + { + AccountsController, + AccountTreeController, + }, + options, + ); + } + + for (const account of fixture.accounts) { + if (account.type !== 'privateKey') continue; + const address = getPrivateKeyAddress(account.value); + let imported = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).find((evmAccount) => evmAccount.address.toLowerCase() === address); + + if (!imported) { + await KeyringController.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [`0x${normalizePrivateKey(account.value)}`], + ); + imported = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).find((evmAccount) => evmAccount.address.toLowerCase() === address); + } + + if (!imported) { + throw new Error( + `Fixture private key import did not create account ${address}`, + ); + } + if (account.name) { + setFixtureAccountName(AccountTreeController, imported, account.name); + } + } +} + /** * Walk a fiber sub-tree looking for a scrollable stateNode (scrollTo or * scrollToOffset). When `walkSiblings` is false only the child axis is @@ -273,6 +653,182 @@ function tryScroll( return false; } +function targetMatches( + fiber: FiberNode, + options: { testId?: string; textContains?: string }, +): boolean { + if (options.testId && fiber.memoizedProps?.testID !== options.testId) { + return false; + } + if (options.textContains) { + const needle = options.textContains.toLowerCase(); + const texts = options.testId + ? collectFiberTexts(fiber) + : collectOwnFiberTexts(fiber); + return texts.some((text) => text.toLowerCase().includes(needle)); + } + return Boolean(options.testId); +} + +function findUiTargetFiber( + rootFiber: FiberNode, + options: { testId?: string; textContains?: string }, +): FiberNode | null { + let result: FiberNode | null = null; + walkFiber(rootFiber, (fiber) => { + if (targetMatches(fiber, options)) { + result = fiber; + return true; + } + return false; + }); + return result; +} + +function findMeasurableStateNode( + fiber: FiberNode | null, +): FiberNode['stateNode'] | null { + let result: FiberNode['stateNode'] | null = null; + walkFiber(fiber, (node) => { + const sn = node.stateNode; + if ( + sn && + (typeof sn.measureInWindow === 'function' || + typeof sn.measure === 'function') + ) { + result = sn; + return true; + } + return false; + }); + return result; +} + +function measureStateNode( + stateNode: FiberNode['stateNode'], +): Promise<{ x: number; y: number; width: number; height: number } | null> { + return new Promise((resolve) => { + if (!stateNode) { + resolve(null); + return; + } + let settled = false; + const settle = ( + value: { x: number; y: number; width: number; height: number } | null, + ) => { + if (settled) return; + settled = true; + resolve(value); + }; + // Native measure callbacks never fire for detached/off-screen views; + // fall back to null after a short delay so callers don't hang forever. + const timeout = setTimeout(() => settle(null), 2500); + const finish = (x: number, y: number, width: number, height: number) => { + clearTimeout(timeout); + settle({ x, y, width, height }); + }; + try { + if (typeof stateNode.measureInWindow === 'function') { + stateNode.measureInWindow(finish); + return; + } + if (typeof stateNode.measure === 'function') { + stateNode.measure((_x, _y, width, height, pageX, pageY) => + finish(pageX, pageY, width, height), + ); + return; + } + } catch (e) { + Logger.log(String(e), 'AgenticService.measureStateNode'); + } + clearTimeout(timeout); + settle(null); + }); +} + +async function queryUiTarget(options: { + testId?: string; + textContains?: string; + visibility?: 'tree' | 'viewport'; +}): Promise<{ + present: boolean; + visible: boolean; + visibility: 'tree' | 'viewport'; + testId?: string; + textContains?: string; + textMatched?: boolean; + rect?: { x: number; y: number; width: number; height: number }; + viewport?: { width: number; height: number }; + error?: string; +}> { + const visibility: 'tree' | 'viewport' = + options.visibility === 'viewport' ? 'viewport' : 'tree'; + let target: FiberNode | null = null; + + walkFiberRoots((rootFiber) => { + target = findUiTargetFiber(rootFiber, options); + return Boolean(target); + }); + + const base = { + present: Boolean(target), + visible: Boolean(target) && visibility === 'tree', + visibility, + testId: options.testId, + textContains: options.textContains, + textMatched: options.textContains + ? Boolean( + target && + collectFiberTexts(target).some((text) => + text + .toLowerCase() + .includes(String(options.textContains).toLowerCase()), + ), + ) + : undefined, + }; + + if (!target || visibility === 'tree') { + return base; + } + + const stateNode = findMeasurableStateNode(target); + if (!stateNode) { + return { + ...base, + visible: false, + error: + 'Target exists in fiber tree but no measurable native node was found', + }; + } + + const rect = await measureStateNode(stateNode); + const viewport = Dimensions.get('window'); + if (!rect) { + return { + ...base, + visible: false, + viewport: { width: viewport.width, height: viewport.height }, + error: 'Target exists in fiber tree but measurement returned no frame', + }; + } + + const visible = + rect.width > 0 && + rect.height > 0 && + rect.x < viewport.width && + rect.y < viewport.height && + rect.x + rect.width > 0 && + rect.y + rect.height > 0; + + return { + ...base, + visible, + rect, + viewport: { width: viewport.width, height: viewport.height }, + }; +} + function appendTextContent(value: unknown, out: string[]) { if (value === null || value === undefined) { return; @@ -311,6 +867,14 @@ function collectFiberTexts(fiber: FiberNode | null): string[] { return dedupeTexts(texts); } +function collectOwnFiberTexts(fiber: FiberNode | null): string[] { + const texts: string[] = []; + if (fiber?.memoizedProps?.children !== undefined) { + appendTextContent(fiber.memoizedProps.children, texts); + } + return dedupeTexts(texts); +} + function findAncestorTexts( fiber: FiberNode | null, predicate: (texts: string[]) => boolean, @@ -382,18 +946,12 @@ function getRowValue( maxTexts?: number; } = {}, ): string | null { - try { - const rowTexts = findRowTexts(label, options); - if (!rowTexts) { - return null; - } - const matcher = new RegExp(pattern); - return ( - rowTexts.find((text) => text !== label && matcher.test(text)) ?? null - ); - } catch { + const rowTexts = findRowTexts(label, options); + if (!rowTexts) { return null; } + const matcher = new RegExp(pattern); + return rowTexts.find((text) => text !== label && matcher.test(text)) ?? null; } // ─── Step HUD callback registry ───────────────────────────────────────────── @@ -434,6 +992,7 @@ const AgenticService = { globalThis.__AGENTIC__ = { platform: Platform.OS, + replayHarnessPatch: 'legacy-wallet-fixture-r2', navigate: (name: string, params?: object) => ( deferredNav as unknown as { @@ -648,6 +1207,19 @@ const AgenticService = { hideStep: () => { _stepHudCallback?.(null); }, + refreshPerpsStreams: async () => { + await PerpsConnectionManager.ensureConnected({ + source: 'agentic_refresh_perps_streams', + suppressError: true, + }); + const streamManager = getStreamManagerInstance(); + streamManager.clearAllChannels(); + const positions = await Engine.context.PerpsController.getPositions(); + return { + ok: true, + positions: Array.isArray(positions) ? positions.length : 0, + }; + }, findFiberByTestId: (testId: string): boolean => { let found = false; walkFiberRoots((rootFiber) => { @@ -659,54 +1231,147 @@ const AgenticService = { }); return found; }, + queryUiTarget, + applyWalletFixture: async (fixture) => { + try { + const { + KeyringController, + AccountsController, + AccountTreeController, + } = Engine.context; + // The backup subscriber reads the Engine class static, so set it on + // the class (not the facade) before importing/renaming accounts to + // avoid racing native keychain export during fixture apply. + EngineClass.disableAutomaticVaultBackup = true; + // Unlock via the real auth flow (loginVaultCreation + dispatchLogin + + // post-login) rather than a bare KeyringController.submitPassword, so + // multichain services and Redux/auth state are consistent before we + // mutate accounts. + if (!KeyringController.isUnlocked()) { + await Authentication.unlockWallet({ password: fixture.password }); + } + // Existing replay vaults can have the same historical multichain + // account-tree init gap as fresh legacy setup. Only the known legacy + // init errors are tolerated by this option; unexpected errors still + // throw from initializeFixtureAccountTree(). + const legacyAccountTreeInitOptions = { + allowLegacyAccountTreeInitFailure: true, + }; + await initializeFixtureAccountTree(legacyAccountTreeInitOptions); + await materializeFixtureAccounts( + fixture, + { + KeyringController, + AccountsController, + AccountTreeController, + }, + legacyAccountTreeInitOptions, + ); + const ethAccs = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).map(toAccountSummary); + return { ok: true, accounts: ethAccs }; + } catch (e) { + return { ok: false, error: String((e as Error).message || e) }; + } + }, setupWallet: async (fixture) => { + let setupStep = 'start'; try { + setupStep = 'read-engine'; const { MultichainAccountService, KeyringController, AccountsController, + AccountTreeController, } = Engine.context; const store = ReduxService.store; const settings = fixture.settings ?? {}; + // Deliberately one-way for the dev harness process: fixture setup + // rewrites vault/account state, so automatic backup must stay disabled + // for the rest of this simulator session to avoid native keychain + // export paths racing the synthetic setup flow. + EngineClass.disableAutomaticVaultBackup = true; // 1. Create wallet from the first mnemonic (same path as onboarding UI) + setupStep = 'create-wallet'; const mnemonicAccount = fixture.accounts.find( (a) => a.type === 'mnemonic', ); + let usedLegacyVaultSetup = false; if (mnemonicAccount) { const mnemonic = mnemonicPhraseToBytes(mnemonicAccount.value); - await MultichainAccountService.createMultichainAccountWallet({ - type: 'restore', - password: fixture.password, - mnemonic, - }); + try { + await MultichainAccountService.createMultichainAccountWallet({ + type: 'restore', + password: fixture.password, + mnemonic, + }); + } catch (error) { + if (!isExpectedLegacyAccountTreeInitError(error)) { + throw error; + } + Logger.log( + '[AgenticService] Falling back to legacy vault restore for historical replay fixture setup', + ); + await KeyringController.createNewVaultAndRestore( + fixture.password, + mnemonic, + ); + usedLegacyVaultSetup = true; + } } else { - await MultichainAccountService.createMultichainAccountWallet({ - type: 'create', - password: fixture.password, - }); + try { + await MultichainAccountService.createMultichainAccountWallet({ + type: 'create', + password: fixture.password, + }); + } catch (error) { + if (!isExpectedLegacyAccountTreeInitError(error)) { + throw error; + } + Logger.log( + '[AgenticService] Falling back to legacy vault creation for historical replay fixture setup', + ); + await KeyringController.createNewVaultAndKeychain( + fixture.password, + ); + usedLegacyVaultSetup = true; + } } // 2. Initialize services (same as Authentication.dispatchLogin) - await AccountTreeInitService.initializeAccountTree(); - await MultichainAccountService.init(); - - // 3. Import private key accounts - for (const account of fixture.accounts) { - if (account.type !== 'privateKey') continue; + setupStep = 'initialize-services'; + if (!usedLegacyVaultSetup) { try { - await KeyringController.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [account.value], - ); - } catch (e) { + await AccountTreeInitService.initializeAccountTree(); + await MultichainAccountService.init(); + } catch (error) { + if (!isExpectedLegacyAccountTreeInitError(error)) { + throw error; + } Logger.log( - `[AgenticService] Failed to import key: ${(e as Error).message}`, + '[AgenticService] Skipping multichain account-tree initialization for historical replay fixture setup', ); } } + // 3. Materialize requested fixture accounts. A mnemonic account can + // declare count/numberOfAccounts plus names[]; this is generic + // fixture semantics, not a dev-account special case. + setupStep = 'materialize-srp-accounts'; + await materializeFixtureAccounts( + fixture, + { + KeyringController, + AccountsController, + AccountTreeController, + }, + { allowLegacyAccountTreeInitFailure: usedLegacyVaultSetup }, + ); + // 4. Dispatch all onboarding/auth flags + setupStep = 'dispatch-onboarding-flags'; store.dispatch(passwordSet()); store.dispatch(seedphraseBackedUp()); store.dispatch(setCompletedOnboarding(true)); @@ -714,6 +1379,7 @@ const AgenticService = { store.dispatch(logIn()); // 5. Suppress post-onboarding modals if explicitly requested + setupStep = 'persist-modal-settings'; if (settings.skipGtmModals === true) { await Promise.all([ StorageWrapper.setItem(PERPS_GTM_MODAL_SHOWN, 'true'), @@ -726,29 +1392,41 @@ const AgenticService = { // 5b. Set metrics UI as seen (prevents Authentication.unlockWallet // from navigating to OptinMetrics after setupWallet resets to Wallet) + setupStep = 'persist-metrics-setting'; if (settings.metametrics !== undefined) { await StorageWrapper.setItem(OPTIN_META_METRICS_UI_SEEN, 'true'); } // 5c. Mark multichain accounts intro modal as seen + setupStep = 'dispatch-multichain-intro-seen'; store.dispatch(setMultichainAccountsIntroModalSeen(true)); // 6. Skip perps tutorial onboarding if requested + setupStep = 'persist-perps-tutorial-setting'; if (settings.skipPerpsTutorial === true) { Engine.context.PerpsController?.markTutorialCompleted(); } // 7. Set auto-lock to "Never" (-1) for agentic workflows + setupStep = 'dispatch-auto-lock'; if (settings.autoLockNever === true) { ReduxService.store.dispatch(setLockTime(-1)); } - // 8. Enable device authentication (biometrics/passcode bypass) - if (settings.deviceAuthEnabled === true) { + // 8. Enable device authentication only on Android fixture runs. + // On iOS simulator, toggling OS auth during synthetic setup can drive + // react-native-keychain/quick-crypto secret export on the JS runtime + // and crash the app after setupWallet returns. Android is the only + // harness path that stores the fixture password for auto-unlock here. + setupStep = 'dispatch-device-auth'; + if ( + settings.deviceAuthEnabled === true && + Platform.OS === 'android' + ) { ReduxService.store.dispatch(setOsAuthEnabled(true)); } - // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only — iOS already handles this) + // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only) if ( settings.deviceAuthEnabled === true && Platform.OS === 'android' @@ -764,6 +1442,7 @@ const AgenticService = { } // 9. Configure MetaMetrics if specified + setupStep = 'configure-metametrics'; if (settings.metametrics === false) { await analytics.optOut(); } else if (settings.metametrics === true) { @@ -771,22 +1450,24 @@ const AgenticService = { } // 10. Navigate to wallet (same as Authentication.unlockWallet) + setupStep = 'navigate-wallet'; NavigationService.navigation?.reset({ routes: [{ name: Routes.ONBOARDING.HOME_NAV }], }); // 11. Collect all ETH accounts for the summary - const ethAccs = ( - Object.values( - AccountsController.state.internalAccounts.accounts, - ) as { id: string; address: string; metadata: { name: string } }[] - ) - .filter((a) => a.address?.startsWith('0x')) - .map(toAccountSummary); + setupStep = 'collect-accounts'; + const ethAccs = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).map(toAccountSummary); return { ok: true, accounts: ethAccs }; } catch (e) { - return { ok: false, error: String((e as Error).message || e) }; + return { + ok: false, + step: setupStep, + error: String((e as Error).message || e), + }; } }, }; diff --git a/app/util/transactions/delegation.test.ts b/app/util/transactions/delegation.test.ts index 5d18b52b32dc..6b8c31152fd8 100644 --- a/app/util/transactions/delegation.test.ts +++ b/app/util/transactions/delegation.test.ts @@ -32,8 +32,11 @@ const NONCE_MOCK = 123; const AUTHORIZATION_SIGNATURE_MOCK = '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cfb0af34e491aa4d6796dececf95569088322e116c4b2f312bb23f20699269'; +const NETWORK_CLIENT_ID_MOCK = 'mainnet'; + const TRANSACTION_META_MOCK = { chainId: '0x1' as Hex, + networkClientId: NETWORK_CLIENT_ID_MOCK, nestedTransactions: [ { data: '0x123456781234' as Hex, @@ -79,7 +82,7 @@ describe('Transaction Delegation Utils', () => { ]); mockGetNonceLock.mockResolvedValue({ - nextNonce: NONCE_MOCK, + nonceDetails: { params: { nextNetworkNonce: NONCE_MOCK } }, releaseLock: jest.fn(), }); }); diff --git a/app/util/transactions/delegation.ts b/app/util/transactions/delegation.ts index 4dd9d0bb81a9..b04cf705abb6 100644 --- a/app/util/transactions/delegation.ts +++ b/app/util/transactions/delegation.ts @@ -139,7 +139,7 @@ async function buildAuthorizationList( networkClientId, ); - const nonce = nonceLock.nextNonce; + const nonce = nonceLock.nonceDetails.params.nextNetworkNonce; nonceLock.releaseLock(); const authorizationSignature = (await messenger.call( diff --git a/docs/perps/myx-validation-report.md b/docs/perps/myx-validation-report.md deleted file mode 100644 index 496012b866ec..000000000000 --- a/docs/perps/myx-validation-report.md +++ /dev/null @@ -1,103 +0,0 @@ -# MYX Provider Validation Report - -**Date:** 2026-02-25 -**Branch:** `fet/perps/myx-reads-write` -**Script:** `scripts/perps/agentic/validate-myx.sh` - ---- - -## Testnet (chainId 421614, Arbitrum Sepolia → `api-test.myx.cash`) - -| Category | Test | Result | Details | -| ------------ | ------------------------ | ---------- | -------------------------------- | -| Init | Provider registered | PASS | `myx` in providers map | -| Init | Markets loaded | PASS | 2 pools, 1 with price data | -| Init | Markets have name | PASS | | -| Prices | Tickers with real prices | PASS | KNY=$65,629 | -| Candles REST | 1h historical | PASS | 101 candles | -| Candles REST | 1D historical | PASS | 101 candles | -| Candles REST | 5m historical | PASS | 101 candles | -| Candles WS | Sustained kline updates | PASS | 3 WS callbacks | -| Prices WS | Live ticker update | PASS | KNY `"65587.50"` | -| Auth | isReadyToTrade | UNVERIFIED | `auth()` is sync, proves nothing | -| Positions | getPositions | UNVERIFIED | 0 (auth not validated) | -| Orders | getOrders | UNVERIFIED | 0 (auth not validated) | -| Account | getAccountState | UNVERIFIED | all zeros (auth not validated) | -| Ping | Health check | PASS | | - -**Summary:** 10 passed, 0 failed, 0 skipped, 4 unverified - -### Testnet Markets - -2 pools on API, 1 returned after filtering (pools without ticker data are excluded): - -| Symbol | Price | Candles | Volume | Chain | -| ------ | ------- | ------- | ------ | ------------------ | -| KNY | $65,629 | Yes | $0.53 | Arb Sepolia 421614 | - -SGLT (Linea Sepolia 59141) is filtered out — paused pool, no ticker data. - ---- - -## Mainnet (chainId 56, BNB Chain → `api.myx.finance`) - -| Category | Test | Result | Details | -| ------------ | ------------------------ | ---------- | ---------------------------------- | -| Init | Provider registered | PASS | `myx` in providers map | -| Init | Markets loaded | PASS | 27 pools, 3 with price data | -| Init | Markets have name | PASS | | -| Prices | Tickers with real prices | PASS | WBTC=$65,585, MYX=$0.40, WBNB=$602 | -| Candles REST | 1h historical | PASS | 101 candles (MYX token) | -| Candles REST | 1D historical | PASS | 101 candles (MYX token) | -| Candles REST | 5m historical | PASS | 101 candles (MYX token) | -| Candles WS | Sustained kline updates | PASS | 3 WS callbacks | -| Prices WS | Live ticker update | PASS | MYX `"0.4012..."` | -| Auth | isReadyToTrade | UNVERIFIED | `auth()` is sync, proves nothing | -| Positions | getPositions | UNVERIFIED | 0 (auth not validated) | -| Orders | getOrders | UNVERIFIED | 0 (auth not validated) | -| Account | getAccountState | UNVERIFIED | all zeros (auth not validated) | -| Ping | Health check | PASS | | - -**Summary:** 10 passed, 0 failed, 0 skipped, 4 unverified - -### Mainnet Markets - -27 pools on API, 3 returned after filtering: - -| Symbol | Price | Candles | Volume | -| ------ | ------- | ------- | ------ | -| WBTC | $65,585 | — | $0 | -| MYX | $0.40 | Yes | $50.34 | -| WBNB | $602 | — | $0 | - -24 pools filtered out — community/meme tokens with no ticker data. MYX uses a Multi-Pool Model where anyone can create pools; most are inactive. - ---- - -## Testnet vs Mainnet Comparison - -| Dimension | Testnet | Mainnet | -| --------------------- | ------------- | ------------------------------- | -| Pools on API | 2 | 27 | -| Active (with prices) | 1 | 3 | -| Prices | KNY=$65,629 | WBTC=$65k, MYX=$0.40, WBNB=$602 | -| REST candles | All intervals | All intervals | -| WS candles + prices | Yes | Yes | -| Auth/positions/orders | Unverified | Unverified | -| Tests passed | 10/14 | 10/14 | - ---- - -## Known Issues - -1. **Most pools have no ticker data** — API only returns prices for active pools (1/2 testnet, 3/27 mainnet). `getMarketDataWithPrices()` filters these out. -2. **Auth never validated** — `myxClient.auth()` is sync (stores callbacks, no API call). Empty results prove nothing. -3. **No mainnet credentials** — `.js.env` has testnet creds only. - ---- - -## Next Steps - -1. **Validate auth** — call token API directly or attempt a testnet order. -2. **Mainnet credentials** — get dedicated `appId`/`apiSecret` from MYX team. -3. **Curated pool list** — get from MYX team which pools to show, or rely on the active-ticker filter. diff --git a/docs/perps/perps-account-abstraction-and-balance-contract.md b/docs/perps/perps-account-abstraction-and-balance-contract.md index 6914d2c174db..a687a0486798 100644 --- a/docs/perps/perps-account-abstraction-and-balance-contract.md +++ b/docs/perps/perps-account-abstraction-and-balance-contract.md @@ -1,389 +1,13 @@ -# Perps — Account Abstraction & Balance Contract +# Perps account abstraction and balance contract -This is the reference doc for the perps `AccountState` balance contract and how mobile handles HyperLiquid's account-abstraction modes (Unified, Standard, Portfolio, DEX-abstraction). Anyone touching balance display, order-entry validation, withdraw flow, or HL-mode-aware logic should start here. +Historical agentic validation for this contract used Mobile-local recipe files. +Recipe authoring has moved out of the Mobile repository to the external Recipe +v1 runner. Keep this document focused on the product contract; executable +recipes and evidence artifacts should live with the external runner. -The current shape — three purpose-built balance fields plus a mode-aware spot-fold gate — was introduced in [TAT-3047 / PR #29303](https://github.com/MetaMask/metamask-mobile/pull/29303), which replaced the earlier overloaded `availableBalance` + optional `availableToTradeBalance` pair (TAT-3016 hotfix) and added end-to-end correctness across the abstraction modes HL exposes. +## Contract summary -## What's in this doc - -1. **The contract** — three fields and what they mean per provider (`AccountState.spendableBalance` / `withdrawableBalance` / `totalBalance`). -2. **HL abstraction modes** — Unified vs Standard vs Portfolio vs DEX-abstraction, mapped to HL web checkboxes and the SDK `userSetAbstraction` enum, with the per-mode balance semantics. -3. **The mode-aware spot-fold gate** — how the provider + subscription service detect and react to HL-side mode changes. -4. **Validation matrix** — the four fixture accounts and recipes that prove the contract holds end-to-end on mainnet. -5. **Known trade-offs** — explicit "not covered" + the cold-start behaviour for Standard-mode users. - -## Why this matters for any future change - -- **UI never branches on provider or HL abstraction mode.** Consumers read `spendableBalance` for "can the user open a trade with this much" and `withdrawableBalance` for "can the user pull this much off the venue". The provider populates each field with the right number for the mode it's in. If a future provider needs different semantics, add the translation in the adapter — not in a hook or component. -- **`addSpotBalanceToAccountState` is provider-agnostic** and takes an explicit `{ foldIntoCollateral }` flag. The HL provider computes the flag from `userAbstraction`; MYX always passes `true`. -- **`hyperLiquidModeFoldsSpot(mode)` is the single source of truth** for "does this HL mode let spot USDC act as perps collateral". Add new HL mode strings here when HL ships them. - -Last validated: 2026-04-24, mainnet (recipe run on 4 fixture accounts including a live Unified ↔ Standard mode flip). - -## The three-field contract - -`AccountState` (in `app/controllers/perps/types/index.ts`) carries three balance fields, each answering one question: - -| Field | Question | Used by | -| --------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- | -| `totalBalance` | What is the user's total wealth at this venue, including PnL? | Balance header, portfolio aggregation, deposit progress watchers | -| `spendableBalance` | How much can immediately collateralize a new position? | Order-form max, pay-token gate, insufficient-balance alerts | -| `withdrawableBalance` | How much can leave this venue to the user's external wallet? | Withdraw-form max, withdraw validation | - -Invariant (documented, not enforced): `withdrawableBalance ≤ spendableBalance ≤ totalBalance`. - -### Per-provider mapping - -| Provider | `totalBalance` | `spendableBalance` | `withdrawableBalance` | -| ---------------------- | ------------------------------------------------------ | ----------------------------- | ----------------------------- | -| HL Unified / Portfolio | `accountValue + spot.total − spot.hold` | `withdrawable + freeSpotUSDC` | `withdrawable + freeSpotUSDC` | -| HL Standard | `accountValue + spot.total − spot.hold` (display only) | `withdrawable` (perps-only) | `withdrawable` (perps-only) | -| MYX | `walletBalance + marginUsed + unrealizedPnl` | `walletBalance` | `walletBalance` | - -On HL Unified/Portfolio, `spendable === withdrawable`: HL's backend treats spot USDC as perps collateral and `withdraw3` draws from the unified ledger server-side. On HL Standard, spot USDC is a separate ledger that HL won't auto-draw from, so spendable/withdrawable stay perps-only and only `totalBalance` reflects the combined wealth (display). - -## HL abstraction modes — glossary - -HL has four account-abstraction modes. The two user-facing ones that matter for this PR are **Unified** (default on app.hyperliquid.xyz) and **Standard**. They are composed on HL web via two checkboxes under Account Settings: - -| HL web checkbox A: "Disable HIP-3 Dex Abstraction" | HL web checkbox B: "Disable Unified Account Mode" | Resulting mode | SDK `userSetAbstraction` value | Balance semantics | -| -------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ☐ unchecked | ☐ unchecked | DEX abstraction (deprecated, to be discontinued) | `dexAbstraction` | USDC defaults to perps, other collateral defaults to spot; HIP-3 cross margin is non-intuitive. | -| ✓ **checked** | ☐ unchecked | **Unified** (default) | `unifiedAccount` | Single USDC balance unified across spot + all USDC-quoted perp DEXes. Spot USDC IS perps collateral. | -| ☐ unchecked | ✓ checked | DEX abstraction variant | — | Not exercised by this recipe. | -| ✓ **checked** | ✓ **checked** | **Standard** | `disabled` | Separate perps and spot balances. Separate per-DEX balances. Spot USDC is NOT auto-collateral for perps; moving it requires an explicit `usdClassTransfer`. | - -When this recipe's `phase2c-flip-standard` step calls `exchangeClient.userSetAbstraction({ abstraction: 'disabled' })` on dev2, it is equivalent to **checking both** boxes in the HL web UI (i.e. producing Standard). `phase2c-restore-unified` calls `userSetAbstraction({ abstraction: 'unifiedAccount' })` which leaves HIP-3 disabled but re-enables Unified (the default state — checkbox A checked, checkbox B unchecked). - -Portfolio margin (pre-alpha, `portfolioMargin`) is a separate toggle not covered here. - -## Accounts and topologies covered - -Four fixture accounts, each in a distinct state. Two abstraction modes (Unified + Standard) are exercised live by flipping `dev2` mid-recipe. - -| Fixture | Address | Modes run live | Perps clearinghouse | Spot USDC | Open positions | Topology | -| ------------- | --------------- | -------------------------------- | ------------------------------------------------- | ----------------------------- | -------------------- | ----------------------------------------------------------- | -| **Trading** | `0x316BDE…01fA` | Unified | `withdrawable=$0`, `accountValue≈$3.35` | `total≈$104.40`, `hold≈$6.87` | Yes (on HIP-3 `xyz`) | Unified, spot-funded, open HIP-3 position ⇒ `spot.hold > 0` | -| **dev1** | `0x8Dc623…9003` | Unified | `withdrawable=$0`, `accountValue=$0` | `total≈$29.67`, `hold=$0` | None | Unified, spot-only, clean | -| **dev2** | `0x5993d2…0916` | **Unified → Standard → Unified** | `withdrawable≈$10`, `accountValue≈$10` (pre-flip) | dust (`≈$0.0004`, `hold=$0`) | None | Perps-funded clean fixture flipped to Standard and back | -| **Account 6** | `0xB9b9E1…42c2` | Unified | `withdrawable=$0`, `accountValue=$0` | `total=$0`, `hold=$0` | None | Zero across all ledgers | - -Note on dev2 ledger drift: `userSetAbstraction` flipping a Unified account to Standard and back can redistribute the USDC between the perps and spot sides at the HL backend (observed: $10 moved perps→spot during the flip cycle). This is HL-side behaviour, not a recipe artefact. The account's total USDC is preserved end-to-end; only the side it reports on changes. - -## Why this set covers the refactor surface - -| Risk the refactor could introduce | Scenario that catches it | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Fields not populated (UI reads `undefined`) | All four scenarios — `hl-balance-contract-check` asserts shape | -| Spot fold applied incorrectly on Unified | Trading (spot-funded) — `hl-balance-math-check` asserts `spendable = Σ(breakdown.spendable) + freeSpot` | -| `spot.hold` not subtracted from total when a position is open | Trading has `spot.hold = $6.87` from an HIP-3 margin hold — math check flags if `totalBalance` includes the double-counted hold | -| Clean spot-only case produces wrong shape | dev1 — simpler topology, catches regressions that only appear without HIP-3 noise | -| Perps-funded clean case produces wrong shape | dev2 Unified baseline — covers the "perps-heavy, spot-light" topology the other fixtures don't | -| Contract not mode-agnostic | dev2 flipped Unified → Standard → Unified — contract + math asserted in both modes | -| Mode flip corrupts persisted state shape | dev2 post-flip-back — contract re-asserted to confirm shape survives the round trip | -| Legacy keys leak back in | All four — contract flow asserts `availableBalance` and `availableToTradeBalance` are absent | -| `spendable` diverges from `withdrawable` on HL | All four — math flow asserts `spendable === withdrawable` | - -### Not covered - -- **Portfolio margin mode** (pre-alpha): no fixture available; behaviour expected to match Unified for borrowable-asset accounts. -- **DEX-abstraction mode** (deprecated by HL): out of scope. -- **Open positions on Standard mode**: would require opening a position after the flip. Not exercised — contract shape is mode-agnostic per our refactor, and the position-open path is covered separately in Trading (Unified with HIP-3 position). -- **Cold-start inflation window for Standard-mode users**: the mode cache starts empty, and `hyperLiquidModeFoldsSpot(null)` intentionally returns `true` (Unified default — see JSDoc on the helper). For Standard-mode users on a fresh app launch, the _first_ spot WS tick can surface a spot-folded `spendableBalance` / `withdrawableBalance` until `userAbstraction` REST completes (typically sub-second). `getAccountState` (REST-driven) fetches `userAbstraction` in parallel via `Promise.allSettled` and applies the gated fold when fulfilled, so the REST path is unaffected. Explicit trade-off: under-reporting Unified on transient endpoint failure was judged a worse trust break than a brief over-report on the minority Standard population. HL rejects bad submits cleanly; no data-loss risk. Sentry logging now surfaces sustained `userAbstraction` failures so ops can track the rate. - -### Standard-mode correctness — fixed - -Earlier revisions of this PR had an unconditional spot-fold in `addSpotBalanceToAccountState`, which inflated `spendableBalance` and `withdrawableBalance` on HL Standard-mode accounts (where spot is a separate ledger, not perps collateral). That would have let the UI approve withdraw/order submissions that HL's backend would reject. - -The PR now includes a mode-aware fold gate: - -- `accountUtils.addSpotBalanceToAccountState` takes an `{ foldIntoCollateral: boolean }` option. Provider-agnostic — doesn't know about HL modes. -- `hyperliquid-types.ts` owns the HL-specific `HyperLiquidAbstractionMode` type (re-export of HL SDK's `UserAbstractionResponse`) and a `hyperLiquidModeFoldsSpot(mode)` helper that returns `true` for `unifiedAccount` / `portfolioMargin` / `default` and `false` for `disabled` (Standard) / `dexAbstraction`. -- `HyperLiquidProvider.getAccountState` fetches `userAbstraction` in parallel with clearinghouse + spot state, then passes `{ foldIntoCollateral: hyperLiquidModeFoldsSpot(mode) }` to the util. -- `HyperLiquidSubscriptionService` caches `userAbstraction` alongside `spotClearinghouseState` (refreshed together, cleared together on cleanup) and applies the same gate on every fold site. - -Migration 133 uses an **asymmetric mapping** so upgraded Standard-mode users see correct cold-start values without waiting for the first live fetch: `withdrawableBalance` migrates from the legacy perps-only `availableBalance` (not from the spot-folded `availableToTradeBalance`). - -Phase 2c of the recipe proves the fix with live numbers on dev2 flipped to Standard mode: - -- `spot.free = $10.01` -- `standardSemanticExpected.spendable = 0` (perps-only) -- `adapterActual.spendable = 0` -- `observedInflation = 0` — no inflation, gate works. - -## Reusable composable flows - -Under `scripts/perps/agentic/teams/perps/flows/`. Each takes `address` + `phaseLabel` and is reusable in any future recipe. - -### `hl-balance-contract-check.json` - -Asserts `PerpsController.accountState` carries the new three-field contract: - -- `spendableBalance: string` present -- `withdrawableBalance: string` present -- `totalBalance: string` present -- `availableBalance` absent (legacy) -- `availableToTradeBalance` absent (legacy) - -### `hl-balance-math-check.json` - -Asserts the spot-fold math by deriving expected values from the controller's own `subAccountBreakdown` (pre-fold per-DEX perps) plus live HL REST `spotClearinghouseState`: - -- Expected `spendable = Σ(breakdown[*].spendableBalance) + max(0, spot.total − spot.hold)` -- Expected `withdrawable = Σ(breakdown[*].withdrawableBalance) + max(0, spot.total − spot.hold)` -- Expected `total = Σ(breakdown[*].totalBalance) + spot.total − spot.hold` -- Asserts `spendable === withdrawable` (HL invariant) -- Tolerates `epsilon = 0.01` for rounding - -This formulation naturally covers single-DEX accounts and HIP-3 multi-DEX accounts — the per-DEX sum is the controller's own truth; the spot REST is independent. Works in both Unified and Standard modes because `freeSpot` comes from raw HL REST. - -Standard-mode regression guard is inlined in the recipe (`pathA-fold-correctness` node) rather than a separate flow — `hl-balance-math-check{foldIntoCollateral=false}` already proves the gate works; the inline `eval_async` quantifies `observedInflation = adapterActual.spendable − Σ(breakdown.spendable)` so a reviewer can read the delta in the trace. - -### `hl-provision-fixture.json` (pre-existing, reused) - -Already in the repo. Used to flip abstraction mode via `userSetAbstraction`. Required for the dev2 Standard-mode scenario. - -## Top-level recipe - -The validation recipe lives in the repo at `scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json`. It is a **single-account state machine** parameterised by EVM address. The runner's `--input address=0x...` flag picks the fixture; the recipe's preflight probe classifies the account into one of two execution paths based on whether positions / orders are open (HL rejects `userSetAbstraction` while either exists). - -``` -setup → toggle_testnet enabled=false → wait isTestnet===false (force mainnet before graph runs) -entry → gate-check-route → go-home (if inside Perps) → select-account ({{address}}) → wait-account → preflight-probe -preflight-probe (HL REST: clearinghouseState + openOrders + spotClearinghouseState + userAbstraction) - └─ classifies pathA = positions===0 && pendingOrders===0 -shared (mode-agnostic — runs on both paths) - ├─ call hl-balance-contract-check - ├─ call hl-balance-math-check - ├─ nav PerpsMarketListView → assert PerpsMarketBalanceActions shows spendableBalance - ├─ screenshot shared-market-list.png - ├─ nav PerpsWithdraw → assert perps-withdraw-available-balance-text shows withdrawableBalance (not $0) - ├─ screenshot shared-withdraw-folded.png - ├─ type_keypad 1 → assert continue-button disabled=false - └─ type_keypad 99999 → assert contract (over-amount > withdrawable) -path-switch - ├─ pathA → pathA-flip-standard (Unified → Standard) - └─ default → pathB-shift-spot (positions present — perps↔spot transfers only) -pathA (clean account, full mode-flip matrix) - ├─ call hl-provision-fixture abstraction=disabled (Unified → Standard) - ├─ wait userAbstraction REST = 'disabled' (HL-side propagation bite) - ├─ call hl-balance-contract-check (shape in Standard mode) - ├─ call hl-balance-math-check foldIntoCollateral=false (math gated for Standard semantics) - ├─ eval_async pathA-fold-correctness (inline regression guard: assert observedInflation ≤ ε) - ├─ call hl-provision-fixture abstraction=unifiedAccount (restore) - ├─ wait userAbstraction REST = 'unifiedAccount' - └─ call hl-balance-contract-check (shape survives round trip) -pathB (positions present — perps↔spot transfer only, no mode flip) - ├─ call hl-provision-fixture transferDirection=to-spot (shift free perps → spot) - ├─ call hl-balance-contract-check (shape unchanged) - ├─ call hl-balance-math-check (math under non-trivial spot.hold) - └─ call hl-provision-fixture transferDirection=to-perp (restore — positions untouched) -teardown - └─ navigate WalletView -``` - -Setup prerequisites (Metro, simulator, wallet fixture, CDP bridge): see `scripts/perps/agentic/README.md`. The recipe forces `isTestnet=false` in its setup block (HyperLiquid mainnet — real fixtures and `userAbstraction`) and expects the fixture wallet (default Trading) to hold ≥ a few USDC. - -Run against a live app (Trading by default): - -```bash -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json -``` - -Pick a different fixture (e.g. dev2 for guaranteed Path A): - -```bash -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json \ - --input address=0x5993d2153F080470BFE765aE81F4fA5fA2080916 -``` - -Dry-run graph walk only (no CDP): - -```bash -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json --dry-run -``` - -Schema validation (syntax + graph reachability): - -```bash -node scripts/perps/agentic/validate-flow-schema.js \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json \ - scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json \ - scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json -``` - -## Manual reproduction - -Anyone with HL web access + a fixture wallet can validate the contract by hand. The state machine below mirrors the recipe 1:1; each row maps to an HL-web action and an expected mobile-app readout. Run on a clean account (no open positions/orders) to walk all five states; on an account with positions you can only do steps 1, 1b (perps→spot transfer), and 1c (transfer back). - -Default fixture: **Trading** (`0x316BDE…01fA`). Substitute any address you can sign for; the recipe's `--input address=...` flag does the same. - -| # | HL web action | Resulting state | Mobile app readout | What it proves | -| ------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | -| **0** | open `app.hyperliquid.xyz/portfolio`, log in with the fixture wallet, ensure no open positions/orders | baseline | (no app action) | precondition for mode flips | -| **1** | none (start in Unified, all funds on perps) | **Unified, perps-only** | PerpsMarketListView header `$X` (= perps balance); PerpsWithdrawView "Available Perps balance" `$X` | three-field shape populated, sane Unified case | -| **1b** | click _Transfer_ (Perp → Spot) for the full perps balance | **Unified, spot-only** | PerpsMarketListView still `$X` (fold); PerpsWithdrawView still `$X` (TAT-3047 fix — was `$0` before this PR) | spot fold on Unified; primary TAT-3047 user-visible fix | -| **2** | Account Settings → check **Disable Unified Account Mode**, confirm signature | **Standard, spot-only** | PerpsMarketListView shows `$0` and the Add Funds CTA; PerpsWithdrawView "Available" reads `$0.00` | mode-aware fold gate working — spot is no longer perps collateral | -| **2b** | click _Transfer_ (Spot → Perp) for half the spot balance | **Standard, mixed (perps + spot)** | PerpsMarketListView `$X/2`; total displayed wealth still `$X` | math invariant: `total = perps + spot − hold` independent of mode | -| **2c** | click _Transfer_ (Spot → Perp) for the remaining spot | **Standard, perps-only** | PerpsMarketListView `$X`; PerpsWithdrawView `$X` | Standard cap matches perps clearinghouse exactly | -| **3** | Account Settings → uncheck **Disable Unified Account Mode**, confirm signature | **Unified, perps-only** (back to baseline) | Same as state 1 | mode round trip preserves contract; HL redistributes the ledger as needed | - -Notes: - -- **HL web "Transfer between Perp and Spot"** is on the Portfolio screen. The mobile app does not expose this transfer — `usdClassTransfer` lives in the SDK and is only invoked by the agentic provisioning flow, not by user-facing UI. -- **Mode-flip restriction**: the _Disable Unified Account Mode_ checkbox is greyed out while any position, order, or TWAP is open on the account. Close everything before attempting the flip. -- **HL ledger drift on flip**: flipping from Unified to Standard can move USDC between the perps and spot sides at the HL backend (HL redistribution). Flipping back does not always restore the original split — it merely re-enables the unified view. The recipe documents this in the Path A trace. -- **Recovery if a flip leaves you in Standard unintentionally**: re-open Account Settings, uncheck the box, sign. The mobile app picks up the new mode within ~60 s via the spot WebSocket → `userAbstraction` REST refresh path documented in the [Mode-aware spot-fold gate](#standard-mode-correctness--fixed) section. - -## Live run evidence - -Most recent run: `.agent/recipe-runs/2026-04-24_08-49-49_recipe/` (local — gitignored). - -### Summary - -``` -Results: 36/36 passed -Recipe: PASS -Teardown: PASS (Trading reselected, navigated to WalletView) -``` - -### Captured values — Phase 1 (Trading, Unified spot-funded + HIP-3) - -| Field | Source | Value | -| ------------------------------------------------------ | ---------- | --------- | -| `accountState.spendableBalance` | controller | `$97.53` | -| `accountState.withdrawableBalance` | controller | `$97.53` | -| `accountState.totalBalance` | controller | `$104.40` | -| `clearinghouseState.withdrawable` (main) | HL REST | `$0.00` | -| `clearinghouseState.marginSummary.accountValue` (main) | HL REST | `$3.35` | -| `spotClearinghouseState.balances[USDC].total` | HL REST | `$104.40` | -| `spotClearinghouseState.balances[USDC].hold` | HL REST | `$6.87` | -| `subAccountBreakdown.main.totalBalance` | controller | `$3.36` | -| `subAccountBreakdown.xyz.totalBalance` (HIP-3) | controller | `$3.52` | - -Math check passes: `Σ(breakdown[*].total) + spot.total − spot.hold ≈ totalBalance` within `ε = 0.01`. - -### Captured values — Phase 2 (dev1, Unified spot-only) - -| Field | Value | -| --------------------- | -------- | -| `spendableBalance` | `$29.67` | -| `withdrawableBalance` | `$29.67` | -| `totalBalance` | `$29.67` | -| `spot.USDC.total` | `$29.67` | -| `spot.USDC.hold` | `$0.00` | - -### Captured values — Phase 2b (dev2, Unified perps-funded, baseline) - -| Field | Value | -| --------------------- | ---------------- | -| `spendableBalance` | `$10.01` | -| `withdrawableBalance` | `$10.01` | -| `totalBalance` | `$10.01` | -| `perps.withdrawable` | `$10.01` | -| `spot.USDC.total` | dust (`$0.0004`) | - -### Phase 2c (dev2, Unified → Standard → Unified) - -Equivalent to the user toggling HL web's "Disable Unified Account Mode" checkbox on, waiting, and toggling it off. Executed via `hl-provision-fixture` which calls `exchangeClient.userSetAbstraction({ abstraction: 'disabled' })` for the move to Standard and `{ abstraction: 'unifiedAccount' }` for the restore. HL accepts both operations for a clean account (no open positions / orders / TWAPs). After each flip the recipe waits for `PerpsController.getAccountState()` to refresh before asserting. - -**Observed side effect of the flip**: HL moves the $10 USDC from the perps side to the spot side as part of the Unified → Standard transition. This leaves dev2 post-flip with `perps.withdrawable = $0, spot.USDC = $10.01` — a meaningful split that exposes the Standard-mode fold limitation. - -Captured live values in Standard mode after the mode-aware fold gate landed: - -```json -{ - "phase": "dev2-standard-mode-correctness", - "spot": { "total": 10.0120682, "hold": 0, "free": 10.0120682 }, - "standardSemanticExpected": { "spendable": 0, "withdrawable": 0 }, - "adapterActual": { "spendable": 0, "withdrawable": 0 }, - "observedInflation": { "spendable": 0, "withdrawable": 0 }, - "standardModeCorrect": true -} -``` - -Interpretation: - -- **Contract-shape check**: passed in Standard mode — fields populated, no legacy keys, shape is mode-agnostic. ✓ -- **Adapter internal-consistency check** (`hl-balance-math-check` with `foldIntoCollateral=false`): passed — adapter output matches the expected perps-only formula when Standard semantics apply. ✓ -- **Standard-mode correctness check** (inline `pathA-fold-correctness` eval): `adapterActual.spendable = 0` even though `spot.free = $10.01`, proving the `hyperLiquidModeFoldsSpot` gate is wired end-to-end through both the subscription service and the provider. ✓ -- **Post-restore contract check**: passed — shape survived the Unified → Standard → Unified round trip. ✓ - -### Captured values — Phase 3 (Account 6, zero) - -| Field | Value | -| ------------------- | ----------------------------------------------------------------------------------- | -| All balance fields | `$0` | -| Empty-state surface | `perps-market-add-funds-button` mounted; `perps-market-balance-value` reads `$0.00` | - -## UI-level assertions — Phase 1 - -Observed on live app: - -| Screen | testID | Rendered text | -| --------------------- | --------------------------------------- | ----------------------------------------------------------------- | -| `PerpsMarketListView` | `perps-market-available-balance-text` | `$97.53` (matches `spendableBalance`) | -| `PerpsWithdrawView` | `perps-withdraw-available-balance-text` | `Available Perps balance: $97.53` (matches `withdrawableBalance`) | - -Continue button state after keypad input (Phase 1): - -| Typed amount | `continue-button.disabled` | Expected | Result | -| ------------------------- | -------------------------- | -------- | ----------------------------------------------------- | -| `$1` (≤ withdrawable) | `false` | enabled | ✓ | -| `$99999` (> withdrawable) | (see note) | disabled | contract-level pass; UI reactivity tracked separately | - -Note: during AC6 the UI disabled state did not flip after typing `$99999`. The recipe asserts the contract-level condition (`99999 > withdrawableBalance`) which is what the refactor is responsible for. UI reactivity on large-keypad-input is a pre-existing quirk tracked as a follow-up. - -## Real withdrawal - -The recipe intentionally does NOT submit `withdraw3` — that call costs \$1 in HL fees. A manual probe was run separately: - -``` -[1] BEFORE: { perps_withdrawable: "0.0", spot_usdc_total: "105.417..." } -[2] CALLING withdraw3({amount: "1.01"}) - response: {"status":"ok","response":{"type":"default"}} -[3] SUCCESS — HL accepted withdraw3 on Unified spot-funded account. -[4] AFTER: { spot_usdc_total: "104.407..." } - spot.usdc.total delta: -1.01 -``` - -Confirmed: on a Unified-mode account with `perps.withdrawable = 0` and spot USDC funded, `withdraw3` succeeds and pulls directly from spot via HL's unified abstraction. No client-side sweep needed — which is exactly why this PR does not carry one. - -## Migration path - -Migration `133.ts` is not covered by the recipe (recipe runs against live state, not redux-persist rehydration). Covered instead by: - -- **Unit-level**: `app/store/migrations/133.ts` maps legacy `availableBalance` / `availableToTradeBalance` into the new fields. Migration follows the repo's `ensureValidState` pattern and handles both the top-level `accountState` and `subAccountBreakdown` entries. -- **Disk cache**: `PERPS_DISK_CACHE_USER_DATA` storage key bumped to `_V2`. Upgraded installs see an empty new-key cache on first run, fall through to skeleton, then backfill from the WS tick. Old-key blob sits orphaned until any reset/logout flow clears it. - -Manual validation of the migration path requires a build-upgrade harness (install prior-version → populate state → install new build → observe rehydration). Out of scope for this recipe. - -## Thoroughness checklist - -- [x] Four distinct account topologies covered: Unified spot-funded + HIP-3 / Unified spot-only clean / Unified perps-funded clean / zero -- [x] Two abstraction modes exercised live (Unified + Standard) via in-flight flip on dev2 -- [x] Mode round trip exercised (Unified → Standard → Unified) and shape re-asserted after restore -- [x] Standard-mode fold-limitation quantified with live numbers (`observedInflation ≈ freeSpot`) -- [x] Controller field shape asserted on every account (no `undefined`, no legacy keys) -- [x] Math check independent of HIP-3 knowledge (uses controller breakdown as perps truth, HL REST as spot truth) -- [x] UI assertions on both `PerpsMarketListView` and `PerpsWithdrawView` -- [x] Keypad input → validation hook behavior asserted for valid and over-amount cases -- [x] Empty-state UI asserted via Add Funds affordance -- [x] Teardown restores fixture account to Trading -- [x] Schema-validated (composable flows + recipe) -- [x] Run repeatable and free (no withdraw3, no fund movement outside the HL-internal ledger redistribution on mode flip) -- [x] Real `withdraw3` behavior verified separately via one-shot script (cost: \$1) -- [x] Flows are reusable via `call` — any future perps PR touching balance fields can compose them - -## Files - -| Path | Purpose | Tracked | -| ------------------------------------------------------------------------ | --------------------------------------------------------- | ------- | -| `scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json` | Composable shape check | ✓ git | -| `scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json` | Composable math check | ✓ git | -| `scripts/perps/agentic/teams/perps/flows/hl-provision-fixture.json` | Pre-existing fixture-provisioning flow (abstraction flip) | ✓ git | -| `scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json` | Top-level single-account state-machine recipe | ✓ git | -| `docs/perps/perps-account-abstraction-and-balance-contract.md` | This document | ✓ git | +Perps account balance display must preserve the distinction between spendable +balance, collateral, and HyperLiquid account abstraction state. Validation should +exercise clean accounts, accounts with positions/orders, and Unified/Standard +mode transitions through the runner-owned recipe suite. diff --git a/docs/perps/perps-agentic-feedback-loop.md b/docs/perps/perps-agentic-feedback-loop.md index b4541207a873..b3acf75f9f14 100644 --- a/docs/perps/perps-agentic-feedback-loop.md +++ b/docs/perps/perps-agentic-feedback-loop.md @@ -1,403 +1,47 @@ -# Perps Agentic Toolkit +# Perps agentic feedback loop -The agentic toolkit lets AI agents interact with a running MetaMask Mobile app via CDP (Chrome DevTools Protocol). Agents execute parameterized flows — JSON test sequences that navigate screens, press buttons, type values, and assert state — to verify their own code changes without human intervention. +MetaMask Mobile exposes a development-only agentic bridge so external Farmslot +Recipe v1 runners can control the app, seed deterministic fixtures, show a small +HUD, and capture proof from the real UI path. -The toolkit lives at `scripts/perps/agentic/`. It works on both iOS Simulator and Android Emulator. +## Repository boundary ---- +The Mobile repository owns the product integration only: -## Architecture +- `app/core/AgenticService/` installs `globalThis.__AGENTIC__` in `__DEV__`. +- `scripts/perps/agentic/cdp-bridge.js` connects external tools to the React + Native CDP target. +- `scripts/perps/agentic/setup-wallet.sh` applies wallet fixtures through the + bridge. +- `scripts/perps/agentic/app-state.sh`, `app-navigate.sh`, and `screenshot.sh` + are small diagnostics used by humans and external runners. -``` -Agent (Claude Code / Cursor / etc.) - | - v -validate-recipe.sh # Orchestrates flow execution - | - +-- cdp-bridge.js # CDP engine (WebSocket -> Metro -> Hermes) - | +-- lib/ws-client.js # WebSocket connection - | +-- lib/target-discovery.js # Find the right CDP target - | +-- lib/cdp-eval.js # Eval sync/async via CDP - | +-- lib/config.js # Port + env resolution - | +-- lib/assert.js # Assertion operators - | +-- lib/registry.js # Pre-condition registry - | - +-- teams/perps/ - +-- flows/ # 12 parameterized flow JSONs - +-- evals/ # Hierarchical eval ref collections - +-- evals.json # Built-in eval refs - +-- pre-conditions.js # Named pre-condition checks -``` +Recipe definitions, flow composition, action manifests, trace/summary output, +and MetaMask domain actions are maintained by the external Recipe v1 runner. +Do not add task-specific recipes or reusable runner actions to Mobile. ---- +## Human workflow -## Quick Start +Use Mobile scripts to start and inspect a controllable runtime: ```bash -# 1. Check app + Metro + CDP are connected +yarn a:ios yarn a:status - -# 2. Run a built-in eval ref (single CDP eval) -bash scripts/perps/agentic/app-state.sh eval-ref perps/positions - -# 3. Run a flow (multi-step UI sequence) -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/flows/market-discovery.json --skip-manual - -# 4. Dry-run a flow (prints steps without executing) -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/flows/trade-open-market.json --dry-run - -# 5. Run all flows (dry-run) -for f in scripts/perps/agentic/teams/perps/flows/*.json; do - bash scripts/perps/agentic/validate-recipe.sh "$f" --dry-run --skip-manual -done -``` - ---- - -## Flows - -A flow is a parameterized JSON file that `validate-recipe.sh` executes step-by-step against the live app. Each flow declares its parameters in an `inputs` block, its required app state in `pre_conditions`, and a sequence of `steps`. - -### Parameter Templating - -Flows use `{{param}}` tokens in titles, expressions, test_ids, and params. Defaults come from the `inputs` block: - -```json -{ - "title": "Trade — market {{side}} {{symbol}} ${{usdAmount}}", - "inputs": { - "side": { "type": "string", "default": "long" }, - "symbol": { "type": "string", "default": "BTC" }, - "usdAmount": { "type": "string", "default": "10" } - } -} -``` - -When run standalone, `inputs` defaults are applied. When called via `flow_ref`, the parent provides values that override defaults. Params without a default are required. - -### Pre-Conditions - -Pre-conditions gate flow execution. If any check fails, the runner aborts with a clear error and hint. - -```json -"pre_conditions": [ - "wallet.unlocked", - "perps.ready_to_trade", - { "name": "perps.open_position", "symbol": "{{symbol}}" } -] -``` - -String form for simple checks, object form for parameterized checks. Shorthand `"perps.open_position(symbol={{symbol}})"` is also supported. - -**Available pre-conditions** (from `teams/perps/pre-conditions.js`): - -| Name | Description | -| ---------------------------------- | ------------------------------------------------- | -| `wallet.unlocked` | Wallet is unlocked and navigable | -| `perps.feature_enabled` | PerpsController is available | -| `perps.trading_flag` | Perps trading remote flag is on | -| `perps.ready_to_trade` | Provider is authenticated | -| `perps.sufficient_balance` | Account has non-zero balance | -| `perps.open_position` | Open position exists (optionally by symbol) | -| `perps.open_position_tpsl` | Position with TP/SL exists (optionally by symbol) | -| `perps.open_limit_order` | Open limit order exists (optionally by symbol) | -| `perps.not_in_watchlist` | Symbol is not in watchlist | -| `ui.homepage_redesign_v1_enabled` | Homepage redesign V1 flag is on | -| `ui.homepage_redesign_v1_disabled` | Homepage redesign V1 flag is off | - -### Authoring Rules - -Enforced by `node scripts/perps/agentic/validate-flow-schema.js`: - -1. **Eval steps must assert.** Every `eval_sync`, `eval_async`, `eval_ref` step needs an `"assert"` block. Use `{"operator":"not_null"}` at minimum. -2. **Terminal step must assert.** The last step must be an asserting eval or a `log_watch`. Never end on `wait`, `navigate`, or `press`. -3. **No unknown actions.** Only recognized action types are allowed. -4. **Inputs must match params.** Every `{{param}}` in steps must have a matching key in `inputs`. - -Full schema: `scripts/perps/agentic/schemas/flow.schema.json` - -### Available Flows - -| Flow | Inputs (defaults) | Pre-conditions | -| ---------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `activity-view` | `tab` ("trades") | wallet.unlocked, perps.feature_enabled | -| `market-discovery` | `symbol` ("BTC") | wallet.unlocked, perps.feature_enabled | -| `market-watchlist` | `symbol` ("BTC") | wallet.unlocked, perps.feature_enabled, perps.not_in_watchlist | -| `order-limit-cancel` | `symbol` (required) | wallet.unlocked, perps.open_limit_order | -| `order-limit-place` | `side` ("long"), `symbol` ("BTC"), `usdAmount` ("10"), `limitPrice` ("60000") | wallet.unlocked, perps.ready_to_trade | -| `position-add-margin` | `symbol` (required), `marginAmount` (required) | wallet.unlocked, perps.open_position | -| `setup-account` | `address` (required) | wallet.unlocked | -| `setup-testnet` | _(none)_ | wallet.unlocked, perps.feature_enabled | -| `tpsl-create` | `symbol` (required), `tpPreset` ("25"), `slPreset` ("-10") | wallet.unlocked, perps.open_position | -| `tpsl-edit` | `symbol` (required), `tpPreset` ("50"), `slPreset` ("-25") | wallet.unlocked, perps.open_position_tpsl | -| `trade-close-position` | `symbol` (required) | wallet.unlocked, perps.open_position | -| `trade-open-market` | `side` ("long"), `symbol` ("BTC"), `usdAmount` ("10") | wallet.unlocked, perps.ready_to_trade, perps.sufficient_balance | - ---- - -## Eval Refs - -Eval refs are named CDP eval expressions in `teams/perps/evals.json` and `teams/perps/evals/*.json`. Unlike flows (multi-step UI sequences), eval refs are single eval calls. - -```bash -# List all eval refs -bash scripts/perps/agentic/app-state.sh eval-ref --list - -# Run an eval ref -bash scripts/perps/agentic/app-state.sh eval-ref perps/positions -bash scripts/perps/agentic/app-state.sh eval-ref perps/core/watchlist -bash scripts/perps/agentic/app-state.sh eval-ref perps/setup/testnet-mode -``` - -**Built-in eval refs** (`perps/`): positions, auth, balances, markets, orders, state, providers, pre-trade, post-trade, place-order - -**Extended eval refs** (`perps/core/`): pump-market, tpsl-orders, positions-by-symbol, leverage-config, watchlist - -**Setup eval refs** (`perps/setup/`): testnet-mode, current-provider - -### eval_ref in Flows - -Use `eval_ref` inside a flow to run a built-in eval ref and assert on its result: - -```json -{ - "id": "check-pos", - "action": "eval_ref", - "ref": "positions", - "assert": { "operator": "length_gt", "field": "positions", "value": 0 } -} +yarn a:navigate WalletView +yarn a:reload ``` ---- - -## CDP Commands +Use the recipe-harness skill or external runner to execute Recipe v1 scenarios. +The runner consumes Mobile's bridge and writes its own evidence artifacts. -All CDP commands go through `cdp-bridge.js` or `app-state.sh` wrappers: +## HUD intent -```bash -CDP="node scripts/perps/agentic/cdp-bridge.js" -AS="bash scripts/perps/agentic/app-state.sh" - -$CDP status # Route + account snapshot -$CDP get-route # Current route name -$CDP eval "" # Sync JS eval (ES5 only) -$CDP eval-async "" # Async eval (Promise, use .then()) -$CDP eval-ref perps/positions # Run a named eval ref -$CDP check-pre-conditions '' # Validate pre-conditions -$CDP press-test-id # Press by testID -$CDP scroll-view --test-id # Scroll a view -$CDP set-input "val" # Type into input -``` - -**ES5 only.** No arrow functions, no `const`/`let`, no template literals, no top-level `await`. - -```bash -# Good: -$CDP eval "var x = Engine.context.PerpsController.state; JSON.stringify(x)" -$CDP eval-async "Engine.context.PerpsController.getPositions().then(function(r){ return JSON.stringify(r) })" - -# Bad: -$CDP eval "const x = () => Engine.context" # arrow + const -$CDP eval-async "await Engine.context.getPos()" # top-level await -``` - ---- - -## Shell Commands - -| Command | Purpose | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `app-state.sh status\|route\|eval\|eval-async\|eval-ref\|accounts\|press\|scroll\|set-input` | State queries and UI interaction | -| `app-navigate.sh [params-json]` | Navigate + auto-screenshot. `--list` discovers all live routes | -| `screenshot.sh [label]` | Cross-platform screenshot (iOS simctl / Android adb) | -| `validate-recipe.sh [--dry-run] [--skip-manual] [--step ]` | Execute a flow/recipe against the live app | -| `validate-flow-schema.js` | Validate all flows against authoring rules | -| `validate-pre-conditions.js` | Verify pre-condition expressions and assertions | -| `start-metro.sh --platform ios\|android` | Start or attach to Metro | -| `setup-wallet.sh` | Seed wallet from `.agent/wallet-fixture.json` | - ---- - -## Assertions - -Every asserting step includes `"assert": { "operator": "", "field": "", "value": }`. - -| Operator | Passes when | -| -------------- | --------------------------------------------- | -| `not_null` | `actual != null` | -| `eq` | `actual === expected` | -| `gt` | `actual > expected` (number) | -| `length_eq` | `actual.length === expected` | -| `length_gt` | `actual.length > expected` | -| `contains` | `actual.includes(expected)` (string or array) | -| `not_contains` | `!actual.includes(expected)` | - -`field` is a dot-path into the result JSON (e.g. `"route"`, `"positions.0.symbol"`). Omit or set to `null` to assert on the entire result. Double-encoded JSON strings are automatically unwrapped. - ---- - -## UI Interactions - -The toolkit interacts with React components by `testID` — no coordinates needed. Under the hood, it walks the React fiber tree via `__REACT_DEVTOOLS_GLOBAL_HOOK__`. - -```bash -bash app-state.sh press # tap a button -bash app-state.sh scroll --test-id --offset 300 # scroll down -bash app-state.sh set-input "0.5" # type into input -``` - -In flows, use `press`, `scroll`, `set_input`, `type_keypad`, `clear_keypad`, and `wait_for` actions. - -**Keypad pattern:** Always clear before typing — use `clear_keypad` (count: 8) before `type_keypad` to wipe any pre-filled value. Assert the displayed amount matches before submitting. - ---- - -## Gherkin to Flow Translation - -Gherkin maps naturally to flow JSON: - -| Gherkin | Flow equivalent | -| --------------------------- | ----------------------------------------------------------------- | -| **Given** (preconditions) | `pre_conditions` array | -| **When** (user actions) | `navigate`, `press`, `set_input`, `type_keypad`, `wait_for` steps | -| **Then** (expected outcome) | `eval_sync`/`eval_async` steps with `assert` | - -**Example:** - -```gherkin -Given the wallet is unlocked - And BTC has an open position -When the user navigates to BTC market detail - And presses the Close Position button -Then the close position screen is shown -``` - -```json -{ - "title": "Close BTC position", - "inputs": { - "symbol": { "type": "string", "description": "Market symbol" } - }, - "validate": { - "runtime": { - "pre_conditions": [ - "wallet.unlocked", - { "name": "perps.open_position", "symbol": "{{symbol}}" } - ], - "steps": [ - { - "id": "nav", - "action": "navigate", - "target": "PerpsMarketDetails", - "params": { - "market": { - "symbol": "{{symbol}}", - "name": "{{symbol}}", - "price": "0", - "change24h": "0", - "change24hPercent": "0", - "volume": "0", - "maxLeverage": "100" - } - } - }, - { - "id": "wait-market", - "action": "wait_for", - "route": "PerpsMarketDetails" - }, - { - "id": "press-close", - "action": "press", - "test_id": "perps-market-details-close-button" - }, - { - "id": "wait-close-screen", - "action": "wait_for", - "route": "PerpsClosePosition" - } - ] - } - } -} -``` - ---- - -## Recipes - -Recipes compose multiple flows via `flow_ref` for integration-level validation. They live in `scripts/perps/agentic/teams//recipes/` and prove that end-to-end scenarios work across flow boundaries. - -See `teams/perps/recipes/full-trade-lifecycle.json` for an example that chains: wallet home → mainnet → perps → testnet → clear position → open market → TP/SL (presets) → close — all via `flow_ref`. - -```bash -# Run a recipe -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json - -# Dry-run -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json --dry-run -``` - ---- - -## Error Recovery - -| Symptom | Fix | -| ------------------------ | --------------------------------------------------------------------------------- | -| Metro crash / no output | `bash start-metro.sh --platform

` | -| CDP "not connected" | Check Metro running + device booted. Poll for `__AGENTIC__` (5-120s after unlock) | -| Hot reload resets app | `app-navigate.sh WalletTabHome` then target screen | -| App crash / white screen | `bash preflight.sh --platform

` | -| eval returns undefined | Use `eval-async` with `.then(function(r){ return JSON.stringify(r) })` | -| "SyntaxError" in eval | ES5 violation — check for arrow functions, const/let, template literals | -| Eval ref assertion fails | Check `eval-ref --list` for correct name; re-read the eval ref JSON | -| adb reverse lost | `adb reverse tcp:PORT tcp:PORT` | -| Route not found | Check route name in the table below; cdp-bridge handles nested routing | - ---- - -## Routes and State Paths - -### Perps Routes - -| Route | Description | Params | -| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `PerpsMarketListView` | Perps home (positions, orders, watchlist) | | -| `PerpsTrendingView` | Market list (all markets) | | -| `PerpsMarketDetails` | Market detail view | `{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}` | -| `PerpsActivity` | Activity history | `{"redirectToPerpsTransactions":true}` | -| `PerpsClosePosition` | Close a position | | -| `PerpsTPSL` | Take-profit / stop-loss | | -| `PerpsAdjustMargin` | Adjust position margin | | -| `PerpsOrderDetailsView` | Order detail view | | -| `PerpsOrderBook` | Order book depth | | -| `PerpsWithdraw` | Withdraw funds | | -| `PerpsTutorial` | Onboarding tutorial | | - -Other useful routes: `WalletTabHome`, `SettingsView`, `DeveloperOptions`, `BrowserTabHome`. - -### Engine Controller Paths - -```bash -Engine.context.PerpsController.state # Positions, orders, balances, config -Engine.context.NetworkController.state # Network selection -Engine.context.AccountsController.state # Accounts, selected account -Engine.context.RemoteFeatureFlagController.state # Feature flags -Engine.context.PreferencesController.state # User preferences -``` +The HUD is a human-facing proof aid. It should display one concise current +intent, optionally one subflow/context line, and failure details when useful. It +must not duplicate internal action names or task-specific debug text. -### Common PerpsController Methods +## Fixture workflow -| Method | Returns | Description | -| --------------------------- | ----------------------- | ------------------------ | -| `getPositions()` | `Promise` | Open positions | -| `getAccountState()` | `Promise` | Balances, margin | -| `getMarketDataWithPrices()` | `Promise` | Markets with live prices | -| `getOpenOrders()` | `Promise` | Active limit/stop orders | -| `getTradeConfiguration()` | `Promise` | Leverage limits, fees | -| `placeOrder(params)` | `Promise` | Submit an order | -| `closePosition({symbol})` | `Promise` | Close by symbol | +Local fixture files belong under `.agent/` and must not be committed. The bridge +supports fixture setup so recipes can start from a deterministic wallet state, +while still validating behavior through the real app runtime. diff --git a/docs/perps/perps-agentic-scripts-quickref.md b/docs/perps/perps-agentic-scripts-quickref.md deleted file mode 100644 index 2e59a61cbec2..000000000000 --- a/docs/perps/perps-agentic-scripts-quickref.md +++ /dev/null @@ -1,106 +0,0 @@ -# Agentic Scripts — Quick Reference - -## Yarn Shortcuts - -| Command | What it does | Time | -| ---------------------- | ---------------------------------------------------------- | ------- | -| `yarn a:setup:ios` | Clean install + build + Metro + launch + CDP + wallet seed | ~2.5min | -| `yarn a:setup:android` | Same as above for Android | ~3min | -| `yarn a:ios` | Metro + launch + CDP + unlock/seed (no clean, no rebuild) | ~30s | -| `yarn a:android` | Same as above for Android | ~30s | -| `yarn a:watch` | Interactive Metro with live reload | — | -| `yarn a:stop` | Stop Metro | — | -| `yarn a:reload` | Reload JS bundle on connected app | — | -| `yarn a:status` | App state snapshot (route + account) | — | -| `yarn a:navigate` | Navigate to a route | — | - -## When to use what - -- **First time / after `git clean`**: `yarn a:setup:ios` (full clean) -- **Daily dev / branch switch**: `yarn a:ios` (reuses existing build, unlocks wallet) -- **Just want Metro**: `yarn a:watch` - -## Prerequisites - -1. `.js.env` must have `WATCHER_PORT`, `IOS_SIMULATOR`, `SIM_UDID` (iOS) or `ANDROID_DEVICE` (Android) -2. `.agent/wallet-fixture.json` must exist (copy from `scripts/perps/agentic/wallet-fixture.example.json`) - -## Flows - -Flows are parameterized JSON test sequences in `scripts/perps/agentic/teams//flows/`. - -```bash -# List all flows -ls scripts/perps/agentic/teams/perps/flows/*.json - -# Run a flow -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/flows/market-discovery.json --skip-manual - -# Dry-run (prints steps, no execution) -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/flows/trade-open-market.json --dry-run - -# Run all flows (dry-run) -for f in scripts/perps/agentic/teams/perps/flows/*.json; do - bash scripts/perps/agentic/validate-recipe.sh "$f" --dry-run --skip-manual -done -``` - -### Parameter Passing - -Flows use `{{param}}` tokens. Defaults are declared in the flow's `inputs` block. Override via `flow_ref` params or by editing the JSON. - -### Pre-Conditions - -Flows can declare `pre_conditions` — named checks that must pass before steps run. If a check fails, the runner aborts with a hint. Available pre-conditions are registered in `teams/perps/pre-conditions.js`. - -## CDP Bridge Commands - -```bash -CDP="node scripts/perps/agentic/cdp-bridge.js" - -$CDP status # Route + account snapshot -$CDP navigate PerpsMarketListView # Navigate to a screen -$CDP get-route # Current route -$CDP get-state engine.backgroundState.NetworkController # Redux state -$CDP eval "1+1" # Eval JS in app -$CDP eval-async "fetch('...')" # Eval async JS -$CDP unlock # Unlock wallet on login screen -$CDP press-test-id # Press component by testID -$CDP scroll-view --test-id # Scroll a ScrollView/FlatList -$CDP list-accounts # All accounts -$CDP switch-account

# Switch active account -$CDP eval-ref perps/positions # Run a named eval ref -$CDP eval-ref --list # List all eval refs -$CDP check-pre-conditions '' # Validate pre-conditions -``` - -## Other Scripts - -```bash -scripts/perps/agentic/app-navigate.sh # Navigate + screenshot -scripts/perps/agentic/app-navigate.sh --list # Discover all live routes -scripts/perps/agentic/screenshot.sh # Capture simulator screenshot -scripts/perps/agentic/setup-wallet.sh # Seed wallet via CDP -scripts/perps/agentic/unlock-wallet.sh # Unlock via CDP -scripts/perps/agentic/validate-recipe.sh # Run PR recipe folder -scripts/perps/agentic/validate-flow-schema.js # Validate flow authoring rules -scripts/perps/agentic/validate-pre-conditions.js # Validate pre-condition registry -``` - -## Architecture - -``` -NavigationService.ts (set navigation) - --> AgenticService.install(navRef, deferredNav) [__DEV__ only] - --> globalThis.__AGENTIC__ = { setupWallet, pressTestId, scrollView, ... } - -CDP Bridge (cdp-bridge.js) - --> Metro /json/list --> WebSocket --> Runtime.evaluate - --> reads globalThis.__AGENTIC__.* -``` - -## Worktree / Multi-Device Mapping - -Ports are set per-slot via `.js.env` `WATCHER_PORT`. When both iOS and Android devices are connected, set `PLATFORM=android` or `PLATFORM=ios` to disambiguate screenshot targets. CDP commands are platform-agnostic. diff --git a/docs/perps/perps-agentic-system-design.md b/docs/perps/perps-agentic-system-design.md deleted file mode 100644 index e1b56c531d60..000000000000 --- a/docs/perps/perps-agentic-system-design.md +++ /dev/null @@ -1,295 +0,0 @@ -# Agentic System Design - -The agentic toolkit is a system that lets AI agents write code, verify it against a running -app, and iterate — all without human intervention. It provides a fast, local feedback loop: -the agent gets signal in seconds from a live app instead of waiting for heavyweight test -frameworks. It complements E2E tests (Detox) and CI — it doesn't replace them. It's built -on three pillars. - -The toolkit was built by the perps team but designed for any team in MetaMask Mobile. The -infrastructure (`scripts/perps/agentic/teams/`) auto-discovers team directories — any team -can add flows, recipes, and pre-conditions without modifying shared code. - ---- - -## The Three Pillars - -1. **Wallet Fixtures & Preflight** — Get to a known state in seconds, not minutes -2. **Recipe & Flow System** — Parameterized, composable, deterministic test sequences -3. **CDP Instrumentation** — Direct app access via Chrome DevTools Protocol, no vision model needed - -These aren't independent tools. They form a flywheel: - -``` -Wallet Fixtures ──→ Known State ──→ Recipes execute deterministically - ↑ │ - └──── Clean state for next iteration ←────┘ - ↑ - CDP: text-based assertions - (no screenshots, no vision tokens) -``` - ---- - -## Pillar 1: Wallet Fixtures & Preflight - -### The problem - -A fresh MetaMask wallet requires ~15 manual steps to reach a usable state: create wallet, -back up seed phrase, dismiss onboarding modals, import trading accounts, enable feature -flags, suppress consent screens, navigate to the target feature. An E2E test takes 2-5 -minutes for this. An agent doing it via UI automation burns tokens on every step and hits -flaky modal dismissals along the way. - -### The solution - -`wallet-fixture.json` defines the desired wallet state declaratively — password, accounts -(mnemonic or private key), and settings that suppress friction: - -```json -{ - "password": "...", - "accounts": [ - { "type": "mnemonic", "value": "twelve word seed ..." }, - { "type": "privateKey", "value": "0xabc...", "name": "Trading" } - ], - "settings": { - "metametrics": false, - "skipGtmModals": true, - "skipPerpsTutorial": true, - "autoLockNever": true - } -} -``` - -`setup-wallet.sh` reads this fixture and calls `__AGENTIC__.setupWallet(fixture)` — a single -CDP eval that restores the wallet, imports accounts, dispatches all onboarding flags, -suppresses modals, and navigates to wallet home. Pure JS execution, no UI navigation, no -modal handling, no screenshot verification. - -`preflight.sh` orchestrates the full environment pipeline: - -| Scenario | What runs | Time | -| ---------------------------------- | ----------------------------------- | ------- | -| Cold start (first time) | build + boot + Metro + CDP + wallet | ~150s | -| Warm start (Metro running) | boot device + CDP + wallet | ~10-20s | -| Hot iteration (everything running) | wallet restore if needed | ~2-5s | - -**Key insight: isolation.** Each agent run starts from a known wallet state. No leaking -state between iterations. The fixture is the contract — deterministic input produces -deterministic starting point. - ---- - -## Pillar 2: Recipe & Flow System - -### The problem - -E2E tests (Detox) take 90-300 seconds per test, run serially, and produce failures that -require screenshots to diagnose. CI on GitHub can take up to 20 minutes per push. These -tools remain essential for final validation, but an agent iterating on a fix needs faster -signal during development. - -### The solution - -JSON-based recipes and flows executed via `validate-recipe.sh`, organized by team under -`scripts/perps/agentic/teams/`. Each team directory follows the same structure: - -- `teams//flows/` — flow JSONs validated by `validate-flow-schema.js` -- `teams//evals.json` — quick eval refs (e.g. `perps/positions`, `swap/quote-status`) -- `teams//evals/` — named eval ref collections -- `teams//pre-conditions.js` — namespaced checks (e.g. `perps.ready_to_trade`, `swap.has_valid_quote`) - -`lib/registry.js` auto-discovers all team directories and merges their pre-conditions at load -time. Duplicate keys across teams cause a load-time error — namespace enforcement by convention. -A new team adds a directory and immediately gets access to all shared infrastructure. - -**Recipes** are single CDP eval expressions — state snapshots that run in <1 second. -The path `/` is the team boundary — `eval-ref perps/positions` is a perps team -eval ref, `eval-ref swap/quote-status` would be a swap team eval ref. - -**Flows** are multi-step UI sequences — navigate, press, type, wait, assert. They run in -10-30 seconds. Parameterized with `{{symbol}}`, composable via `flow_ref` and `eval_ref`. - -| Dimension | E2E (Detox) | Recipes/Flows | -| ------------- | --------------------------- | ----------------------------------------- | -| Speed | 90-300s/test | 1-30s/flow | -| Flakiness | High (animations, timing) | Low (explicit waits, direct fiber access) | -| Output | Screenshots (vision tokens) | JSON text (cheap) | -| Composability | Copy entire test files | `flow_ref` + `eval_ref` + params | - -Flows declare their requirements via pre-conditions. If the wallet isn't unlocked or no -position exists, the runner aborts with a clear error before wasting time on doomed steps. - -### Recipes are the agent's eyes - -Instead of "take a screenshot and look at it" (thousands of vision tokens), the agent runs -`recipe perps/positions` and gets structured JSON back. The assertion system (`eq`, `gt`, -`length_gt`, `contains`) lets the agent verify state without seeing the screen. One recipe -call costs one tool invocation. One screenshot costs a vision model call plus the tokens -to describe what's in the image. - -### Recipes are proof - -When an agent fixes a bug, it writes a recipe that reproduces the bug (assertion fails), -applies the fix, re-runs the recipe (assertion passes). The recipe IS the proof. It goes -into the PR as `recipe.json` — reviewers can re-run it to verify. The same recipe becomes -a regression check for future changes. - ---- - -## Pillar 3: CDP Instrumentation - -### The problem - -Traditional mobile test automation requires either a native framework (Detox, Appium) with -heavy setup, or coordinate-based tapping that breaks on layout changes. - -### The solution - -The `__AGENTIC__` bridge on `globalThis`, installed by `AgenticService.ts` in `__DEV__` -mode when NavigationService sets its ref. It exposes: - -- **Navigation**: `navigate()`, `getRoute()`, `canGoBack()`, `goBack()` -- **Accounts**: `listAccounts()`, `getSelectedAccount()`, `switchAccount()` -- **UI interaction**: `pressTestId()`, `scrollView()`, `setInput()` -- **Setup**: `setupWallet()` (the 11-step initialization from Pillar 1) - -`pressTestId` walks the React fiber tree via `__REACT_DEVTOOLS_GLOBAL_HOOK__` to find the -component with a matching `testID` prop and calls its `onPress` handler directly. No -coordinates, no image recognition, no screenshots. Same for `setInput` (calls -`onChangeText`) and `scrollView` (calls `scrollTo` on the nearest scrollable ancestor). - -`cdp-bridge.js` connects via Metro's Hermes WebSocket — same protocol on iOS and Android. -Everything returns structured JSON. - -**Key insight: the bridge turns the running app into an API.** Instead of "look at the -screen, find the button, tap at coordinates", the agent says -`press perps-market-details-long-button`. Instead of "take a screenshot to check what -screen we're on", the agent evaluates `getRoute().name` and gets `"PerpsMarketDetails"` -as a string. - ---- - -## The Flywheel: How It All Connects - -### Agent development cycle - -1. Agent gets a task (bug fix, new feature, PR review) -2. Preflight restores wallet to known state (~2-5s warm) -3. Agent reads code, understands the problem -4. Agent writes a recipe that reproduces the bug (assertion fails) -5. Agent fixes the code -6. Metro hot-reloads (~2s) -7. Agent re-runs the recipe (assertion passes) — **sub-minute verification** -8. Agent commits fix + recipe as PR evidence - -**Without the toolkit:** the agent's fastest feedback is Detox (90-300s per test) or pushing -to CI (up to 20 minutes). Screenshots require vision models (expensive, fragile). - -**With the toolkit:** the agent verifies locally against a running app. Metro auto-reloads -on save (HMR for React changes is instantaneous), and feedback comes back as text. - -### Feedback channels — cheapest to most expensive - -The toolkit provides multiple feedback layers. In practice, ~95% of verification uses the -cheapest one: - -1. **DevLogger + grep (primary)** — Drop a tagged log line in any render path or hook, save - the file, Metro hot-reloads instantly, grep the Metro log for your tag. One log line + - one grep = instant signal about what the UI is actually doing. Works for state bugs, race - conditions, render order, data flow — anything where you need to know _what happened_, not - _what it looks like_. Zero vision tokens, near-zero cost. -2. **CDP eval / recipes** — Query app state directly via `__AGENTIC__` bridge. Returns - structured JSON. Use when you need to assert on controller state, position data, or - any value the UI consumes. Cheap but each call is a tool invocation. -3. **Screenshots** — Capture the screen for visual feedback. Use when implementing from a - design reference and comparing against designer mockups. Triggers a vision model call — - reserve for cases where visual appearance is what you're verifying. -4. **System logs (logcat / Console.app)** — For native module work (Objective-C, Java/Kotlin). - Rare on MetaMask Mobile since most code is JS/TS in the React Native layer. - -**Rule of thumb:** if you can verify with a log line, don't take a screenshot. If you can -verify with a recipe, don't write custom CDP eval. Always start at level 1. - -### HUD overlay — making videos reviewable - -Agents produce video recordings as PR evidence, but raw video of an app being tapped by -an invisible hand is hard for human reviewers to follow. The **Agent Step HUD** -(`AgentStepHud.tsx`) solves this by rendering a persistent on-screen overlay during recipe -execution that shows the current step ID, description, and action type. - -The HUD is enabled by default. Use `--no-hud` to disable it. Before each step executes, -the runner sends the step metadata to the app via CDP eval, and `AgentStepHud` renders it -as an overlay banner. The HUD propagates through `flow_ref` sub-invocations -automatically, so nested flow steps are annotated too. - -This turns an opaque screen recording into a narrated walkthrough: reviewers see exactly -what the agent is testing at each moment, which assertion is running, and what the -expected outcome is — without needing to cross-reference the recipe JSON. The result is a -tighter feedback loop between autonomous agents and human reviewers: the video itself -communicates intent. - -### The compounding effect - -- Wallet fixtures make recipes deterministic (known starting state) -- Recipes make bug fixes provable (assertion = proof) -- CDP instrumentation makes recipes cheap (text, not vision) -- Pre-conditions catch stale state early (fail fast with hints) -- `flow_ref` lets agents compose complex scenarios from simple building blocks -- Each recipe written for one PR becomes reusable regression for future PRs - -### Beyond single agents - -The toolkit is designed to be consumed by autonomous orchestration systems. The orchestrator -dispatches tasks using **workflow templates** (bug fix, PR review, feature dev) that are -project-scoped, not team-scoped. An outer orchestrator can: - -1. **Dispatch tasks** — assign a Jira ticket to an agent with a worker template -2. **Prepare the environment** — run `preflight.sh` to get the slot ready -3. **Monitor progress** — poll the task file for status transitions -4. **Validate results** — re-run the agent's recipe to confirm the fix independently -5. **Scale horizontally** — run multiple agents in parallel worktrees, each with its own - `WATCHER_PORT`, device, and wallet fixture - -The worker template injects team-specific context (which flows to run, which pre-conditions -to check) via template variables — different teams have different flow libraries but share -the same preflight, CDP bridge, recipe runner, and assertion engine. - -This works because the toolkit's contracts are stable: fixtures produce known state, recipes -produce JSON assertions, CDP returns structured data. An orchestrator just prepares the -environment and lets the agent use the toolkit's primitives. - ---- - -## Practical Example: Bug Fix Workflow - -Here's a concrete example from the perps team — the first adopter. The same pattern -applies to any team's flows. - -An agent is assigned: "TP/SL values don't persist after edit." - -1. **Preflight** — wallet restored with funds on testnet (~5s) -2. **`flow_ref: trade-open-market`** — opens a BTC long position ($10) -3. **`flow_ref: tpsl-create`** — sets initial TP/SL using percentage presets (TP +25%, SL -10%) -4. **Recipe: read TP/SL** — `recipe perps/core/tpsl-orders` → assert TP/SL orders exist (PASS) -5. **`flow_ref: tpsl-edit`** — changes TP/SL presets (TP +50%, SL -25%) -6. **Recipe: read TP/SL** — assert updated TP/SL values (FAIL — bug confirmed, still shows old values) -7. **Agent reads code** — finds stale cache in the edit handler, fixes it -8. **Hot-reload** — Metro picks up changes (~2s) -9. **Re-run steps 5-6** — assert updated TP/SL values (PASS — fix verified) -10. **Recipe goes into PR** as `recipe.json` — reviewer runs `validate-recipe.sh` to verify - -Total time from bug confirmation to verified fix: under 3 minutes of agent wall time. -The recipe.json is the test, the reproduction, and the proof — all in one file. - ---- - -## Cross-Reference - -- `docs/perps/perps-agentic-feedback-loop.md` — full reference for all commands, actions, - routes, and pre-conditions -- `docs/perps/agentic-scripts-quickref.md` — cheat sheet for daily use -- `scripts/perps/agentic/schemas/flow.schema.json` — formal flow specification -- `scripts/perps/agentic/teams/README.md` — contribution guide for adding a new team -- `app/core/AgenticService/AgenticService.ts` — bridge implementation diff --git a/locales/languages/en.json b/locales/languages/en.json index 5588603e8747..80dd67c4349d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2301,6 +2301,7 @@ }, "world_cup": { "title": "World Cup", + "predictions_title": "World Cup predictions", "banner_title": "World Cup 2026", "banner_description": "Trade every match, every moment.", "tabs": { @@ -2380,9 +2381,15 @@ "new_prediction": "New prediction", "resolved_markets": "Resolved markets" }, + "positions_empty": { + "description": "Your predictions will appear here, showing your stake and market movements", + "browse_markets": "Browse markets" + }, "tabs": { "about": "About", + "active_positions": "Active positions", "positions": "Positions", + "history": "History", "outcomes": "Outcomes" }, "outcome_groups": { @@ -4068,13 +4075,18 @@ "risky": "Risky", "malicious_token_title": "Malicious token", "malicious_token_description": "{{symbol}} is a malicious token. Avoid interacting with it or trading it.", + "malicious_token_description_no_symbol": "This is a malicious token. Avoid interacting with it or trading it.", "malicious_token_banner_description": "{{symbol}} is flagged as malicious. It's likely to steal funds from anyone who interacts with it.", + "malicious_token_banner_description_no_symbol": "This token is flagged as malicious. It's likely to steal funds from anyone who interacts with it.", "suspicious_token_description": "{{symbol}} is a suspicious token.", + "suspicious_token_description_no_symbol": "This is a suspicious token.", "verified_token_title": "Verified token", "verified_token_description": "{{symbol}} is actively traded and is widely recognized. Verification is not an endorsement by MetaMask.", "risky_token_title": "Suspicious token", "risky_token_description": "{{symbol}} is flagged as suspicious. Take a look at the risks before you continue.", + "risky_token_description_no_symbol": "This token is flagged as suspicious. Take a look at the risks before you continue.", "malicious_token_sheet_description": "Serious risk signals detected on {{symbol}}. We recommend not trading this token.", + "malicious_token_sheet_description_no_symbol": "Serious risk signals detected on this token. We recommend not trading this token.", "got_it": "Got it", "proceed": "Proceed", "continue_anyway": "Continue anyway", @@ -9252,6 +9264,8 @@ "title": "Explore", "crypto_movers": "Crypto movers", "perps_movers": "Perps movers", + "perps_movers_pill_gainers": "Gainers", + "perps_movers_pill_losers": "Losers", "trending_tokens": "Trending", "crypto": "Crypto", "stocks": "Stocks", diff --git a/package.json b/package.json index 081792342ed9..2e938b775ef7 100644 --- a/package.json +++ b/package.json @@ -160,10 +160,10 @@ "a:status": "scripts/perps/agentic/app-state.sh status", "a:reload": "scripts/perps/agentic/reload-metro.sh", "a:navigate": "scripts/perps/agentic/app-navigate.sh", - "a:ios": "scripts/perps/agentic/preflight.sh --platform ios --wallet-setup", - "a:android": "scripts/perps/agentic/preflight.sh --platform android --wallet-setup", - "a:setup:ios": "scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup", - "a:setup:android": "scripts/perps/agentic/preflight.sh --platform android --clean --wallet-setup" + "a:ios": "scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup", + "a:android": "scripts/perps/agentic/preflight.sh --platform android --mode fast --wallet-setup", + "a:setup:ios": "scripts/perps/agentic/preflight.sh --platform ios --mode clean --wallet-setup", + "a:setup:android": "scripts/perps/agentic/preflight.sh --platform android --mode clean --wallet-setup" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -243,12 +243,12 @@ "@metamask/account-tree-controller": "^7.2.0", "@metamask/accounts-controller": "^38.0.0", "@metamask/address-book-controller": "^7.1.2", - "@metamask/ai-controllers": "0.6.3", + "@metamask/ai-controllers": "^0.7.0", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", - "@metamask/assets-controller": "^8.0.1", - "@metamask/assets-controllers": "^108.1.0", + "@metamask/assets-controller": "^8.2.0", + "@metamask/assets-controllers": "^108.3.0", "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.11.0", diff --git a/scripts/perps/agentic/CDP-capabilities-mobile.md b/scripts/perps/agentic/CDP-capabilities-mobile.md index 5d9473da8a8e..1c8b3e6ee4d7 100644 --- a/scripts/perps/agentic/CDP-capabilities-mobile.md +++ b/scripts/perps/agentic/CDP-capabilities-mobile.md @@ -1,127 +1,17 @@ -# MetaMask Mobile — CDP Capabilities +# Mobile CDP bridge capabilities -Mobile mirror of the extension's CDP capabilities study. Records which runner capabilities are exposed, how they're validated, and which families are structurally absent. +Mobile keeps a small product-side bridge for local development builds. External +Recipe v1 runners use this bridge to implement portable actions. -Substrate: +Supported bridge commands include: -- **Hermes CDP** via Metro inspector-proxy (Runtime.evaluate, Profiler) -- **Device layer** via `xcrun simctl` (iOS) / `adb` (Android) -- **In-app bridges**: `globalThis.__AGENTIC__`, Redux `store`, `Engine.context.*`, React DevTools hook +- route/status inspection; +- navigation and back navigation; +- selected-account reads and account switching; +- testID press, input, and scroll helpers; +- screenshot capture through the companion shell script; +- Hermes profiler start/stop; +- console/error issue capture for validation evidence. -## Supported families - -| Family | Recipe verbs | Mobile path | Status | -| --- | --- | --- | --- | -| Runtime / eval | `eval_sync`, `eval_async`, `eval_ref` | `Runtime.evaluate` | validated | -| UI interaction | `navigate`, `press`, `scroll`, `set_input`, `type_keypad`, `wait_for` | `__AGENTIC__` + fiber walk | validated | -| Lifecycle | `app_background`, `app_foreground`, `app_restart` | simctl / adb | validated | -| App surface | `select_account`, `toggle_testnet`, `switch_provider` | Redux + `Engine.context.*` | validated | -| Evidence (manual) | `screenshot`, `log_watch`, `manual` | `screenshot.sh` / Metro log | validated | -| Evidence (automatic) | built-in run-wide issue review | Metro log + in-app console hook | validated 2026-04-17 | -| Performance | `eval_sync` on `HermesInternal.getInstrumentedStats()` | Hermes built-in | validated 2026-04-17 | -| Tracing | `trace_start`, `trace_stop` | Hermes `Profiler` via CDP | validated 2026-04-17 | - -The runner exposes intent, not raw CDP plumbing. Canonical recipes live in `teams/perps/recipes/capabilities/`. - -## Structurally absent (document, don't force) - -| Family | Why | Workaround | -| --- | --- | --- | -| Network (offline/throttling) | no Hermes Network domain; iOS NLC is device-wide | XHR/fetch monkey-patch via `eval_sync` (narrow); simctl NLC (blunt) | -| Emulation — CPU | no Hermes Emulation domain | synthetic JS burn loop (not equivalent) | -| Emulation — media / timezone | no Hermes Emulation domain | `xcrun simctl status_bar` + appearance | -| Storage (web) | no Hermes Storage domain | MMKV / Redux clear via `eval_ref` | -| Service worker | no RN analog | `app_background` / `app_foreground` | -| Target (multi-page) | one Hermes target per simulator | N/A | -| Browser permissions | no Browser CDP domain | `xcrun simctl privacy` (deferred) | -| Fetch (request failure) | no Hermes Fetch domain | `global.fetch` / XHR monkey-patch | - -## Capability details - -### Performance metrics snapshot - -`eval_sync` on `HermesInternal.getInstrumentedStats()` — GC counters, heap/allocation stats, RN bridge counters — plus `global.performance.now()` for timestamps. Direct Hermes analog to Chrome's `Metrics.Timestamp`. Canonical: `capabilities/performance-metrics-smoke.json`. - -### Hermes sampling profiler - -`trace_start` / `trace_stop` call the CDP `Profiler` domain. Output is a Chrome-compatible `.cpuprofile` under `.agent/recipe-runs//traces/trace-