diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 23d7b5a51cb..508d5c5cd92 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -8,7 +8,7 @@ on: required: true type: string environment: - description: 'Build environment / track. Must be one of: exp, beta, rc.' + description: 'Build environment / track. Must be one of: exp, beta, rc, prod.' required: true type: string upload_to_sentry: @@ -43,9 +43,10 @@ on: required: true type: choice options: - - exp - - beta + - prod - rc + - beta + - exp default: rc upload_to_sentry: description: 'Upload JS source maps and native debug symbols to Sentry during the build (requires Sentry auth in the build environment)' @@ -75,8 +76,8 @@ jobs: ENVIRONMENT: ${{ inputs.environment }} run: | case "$ENVIRONMENT" in - exp|beta|rc) echo "✅ environment=$ENVIRONMENT is allowed" ;; - *) echo "::error::Invalid environment '$ENVIRONMENT'. Must be one of: exp, beta, rc"; exit 1 ;; + exp|beta|rc|prod) echo "✅ environment=$ENVIRONMENT is allowed" ;; + *) echo "::error::Invalid environment '$ENVIRONMENT'. Must be one of: exp, beta, rc, prod"; exit 1 ;; esac generate-build-version: diff --git a/.gitignore b/.gitignore index 582846d5840..6fa6c4955d7 100644 --- a/.gitignore +++ b/.gitignore @@ -199,13 +199,18 @@ release-test-plan.json release-delta.json release-signoffs.json -# Per-engineer skills config (auto-generated by Consensys/skills sync). +# Per-engineer skills config (auto-generated by MetaMask/skills sync). # Copy `.skills.local.example` to `.skills.local` and edit `SKILLS_DOMAINS=`. .skills.local +# Local cache used by `postinstall` to auto-update skills from the public +# MetaMask/skills repo. Safe to delete — postinstall recreates on next install. +.skills-cache/ + # Agent skills/commands/rules — never tracked. Synced via `yarn skills` from -# Consensys/skills, or hand-authored locally. (See ADR #57.) Only IDE/bugbot -# config under `.cursor/` is tracked via the carve-outs below. +# MetaMask/skills (public) and optionally Consensys/skills (private overlay), +# or hand-authored locally. (See ADR #57.) Only IDE/bugbot config under +# `.cursor/` is tracked via the carve-outs below. .claude/skills/ .claude/commands/ .agents/skills/ diff --git a/.skills.local.example b/.skills.local.example index 4b5b4c4e80a..04c1476aaea 100644 --- a/.skills.local.example +++ b/.skills.local.example @@ -1,12 +1,26 @@ # Template for per-engineer skills config used by `yarn skills`. -# Copy this file to `.skills.local` (gitignored) and set the domains -# you want installed by default. +# Copy this file to `.skills.local` (gitignored). # -# Examples: -# SKILLS_DOMAINS= # interactive prompt each run +# Zero-config default: the `postinstall` hook clones MetaMask/skills into +# `.skills-cache/metamask-skills` on every `yarn install`. `yarn skills` +# auto-detects that cache when no env var is set — nothing to do. +# +# Optional persistent skills config belongs in this file. Environment variables +# with the same names are only for one-off shell or CI overrides and take +# precedence over this file. +# METAMASK_SKILLS_DIR path to MetaMask/skills checkout (public, no auth) +# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay) +# +# Example local setup (only if you want to override the cache): +# METAMASK_SKILLS_DIR=~/dev/metamask/skills +# CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional +# +# Default behavior installs ALL domains. Set SKILLS_DOMAINS to opt out of some: +# SKILLS_DOMAINS= # all (default) # SKILLS_DOMAINS=perps # single domain -# SKILLS_DOMAINS=perps,testing,pr # multiple domains +# SKILLS_DOMAINS=perps,testing,pr-workflow # multiple domains # # Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain `. +# Pick interactively with `yarn skills --select`. # Use `yarn skills --reset` to wipe. SKILLS_DOMAINS= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 03e98c17f46..c59b9b644df 100644 --- a/README.md +++ b/README.md @@ -199,25 +199,27 @@ yarn start:android ### AI Agent Skills (`yarn skills`) -AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the [Consensys/skills](https://github.com/Consensys/skills) repo. Per [ADR #57](https://github.com/MetaMask/decisions/pull/162) this content is **not committed here** — `yarn skills` syncs it on demand into local-only paths under `.cursor/`, `.claude/`, and `.agents/`. +AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the [MetaMask/skills](https://github.com/MetaMask/skills) repo, with an optional private overlay from [Consensys/skills](https://github.com/Consensys/skills). Per [ADR #57](https://github.com/MetaMask/decisions/pull/162) this content is **not committed here** — `yarn skills` syncs it on demand into local-only paths under `.cursor/`, `.claude/`, and `.agents/`. -One-time setup: +Zero-config setup: ```bash -git clone git@github.com:Consensys/skills.git ~/path/to/consensys-skills -export CONSENSYS_SKILLS_DIR=~/path/to/consensys-skills # add to your shell rc +yarn install # clones MetaMask/skills into .skills-cache/metamask-skills +yarn skills # syncs all default skills from the cache ``` -Keep that checkout on `main` — `yarn skills` syncs from whatever revision is checked out there. - -Then in this repo: +Optional local configuration: ```bash -yarn skills # interactive prompt -SKILLS_DOMAINS=perps,testing yarn skills # non-interactive +cp .skills.local.example .skills.local +# edit .skills.local to set SKILLS_DOMAINS or override skills source paths +yarn skills --select # interactively pick domains +SKILLS_DOMAINS=perps,testing yarn skills # one-off domain override ``` -If `CONSENSYS_SKILLS_DIR` is unset, `yarn skills` prints the same setup instructions and exits. Skipping it is fine — it only affects agent tooling, not the app build. +Use `.skills.local` for persistent skills configuration. Shell environment variables with the same names are supported for one-off or CI overrides and take precedence. + +Skipping `yarn skills` is fine — it only affects agent tooling, not the app build. ### Git Hooks (Husky) diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts index 202609b23c2..fbc9cd23748 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts @@ -9,6 +9,7 @@ export const BridgeViewSelectorsIDs = { FEE_DISCLAIMER: 'bridge-fee-disclaimer', QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton', MISSING_PRICE_BANNER: 'bridge-missing-price-banner', + APPROVAL_TOOLTIP: 'bridge-approval-text', } as const; export type BridgeViewSelectorsIDsType = typeof BridgeViewSelectorsIDs; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx index 076f5ac3cb4..dd4d46d5796 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx @@ -10,6 +10,7 @@ import { RequestStatus, type QuoteResponse, MetaMetricsSwapsEventSource, + BRIDGE_MM_FEE_RATE, } from '@metamask/bridge-controller'; import { Hex } from '@metamask/utils'; import { isHardwareAccount } from '../../../../../util/address'; @@ -74,6 +75,16 @@ jest.mock('../../components/ApprovalText', () => { }; }); +jest.mock('../../../Rewards/components/RewardsVipBadge/RewardsVipBadge', () => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + MockReact.createElement(View, { testID: 'rewards-vip-badge' }), + }; +}); + // ─── Helpers ───────────────────────────────────────────────────────────────── const mockLocation = MetaMetricsSwapsEventSource.MainView; @@ -303,6 +314,29 @@ describe('BridgeViewFooter', () => { }); }); + it('shows discounted fee disclaimer with fee percentage when fee is less than base fee', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: { + ...mockQuoteWithMetadata, + quote: { + feeData: { metabridge: { quoteBpsFee: 57.5, baseBpsFee: 90 } }, + }, + }, + })); + + const { getByTestId } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect(getByTestId('rewards-vip-badge')).toBeTruthy(); + expect( + getByTestId(BridgeViewSelectorsIDs.FEE_DISCLAIMER), + ).toHaveTextContent('Includes0.9%0.575% MM fee.'); + }); + }); + it('shows no MM fee disclaimer when dest token is mUSD and fee is zero', async () => { const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; @@ -313,7 +347,14 @@ describe('BridgeViewFooter', () => { isLoading: false, activeQuote: { ...(mockQuoteWithMetadata as unknown as QuoteResponse), - quote: { feeData: { metabridge: { quoteBpsFee: 0 } } }, + quote: { + ...mockQuoteWithMetadata.quote, + destAsset: { + ...mockQuoteWithMetadata.quote.destAsset, + symbol: 'mUSD', + }, + feeData: { metabridge: { quoteBpsFee: 0, baseBpsFee: 87.5 } }, + }, } as unknown as QuoteResponse, })); @@ -354,6 +395,72 @@ describe('BridgeViewFooter', () => { ).toBeTruthy(); }); }); + + it('shows fee disclaimer when fee is undefined', async () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: false, + activeQuote: { + ...(mockQuoteWithMetadata as unknown as QuoteResponse), + quote: { + ...mockQuoteWithMetadata.quote, + destAsset: { + ...mockQuoteWithMetadata.quote.destAsset, + symbol: 'mUSD', + }, + feeData: { + metabridge: { quoteBpsFee: undefined, baseBpsFee: undefined }, + }, + }, + } as unknown as QuoteResponse, + })); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: 12, + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + }, + destToken: { + address: musdAddress, + chainId: '0x1' as Hex, + decimals: 6, + image: '', + name: 'MetaMask USD', + symbol: 'mUSD', + }, + }, + }); + + const { getByText } = renderFooter(testState as DeepPartial); + + await waitFor(() => { + expect( + getByText( + strings('bridge.fee_disclaimer', { + feePercentage: BRIDGE_MM_FEE_RATE, + }), + { + exact: false, + }, + ), + ).toBeTruthy(); + }); + }); }); describe('Approval Disclaimer', () => { diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx index 061bb74f742..bff4468e7e0 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { Box } from '../../../Box/Box'; -import { FlexDirection, AlignItems } from '../../../Box/box.types'; +import { + FlexDirection, + AlignItems, + JustifyContent, +} from '../../../Box/box.types'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { selectSourceAmount, - selectDestToken, selectSourceToken, selectBridgeControllerState, selectIsSolanaSourced, @@ -17,11 +20,7 @@ import { BannerAlertSeverity } from '../../../../../component-library/components import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; import { isHardwareAccount } from '../../../../../util/address'; import ApprovalTooltip from '../../components/ApprovalText'; -import { - BRIDGE_MM_FEE_RATE, - MetaMetricsSwapsEventSource, -} from '@metamask/bridge-controller'; -import { isNullOrUndefined } from '@metamask/utils'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.tsx'; import { useStyles } from '../../../../../component-library/hooks/useStyles.ts'; import { createStyles } from './BridgeView.styles.ts'; @@ -32,6 +31,9 @@ import { } from '@metamask/design-system-react-native'; import { BridgeViewSelectorsIDs } from './BridgeView.testIds.ts'; import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import RewardsVipBadge from '../../../Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx'; +import { formatAccountToCaipAccountId } from '../../hooks/useRewards/useRewards.ts'; +import { useFeeDisclaimer } from '../../hooks/useFeeDisclaimer'; interface Props { latestSourceBalance: ReturnType; @@ -47,7 +49,6 @@ export const BridgeViewFooter = ({ const { styles } = useStyles(createStyles); const sourceAmount = useSelector(selectSourceAmount); const sourceToken = useSelector(selectSourceToken); - const destToken = useSelector(selectDestToken); const { quotesLastFetched } = useSelector(selectBridgeControllerState); const selectedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, @@ -56,6 +57,8 @@ export const BridgeViewFooter = ({ const { activeQuote, isLoading, blockaidError, needsNewQuote } = useBridgeQuoteDataContext(); + const { showVipBadge, infoText, infoSuffix, baseFeePercentage } = + useFeeDisclaimer({ activeQuote }); const isValidSourceAmount = sourceAmount !== undefined && sourceAmount !== '.' && sourceToken?.decimals; @@ -84,14 +87,10 @@ export const BridgeViewFooter = ({ return null; } - // TODO: remove this once controller types are updated - // @ts-expect-error: controller types are not up to date yet - const quoteBpsFee = activeQuote?.quote?.feeData?.metabridge?.quoteBpsFee; - const feePercentage = !isNullOrUndefined(quoteBpsFee) - ? quoteBpsFee / 100 - : BRIDGE_MM_FEE_RATE; - - const hasFee = activeQuote && feePercentage > 0; + const caipAccountId = + selectedAddress && sourceToken?.chainId + ? formatAccountToCaipAccountId(selectedAddress, sourceToken.chainId) + : null; const approval = activeQuote?.approval && sourceAmount && sourceToken @@ -121,28 +120,64 @@ export const BridgeViewFooter = ({ latestSourceBalance={latestSourceBalance} transactionActiveAbTests={transactionActiveAbTests} /> - - - {hasFee - ? strings('bridge.fee_disclaimer', { - feePercentage, - }) - : strings('bridge.no_mm_fee_disclaimer', { - destTokenSymbol: destToken?.symbol, - })} - {approval - ? ` ${strings('bridge.approval_needed', approval)}` - : ''}{' '} - + + + {showVipBadge && caipAccountId ? ( + + ) : null} + + + {infoText} + + + {baseFeePercentage && ( + + {baseFeePercentage} + + )} + + {infoSuffix && ( + + {infoSuffix} + + )} + + {approval && ( - + + + {strings('bridge.approval_needed', approval)} + + + )} diff --git a/app/components/UI/Bridge/hooks/useFeeDisclaimer.ts b/app/components/UI/Bridge/hooks/useFeeDisclaimer.ts new file mode 100644 index 00000000000..d5d21851bb7 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useFeeDisclaimer.ts @@ -0,0 +1,75 @@ +import { BRIDGE_MM_FEE_RATE, QuoteResponse } from '@metamask/bridge-controller'; +import { isNullOrUndefined } from '@metamask/utils'; +import { useMemo } from 'react'; +import { strings } from '../../../../../locales/i18n'; + +/** + * Checks if the fee is discounted and returns the appropriate strings to display in the fee disclaimer. + +* @param activeQuote - The active quote from the bridge controller + * @returns An object containing the following properties: + * - showVipBadge: boolean - Whether to show the VIP badge + * - infoText: string - The text to display in the fee disclaimer + * - infoSuffix: string - The suffix to display in the fee disclaimer + * - baseFeePercentage: string - The base fee percentage to display in the fee disclaimer + */ +export const useFeeDisclaimer = ({ + activeQuote, +}: { + activeQuote?: QuoteResponse | null; +}) => { + // @ts-expect-error: controller types are not up to date yet + const baseBpsFee = activeQuote?.quote.feeData.metabridge?.baseBpsFee; + const baseFeePercentage = !isNullOrUndefined(baseBpsFee) + ? baseBpsFee / 100 + : BRIDGE_MM_FEE_RATE; + // TODO: remove this once controller types are updated + // @ts-expect-error: controller types are not up to date yet + const quoteBpsFee = activeQuote?.quote.feeData.metabridge?.quoteBpsFee; + const feePercentage = !isNullOrUndefined(quoteBpsFee) + ? quoteBpsFee / 100 + : BRIDGE_MM_FEE_RATE; + + const hasFee = activeQuote && feePercentage > 0; + + const isDiscounted = + activeQuote && + Boolean(baseBpsFee) && + Boolean(quoteBpsFee) && + baseBpsFee > quoteBpsFee; + + const infoText = useMemo(() => { + if (isDiscounted) { + return strings('bridge.fee_includes'); + } + + if (hasFee) { + return strings('bridge.fee_disclaimer', { + feePercentage, + }); + } + + if (!activeQuote) { + return undefined; + } + + return strings('bridge.no_mm_fee_disclaimer', { + destTokenSymbol: activeQuote.quote.destAsset.symbol, + }); + }, [isDiscounted, hasFee, activeQuote, feePercentage]); + + return { + showVipBadge: isDiscounted, + infoText, + infoSuffix: isDiscounted + ? strings('bridge.fee_percentage_meta_mask', { + feePercentage, + }) + : undefined, + baseFeePercentage: isDiscounted + ? strings('bridge.fee_percentage', { + feePercentage: baseFeePercentage, + }) + : undefined, + }; +}; diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts index 73a0ed0bc1d..3f38c210c0c 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts @@ -76,7 +76,7 @@ interface UseRewardsResult { /** * Formats an address to CAIP-10 account ID */ -const formatAccountToCaipAccountId = ( +export const formatAccountToCaipAccountId = ( address: string, chainId: string, ): CaipAccountId | null => { diff --git a/app/components/UI/Carousel/StackCard/StackCard.tsx b/app/components/UI/Carousel/StackCard/StackCard.tsx index e254476500e..9d2800eba0b 100644 --- a/app/components/UI/Carousel/StackCard/StackCard.tsx +++ b/app/components/UI/Carousel/StackCard/StackCard.tsx @@ -77,7 +77,7 @@ export const StackCard: React.FC = ({ pressed && 'bg-default-pressed', ) } - onPress={() => onSlideClick(slide.id, slide.navigation)} + onPress={() => onSlideClick(slide)} > {/* Animated pressed background overlay for next card */} {!isCurrentCard && ( diff --git a/app/components/UI/Carousel/StackCard/StackCard.types.ts b/app/components/UI/Carousel/StackCard/StackCard.types.ts index d1c8e6b0cf8..e7c2e864a9f 100644 --- a/app/components/UI/Carousel/StackCard/StackCard.types.ts +++ b/app/components/UI/Carousel/StackCard/StackCard.types.ts @@ -1,5 +1,5 @@ import { Animated } from 'react-native'; -import { CarouselSlide, NavigationAction } from '../types'; +import { CarouselSlide } from '../types'; export interface StackCardProps { slide: CarouselSlide; @@ -11,6 +11,6 @@ export interface StackCardProps { nextCardScale: Animated.Value; nextCardTranslateY: Animated.Value; nextCardBgOpacity: Animated.Value; - onSlideClick: (slideId: string, navigation: NavigationAction) => void; + onSlideClick: (slide: CarouselSlide) => void; onTransitionToNextCard?: () => void; } diff --git a/app/components/UI/Carousel/index.test.tsx b/app/components/UI/Carousel/index.test.tsx index 0767247312b..5d697779d6b 100644 --- a/app/components/UI/Carousel/index.test.tsx +++ b/app/components/UI/Carousel/index.test.tsx @@ -25,6 +25,11 @@ import { SolScope } from '@metamask/keyring-api'; import { setContentPreviewToken } from '../../../actions/notification/helpers'; import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { + AnalyticsEventBuilder, + type AnalyticsTrackingEvent, +} from '../../../util/analytics/AnalyticsEventBuilder'; +import type { UseAnalyticsHook } from '../../../components/hooks/useAnalytics/useAnalytics.types'; const makeMockState = () => ({ @@ -110,6 +115,22 @@ const mockReduxHooks = (state?: RootState) => { .mockImplementation((selector) => selector(state ?? makeMockState())); }; +const mockAnalyticsTracking = () => { + const mockTrackEvent = jest.fn< + ReturnType, + Parameters + >(); + + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), + ); + + return mockTrackEvent; +}; + beforeEach(() => { jest.clearAllMocks(); jest.mocked(useAnalytics).mockReturnValue(createMockUseAnalyticsHook()); @@ -292,6 +313,93 @@ describe('Carousel Navigation', () => { }); }); +describe('Carousel Analytics', () => { + it('tracks Banner Display with the Contentful id when variableName is blank', async () => { + const mockTrackEvent = mockAnalyticsTracking(); + const slide = createMockSlide({ + id: 'contentful-empty-variable-name', + variableName: '', + }); + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: [slide], + }); + + render(); + + await waitFor(() => { + const displayEvents = mockTrackEvent.mock.calls + .map(([event]) => event) + .filter((event) => event.name === 'Banner Display'); + + expect(displayEvents).toEqual([ + expect.objectContaining>({ + name: 'Banner Display', + properties: { name: 'contentful-empty-variable-name' }, + }), + ]); + }); + }); + + it('tracks Banner Display only for the current displayed slide', async () => { + const mockTrackEvent = mockAnalyticsTracking(); + const slides = [ + createMockSlide({ + id: 'current-slide', + variableName: 'current', + }), + createMockSlide({ + id: 'stacked-slide', + variableName: 'stacked', + }), + ]; + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: slides, + }); + + render(); + + await waitFor(() => { + const displayEvents = mockTrackEvent.mock.calls + .map(([event]) => event) + .filter((event) => event.name === 'Banner Display'); + + expect(displayEvents).toEqual([ + expect.objectContaining>({ + name: 'Banner Display', + properties: { name: 'current' }, + }), + ]); + }); + }); + + it('tracks Banner Select with the variableName', async () => { + const mockTrackEvent = mockAnalyticsTracking(); + const slide = createMockSlide({ + id: 'contentful-card-banner', + variableName: 'card', + }); + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: [slide], + }); + + const { findByTestId } = render(); + + fireEvent.press( + await findByTestId('carousel-slide-contentful-card-banner'), + ); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining>({ + name: 'Banner Select', + properties: { name: 'card' }, + }), + ); + }); +}); + describe('Carousel Slide Dismissal', () => { it('triggers transition animation when close button is clicked', async () => { const dismissibleSlide = createMockSlide({ diff --git a/app/components/UI/Carousel/index.tsx b/app/components/UI/Carousel/index.tsx index 3f98b12a30b..617d3e316bc 100644 --- a/app/components/UI/Carousel/index.tsx +++ b/app/components/UI/Carousel/index.tsx @@ -9,7 +9,7 @@ import React, { import { Dimensions, Animated, Linking } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; -import { CarouselProps, CarouselSlide, NavigationAction } from './types'; +import { CarouselProps, CarouselSlide } from './types'; import { dismissBanner } from '../../../reducers/banners'; import { StackCard } from './StackCard'; import { StackCardEmpty } from './StackCardEmpty'; @@ -54,6 +54,16 @@ const SCREEN_WIDTH = Dimensions.get('window').width; const BANNER_WIDTH = SCREEN_WIDTH - 32; const BANNER_HEIGHT = 100; +function getSlideVariableName(slide: Pick) { + return slide.variableName; +} + +function getSlideAnalyticsName( + slide: Pick, +) { + return getSlideVariableName(slide) || slide.id; +} + function orderByCardPlacement(slides: CarouselSlide[]): CarouselSlide[] { const placed: (CarouselSlide | undefined)[] = []; const unplaced: CarouselSlide[] = []; @@ -188,8 +198,10 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const applyLocalNavigation = useCallback( (s: CarouselSlide): CarouselSlide => { + const variableName = getSlideVariableName(s); + // fund → open buy flow - if (s.variableName === 'fund') { + if (variableName === 'fund') { return { ...s, navigation: { @@ -200,7 +212,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { } ///: BEGIN:ONLY_INCLUDE_IF(solana) // solana → open add-account flow (if we don't already redirect below) - if (s.variableName === 'solana') { + if (variableName === 'solana') { return { ...s, navigation: { @@ -231,7 +243,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { // Get base slides const patch = (s: CarouselSlide): CarouselSlide => { const withNav = applyLocalNavigation(s); - if (withNav.variableName === 'fund' && isZeroBalance) { + if (getSlideVariableName(withNav) === 'fund' && isZeroBalance) { return { ...withNav, undismissable: withNav.undismissable || true }; } return withNav; @@ -280,7 +292,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { ///: BEGIN:ONLY_INCLUDE_IF(solana) if ( - slide.variableName === 'solana' && + getSlideVariableName(slide) === 'solana' && selectedAccount?.type === SolAccountType.DataAccount ) { return false; @@ -294,7 +306,9 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { // keep the empty card in visibleSlides so the animation completes if (dismissingLastCardRef.current && filtered.length === 0) { // Re-add the empty card so the animation completes - const emptyCards = slidesConfig.filter((s) => s.variableName === 'empty'); + const emptyCards = slidesConfig.filter( + (s) => getSlideVariableName(s) === 'empty', + ); return emptyCards.length > 0 ? emptyCards : []; } @@ -313,6 +327,8 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { visibleSlides.length - 1, ); const currentSlide = visibleSlides[safeActiveSlideIndex]; + const currentSlideId = currentSlide?.id; + const currentSlideVariableName = currentSlide?.variableName; const nextSlide = visibleSlides[safeActiveSlideIndex + 1]; // Next card in stack const hasNextSlide = !!nextSlide; @@ -388,11 +404,13 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { }; const handleSlideClick = useCallback( - (slideId: string, navigation: NavigationAction) => { + (slide: CarouselSlide) => { + const slideName = getSlideAnalyticsName(slide); + const { navigation } = slide; const extraProperties: Record = {}; ///: BEGIN:ONLY_INCLUDE_IF(solana) - const isSolanaBanner = slideId === 'solana'; + const isSolanaBanner = slideName === 'solana'; if (isSolanaBanner && lastSelectedSolanaAccount) { extraProperties.action = 'redirect-solana-account'; } else if (isSolanaBanner && !lastSelectedSolanaAccount) { @@ -403,11 +421,12 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { trackEvent( createEventBuilder({ category: 'Banner Select', - properties: { - name: slideId, + }) + .addProperties({ + name: slideName, ...extraProperties, - }, - }).build(), + }) + .build(), ); ///: BEGIN:ONLY_INCLUDE_IF(solana) @@ -446,7 +465,8 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { setIsTransitioning(true); // Check if next card is the empty card (last non-empty slide being dismissed) - const isNextCardEmpty = nextSlide?.variableName === 'empty'; + const isNextCardEmpty = + nextSlide && getSlideVariableName(nextSlide) === 'empty'; // Set flag to keep empty card visible during dismissal animation if (isNextCardEmpty) { @@ -540,7 +560,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const renderCard = useCallback( (slide: CarouselSlide, isCurrentCard: boolean) => { - const isEmptyCard = slide.variableName === 'empty'; + const isEmptyCard = getSlideVariableName(slide) === 'empty'; if (isEmptyCard) { return ( @@ -590,33 +610,29 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { ], ); - // Track banner display events when visible slides change - useEffect(() => { - visibleSlides.forEach((slide: CarouselSlide) => { - trackEvent( - createEventBuilder({ - category: BANNER_EVENT_DISPLAY, - properties: { - name: slide.variableName ?? slide.id, - }, - }).build(), - ); - }); - }, [visibleSlides, trackEvent, createEventBuilder]); - - // Track current slide display + // Track a banner display only when a real banner becomes the current card. useEffect(() => { - if (currentSlide) { - trackEvent( - createEventBuilder({ - category: BANNER_EVENT_DISPLAY, - properties: { - name: currentSlide.variableName ?? currentSlide.id, - }, - }).build(), - ); + if (!currentSlideId || currentSlideVariableName === 'empty') { + return; } - }, [currentSlide, trackEvent, createEventBuilder]); + + const slideAnalyticsName = currentSlideVariableName || currentSlideId; + + trackEvent( + createEventBuilder({ + category: BANNER_EVENT_DISPLAY, + }) + .addProperties({ + name: slideAnalyticsName, + }) + .build(), + ); + }, [ + currentSlideId, + currentSlideVariableName, + trackEvent, + createEventBuilder, + ]); if ( !isCarouselVisible || diff --git a/app/components/UI/NetworkManager/index.test.tsx b/app/components/UI/NetworkManager/index.test.tsx index 7a175d0e3e7..21b7406d6b7 100644 --- a/app/components/UI/NetworkManager/index.test.tsx +++ b/app/components/UI/NetworkManager/index.test.tsx @@ -122,7 +122,7 @@ jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder: mockCreateEventBuilder, - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, }), })); diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index 10c172d7a93..6903223b492 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -83,7 +83,7 @@ const NetworkManager = () => { const navigation = useNavigation(); const { colors } = useTheme(); const { styles } = useStyles(createStyles, { colors }); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const { disableNetwork, enabledNetworksByNamespace } = useNetworkEnablement(); const enabledNetworks = useMemo(() => { @@ -296,11 +296,11 @@ const NetworkManager = () => { NetworkController.removeNetwork(chainId); disableNetwork(showConfirmDeleteModal.caipChainId); - addTraitsToUser(removeItemFromChainIdList(chainId)); + identify(removeItemFromChainIdList(chainId)); setShowConfirmDeleteModal(initialShowConfirmDeleteModal); } - }, [showConfirmDeleteModal, disableNetwork, addTraitsToUser]); + }, [showConfirmDeleteModal, disableNetwork, identify]); const cancelButtonProps: ButtonProps = useMemo( () => ({ diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 16c8017e8af..ae2c4bc363c 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -561,9 +561,39 @@ export const PerpsOrderViewSelectorsIDs = { // Row touchables that open bottom sheets LEVERAGE_ROW: 'perps-order-view-leverage-row', LIMIT_PRICE_ROW: 'perps-order-view-limit-price-row', + // Slippage + SLIPPAGE_ROW: 'perps-order-view-slippage-row', + SLIPPAGE_VALUE: 'perps-order-view-slippage-value', SERVICE_INTERRUPTION_BANNER: 'perps-order-view-service-interruption-banner', }; +// ======================================== +// PERPS SLIPPAGE CONFIG BOTTOM SHEET SELECTORS +// ======================================== + +export const PerpsSlippageConfigSelectorsIDs = { + SET: 'perps-slippage-config-set', + EDIT_CHIP: 'perps-slippage-config-edit-chip', +} as const; + +export const getPerpsSlippageConfigSelector = { + preset: (pct: number) => `perps-slippage-config-preset-${pct}`, +}; + +// ======================================== +// PERPS CUSTOM SLIPPAGE BOTTOM SHEET SELECTORS +// ======================================== + +export const PerpsCustomSlippageBottomSheetSelectorsIDs = { + DISPLAY: 'perps-custom-slippage-display', + DECREMENT: 'perps-custom-slippage-decrement', + INCREMENT: 'perps-custom-slippage-increment', + KEYPAD: 'perps-custom-slippage-keypad', + CANCEL: 'perps-custom-slippage-cancel', + SET: 'perps-custom-slippage-set', + ERROR: 'perps-custom-slippage-error', +} as const; + // ======================================== // PERPS LIMIT PRICE BOTTOM SHEET SELECTORS // ======================================== diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts index 595ce1abf6a..03f4932a0a9 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts @@ -60,6 +60,11 @@ const createStyles = (colors: Colors) => alignItems: 'center', flex: 1, }, + slippageValueRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, infoIcon: { marginLeft: 0, padding: 10, // Increases touch target from 20x20 to 40x40 for better accessibility @@ -71,6 +76,10 @@ const createStyles = (colors: Colors) => paddingHorizontal: 16, borderRadius: 12, }, + infoSectionSpacer: { + flex: 1, + minHeight: 16, + }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 9253a52719a..ee83bd91b3d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -57,6 +57,8 @@ import { usePerpsLivePrices, usePerpsTopOfBook, } from '../../hooks/stream'; +import { usePerpsEstimatedSlippage } from '../../hooks/usePerpsEstimatedSlippage'; +import { usePerpsMaxSlippage } from '../../hooks/usePerpsMaxSlippage'; import { PerpsStreamManager, PerpsStreamProvider, @@ -702,6 +704,21 @@ jest.mock( }, ); +jest.mock('../../hooks/usePerpsEstimatedSlippage', () => ({ + usePerpsEstimatedSlippage: jest.fn(() => ({ + estimatedSlippageBps: null, + isReady: false, + })), +})); + +jest.mock('../../hooks/usePerpsMaxSlippage', () => ({ + usePerpsMaxSlippage: jest.fn(() => ({ + maxSlippageBps: 300, + maxSlippageSource: 'default', + setMaxSlippage: jest.fn(), + })), +})); + // Test setup const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -4231,4 +4248,129 @@ describe('PerpsOrderView', () => { }); }); }); + + describe('slippage block on submit', () => { + beforeEach(() => { + // Earlier tests in the file mutate shared mocks (order form, validation, + // toasts, slippage hooks) without restoring them. Reset every mock the + // block path reads so this suite is self-contained regardless of run order. + (usePerpsEstimatedSlippage as jest.Mock).mockReturnValue({ + estimatedSlippageBps: null, + isReady: false, + }); + (usePerpsMaxSlippage as jest.Mock).mockReturnValue({ + maxSlippageBps: 300, + maxSlippageSource: 'default', + setMaxSlippage: jest.fn(), + }); + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'ETH', + amount: '11', + leverage: 3, + direction: 'long', + type: 'market', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '11', + positionSize: '0.0037', + }, + }); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + }); + + it('blocks placeOrder when estimated slippage exceeds the configured cap', async () => { + const mockPlaceOrder = jest.fn().mockResolvedValue({ success: true }); + (usePerpsOrderExecution as jest.Mock).mockImplementation(() => ({ + placeOrder: mockPlaceOrder, + isPlacing: false, + })); + + const mockValidationError = jest.fn(() => ({ + id: 'slippage-block-toast', + })); + const mockShowToast = jest.fn(); + (usePerpsToasts as jest.Mock).mockReturnValue({ + showToast: mockShowToast, + PerpsToastOptions: { + formValidation: { + orderForm: { + limitPriceRequired: {}, + validationError: mockValidationError, + }, + }, + orderManagement: { + market: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + limit: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + shared: { submitting: jest.fn() }, + }, + positionManagement: { tpsl: { updateTPSLError: jest.fn() } }, + dataFetching: { + market: { error: { marketDataUnavailable: jest.fn() } }, + }, + accountManagement: { + deposit: { + inProgress: jest.fn(), + takingLonger: {}, + tradeCanceled: {}, + error: {}, + }, + }, + }, + }); + + (usePerpsEstimatedSlippage as jest.Mock).mockReturnValue({ + estimatedSlippageBps: 500, // 5% + isReady: true, + }); + (usePerpsMaxSlippage as jest.Mock).mockReturnValue({ + maxSlippageBps: 100, // 1% — estimate exceeds the cap + maxSlippageSource: 'user_configured', + setMaxSlippage: jest.fn(), + }); + + render(, { wrapper: TestWrapper }); + + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + await act(async () => { + fireEvent.press(placeOrderButton); + }); + + // The critical AC invariant: an order whose estimated slippage exceeds + // the configured cap must NOT reach the order execution path. (The toast + // copy and event payload are verified separately by the slippage agentic + // recipe and the `eventNames` constants tests.) + expect(mockPlaceOrder).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index b2ef1246832..8cf6cef4390 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -78,6 +78,7 @@ import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip import PerpsFeesDisplay from '../../components/PerpsFeesDisplay'; import PerpsLeverageBottomSheet from '../../components/PerpsLeverageBottomSheet'; import PerpsLimitPriceBottomSheet from '../../components/PerpsLimitPriceBottomSheet'; +import PerpsSlippageBottomSheet from '../../components/PerpsSlippageBottomSheet'; import PerpsOICapWarning from '../../components/PerpsOICapWarning'; import PerpsOrderHeader from '../../components/PerpsOrderHeader'; import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet'; @@ -86,7 +87,6 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, DECIMAL_PRECISION_CONFIG, - ORDER_SLIPPAGE_CONFIG, PERPS_CONSTANTS, getPerpsDisplaySymbol, calculateMarginRequired, @@ -95,7 +95,9 @@ import { type OrderParams, type OrderType, type Position, + ORDER_SLIPPAGE_CONFIG, } from '@metamask/perps-controller'; +import { bpsToPercent } from '../../constants/slippageConfig'; import { PerpsOrderProvider, usePerpsOrderContext, @@ -120,6 +122,8 @@ import { usePerpsTopOfBook, } from '../../hooks/stream'; import { usePerpsConnection } from '../../hooks/usePerpsConnection'; +import { usePerpsEstimatedSlippage } from '../../hooks/usePerpsEstimatedSlippage'; +import { usePerpsMaxSlippage } from '../../hooks/usePerpsMaxSlippage'; import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; @@ -373,9 +377,17 @@ const PerpsOrderViewContentBase: React.FC = ({ const [isLeverageVisible, setIsLeverageVisible] = useState(false); const [isLimitPriceVisible, setIsLimitPriceVisible] = useState(false); const [isOrderTypeVisible, setIsOrderTypeVisible] = useState(false); + const [isSlippageVisible, setIsSlippageVisible] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); const [shouldOpenLimitPrice, setShouldOpenLimitPrice] = useState(false); + // Max slippage from persisted controller state via hook so the component + // never reaches into PerpsController directly (perps anti-pattern rule). + // The hook also exposes the source (default vs user-configured) for + // MetaMetrics and a setter that refreshes the read on save. + const { maxSlippageBps, maxSlippageSource, setMaxSlippage } = + usePerpsMaxSlippage(); + const isPayRowVisible = Boolean( isTradeWithAnyTokenEnabled && activeTransactionMeta, ); @@ -512,9 +524,49 @@ const PerpsOrderViewContentBase: React.FC = ({ const shouldBlockBecauseOfFeesLoading = hasCustomTokenSelected && isPayTotalsLoading; + const isMarketOrder = orderForm.type === 'market'; + // Simple boolean calculation - no need for expensive memoization const hasValidAmount = parseFloat(orderForm.amount) > 0; + // Live VWAP slippage estimate for market orders; limit orders skip it. + const orderUsdAmount = useMemo( + () => parseFloat(orderForm.amount) || 0, + [orderForm.amount], + ); + const { estimatedSlippageBps } = usePerpsEstimatedSlippage({ + symbol: orderForm.asset, + sizeUsd: orderUsdAmount, + isBuy: orderForm.direction === 'long', + // Gate on `isInitialized` so the order-book subscription waits until the + // perps providers are wired; otherwise the subscription becomes a no-op + // and the estimate stays null until the screen remounts. + enabled: isMarketOrder && hasValidAmount && isInitialized, + }); + // Keep the estimate nullable so the row can render a `--` placeholder when + // the L2 book has not produced data yet (per the perps anti-pattern doc: + // never default unavailable data to `0`). When the estimate is unknown the + // user-configured cap still flows through to HyperLiquid as the limit-price + // buffer, so we surface "estimate pending" without blocking the order. + // Numeric percent for analytics and comparisons; formatted string for UI so + // the row never shows `3.333333%` noise. + const estimatedSlippagePct: number | null = useMemo( + () => + typeof estimatedSlippageBps === 'number' + ? bpsToPercent(estimatedSlippageBps) + : null, + [estimatedSlippageBps], + ); + const estimatedSlippagePctDisplay: string | null = useMemo( + () => + estimatedSlippagePct === null ? null : estimatedSlippagePct.toFixed(2), + [estimatedSlippagePct], + ); + const exceedsMaxSlippage = + isMarketOrder && + typeof estimatedSlippageBps === 'number' && + estimatedSlippageBps > maxSlippageBps; + // Get rewards state using the new hook const rewardsState = usePerpsRewards({ feeResults, @@ -924,6 +976,30 @@ const PerpsOrderViewContentBase: React.FC = ({ return; } + // Bail out before the pay-with-any-token deposit branch so an + // excessive-slippage order never starts a deposit/signature flow. + if (exceedsMaxSlippage && typeof estimatedSlippageBps === 'number') { + const estPct = bpsToPercent(estimatedSlippageBps); + const maxPct = bpsToPercent(maxSlippageBps); + showToast( + PerpsToastOptions.formValidation.orderForm.validationError( + strings('perps.slippage.exceeds_max', { + est: estPct.toFixed(2), + max: maxPct.toFixed(2), + }), + ), + ); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SLIPPAGE_LIMIT_BLOCKED_ORDER, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT]: maxPct, + [PERPS_EVENT_PROPERTY.ESTIMATED_SLIPPAGE_PCT]: estPct, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE]: maxSlippageSource, + }); + return; + } + // Check if deposit is needed first (when custom token is selected) const needsDeposit = isTradeWithAnyTokenEnabled && @@ -1087,8 +1163,8 @@ const PerpsOrderViewContentBase: React.FC = ({ priceAtCalculation: effectivePrice, // Price snapshot when size was calculated (for slippage validation) maxSlippageBps: orderForm.type === 'limit' - ? ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps // 1% for limit orders - : ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, // 3% for market orders + ? ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps // 1% for limit orders (fixed) + : maxSlippageBps, // User-configured for market orders (already in bps) // Only add TP/SL/Limit if they are truthy and/or not empty strings ...(orderForm.type === 'limit' && orderForm.limitPrice ? { price: orderForm.limitPrice } @@ -1214,6 +1290,10 @@ const PerpsOrderViewContentBase: React.FC = ({ onDepositConfirm, handleDepositConfirm, fromTokenDetails, + maxSlippageBps, + maxSlippageSource, + estimatedSlippageBps, + exceedsMaxSlippage, ], ); @@ -1576,20 +1656,22 @@ const PerpsOrderViewContentBase: React.FC = ({ )} + {/* Spacer pushes the info section to the bottom of the scroll view */} + {!isInputFocused && } + {/* Info Section */} 0 ? 16 : -16 }, + { marginBottom: orderValidation.errors.length > 0 ? 16 : 8 }, // eslint-disable-next-line react-native/no-inline-styles { marginTop: isInputFocused ? 16 : 0 }, ]} > - + {strings('perps.order.margin')} = ({ @@ -1619,7 +1701,7 @@ const PerpsOrderViewContentBase: React.FC = ({ - + {strings('perps.order.liquidation_price')} = ({ @@ -1648,9 +1730,57 @@ const PerpsOrderViewContentBase: React.FC = ({ : PERPS_CONSTANTS.FallbackDataDisplay} + {isMarketOrder && ( + { + setIsSlippageVisible(true); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SLIPPAGE_CONFIG_OPENED, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT]: + bpsToPercent(maxSlippageBps), + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE]: maxSlippageSource, + }); + }} + > + + + {strings('perps.slippage.slippage')} + + + + {estimatedSlippagePctDisplay === null + ? strings('perps.slippage.row_format_pending', { + value: bpsToPercent(maxSlippageBps), + }) + : strings('perps.slippage.row_format', { + est: estimatedSlippagePctDisplay, + value: bpsToPercent(maxSlippageBps), + })} + + + + + + )} - + {strings('perps.order.fees')} = ({ }) } testID={PerpsOrderViewSelectorsIDs.FEES_VALUE} - variant={TextVariant.BodyMD} + variant={TextVariant.BodySM} /> )} @@ -1692,7 +1822,7 @@ const PerpsOrderViewContentBase: React.FC = ({ {strings('perps.estimated_points')} @@ -1966,6 +2096,25 @@ const PerpsOrderViewContentBase: React.FC = ({ asset={orderForm.asset} direction={orderForm.direction} /> + {/* Slippage Config Bottom Sheet */} + setIsSlippageVisible(false)} + onSave={(valueBps) => { + setMaxSlippage(valueBps); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SLIPPAGE_CONFIG_CHANGED, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT]: bpsToPercent(valueBps), + [PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE]: + PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE.USER_CONFIGURED, + [PERPS_EVENT_PROPERTY.SETTING_TYPE]: + PERPS_EVENT_VALUE.SETTING_TYPE.SLIPPAGE, + }); + }} + /> {selectedTooltip && ( { const [isUpdating, setIsUpdating] = useState(false); const { colors } = useTheme(); const styles = createStyles(colors); + const { top: topInset } = useSafeAreaInsets(); const scrollViewRef = useRef(null); @@ -447,11 +451,16 @@ const PerpsTPSLView: React.FC = () => { return ( {/* Simple header with back button and title */} - + 0 ? { paddingTop: 16 + topInset } : undefined, + ]} + > + StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + displayRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + paddingVertical: 24, + }, + displayCenter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + displayValue: { + fontSize: 40, + lineHeight: 48, + color: colors.text.default, + fontWeight: '500', + }, + displaySuffix: { + fontSize: 40, + lineHeight: 48, + color: colors.text.default, + fontWeight: '500', + marginLeft: 4, + }, + cursor: { + width: 2, + height: 36, + marginHorizontal: 2, + backgroundColor: colors.primary.default, + }, + keypadContainer: { + marginTop: 8, + }, + errorText: { + textAlign: 'center', + marginTop: 4, + marginBottom: 8, + }, + footerContainer: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingBottom: 16, + gap: 8, + }, + footerButton: { + flex: 1, + }, + }); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx new file mode 100644 index 00000000000..6b5ef9a20f0 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsCustomSlippageBottomSheet.tsx @@ -0,0 +1,228 @@ +import { + ButtonIcon, + ButtonIconSize, + ButtonIconVariant, + IconName, +} from '@metamask/design-system-react-native'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Animated, View } from 'react-native'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useTheme } from '../../../../../util/theme'; +import Keypad from '../../../../Base/Keypad'; +import { + PERPS_SLIPPAGE_MAX_BPS, + PERPS_SLIPPAGE_MIN_BPS, + PERPS_SLIPPAGE_STEP_BPS, + bpsToPercent, + percentToBps, +} from '../../constants/slippageConfig'; +import { PerpsCustomSlippageBottomSheetSelectorsIDs } from '../../Perps.testIds'; +import { createStyles } from './PerpsCustomSlippageBottomSheet.styles'; + +interface PerpsCustomSlippageBottomSheetProps { + isVisible: boolean; + currentValueBps: number; + onClose: () => void; + onSave: (valueBps: number) => void; +} + +const MIN_PCT = bpsToPercent(PERPS_SLIPPAGE_MIN_BPS); +const MAX_PCT = bpsToPercent(PERPS_SLIPPAGE_MAX_BPS); +const STEP_PCT = bpsToPercent(PERPS_SLIPPAGE_STEP_BPS); + +function snapToStep(pct: number): number { + const snappedBps = + Math.round(percentToBps(pct) / PERPS_SLIPPAGE_STEP_BPS) * + PERPS_SLIPPAGE_STEP_BPS; + return bpsToPercent(snappedBps); +} + +function clampToRange(pct: number): number { + return Math.min(MAX_PCT, Math.max(MIN_PCT, pct)); +} + +const PerpsCustomSlippageBottomSheet: React.FC< + PerpsCustomSlippageBottomSheetProps +> = ({ isVisible, currentValueBps, onClose, onSave }) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const bottomSheetRef = useRef(null); + const cursorOpacity = useRef(new Animated.Value(1)).current; + + const [draftValue, setDraftValue] = useState( + bpsToPercent(currentValueBps).toString(), + ); + + useEffect(() => { + if (isVisible) { + setDraftValue(bpsToPercent(currentValueBps).toString()); + bottomSheetRef.current?.onOpenBottomSheet(); + Animated.loop( + Animated.sequence([ + Animated.timing(cursorOpacity, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(cursorOpacity, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + ]), + ).start(); + } else { + cursorOpacity.stopAnimation(); + cursorOpacity.setValue(1); + } + // Stop the cursor animation when the component unmounts so the loop does + // not keep running on an orphaned Animated.Value. + return () => { + cursorOpacity.stopAnimation(); + }; + }, [isVisible, currentValueBps, cursorOpacity]); + + const parsedDraft = Number.parseFloat(draftValue); + const draftIsEmpty = draftValue.trim() === '' || draftValue === '.'; + const draftIsFiniteNumber = Number.isFinite(parsedDraft); + const draftIsInRange = + draftIsFiniteNumber && parsedDraft >= MIN_PCT && parsedDraft <= MAX_PCT; + const showError = !draftIsEmpty && !draftIsInRange; + + const handleKeypadChange = useCallback( + ({ value }: { value: string; valueAsNumber: number }) => { + setDraftValue(value); + }, + [], + ); + + const adjustBy = useCallback( + (deltaPct: number) => { + const basePct = draftIsFiniteNumber ? parsedDraft : MIN_PCT; + const next = snapToStep(clampToRange(basePct + deltaPct)); + setDraftValue(next.toString()); + }, + [draftIsFiniteNumber, parsedDraft], + ); + + const handleDecrement = useCallback(() => adjustBy(-STEP_PCT), [adjustBy]); + const handleIncrement = useCallback(() => adjustBy(STEP_PCT), [adjustBy]); + + const handleSet = useCallback(() => { + if (!draftIsInRange) return; + const finalPct = snapToStep(clampToRange(parsedDraft)); + onSave(percentToBps(finalPct)); + }, [draftIsInRange, parsedDraft, onSave]); + + const footerButtonProps = [ + { + label: strings('perps.slippage.cancel'), + testID: PerpsCustomSlippageBottomSheetSelectorsIDs.CANCEL, + variant: ButtonVariants.Secondary, + size: ButtonSize.Lg, + onPress: onClose, + }, + { + label: strings('perps.slippage.set'), + testID: PerpsCustomSlippageBottomSheetSelectorsIDs.SET, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + onPress: handleSet, + isDisabled: !draftIsInRange, + }, + ]; + + if (!isVisible) return null; + + return ( + + + + {strings('perps.slippage.use_custom_title')} + + + + + + + + {draftValue || '0'} + + % + + = MAX_PCT - 1e-9} + testID={PerpsCustomSlippageBottomSheetSelectorsIDs.INCREMENT} + accessibilityLabel={strings('perps.slippage.increment_label')} + /> + + + {showError && ( + + {strings('perps.slippage.out_of_range', { + min: `${MIN_PCT}`, + max: `${MAX_PCT}`, + })} + + )} + + + + + + + + + ); +}; + +PerpsCustomSlippageBottomSheet.displayName = 'PerpsCustomSlippageBottomSheet'; + +export default memo(PerpsCustomSlippageBottomSheet); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts new file mode 100644 index 00000000000..12967b20bfa --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.styles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +export const createStyles = (_colors: Theme['colors']) => + StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + description: { + marginTop: 8, + marginBottom: 16, + }, + chipRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + chip: { + flex: 1, + height: 48, + borderRadius: 999, + paddingHorizontal: 8, + }, + editChip: { + height: 48, + width: 64, + borderRadius: 999, + paddingHorizontal: 8, + }, + }); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx new file mode 100644 index 00000000000..b8a1b56035c --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.test.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import PerpsSlippageBottomSheet from './PerpsSlippageBottomSheet'; +import { PerpsSlippageConfigSelectorsIDs } from '../../Perps.testIds'; + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + const translations: Record = { + 'perps.slippage.config_title': 'Set slippage', + 'perps.slippage.config_description': + "Your transaction won't go through if the price shifts beyond this threshold.", + 'perps.slippage.set': 'Set', + 'perps.slippage.cancel': 'Cancel', + 'perps.slippage.use_custom_title': 'Use custom slippage', + }; + if (key === 'perps.slippage.out_of_range' && params) { + return `Must be between ${params.min}% and ${params.max}%`; + } + return translations[key] || key; + }), +})); + +const { mockTheme } = jest.requireActual('../../../../../util/theme'); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => mockTheme, +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactModule = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ReactModule.forwardRef( + ({ children }: { children: React.ReactNode }, _ref: unknown) => + ReactModule.createElement(View, null, children), + ), + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View } = jest.requireActual('react-native'); + return function MockBottomSheetHeader({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return function MockBottomSheetFooter({ + buttonPropsArray, + }: { + buttonPropsArray: { + label: string; + onPress: () => void; + isDisabled?: boolean; + testID?: string; + }[]; + }) { + return ( + + {buttonPropsArray.map((btn) => ( + + {btn.label} + + ))} + + ); + }; + }, +); + +jest.mock('@metamask/design-system-react-native', () => { + const { TouchableOpacity, Text, View } = jest.requireActual('react-native'); + return { + ButtonBaseSize: { Sm: 'sm', Md: 'md', Lg: 'lg' }, + ButtonFilter: ({ + children, + onPress, + testID, + isActive, + startIconName, + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + isActive?: boolean; + startIconName?: string; + }) => ( + + {startIconName ? {`icon:${startIconName}`} : null} + {children} + + ), + ButtonIcon: ({ + iconName, + onPress, + testID, + }: { + iconName: string; + onPress?: () => void; + testID?: string; + }) => ( + + {`icon:${iconName}`} + + ), + ButtonIconSize: { Sm: 'sm', Md: 'md', Lg: 'lg' }, + ButtonIconVariant: { + Default: 'default', + Filled: 'filled', + Floating: 'floating', + }, + IconName: { Edit: 'Edit', Add: 'Add', Minus: 'Minus' }, + View, + }; +}); + +jest.mock('./PerpsCustomSlippageBottomSheet', () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + isVisible, + currentValueBps, + onClose, + onSave, + }: { + isVisible: boolean; + currentValueBps: number; + onClose: () => void; + onSave: (bps: number) => void; + }) => + isVisible ? ( + + {`current:${currentValueBps}`} + + onSave(450)} + /> + + ) : null, + }; +}); + +jest.mock('../../../../../component-library/components/Texts/Text', () => { + const { Text } = jest.requireActual('react-native'); + const MockText = ({ children, testID, ...rest }: Record) => ( + + {children as React.ReactNode} + + ); + MockText.displayName = 'MockText'; + return { + __esModule: true, + default: MockText, + TextColor: { + Alternative: 'Alternative', + Error: 'Error', + Default: 'Default', + Inverse: 'Inverse', + }, + TextVariant: { + HeadingMD: 'HeadingMD', + BodySM: 'BodySM', + BodyLGMedium: 'BodyLGMedium', + }, + }; +}); + +const defaultProps = { + isVisible: true, + currentValueBps: 300, // 3% (preset) + onClose: jest.fn(), + onSave: jest.fn(), +}; + +describe('PerpsSlippageBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when not visible', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders three preset chips and an edit chip when value matches a preset', () => { + render(); + expect( + screen.getByTestId('perps-slippage-config-preset-0.5'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('perps-slippage-config-preset-2'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('perps-slippage-config-preset-3'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ).toBeOnTheScreen(); + }); + + it('saves preset bps value when preset chip is pressed and Set tapped', () => { + render(); + fireEvent.press(screen.getByTestId('perps-slippage-config-preset-2')); + fireEvent.press(screen.getByTestId(PerpsSlippageConfigSelectorsIDs.SET)); + expect(defaultProps.onSave).toHaveBeenCalledWith(200); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('opens custom slippage sheet when edit chip is pressed', () => { + render(); + fireEvent.press( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ); + expect(screen.getByTestId('mock-custom-slippage-sheet')).toBeOnTheScreen(); + }); + + it('marks the edit chip as selected when current value is custom', () => { + render( + , + ); + const chip = screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP); + expect(chip.props.accessibilityState?.selected).toBe(true); + }); + + it('commits a value chosen via the custom sheet on Set', () => { + render(); + fireEvent.press( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ); + // Custom sheet mock saves 450 bps + fireEvent.press(screen.getByTestId('mock-custom-save-450')); + // Back on main sheet; Set commits 450 + fireEvent.press(screen.getByTestId(PerpsSlippageConfigSelectorsIDs.SET)); + expect(defaultProps.onSave).toHaveBeenCalledWith(450); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('returns to main sheet when custom sheet is cancelled', () => { + render(); + fireEvent.press( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ); + fireEvent.press(screen.getByTestId('mock-custom-cancel')); + expect( + screen.queryByTestId('mock-custom-slippage-sheet'), + ).not.toBeOnTheScreen(); + expect( + screen.getByTestId(PerpsSlippageConfigSelectorsIDs.EDIT_CHIP), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx new file mode 100644 index 00000000000..ad8d6ee73f6 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/PerpsSlippageBottomSheet.tsx @@ -0,0 +1,174 @@ +import { + ButtonBaseSize, + ButtonFilter, + IconName, +} from '@metamask/design-system-react-native'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useTheme } from '../../../../../util/theme'; +import { + PERPS_SLIPPAGE_QUICK_PICKS_BPS, + bpsToPercent, +} from '../../constants/slippageConfig'; +import { + PerpsSlippageConfigSelectorsIDs, + getPerpsSlippageConfigSelector, +} from '../../Perps.testIds'; +import PerpsCustomSlippageBottomSheet from './PerpsCustomSlippageBottomSheet'; +import { createStyles } from './PerpsSlippageBottomSheet.styles'; + +interface PerpsSlippageBottomSheetProps { + isVisible: boolean; + currentValueBps: number; + onClose: () => void; + onSave: (valueBps: number) => void; +} + +function matchesPreset(bps: number): boolean { + return PERPS_SLIPPAGE_QUICK_PICKS_BPS.includes(bps); +} + +const PerpsSlippageBottomSheet: React.FC = ({ + isVisible, + currentValueBps, + onClose, + onSave, +}) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const bottomSheetRef = useRef(null); + + const [selectedBps, setSelectedBps] = useState(currentValueBps); + const [isCustomOpen, setIsCustomOpen] = useState(false); + + useEffect(() => { + if (isVisible) { + setSelectedBps(currentValueBps); + setIsCustomOpen(false); + } + }, [isVisible, currentValueBps]); + + const isCustom = !matchesPreset(selectedBps); + + const handlePresetPress = useCallback((bps: number) => { + setSelectedBps(bps); + }, []); + + const handleOpenCustom = useCallback(() => { + setIsCustomOpen(true); + }, []); + + const handleCustomClose = useCallback(() => { + setIsCustomOpen(false); + }, []); + + const handleCustomSave = useCallback((bps: number) => { + setSelectedBps(bps); + setIsCustomOpen(false); + }, []); + + const handleSet = useCallback(() => { + onSave(selectedBps); + onClose(); + }, [onSave, onClose, selectedBps]); + + const footerButtonProps = [ + { + label: strings('perps.slippage.set'), + testID: PerpsSlippageConfigSelectorsIDs.SET, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + onPress: handleSet, + }, + ]; + + if (!isVisible) return null; + + if (isCustomOpen) { + return ( + + ); + } + + const customLabel = isCustom ? `${bpsToPercent(selectedBps)}%` : undefined; + + return ( + + + + {strings('perps.slippage.config_title')} + + + + + + {strings('perps.slippage.config_description')} + + + + {PERPS_SLIPPAGE_QUICK_PICKS_BPS.map((bps) => { + const pct = bpsToPercent(bps); + const isSelected = !isCustom && selectedBps === bps; + return ( + handlePresetPress(bps)} + testID={getPerpsSlippageConfigSelector.preset(pct)} + style={styles.chip} + > + {`${pct}%`} + + ); + })} + + + {isCustom && customLabel ? customLabel : ''} + + + + + + + ); +}; + +PerpsSlippageBottomSheet.displayName = 'PerpsSlippageBottomSheet'; + +export default memo(PerpsSlippageBottomSheet); diff --git a/app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts new file mode 100644 index 00000000000..70922a2e4bf --- /dev/null +++ b/app/components/UI/Perps/components/PerpsSlippageBottomSheet/index.ts @@ -0,0 +1 @@ +export { default } from './PerpsSlippageBottomSheet'; diff --git a/app/components/UI/Perps/constants/slippageConfig.test.ts b/app/components/UI/Perps/constants/slippageConfig.test.ts new file mode 100644 index 00000000000..9b4ccfe0c32 --- /dev/null +++ b/app/components/UI/Perps/constants/slippageConfig.test.ts @@ -0,0 +1,65 @@ +import { + PERPS_SLIPPAGE_DEFAULT_BPS, + PERPS_SLIPPAGE_MIN_BPS, + PERPS_SLIPPAGE_MAX_BPS, + PERPS_SLIPPAGE_STEP_BPS, + PERPS_SLIPPAGE_QUICK_PICKS_BPS, + bpsToPercent, + percentToBps, +} from './slippageConfig'; + +describe('slippageConfig constants', () => { + it('exports expected default values', () => { + expect(PERPS_SLIPPAGE_DEFAULT_BPS).toBe(300); + expect(PERPS_SLIPPAGE_MIN_BPS).toBe(10); + expect(PERPS_SLIPPAGE_MAX_BPS).toBe(1000); + expect(PERPS_SLIPPAGE_STEP_BPS).toBe(10); + }); + + it('exports quick-pick presets 0.5%, 2%, 3%', () => { + expect(PERPS_SLIPPAGE_QUICK_PICKS_BPS).toEqual([50, 200, 300]); + }); + + it('quick-pick presets are within valid range', () => { + for (const bps of PERPS_SLIPPAGE_QUICK_PICKS_BPS) { + expect(bps).toBeGreaterThanOrEqual(PERPS_SLIPPAGE_MIN_BPS); + expect(bps).toBeLessThanOrEqual(PERPS_SLIPPAGE_MAX_BPS); + } + }); +}); + +describe('bpsToPercent', () => { + it('converts 300 bps to 3%', () => { + expect(bpsToPercent(300)).toBe(3); + }); + + it('converts 10 bps to 0.1%', () => { + expect(bpsToPercent(10)).toBe(0.1); + }); + + it('converts 1000 bps to 10%', () => { + expect(bpsToPercent(1000)).toBe(10); + }); + + it('converts 0 bps to 0%', () => { + expect(bpsToPercent(0)).toBe(0); + }); +}); + +describe('percentToBps', () => { + it('converts 3% to 300 bps', () => { + expect(percentToBps(3)).toBe(300); + }); + + it('converts 0.1% to 10 bps', () => { + expect(percentToBps(0.1)).toBe(10); + }); + + it('converts 10% to 1000 bps', () => { + expect(percentToBps(10)).toBe(1000); + }); + + it('rounds to nearest integer', () => { + expect(percentToBps(3.456)).toBe(346); + }); +}); diff --git a/app/components/UI/Perps/constants/slippageConfig.ts b/app/components/UI/Perps/constants/slippageConfig.ts new file mode 100644 index 00000000000..8fd0c151a52 --- /dev/null +++ b/app/components/UI/Perps/constants/slippageConfig.ts @@ -0,0 +1,24 @@ +import { + MAX_SLIPPAGE_BOUNDS, + ORDER_SLIPPAGE_CONFIG, +} from '@metamask/perps-controller'; + +/** + * Slippage configuration constants for the perps order entry surface. + * All values in basis points (1 bps = 0.01%). + * Range 10–1000 bps (0.1%–10%) in 10 bps (0.1%) steps. + */ +export const PERPS_SLIPPAGE_DEFAULT_BPS = + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; +export const PERPS_SLIPPAGE_MIN_BPS = MAX_SLIPPAGE_BOUNDS.MinBps; +export const PERPS_SLIPPAGE_MAX_BPS = MAX_SLIPPAGE_BOUNDS.MaxBps; +export const PERPS_SLIPPAGE_STEP_BPS = MAX_SLIPPAGE_BOUNDS.StepBps; + +/** Quick-pick presets in basis points (0.5%, 2%, 3%) */ +export const PERPS_SLIPPAGE_QUICK_PICKS_BPS = [50, 200, 300]; + +/** Convert bps to percent for display */ +export const bpsToPercent = (bps: number): number => bps / 100; + +/** Convert percent to bps for storage */ +export const percentToBps = (pct: number): number => Math.round(pct * 100); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index 33613b35b3e..f856a682d11 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -16,6 +16,7 @@ import { useNavigation } from '@react-navigation/native'; import useApprovalRequest from '../../../Views/confirmations/hooks/useApprovalRequest'; import { selectPerpsAccountState } from '../selectors/perpsController'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), @@ -42,6 +43,10 @@ jest.mock('images/perps-pay-token-icon.png', () => ({ uri: 'perps-pay-token-icon-uri', })); +jest.mock('../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual('../../../Views/confirmations/utils/transaction-pay'), + isPayWithBottomSheetEnabled: jest.fn(() => false), +})); jest.mock('../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', () => jest.fn( () => (value: { toNumber: () => number }) => @@ -70,6 +75,10 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< const mockUseApprovalRequest = useApprovalRequest as jest.MockedFunction< typeof useApprovalRequest >; +const mockIsPayWithBottomSheetEnabled = + isPayWithBottomSheetEnabled as jest.MockedFunction< + typeof isPayWithBottomSheetEnabled + >; describe('usePerpsBalanceTokenFilter', () => { const chainId = '0xa4b1'; @@ -80,6 +89,7 @@ describe('usePerpsBalanceTokenFilter', () => { beforeEach(() => { jest.clearAllMocks(); + mockIsPayWithBottomSheetEnabled.mockReturnValue(false); mockUseTransactionMetadataRequest.mockReturnValue(undefined); mockUseIsPerpsBalanceSelected.mockReturnValue(false); mockUseSelector.mockImplementation( @@ -401,5 +411,62 @@ describe('usePerpsBalanceTokenFilter', () => { expect(mockOnPerpsPaymentTokenChange).toHaveBeenCalledWith(null); } }); + + it('omits the synthetic perps balance row when the new Pay With bottom sheet is enabled', () => { + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xabc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(false); + expect((output[0] as AssetType).address).toBe('0xabc'); + }); + + it('still applies the allowlist filter when the new Pay With bottom sheet is enabled', () => { + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + const allowlistKey = `${chainId}.0xusdc`.toLowerCase(); + mockUseSelector.mockImplementation( + (selector: (state: unknown) => unknown) => { + if (selector === selectPerpsAccountState) + return { spendableBalance: '100.00' }; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) + return [allowlistKey]; + return []; + }, + ); + const inputTokens: AssetType[] = [ + { + address: '0xusdc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '500', + } as AssetType, + { + address: '0xother', + chainId, + symbol: 'OTHER', + name: 'Other', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(false); + expect((output[0] as AssetType).address).toBe('0xusdc'); + }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 86ee09a367b..10605edf341 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -12,6 +12,7 @@ import { HighlightedItem, type TokenListItem, } from '../../../Views/confirmations/types/token'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; import { selectPerpsAccountState } from '../selectors/perpsController'; @@ -108,6 +109,10 @@ export function usePerpsBalanceTokenFilter(): ( return mappedTokens; } + if (isPayWithBottomSheetEnabled()) { + return mappedTokens; + } + const highlightedAction: HighlightedItem = { position: 'outside_of_asset_list', icon: PERPS_BALANCE_ICON_URI, diff --git a/app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts b/app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts new file mode 100644 index 00000000000..199079b0416 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsEstimatedSlippage.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { usePerpsLiveOrderBook } from './stream/usePerpsLiveOrderBook'; +import { calculateEstimatedSlippageBps } from '../utils/slippageCalculation'; +import { PERFORMANCE_CONFIG } from '@metamask/perps-controller'; + +export interface UsePerpsEstimatedSlippageOptions { + /** Asset symbol (e.g. 'BTC'). */ + symbol: string; + /** USD notional to fill. Pass undefined / 0 to disable the calc. */ + sizeUsd: number | undefined; + /** true = BUY (sweeps asks), false = SELL (sweeps bids). */ + isBuy: boolean; + /** + * Disable the subscription entirely (e.g. for limit orders). + * Defaults to true. + */ + enabled?: boolean; +} + +export interface UsePerpsEstimatedSlippageReturn { + /** Estimated slippage in bps, or null when the book is loading or too shallow. */ + estimatedSlippageBps: number | null; + /** True once the underlying order book subscription has produced data. */ + isReady: boolean; +} + +/** + * Estimates the slippage in basis points a market order would incur given the + * live HyperLiquid order book and the requested USD size. Combines the L2 book + * subscription with the pure VWAP calc helper so the order screen can show a + * "Est: X%" value and block submission when the estimate exceeds the user cap. + * + * @param options - Symbol, USD size, direction, and an optional enable flag. + * @returns Estimated slippage in bps and a readiness flag. + */ +export function usePerpsEstimatedSlippage({ + symbol, + sizeUsd, + isBuy, + enabled = true, +}: UsePerpsEstimatedSlippageOptions): UsePerpsEstimatedSlippageReturn { + // Throttle the L2 book at `SlippageEstimateThrottleMs`. The slippage row + // needs sub-second updates while the user types, which is faster than the + // generic order-form price guideline; the downstream `useMemo` keeps each + // tick cheap (one VWAP walk). + const { orderBook } = usePerpsLiveOrderBook({ + symbol, + enabled: enabled && Boolean(symbol), + levels: PERFORMANCE_CONFIG.SlippageEstimateBookLevels, + throttleMs: PERFORMANCE_CONFIG.SlippageEstimateThrottleMs, + }); + + const estimatedSlippageBps = useMemo(() => { + if (!enabled || !sizeUsd || sizeUsd <= 0) { + return null; + } + return calculateEstimatedSlippageBps({ + orderBook, + sizeUsd, + isBuy, + }); + }, [orderBook, sizeUsd, isBuy, enabled]); + + return { + estimatedSlippageBps, + isReady: orderBook !== null, + }; +} diff --git a/app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts new file mode 100644 index 00000000000..ddf6d1ed573 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { usePerpsMaxSlippage } from './usePerpsMaxSlippage'; +import Engine from '../../../../core/Engine'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + getMaxSlippage: jest.fn(), + setMaxSlippage: jest.fn(), + }, + }, +})); + +const mockController = Engine.context.PerpsController as unknown as { + getMaxSlippage: jest.Mock; + setMaxSlippage: jest.Mock; +}; + +describe('usePerpsMaxSlippage', () => { + beforeEach(() => { + mockController.getMaxSlippage.mockReset(); + mockController.setMaxSlippage.mockReset(); + }); + + it('returns the controller value with `user_configured` source', () => { + mockController.getMaxSlippage.mockReturnValue(500); + const { result } = renderHook(() => usePerpsMaxSlippage()); + expect(result.current.maxSlippageBps).toBe(500); + expect(result.current.maxSlippageSource).toBe('user_configured'); + }); + + it('falls back to the controller default with `default` source when unset', () => { + mockController.getMaxSlippage.mockReturnValue(undefined); + const { result } = renderHook(() => usePerpsMaxSlippage()); + expect(result.current.maxSlippageBps).toBe(300); + expect(result.current.maxSlippageSource).toBe('default'); + }); + + it('persists a new value and refreshes the read', () => { + mockController.getMaxSlippage.mockReturnValue(undefined); + const { result } = renderHook(() => usePerpsMaxSlippage()); + + expect(result.current.maxSlippageBps).toBe(300); + expect(result.current.maxSlippageSource).toBe('default'); + + mockController.getMaxSlippage.mockReturnValue(450); + + act(() => { + result.current.setMaxSlippage(450); + }); + + expect(mockController.setMaxSlippage).toHaveBeenCalledWith(450); + expect(result.current.maxSlippageBps).toBe(450); + expect(result.current.maxSlippageSource).toBe('user_configured'); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts new file mode 100644 index 00000000000..9e29abbacf4 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsMaxSlippage.ts @@ -0,0 +1,50 @@ +import { useCallback, useMemo, useState } from 'react'; +import Engine from '../../../../core/Engine'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import { PERPS_SLIPPAGE_DEFAULT_BPS } from '../constants/slippageConfig'; + +type MaxSlippageSource = + (typeof PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE)[keyof typeof PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE]; + +export interface UsePerpsMaxSlippageReturn { + /** Resolved max slippage in basis points (falls back to the documented default). */ + maxSlippageBps: number; + /** Indicates whether the value comes from a persisted user choice or the default. */ + maxSlippageSource: MaxSlippageSource; + /** Persist a new max-slippage value (basis points). */ + setMaxSlippage: (bps: number) => void; +} + +/** + * Reads the user's persisted max slippage out of `PerpsController` so the + * order screen never reaches across the controller boundary directly. Returns + * both the resolved bps value and the source (default vs user-configured) so + * callers can pass `max_slippage_source` to MetaMetrics without re-running the + * lookup. Exposes a `setMaxSlippage` helper that bumps an internal revision + * counter, which forces the memoised reads to refresh after a save. + */ +export function usePerpsMaxSlippage(): UsePerpsMaxSlippageReturn { + const [revision, setRevision] = useState(0); + + const setMaxSlippage = useCallback((bps: number) => { + Engine.context.PerpsController?.setMaxSlippage(bps); + setRevision((current) => current + 1); + }, []); + + return useMemo(() => { + const stored = Engine.context.PerpsController?.getMaxSlippage?.(); + const maxSlippageBps = stored ?? PERPS_SLIPPAGE_DEFAULT_BPS; + const maxSlippageSource: MaxSlippageSource = + stored === undefined + ? PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE.DEFAULT + : PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE.USER_CONFIGURED; + return { + maxSlippageBps, + maxSlippageSource, + setMaxSlippage, + }; + // Engine.context read is intentionally not a hook dep; the revision + // counter forces the memo to re-run after `setMaxSlippage` writes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revision, setMaxSlippage]); +} diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts index 6b5b62e1b32..546d3043c31 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts @@ -5,15 +5,20 @@ import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/u import Engine from '../../../../core/Engine'; import { parsePayWithToken } from '../utils/parsePayWithToken'; +export type PerpsPaymentTokenInput = + | AssetType + | { address: string; chainId: string } + | null; + export interface UsePerpsPaymentTokenResult { - onPaymentTokenChange: (token: AssetType | null) => void; + onPaymentTokenChange: (token: PerpsPaymentTokenInput) => void; } export function usePerpsPaymentToken(): UsePerpsPaymentTokenResult { const { setPayToken } = useTransactionPayToken(); const onPaymentTokenChange = useCallback( - (token: AssetType | null) => { + (token: PerpsPaymentTokenInput) => { const parsed = token === null || token === undefined ? null : parsePayWithToken(token); diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts index d15f5762a5b..9daf60ed8a1 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts @@ -2,6 +2,7 @@ import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { useNavigation } from '@react-navigation/native'; import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { providerErrors } from '@metamask/rpc-errors'; import { renderHook, act, waitFor } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { selectSelectedInternalAccountAddress } from '../../../../selectors/accountsController'; @@ -193,6 +194,23 @@ describe('usePerpsWithdrawConfirmation', () => { expect(mockShowToast).toHaveBeenCalledWith(mockWithdrawalStartFailedToast); }); + it('does not show the start failure toast when the user rejects the confirmation', async () => { + const error = providerErrors.userRejectedRequest(); + mockAddTransactionBatch.mockRejectedValueOnce(error); + + const { result } = renderHook(() => usePerpsWithdrawConfirmation()); + + await expect( + act(async () => { + await result.current.withdrawWithConfirmation(); + }), + ).rejects.toThrow(error.message); + + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockWithdrawalStartFailed).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + it('swallows retry failures after showing another retryable error toast', async () => { mockAddTransactionBatch .mockRejectedValueOnce(new Error('batch failed')) diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts index 4f746df4beb..77d313d60d4 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts @@ -14,8 +14,47 @@ import { ARBITRUM_USDC } from '../../../Views/confirmations/constants/perps'; import { RootState } from '../../../../reducers'; import Routes from '../../../../constants/navigation/Routes'; import { ensureError } from '../../../../util/errorUtils'; +import { containsUserRejectedError } from '../../../../util/middlewares'; import usePerpsToasts from './usePerpsToasts'; +interface ErrorLike { + code?: unknown; + message?: unknown; +} + +function getErrorLike(error: unknown): ErrorLike | undefined { + return typeof error === 'object' && error !== null + ? (error as ErrorLike) + : undefined; +} + +function getErrorCode(error: unknown): number | undefined { + const code = getErrorLike(error)?.code; + + if (typeof code === 'number') { + return code; + } + + if (typeof code === 'string') { + const numericCode = Number(code); + return Number.isNaN(numericCode) ? undefined : numericCode; + } + + return undefined; +} + +function getErrorMessage(error: unknown, fallbackMessage: string): string { + const message = getErrorLike(error)?.message; + return typeof message === 'string' ? message : fallbackMessage; +} + +function isUserRejectedError(error: unknown, fallbackMessage: string): boolean { + return containsUserRejectedError( + getErrorMessage(error, fallbackMessage), + getErrorCode(error), + ); +} + /** * Hook that triggers the Perps "withdraw to any token" confirmation flow. * @@ -69,6 +108,10 @@ export function usePerpsWithdrawConfirmation() { 'usePerpsWithdrawConfirmation.withdrawWithConfirmation', ); + if (isUserRejectedError(error, errorObj.message)) { + throw errorObj; + } + navigation.goBack(); showToast( PerpsToastOptions.accountManagement.withdrawal.withdrawalStartFailed( diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 48869fb4d55..9270428dd1c 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -44,6 +44,7 @@ import { HIP3DebugView } from '../Debug'; import PerpsCrossMarginWarningBottomSheet from '../components/PerpsCrossMarginWarningBottomSheet'; import PerpsSelectProviderView from '../Views/PerpsSelectProviderView'; import { PayWithModal } from '../../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; +import { PayWithBottomSheet } from '../../../Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; /* eslint-disable-next-line */ import { NavigationContext } from '@react-navigation/core'; @@ -443,6 +444,14 @@ const PerpsScreenStack = () => { ...transparentModalScreenOptions, }} /> + {/* Order redirect screen - handles one-click trade from token details */} ({ + price: String(price), + size: String(size), + total: String(size), + notional: String(price * size), + totalNotional: String(price * size), +}); + +const buildBook = ( + midPrice: number, + asks: OrderBookLevel[], + bids: OrderBookLevel[], +): OrderBookData => ({ + midPrice: String(midPrice), + asks, + bids, + spread: '0', + spreadPercentage: '0', + lastUpdated: 0, + maxTotal: '0', +}); + +describe('calculateEstimatedSlippageBps', () => { + it('returns null when the order book is null', () => { + expect( + calculateEstimatedSlippageBps({ + orderBook: null, + sizeUsd: 1000, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns null when sizeUsd is non-positive', () => { + const book = buildBook(100, [level(101, 10)], [level(99, 10)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 0, + isBuy: true, + }), + ).toBeNull(); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: -50, + isBuy: false, + }), + ).toBeNull(); + }); + + it('returns null when midPrice is not finite', () => { + const book = buildBook(NaN, [level(101, 10)], [level(99, 10)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 100, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns null when the targeted side has no levels', () => { + const book = buildBook(100, [], [level(99, 10)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 100, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns null when the book is too shallow to fill the request', () => { + const book = buildBook(100, [level(101, 1)], [level(99, 1)]); + expect( + calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 10_000, + isBuy: true, + }), + ).toBeNull(); + }); + + it('returns 0 bps for a buy that fills entirely at the first ask level above mid', () => { + // Mid 100, ask 100 means the VWAP equals mid → no slippage. + const book = buildBook(100, [level(100, 100)], [level(99, 100)]); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1000, + isBuy: true, + }); + expect(result).toBeCloseTo(0, 5); + }); + + it('returns the exact bps for a buy that walks two ask levels', () => { + // Mid 100, asks [100 x 10, 110 x 10]. + // Target base size = sizeUsd / midPrice = 1500 / 100 = 15. + // Walk: 10 @ 100, then 5 @ 110. + // VWAP = (10 * 100 + 5 * 110) / 15 = 1550 / 15 ≈ 103.3333. + // Slippage bps = (103.3333 - 100) / 100 * 10000 ≈ 333.33. + const book = buildBook(100, [level(100, 10), level(110, 10)], []); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1500, + isBuy: true, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeCloseTo(333.333, 2); + }); + + it('returns the exact bps for a sell that walks two bid levels', () => { + // Mid 100, bids [100 x 10, 90 x 10] (descending price). + // Target base size = 1500 / 100 = 15. + // Walk: 10 @ 100, then 5 @ 90. + // VWAP = (10 * 100 + 5 * 90) / 15 = 1450 / 15 ≈ 96.6667. + // Slippage bps = (100 - 96.6667) / 100 * 10000 ≈ 333.33. + const book = buildBook(100, [], [level(100, 10), level(90, 10)]); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1500, + isBuy: false, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeCloseTo(333.333, 2); + }); + + it('skips levels with zero or non-finite size', () => { + // The bad first level should not stop the walk. + const book = buildBook(100, [level(100, 0), level(101, 100)], []); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 1000, + isBuy: true, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeGreaterThan(0); + }); + + it('never returns a negative slippage', () => { + // A "favourable" buy where the ask sits below mid still clamps to 0. + const book = buildBook(100, [level(90, 100)], []); + const result = calculateEstimatedSlippageBps({ + orderBook: book, + sizeUsd: 100, + isBuy: true, + }); + expect(result).not.toBeNull(); + expect(result as number).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/app/components/UI/Perps/utils/slippageCalculation.ts b/app/components/UI/Perps/utils/slippageCalculation.ts new file mode 100644 index 00000000000..47115133ddc --- /dev/null +++ b/app/components/UI/Perps/utils/slippageCalculation.ts @@ -0,0 +1,86 @@ +import { + BASIS_POINTS_DIVISOR, + type OrderBookData, +} from '@metamask/perps-controller'; + +export interface EstimatedSlippageParams { + /** Live order book snapshot (typically from usePerpsLiveOrderBook). */ + orderBook: OrderBookData | null; + /** USD notional to fill. */ + sizeUsd: number; + /** true = BUY (sweeps asks), false = SELL (sweeps bids). */ + isBuy: boolean; +} + +/** + * Estimate slippage in basis points for a market order of `sizeUsd` against + * the current L2 book. Converts the USD size to a target base size + * (`sizeUsd / midPrice`) — matching the provider's execution model — walks + * the relevant side accumulating base size, then returns the VWAP's distance + * from the mid. Returns `null` when the book is missing or too shallow; the + * caller must treat that as "unknown" rather than zero. + * + * @param params - Order book snapshot, USD notional, and direction. + * @returns Estimated slippage in basis points (always non-negative) or null. + */ +export function calculateEstimatedSlippageBps({ + orderBook, + sizeUsd, + isBuy, +}: EstimatedSlippageParams): number | null { + if (!orderBook || !(sizeUsd > 0)) { + return null; + } + + const midPrice = Number(orderBook.midPrice); + if (!Number.isFinite(midPrice) || midPrice <= 0) { + return null; + } + + const levels = isBuy ? orderBook.asks : orderBook.bids; + if (!levels || levels.length === 0) { + return null; + } + + // Mirror the HyperLiquid execution model: the provider derives a fixed base + // size from `usdValue / currentPrice` and submits a limit at the slippage- + // buffered price, so the book walk must accumulate base size rather than + // quote notional. Walking by USD notional underestimates buy slippage and + // overestimates sell slippage versus the real fill. + const targetBaseSize = sizeUsd / midPrice; + if (!Number.isFinite(targetBaseSize) || targetBaseSize <= 0) { + return null; + } + + let filledBaseSize = 0; + let weightedPriceSum = 0; + + for (const level of levels) { + const price = Number(level.price); + const size = Number(level.size); + if (!Number.isFinite(price) || !Number.isFinite(size) || size <= 0) { + continue; + } + + const remainingBase = targetBaseSize - filledBaseSize; + + if (remainingBase <= size) { + // This level finishes the fill — only take the remaining base size. + weightedPriceSum += remainingBase * price; + filledBaseSize += remainingBase; + break; + } + + weightedPriceSum += size * price; + filledBaseSize += size; + } + + if (filledBaseSize < targetBaseSize || filledBaseSize <= 0) { + return null; + } + + const vwap = weightedPriceSum / filledBaseSize; + const slippageBps = + ((vwap - midPrice) / midPrice) * BASIS_POINTS_DIVISOR * (isBuy ? 1 : -1); + return Math.max(0, slippageBps); +} diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx index e16c12dff40..8ee4c9b1af0 100644 --- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx +++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx @@ -18,12 +18,12 @@ describe('PredictKeypad', () => { mockOnChange = null; }); const defaultProps = { - isInputFocused: true, + isKeypadOpen: true, currentValue: 1, currentValueUSDString: '1.00', setCurrentValue: jest.fn(), setCurrentValueUSDString: jest.fn(), - setIsInputFocused: jest.fn(), + setIsKeypadOpen: jest.fn(), }; beforeEach(() => { @@ -35,32 +35,26 @@ describe('PredictKeypad', () => { }); describe('Rendering', () => { - it('renders keypad when input is focused', () => { - // Arrange - const props = { ...defaultProps, isInputFocused: true }; + it('renders keypad when keypad is open', () => { + const props = { ...defaultProps, isKeypadOpen: true }; - // Act const { getByText } = render(); - // Assert - expect(getByText('$20')).toBeTruthy(); - expect(getByText('$50')).toBeTruthy(); - expect(getByText('$100')).toBeTruthy(); - expect(getByText('Done')).toBeTruthy(); + expect(getByText('$20')).toBeOnTheScreen(); + expect(getByText('$50')).toBeOnTheScreen(); + expect(getByText('$100')).toBeOnTheScreen(); + expect(getByText('Done')).toBeOnTheScreen(); }); - it('does not render keypad when input is not focused', () => { - // Arrange - const props = { ...defaultProps, isInputFocused: false }; + it('does not render keypad when keypad is closed', () => { + const props = { ...defaultProps, isKeypadOpen: false }; - // Act const { queryByText } = render(); - // Assert - expect(queryByText('$20')).toBeNull(); - expect(queryByText('$50')).toBeNull(); - expect(queryByText('$100')).toBeNull(); - expect(queryByText('Done')).toBeNull(); + expect(queryByText('$20')).not.toBeOnTheScreen(); + expect(queryByText('$50')).not.toBeOnTheScreen(); + expect(queryByText('$100')).not.toBeOnTheScreen(); + expect(queryByText('Done')).not.toBeOnTheScreen(); }); }); @@ -117,7 +111,7 @@ describe('PredictKeypad', () => { fireEvent.press(getByText('Done')); // Assert - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); it('exposes handleAmountPress handler through ref', () => { @@ -130,7 +124,7 @@ describe('PredictKeypad', () => { ref.current?.handleAmountPress(); // Assert - expect(props.setIsInputFocused).toHaveBeenCalledWith(true); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(true); }); it('exposes handleKeypadAmountPress handler through ref', () => { @@ -157,7 +151,7 @@ describe('PredictKeypad', () => { ref.current?.handleDonePress(); // Assert - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); }); @@ -184,7 +178,7 @@ describe('PredictKeypad', () => { expect(props.setCurrentValueUSDString).toHaveBeenCalledWith('25'); expect(props.setCurrentValue).toHaveBeenCalledWith(25); - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); it('handles empty string after removing decimal point', () => { @@ -212,7 +206,7 @@ describe('PredictKeypad', () => { ref.current?.handleDonePress(); expect(props.setCurrentValueUSDString).not.toHaveBeenCalled(); - expect(props.setIsInputFocused).toHaveBeenCalledWith(false); + expect(props.setIsKeypadOpen).toHaveBeenCalledWith(false); }); }); diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx index f86e9b450e7..be44445c5e6 100644 --- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx +++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx @@ -8,12 +8,12 @@ import Button, { import Keypad from '../../../../Base/Keypad'; interface PredictKeypadProps { - isInputFocused: boolean; + isKeypadOpen: boolean; currentValue: number; currentValueUSDString: string; setCurrentValue: (value: number) => void; setCurrentValueUSDString: (value: string) => void; - setIsInputFocused: (focused: boolean) => void; + setIsKeypadOpen: (open: boolean) => void; hideHeader?: boolean; } @@ -26,12 +26,12 @@ export interface PredictKeypadHandles { const PredictKeypad = forwardRef( ( { - isInputFocused, + isKeypadOpen, currentValue, currentValueUSDString, setCurrentValue, setCurrentValueUSDString, - setIsInputFocused, + setIsKeypadOpen, hideHeader = false, }, ref, @@ -39,8 +39,8 @@ const PredictKeypad = forwardRef( const tw = useTailwind(); const handleAmountPress = useCallback(() => { - setIsInputFocused(true); - }, [setIsInputFocused]); + setIsKeypadOpen(true); + }, [setIsKeypadOpen]); const handleKeypadAmountPress = useCallback( (amount: number) => { @@ -58,9 +58,9 @@ const PredictKeypad = forwardRef( setCurrentValueUSDString(cleanedValue); setCurrentValue(parseFloat(cleanedValue) || 0); } - setIsInputFocused(false); + setIsKeypadOpen(false); }, [ - setIsInputFocused, + setIsKeypadOpen, currentValueUSDString, setCurrentValueUSDString, setCurrentValue, @@ -93,9 +93,9 @@ const PredictKeypad = forwardRef( adjustedValue = value.replace('.', ''); } - // Set focus flag immediately - if (!isInputFocused) { - setIsInputFocused(true); + // Open the keypad immediately on any keystroke + if (!isKeypadOpen) { + setIsKeypadOpen(true); } // Enforce 9-digit limit (ignoring non-digits). Block the change if exceeded. @@ -129,14 +129,14 @@ const PredictKeypad = forwardRef( }, [ currentValue, - isInputFocused, + isKeypadOpen, setCurrentValue, setCurrentValueUSDString, - setIsInputFocused, + setIsKeypadOpen, ], ); - if (!isInputFocused) return null; + if (!isKeypadOpen) return null; return ( diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx index 7b3d98cf7d8..93b0821800f 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx @@ -2207,7 +2207,7 @@ describe('PredictBuyPreview', () => { }); describe('renderBottomContent visibility', () => { - it('returns null when isInputFocused is true', () => { + it('returns null when keypad is open', () => { mockBalance = 1000; mockBalanceLoading = false; @@ -2219,7 +2219,7 @@ describe('PredictBuyPreview', () => { ).not.toBeOnTheScreen(); }); - it('renders bottom content when isInputFocused is false', () => { + it('renders bottom content when keypad is closed', () => { mockBalance = 1000; mockBalanceLoading = false; diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index 0f2c8d9ff91..f5493f8fc49 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -197,7 +197,7 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { const [currentValue, setCurrentValue] = useState(0); const [currentValueUSDString, setCurrentValueUSDString] = useState(''); - const [isInputFocused, setIsInputFocused] = useState(true); + const [isKeypadOpen, setIsKeypadOpen] = useState(true); const [isUserInputChange, setIsUserInputChange] = useState(false); const [isFeeBreakdownVisible, setIsFeeBreakdownVisible] = useState(false); const previousValueRef = useRef(0); @@ -487,7 +487,7 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { keypadRef.current?.handleAmountPress()} - isActive={isInputFocused} + isActive={isKeypadOpen} hasError={isInsufficientBalance} /> @@ -605,7 +605,7 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { }; const renderBottomContent = () => { - if (isInputFocused) { + if (isKeypadOpen) { return null; } @@ -669,12 +669,12 @@ const PredictBuyPreview = (props: PredictBuyPreviewProps) => { {renderMinimumBetWarning()} {renderBottomContent()} {isFeeBreakdownVisible && ( diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx index 77fc3709925..bfa38645c8f 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx @@ -14,7 +14,7 @@ const mockResetOrderNotFilled = jest.fn(); const mockClearBuyErrorBanner = jest.fn(); const mockSetCurrentValue = jest.fn(); const mockSetCurrentValueUSDString = jest.fn(); -const mockSetIsInputFocused = jest.fn(); +const mockSetIsKeypadOpen = jest.fn(); const mockSetIsUserInputChange = jest.fn(); const mockSetIsConfirming = jest.fn(); const mockHandleRetryWithBestPrice = jest.fn(); @@ -133,19 +133,22 @@ jest.mock('./hooks/usePredictBuyAvailableBalance', () => ({ }), })); +const mockUsePredictBuyInputState = jest.fn((..._args: unknown[]) => ({ + currentValue: 20, + setCurrentValue: mockSetCurrentValue, + currentValueUSDString: '$20.00', + setCurrentValueUSDString: mockSetCurrentValueUSDString, + isKeypadOpen: false, + setIsKeypadOpen: mockSetIsKeypadOpen, + isUserInputChange: true, + setIsUserInputChange: mockSetIsUserInputChange, + isConfirming: false, + setIsConfirming: mockSetIsConfirming, +})); + jest.mock('./hooks/usePredictBuyInputState', () => ({ - usePredictBuyInputState: () => ({ - currentValue: 20, - setCurrentValue: mockSetCurrentValue, - currentValueUSDString: '$20.00', - setCurrentValueUSDString: mockSetCurrentValueUSDString, - isInputFocused: false, - setIsInputFocused: mockSetIsInputFocused, - isUserInputChange: true, - setIsUserInputChange: mockSetIsUserInputChange, - isConfirming: false, - setIsConfirming: mockSetIsConfirming, - }), + usePredictBuyInputState: (...args: unknown[]) => + mockUsePredictBuyInputState(...args), })); jest.mock('./hooks/usePredictBuyInfo', () => ({ @@ -317,15 +320,18 @@ jest.mock('../../components/PredictOrderRetrySheet', () => { )); }); +const mockPredictPayWithAnyTokenInfo = jest.fn(); + jest.mock('./components/PredictPayWithAnyTokenInfo', () => { const { Text } = jest.requireActual('react-native'); - return function MockPredictPayWithAnyTokenInfo({ - currentValue, - }: { + return function MockPredictPayWithAnyTokenInfo(props: { currentValue: number; - isInputFocused: boolean; + shouldDeferRelaySetup: boolean; }) { - return {currentValue}; + mockPredictPayWithAnyTokenInfo(props); + return ( + {props.currentValue} + ); }; }); @@ -544,6 +550,14 @@ describe('PredictBuyWithAnyToken', () => { ).not.toBeOnTheScreen(); }); + it('initialises usePredictBuyInputState with initialKeypadOpen=false', () => { + renderWithProvider(); + + expect(mockUsePredictBuyInputState).toHaveBeenCalledWith({ + initialKeypadOpen: false, + }); + }); + it('renders PredictQuickAmounts inside bottom content', () => { renderWithProvider(); @@ -564,12 +578,12 @@ describe('PredictBuyWithAnyToken', () => { expect(screen.getByTestId('predict-keypad')).toBeOnTheScreen(); }); - it('sets isInputFocused to false when quick amount is tapped', () => { + it('closes the keypad when a quick amount is tapped', () => { renderWithProvider(); fireEvent.press(screen.getByTestId('quick-amount-20')); - expect(mockSetIsInputFocused).toHaveBeenCalledWith(false); + expect(mockSetIsKeypadOpen).toHaveBeenCalledWith(false); expect(mockSetCurrentValue).toHaveBeenCalledWith(20); expect(mockSetCurrentValueUSDString).toHaveBeenCalledWith('20'); }); @@ -640,6 +654,14 @@ describe('PredictBuyWithAnyToken', () => { }); describe('non-sheet mode', () => { + it('initialises usePredictBuyInputState with initialKeypadOpen=true', () => { + renderWithProvider(); + + expect(mockUsePredictBuyInputState).toHaveBeenCalledWith({ + initialKeypadOpen: true, + }); + }); + it('does NOT render the banner even if buyErrorBanner is set', () => { mockBuyErrorBanner = { variant: 'order_failed', @@ -670,6 +692,78 @@ describe('PredictBuyWithAnyToken', () => { }); }); + describe('shouldDeferRelaySetup propagation', () => { + const sheetProps = { + mode: 'sheet' as const, + market: { id: 'market-1' }, + outcome: { id: 'outcome-1' }, + outcomeToken: { id: 'token-1', title: 'Yes', price: 0.62 }, + entryPoint: 'market_details', + onClose: jest.fn(), + } as unknown as PredictBuyPreviewProps; + + const mockHookReturnWithKeypadOpen = (isKeypadOpen: boolean) => ({ + currentValue: 20, + setCurrentValue: mockSetCurrentValue, + currentValueUSDString: '$20.00', + setCurrentValueUSDString: mockSetCurrentValueUSDString, + isKeypadOpen, + setIsKeypadOpen: mockSetIsKeypadOpen, + isUserInputChange: true, + setIsUserInputChange: mockSetIsUserInputChange, + isConfirming: false, + setIsConfirming: mockSetIsConfirming, + }); + + it('passes shouldDeferRelaySetup=false in sheet mode when keypad is closed', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(false), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: false }), + ); + }); + + it('passes shouldDeferRelaySetup=false in sheet mode even when keypad is open', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(true), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: false }), + ); + }); + + it('passes shouldDeferRelaySetup=false in non-sheet mode when keypad is closed', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(false), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: false }), + ); + }); + + it('passes shouldDeferRelaySetup=true in non-sheet mode when keypad is open', () => { + mockUsePredictBuyInputState.mockReturnValueOnce( + mockHookReturnWithKeypadOpen(true), + ); + + renderWithProvider(); + + expect(mockPredictPayWithAnyTokenInfo).toHaveBeenLastCalledWith( + expect.objectContaining({ shouldDeferRelaySetup: true }), + ); + }); + }); + describe('CTA button modes', () => { const sheetProps = { mode: 'sheet' as const, diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index 0ef11d2a810..d3022883b77 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -165,21 +165,21 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { setCurrentValue, currentValueUSDString, setCurrentValueUSDString, - isInputFocused, - setIsInputFocused, + isKeypadOpen, + setIsKeypadOpen, isUserInputChange, setIsUserInputChange, isConfirming, setIsConfirming, - } = usePredictBuyInputState(); + } = usePredictBuyInputState({ initialKeypadOpen: !isSheetMode }); const handleQuickAmount = useCallback( (amount: number) => { setCurrentValue(amount); setCurrentValueUSDString(amount.toString()); - setIsInputFocused(false); + setIsKeypadOpen(false); }, - [setCurrentValue, setCurrentValueUSDString, setIsInputFocused], + [setCurrentValue, setCurrentValueUSDString, setIsKeypadOpen], ); const handleFeesInfoPress = useCallback(() => { @@ -334,14 +334,14 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { previousValueRef.current = currentValue; }, [currentValue, isUserInputChange, clearBuyErrorBanner]); - // When the banner appears in sheet mode, blur the amount input so the keypad - // collapses and the Retry CTA + banner are immediately visible without the - // user having to dismiss the keyboard. + // When the banner appears in sheet mode, close the keypad so the Retry CTA + // + banner are immediately visible without the user having to dismiss the + // keyboard. useEffect(() => { - if (isSheetMode && isBannerActive && isInputFocused) { - setIsInputFocused(false); + if (isSheetMode && isBannerActive && isKeypadOpen) { + setIsKeypadOpen(false); } - }, [isSheetMode, isBannerActive, isInputFocused, setIsInputFocused]); + }, [isSheetMode, isBannerActive, isKeypadOpen, setIsKeypadOpen]); const handleBuyButtonPress = useCallback(() => { if (isPaymentSelectorNavigationLocked) { @@ -433,7 +433,7 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { { { {!isSheetMode && ( )} - - {isSheetMode && ( - - )} - {payWithAnyTokenEnabled && isSheetMode && ( - + {isSheetMode && ( + + )} + {payWithAnyTokenEnabled && isSheetMode && ( + + )} + {/* Always enabled when rendered: in legacy mode the parent only + mounts PredictBuyBottomContent while the keypad is closed; in + sheet mode the fee summary is always actionable. */} + - )} - - {isSheetMode && buyErrorBanner && ( - + )} + - )} - - + + )} {isSheetMode && ( )} @@ -582,7 +584,7 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx index b371304903f..fdadcfe21e0 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx @@ -71,7 +71,7 @@ describe('PredictBuyAmountSection', () => { { { { { { { { { { { { { { { { { { { ; - isInputFocused: boolean; + isKeypadOpen: boolean; isBalanceLoading: boolean; isBalancePulsing: boolean; availableBalanceDisplay: string; @@ -32,7 +32,7 @@ interface PredictBuyAmountSectionProps { const PredictBuyAmountSection = ({ currentValueUSDString, keypadRef, - isInputFocused, + isKeypadOpen, isBalanceLoading, isBalancePulsing, availableBalanceDisplay, @@ -75,7 +75,7 @@ const PredictBuyAmountSection = ({ onPress={() => !isPlacingOrder && keypadRef.current?.handleAmountPress() } - isActive={isInputFocused && !isPlacingOrder} + isActive={isKeypadOpen && !isPlacingOrder} hasError={false} /> diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx index ca84d50fcf9..46266e4e0f1 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx @@ -34,25 +34,10 @@ describe('PredictBuyBottomContent', () => { jest.clearAllMocks(); }); - describe('when isInputFocused is true', () => { - it('returns null and does not render anything', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.queryByText(/Disclaimer text/)).not.toBeOnTheScreen(); - expect(screen.queryByTestId('children-content')).not.toBeOnTheScreen(); - }); - }); - - describe('when isInputFocused is false', () => { + describe('rendering', () => { it('renders children content', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(screen.getByTestId('children-content')).toBeOnTheScreen(); @@ -60,9 +45,7 @@ describe('PredictBuyBottomContent', () => { it('renders disclaimer text', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(screen.getByText(/Disclaimer text/)).toBeOnTheScreen(); @@ -70,9 +53,7 @@ describe('PredictBuyBottomContent', () => { it('renders learn more link', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(screen.getByText(/Learn more/)).toBeOnTheScreen(); @@ -80,9 +61,7 @@ describe('PredictBuyBottomContent', () => { it('opens Polymarket TOS URL when learn more is pressed', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); const learnMoreLink = screen.getByText(/Learn more/); @@ -108,9 +87,7 @@ describe('PredictBuyBottomContent', () => { ); renderWithProvider( - - {multipleChildren} - , + {multipleChildren}, ); expect(screen.getByTestId('child-1')).toBeOnTheScreen(); @@ -121,9 +98,7 @@ describe('PredictBuyBottomContent', () => { describe('Linking behavior', () => { it('calls Linking.openURL with correct URL', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); const learnMoreLink = screen.getByText(/Learn more/); @@ -137,9 +112,7 @@ describe('PredictBuyBottomContent', () => { it('opens URL only when learn more is pressed', () => { renderWithProvider( - - {mockChildren} - , + {mockChildren}, ); expect(Linking.openURL).not.toHaveBeenCalled(); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx index 316da14465c..e507c9de80a 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx @@ -12,23 +12,16 @@ import { Linking } from 'react-native'; import { strings } from '../../../../../../../../locales/i18n'; interface PredictBuyBottomContentProps { - isInputFocused: boolean; - hideBorder?: boolean; children: React.ReactNode; } const PredictBuyBottomContent = ({ - isInputFocused, hideBorder = false, children, }: PredictBuyBottomContentProps) => { const tw = useTailwind(); - if (isInputFocused) { - return null; - } - return ( { , ); @@ -149,7 +149,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -163,7 +163,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -177,7 +177,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -204,7 +204,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -229,7 +229,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -254,7 +254,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -281,7 +281,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -299,7 +299,7 @@ describe('PredictPayWithAnyTokenInfo', () => { preview={createMockPreview({ maxAmountSpent: 99.99, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -323,7 +323,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -333,28 +333,28 @@ describe('PredictPayWithAnyTokenInfo', () => { }); describe('deposit amount gating', () => { - it('does not commit deposit amount while input is focused', () => { + it('does not commit deposit amount while relay setup is deferred', () => { mockActiveTransactionMeta = { id: 'tx-1' }; render( , ); expect(mockUpdatePendingAmount).not.toHaveBeenCalled(); }); - it('commits deposit amount when input loses focus', () => { + it('commits deposit amount when relay setup deferral is released', () => { mockActiveTransactionMeta = { id: 'tx-1' }; const { rerender } = render( , ); @@ -364,7 +364,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -378,7 +378,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -391,7 +391,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -399,7 +399,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -414,7 +414,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -429,7 +429,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -444,7 +444,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -455,7 +455,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -465,7 +465,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -482,7 +482,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -497,7 +497,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -512,7 +512,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -527,7 +527,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -551,7 +551,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -569,7 +569,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -594,7 +594,7 @@ describe('PredictPayWithAnyTokenInfo', () => { collector: '0xCollector', }, })} - isInputFocused={false} + shouldDeferRelaySetup={false} />, ); @@ -610,7 +610,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -626,7 +626,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -642,7 +642,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -658,7 +658,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -674,7 +674,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -696,7 +696,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -711,7 +711,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -729,7 +729,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -754,7 +754,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -779,7 +779,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -798,7 +798,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -813,7 +813,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -830,7 +830,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -847,7 +847,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); @@ -865,7 +865,7 @@ describe('PredictPayWithAnyTokenInfo', () => { , ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx index 3a7013abb42..a43c4af2dbb 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx @@ -15,13 +15,21 @@ import { getPredictBuyAllInCost } from '../../../../utils/orders'; interface PredictPayWithAnyTokenInfoProps { currentValue: number; preview?: OrderPreview | null; - isInputFocused: boolean; + /** + * When true, defers the mm_pay relay-config side effects + * (`updatePendingAmount` / `setPayToken`). The legacy full-screen flow + * sets this while the keypad is open and only releases it on Done so the + * relay isn't reconfigured on every keystroke. The bottom-sheet flow + * keeps it false because there is no Done affordance and the user can + * tap Confirm while the keypad is still open. + */ + shouldDeferRelaySetup: boolean; } const PredictPayWithAnyTokenInfo = ({ currentValue, preview, - isInputFocused, + shouldDeferRelaySetup, }: PredictPayWithAnyTokenInfoProps) => { const transactionMeta = useTransactionMetadataRequest(); @@ -33,7 +41,7 @@ const PredictPayWithAnyTokenInfo = ({ ); }; @@ -41,7 +49,7 @@ const PredictPayWithAnyTokenInfo = ({ function PredictPayWithAnyTokenInfoInner({ currentValue, preview, - isInputFocused, + shouldDeferRelaySetup, }: PredictPayWithAnyTokenInfoProps) { const [depositAmount, setDepositAmount] = useState(''); @@ -66,8 +74,8 @@ function PredictPayWithAnyTokenInfoInner({ !isPredictBalanceSelected && !!fees && currentValue >= MINIMUM_BET && - !isInputFocused, - [isPredictBalanceSelected, fees, currentValue, isInputFocused], + !shouldDeferRelaySetup, + [isPredictBalanceSelected, fees, currentValue, shouldDeferRelaySetup], ); const computedDepositAmount = useMemo(() => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts index 43c1a909ecc..507043cac8e 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts @@ -94,21 +94,37 @@ describe('usePredictBuyInputState', () => { }); }); - describe('isInputFocused', () => { - it('initializes to true', () => { + describe('isKeypadOpen', () => { + it('initializes to true by default', () => { const { result } = renderHook(() => usePredictBuyInputState()); - expect(result.current.isInputFocused).toBe(true); + expect(result.current.isKeypadOpen).toBe(true); }); - it('updates isInputFocused via setIsInputFocused', () => { + it('honours initialKeypadOpen=false from caller options', () => { + const { result } = renderHook(() => + usePredictBuyInputState({ initialKeypadOpen: false }), + ); + + expect(result.current.isKeypadOpen).toBe(false); + }); + + it('honours initialKeypadOpen=true from caller options', () => { + const { result } = renderHook(() => + usePredictBuyInputState({ initialKeypadOpen: true }), + ); + + expect(result.current.isKeypadOpen).toBe(true); + }); + + it('updates isKeypadOpen via setIsKeypadOpen', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { - result.current.setIsInputFocused(false); + result.current.setIsKeypadOpen(false); }); - expect(result.current.isInputFocused).toBe(false); + expect(result.current.isKeypadOpen).toBe(false); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts index a65dd005822..214b171a7bf 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts @@ -1,7 +1,13 @@ import { SetStateAction, useCallback, useRef, useState } from 'react'; import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -export const usePredictBuyInputState = () => { +interface UsePredictBuyInputStateOptions { + initialKeypadOpen?: boolean; +} + +export const usePredictBuyInputState = ({ + initialKeypadOpen = true, +}: UsePredictBuyInputStateOptions = {}) => { const { clearOrderError } = usePredictActiveOrder(); const [currentValue, setCurrentValueState] = useState(0); @@ -13,14 +19,14 @@ export const usePredictBuyInputState = () => { currentValue ? currentValue.toString() : '', ); - const [isInputFocused, setIsInputFocusedState] = useState(true); + const [isKeypadOpen, setIsKeypadOpenState] = useState(initialKeypadOpen); const shouldSyncCurrentValueRef = useRef(false); const shouldClearAmountErrorRef = useRef(false); - const shouldSyncInputFocusRef = useRef(false); + const shouldSyncKeypadOpenRef = useRef(false); - const setIsInputFocused = useCallback((nextIsInputFocused: boolean) => { - shouldSyncInputFocusRef.current = true; - setIsInputFocusedState(nextIsInputFocused); + const setIsKeypadOpen = useCallback((nextIsKeypadOpen: boolean) => { + shouldSyncKeypadOpenRef.current = true; + setIsKeypadOpenState(nextIsKeypadOpen); }, []); const [isUserInputChange, setIsUserInputChange] = useState(false); @@ -56,8 +62,8 @@ export const usePredictBuyInputState = () => { setCurrentValue, currentValueUSDString, setCurrentValueUSDString, - isInputFocused, - setIsInputFocused, + isKeypadOpen, + setIsKeypadOpen, isUserInputChange, setIsUserInputChange, isConfirming, diff --git a/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx new file mode 100644 index 00000000000..acd4a8a2362 --- /dev/null +++ b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import RewardsVipBadge from './RewardsVipBadge'; +import { CaipAccountId } from '@metamask/utils'; + +const mockGetVipTierForAccount = jest.fn(); +jest.mock('../../../../../core/Engine', () => ({ + context: { + RewardsController: { + getVipTierForAccount: (accountId: CaipAccountId) => + mockGetVipTierForAccount(accountId), + }, + }, +})); +describe('RewardsVipBadge', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders vip badge', async () => { + mockGetVipTierForAccount.mockResolvedValueOnce(1); + const { getByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(getByTestId('rewards-vip-badge')).toHaveTextContent('VIP 1'); + }); + }); + + it('renders nothing if vip tier is not found', async () => { + mockGetVipTierForAccount.mockResolvedValueOnce(null); + const { queryByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(queryByTestId('rewards-vip-badge')).toBeNull(); + }); + }); + + it('renders nothing if vip tier is 0', async () => { + mockGetVipTierForAccount.mockResolvedValueOnce(0); + const { queryByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(queryByTestId('rewards-vip-badge')).toBeNull(); + }); + }); + + it('renders nothing if getting vip tier fails', async () => { + mockGetVipTierForAccount.mockRejectedValueOnce( + new Error('Failed to get vip tier'), + ); + const { queryByTestId } = render( + , + ); + + expect(mockGetVipTierForAccount).toHaveBeenCalledWith('eip155:1:0x1213'); + + await waitFor(() => { + expect(queryByTestId('rewards-vip-badge')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx new file mode 100644 index 00000000000..afaf4f166b0 --- /dev/null +++ b/app/components/UI/Rewards/components/RewardsVipBadge/RewardsVipBadge.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; +import FoxRewardIcon from '../../../../../images/rewards/metamask-rewards-points-vip.svg'; +import { strings } from '../../../../../../locales/i18n'; +import { CaipAccountId } from '@metamask/utils'; +import { + Box, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import Engine from '../../../../../core/Engine'; +import LinearGradient from 'react-native-linear-gradient'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +interface RewardsVipBadgeProps { + accountId: CaipAccountId; +} + +const RewardsVipBadge: React.FC = ({ accountId }) => { + const tw = useTailwind(); + + const [vipTier, setVipTier] = useState(null); + + useEffect(() => { + Engine.context.RewardsController.getVipTierForAccount(accountId) + .then((result) => { + setVipTier(result); + }) + .catch((error) => { + console.warn('Error fetching vip tier:', error); + setVipTier(null); + }); + }, [accountId]); + + if (!vipTier) return null; + + return ( + + + + + + + {strings('rewards.vip.badge_label', { + tier: vipTier.toString(), + })} + + + + + + ); +}; + +export default RewardsVipBadge; diff --git a/app/components/UI/Rewards/components/RewardsVipBadge/index.ts b/app/components/UI/Rewards/components/RewardsVipBadge/index.ts new file mode 100644 index 00000000000..716f2725527 --- /dev/null +++ b/app/components/UI/Rewards/components/RewardsVipBadge/index.ts @@ -0,0 +1 @@ +export { default } from './RewardsVipBadge'; diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx index e894e05b1d8..221d77a8b3e 100644 --- a/app/components/Views/AccountActions/AccountActions.tsx +++ b/app/components/Views/AccountActions/AccountActions.tsx @@ -27,7 +27,7 @@ import Logger from '../../../util/Logger'; import { protectWalletModalVisible } from '../../../actions/user'; import Routes from '../../../constants/navigation/Routes'; import { AccountActionsBottomSheetSelectorsIDs } from './AccountActionsBottomSheet.testIds'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { isHardwareAccount, isHDOrFirstPartySnapAccount, @@ -64,7 +64,7 @@ const AccountActions = () => { const sheetRef = useRef(null); const { navigate } = useNavigation(); const dispatch = useDispatch(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { networkSupporting7702Present } = useEIP7702Networks( selectedAccount.address, ); diff --git a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx index 5e2f63e78af..d7ecb0e412f 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx @@ -50,29 +50,6 @@ jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ }), })); -jest.mock('../../../core/Analytics', () => ({ - MetaMetrics: { - getInstance: () => ({ - trackEvent: mockTrackEvent, - }), - }, -})); - -jest.mock('../../../core/Analytics/MetaMetrics.events', () => ({ - EVENT_NAME: { - CARD_HOME_CLICKED: 'Card Home Clicked', - SETTINGS_VIEWED: 'Settings Viewed', - SETTINGS_ABOUT: 'About MetaMask', - NAVIGATION_TAPS_SEND_FEEDBACK: 'Send Feedback', - NAVIGATION_TAPS_GET_HELP: 'Get Help', - NAVIGATION_TAPS_LOGOUT: 'Logout', - QR_SCANNER_OPENED: 'QR Scanner Opened', - RAMPS_BUTTON_CLICKED: 'Ramps Button Clicked', - NOTIFICATIONS_MENU_OPENED: 'Notifications Menu Opened', - NOTIFICATIONS_ACTIVATED: 'Notifications Activated', - }, -})); - jest.mock('../../../core/Analytics/MetricsEventBuilder', () => ({ MetricsEventBuilder: { createEventBuilder: jest.fn(() => ({ diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts index 82aa44c975d..7f310f62dfa 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts @@ -84,7 +84,7 @@ const mockCreateEventBuilder = jest.fn(() => ({ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent, - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, createEventBuilder: mockCreateEventBuilder, }), })); diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts index 9fb76378194..a1d542c1981 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts @@ -84,7 +84,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const providerConfig = useSelector(selectProviderConfig); const isAllNetworks = useSelector(selectIsAllNetworks); const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); - const { trackEvent, addTraitsToUser, createEventBuilder } = useAnalytics(); + const { trackEvent, identify, createEventBuilder } = useAnalytics(); // ---- Handle network add/update ------------------------------------------ const handleNetworkUpdate = useCallback( @@ -204,7 +204,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { await NetworkController.addNetwork({ ...networkConfig, } as unknown as AddNetworkFields); - addTraitsToUser(addItemToChainIdList(networkConfig.chainId)); + identify(addItemToChainIdList(networkConfig.chainId)); } if (!skipPostSaveNavigation) { @@ -221,7 +221,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { navigation, networkConfigurations, trackEvent, - addTraitsToUser, + identify, createEventBuilder, ], ); @@ -396,11 +396,11 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const { NetworkController } = Engine.context; NetworkController.removeNetwork(hexChainId); - addTraitsToUser(removeItemFromChainIdList(hexChainId)); + identify(removeItemFromChainIdList(hexChainId)); navigation.goBack(); }, - [navigation, networkConfigurations, providerConfig, addTraitsToUser], + [navigation, networkConfigurations, providerConfig, identify], ); // ---- Navigate to edit --------------------------------------------------- diff --git a/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx b/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx index 2cce94563c6..8ce0f4270e3 100644 --- a/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx +++ b/app/components/Views/Settings/AutoDetectNFTSettings/index.test.tsx @@ -59,7 +59,7 @@ describe('AutoDetectNFTSettings', () => { jest.mocked(useAnalytics).mockReturnValue( createMockUseAnalyticsHook({ trackEvent: mockTrackEvent, - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, }), ); (useNavigation as jest.Mock).mockImplementation(() => mockNavigation); diff --git a/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx b/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx index 813ba091532..bf777a2fb46 100644 --- a/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx +++ b/app/components/Views/Settings/AutoDetectNFTSettings/index.tsx @@ -23,7 +23,7 @@ import createStyles from './index.styles'; import { NFT_AUTO_DETECT_MODE_SECTION } from './index.constants'; const AutoDetectNFTSettings = () => { - const { trackEvent, addTraitsToUser, createEventBuilder } = useAnalytics(); + const { trackEvent, identify, createEventBuilder } = useAnalytics(); const theme = useTheme(); const { colors } = theme; const styles = createStyles(); @@ -38,7 +38,7 @@ const AutoDetectNFTSettings = () => { } PreferencesController.setUseNftDetection(value); - addTraitsToUser({ + identify({ ...(value && { [UserProfileProperty.ENABLE_OPENSEA_API]: value ? UserProfileProperty.ON @@ -57,7 +57,7 @@ const AutoDetectNFTSettings = () => { .build(), ); }, - [addTraitsToUser, trackEvent, createEventBuilder], + [identify, trackEvent, createEventBuilder], ); return ( diff --git a/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx b/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx index 8ebb24a41ba..ba8ef470db1 100644 --- a/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx +++ b/app/components/Views/Settings/AutoDetectTokensSettings/index.test.tsx @@ -44,7 +44,7 @@ describe('AssetSettings', () => { jest .mocked(useAnalytics) .mockReturnValue( - createMockUseAnalyticsHook({ addTraitsToUser: mockAddTraitsToUser }), + createMockUseAnalyticsHook({ identify: mockAddTraitsToUser }), ); }); diff --git a/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx b/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx index 33692785b59..cef02e14dd2 100644 --- a/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx +++ b/app/components/Views/Settings/AutoDetectTokensSettings/index.tsx @@ -26,20 +26,20 @@ const AutoDetectTokensSettings = () => { const theme = useTheme(); const { colors } = theme; const { styles } = useStyles(styleSheet, {}); - const { addTraitsToUser } = useAnalytics(); + const { identify } = useAnalytics(); const isTokenDetectionEnabled = useSelector(selectUseTokenDetection); const toggleTokenDetection = useCallback( (value: boolean) => { Engine.context.PreferencesController.setUseTokenDetection(value); - addTraitsToUser({ + identify({ [UserProfileProperty.TOKEN_DETECTION]: value ? UserProfileProperty.ON : UserProfileProperty.OFF, }); }, - [addTraitsToUser], + [identify], ); return ( diff --git a/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx b/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx index 49aaf6ed890..5bb9b3bea45 100644 --- a/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx +++ b/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx @@ -25,7 +25,7 @@ const BatchAccountBalanceSettings = () => { const theme = useTheme(); const { colors } = theme; const { styles } = useStyles(styleSheet, {}); - const { addTraitsToUser } = useAnalytics(); + const { identify } = useAnalytics(); const isMultiAccountBalancesEnabled = useSelector( selectIsMultiAccountBalancesEnabled, @@ -36,13 +36,13 @@ const BatchAccountBalanceSettings = () => { PreferencesController.setIsMultiAccountBalancesEnabled( multiAccountBalancesEnabled, ); - addTraitsToUser({ + identify({ [UserProfileProperty.MULTI_ACCOUNT_BALANCE]: multiAccountBalancesEnabled ? UserProfileProperty.ON : UserProfileProperty.OFF, }); }, - [PreferencesController, addTraitsToUser], + [PreferencesController, identify], ); return ( diff --git a/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx b/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx index 4cd6db81cbd..d36a1dc2cba 100644 --- a/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx +++ b/app/components/Views/Settings/DisplayNFTMediaSettings/index.test.tsx @@ -43,7 +43,7 @@ describe('DisplayNFTMediaSettings', () => { jest .mocked(useAnalytics) .mockReturnValue( - createMockUseAnalyticsHook({ addTraitsToUser: mockAddTraitsToUser }), + createMockUseAnalyticsHook({ identify: mockAddTraitsToUser }), ); }); diff --git a/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx b/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx index 6f0c3ff2727..caf0ab57dc2 100644 --- a/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx +++ b/app/components/Views/Settings/DisplayNFTMediaSettings/index.tsx @@ -21,7 +21,7 @@ const DisplayNFTMediaSettings = () => { const theme = useTheme(); const { colors } = theme; const { styles } = useStyles(styleSheet, {}); - const { addTraitsToUser } = useAnalytics(); + const { identify } = useAnalytics(); const displayNftMedia = useSelector(selectDisplayNftMedia); @@ -39,7 +39,7 @@ const DisplayNFTMediaSettings = () => { [UserProfileProperty.NFT_AUTODETECTION]: UserProfileProperty.OFF, }), }; - addTraitsToUser(traits); + identify(traits); }; return ( diff --git a/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx b/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx index 32875c72f78..f3c4104e193 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/BlockaidSettings.tsx @@ -21,7 +21,7 @@ import { SECURITY_ALERTS_URL } from '../../../../../constants/urls'; const BlockaidSettings = () => { const theme = useTheme(); const { colors } = useTheme(); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const styles = createStyles(); const securityAlertsEnabled = useSelector(selectIsSecurityAlertsEnabled); @@ -41,7 +41,7 @@ const BlockaidSettings = () => { .build(), ); - addTraitsToUser({ + identify({ [UserProfileProperty.SECURITY_PROVIDERS]: newSecurityAlertsEnabledState ? 'blockaid' : '', diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index 3dc26f1082c..044e59c3120 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useMemo, useRef } from 'react'; import { Hex } from '@metamask/utils'; +import { StackActions, useNavigation } from '@react-navigation/native'; import Engine from '../../../../../../core/Engine'; +import { useParams } from '../../../../../../util/navigation/navUtils'; import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; import { useTransactionPayWithdraw } from '../../../hooks/pay/useTransactionPayWithdraw'; import { useWithdrawTokenFilter } from '../../../hooks/pay/useWithdrawTokenFilter'; @@ -40,7 +42,22 @@ import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaym import { usePredictBalanceTokenFilter } from '../../../../../UI/Predict/hooks/usePredictBalanceTokenFilter'; import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +interface PayWithModalParams { + /** + * When > 1, PayWithModal owns navigation on close by dispatching + * `StackActions.pop(N)` atomically instead of relying on the legacy + * `BottomSheet`'s built-in `navigation.goBack()`. Set to 2 by the new Pay + * With bottom sheet's "Other assets" launcher so picking a token pops both + * this modal AND the bottom sheet underneath in a single navigator + * dispatch — avoids the Android view-hierarchy race that crashes with + * `IllegalStateException` on two adjacent pops. + */ + dismissOnSelectCount?: number; +} + export function PayWithModal() { + const navigation = useNavigation(); + const { dismissOnSelectCount = 1 } = useParams({}); const transactionMeta = useTransactionMetadataRequest(); const hideNetworkFilter = hasTransactionType( transactionMeta, @@ -51,13 +68,6 @@ export function PayWithModal() { const requiredTokens = useTransactionPayRequiredTokens(); const fiatPayment = useTransactionPayFiatPayment(); const fiatHighlightedActions = useFiatPaymentHighlightedActions(); - /** - * Suppress fiat highlighted items in the modal when the new Pay With - * bottom sheet is enabled. In that mode the Bank/Card section is the single - * source of truth for fiat payment methods, while this modal continues to - * serve as the crypto/tokens picker via the "Other assets" entry point. - * Remove this gate at CONF-1313 GA along with the env util. - */ const effectiveFiatHighlightedActions = useMemo( () => (isPayWithBottomSheetEnabled() ? [] : fiatHighlightedActions), [fiatHighlightedActions], @@ -89,6 +99,14 @@ export function PayWithModal() { bottomSheetRef.current?.onCloseBottomSheet(onClosed); }, []); + const handleClose = useCallback(() => { + if (dismissOnSelectCount > 1) { + close(() => navigation.goBack()); + } else { + close(); + } + }, [close, dismissOnSelectCount, navigation]); + const wrapHighlightedItemCallbacks = useCallback( (items: TokenListItem[]): TokenListItem[] => items.map((item) => { @@ -114,6 +132,10 @@ export function PayWithModal() { const handleTokenSelect = useCallback( (token: AssetType) => { const onClosed = async () => { + if (dismissOnSelectCount > 1) { + navigation.dispatch(StackActions.pop(dismissOnSelectCount)); + } + if ( hasTransactionType(transactionMeta, [TransactionType.musdConversion]) ) { @@ -171,8 +193,10 @@ export function PayWithModal() { }, [ close, + dismissOnSelectCount, isPredictContext, isWithdraw, + navigation, onMusdPaymentTokenChange, onPerpsPaymentTokenChange, onPredictPaymentTokenChange, @@ -245,13 +269,9 @@ export function PayWithModal() { isFullscreen ref={bottomSheetRef} keyboardAvoidingViewEnabled={false} + shouldNavigateBack={dismissOnSelectCount <= 1} > - close()} - /> + ({ jest.mock('../../../../../../util/navigation/navUtils'); jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); jest.mock('../../transactions/useTransactionMetadataRequest'); +jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); jest.mock('../useLastUsedPaymentMethod'); jest.mock('../usePayWithPreferredToken'); jest.mock('../usePayWithSelectedToken'); @@ -71,6 +75,8 @@ describe('usePayWithCryptoSection', () => { const usePayWithPreferredTokenMock = jest.mocked(usePayWithPreferredToken); const usePayWithSelectedTokenMock = jest.mocked(usePayWithSelectedToken); const useLastUsedPaymentMethodMock = jest.mocked(useLastUsedPaymentMethod); + const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); const useTransactionPayFiatPaymentMock = jest.mocked( useTransactionPayFiatPayment, ); @@ -79,6 +85,7 @@ describe('usePayWithCryptoSection', () => { const goBackMock = jest.fn(); const selectTokenMock = jest.fn(); const setPayTokenMock = jest.fn(); + const onPerpsPaymentTokenChangeMock = jest.fn(); const isLastUsedMock = jest.fn().mockReturnValue(false); beforeEach(() => { @@ -87,6 +94,7 @@ describe('usePayWithCryptoSection', () => { useNavigationMock.mockReturnValue({ navigate: navigateMock, goBack: goBackMock, + isFocused: jest.fn().mockReturnValue(true), } as never); useParamsMock.mockReturnValue({}); useTransactionMetadataRequestMock.mockReturnValue(undefined); @@ -111,6 +119,10 @@ describe('usePayWithCryptoSection', () => { lastUsedToken: undefined, isLastUsed: isLastUsedMock, }); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPerpsPaymentTokenChangeMock, + }); useTransactionPayFiatPaymentMock.mockReturnValue(undefined); useTransactionPayTokenMock.mockReturnValue({ payToken: TOKEN_MOCK, @@ -257,6 +269,42 @@ describe('usePayWithCryptoSection', () => { expect(result.current?.rows[1].icon).toEqual(expect.any(Object)); }); + it('does not mark the preferred token row as selected on perpsDepositAndOrder flows when Perps balance is the implicit default', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'crypto-preferred-token', + isSelected: false, + trailingElement: 'none', + }), + ); + }); + + it('still marks the preferred token row as selected on perpsDepositAndOrder flows when the user explicitly picked the preferred token via "Other assets"', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'crypto-preferred-token', + isSelected: true, + trailingElement: 'checkmark', + }), + ); + }); + it('does not select the preferred token row when another token is selected', () => { const distinctSelectedToken = { ...TOKEN_MOCK, @@ -399,6 +447,7 @@ describe('usePayWithCryptoSection', () => { address: TOKEN_MOCK.address, chainId: TOKEN_MOCK.chainId, }); + expect(onPerpsPaymentTokenChangeMock).not.toHaveBeenCalled(); expect(goBackMock).toHaveBeenCalledTimes(1); }); @@ -434,6 +483,56 @@ describe('usePayWithCryptoSection', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + it('routes the preferred-row tap through onPerpsPaymentTokenChange on perpsDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPerpsPaymentTokenChangeMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(setPayTokenMock).not.toHaveBeenCalled(); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('hides the user-selected token row when Perps balance is the implicit default on perpsDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + const distinctSelectedToken = { + ...TOKEN_MOCK, + address: SELECTED_TOKEN_MOCK.address, + symbol: SELECTED_TOKEN_MOCK.symbol, + }; + usePayWithPreferredTokenMock.mockReturnValue({ + hasTokens: true, + preferredToken: TOKEN_MOCK, + selectedToken: distinctSelectedToken, + }); + usePayWithSelectedTokenMock.mockReturnValue({ + isSelectedDistinctFromAutomatic: true, + selectedToken: SELECTED_TOKEN_MOCK, + selectToken: selectTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const selectedRow = result.current?.rows.find( + (row) => row.id === 'crypto-selected-token', + ); + + expect(selectedRow).toBeUndefined(); + }); + it('does not assign a tap handler to the user-selected token row', () => { const distinctSelectedToken = { ...TOKEN_MOCK, @@ -469,6 +568,7 @@ describe('usePayWithCryptoSection', () => { expect(navigateMock).toHaveBeenCalledWith( Routes.CONFIRMATION_PAY_WITH_MODAL, + { dismissOnSelectCount: 2 }, ); }); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts index 176ee227457..598176271c0 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; import { BigNumber } from 'bignumber.js'; +import { TransactionType } from '@metamask/transaction-controller'; import { Icon, IconColor, @@ -16,6 +17,8 @@ import { PayWithRowConfig, PayWithSectionConfig, } from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { hasTransactionType } from '../../../utils/transaction'; import { isMatchingPayToken, resolvePreferredPayToken, @@ -26,6 +29,7 @@ import { usePayWithPreferredToken } from '../usePayWithPreferredToken'; import { usePayWithSelectedToken } from '../usePayWithSelectedToken'; import { useTransactionPayFiatPayment } from '../useTransactionPayData'; import { useTransactionPayToken } from '../useTransactionPayToken'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; interface PayWithCryptoSectionParams { @@ -63,24 +67,47 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { selectedToken: selectedTokenDisplay, } = usePayWithSelectedToken({ preferredToken: resolvedPreferredToken }); const { setPayToken } = useTransactionPayToken(); + const { onPaymentTokenChange: onPerpsPaymentTokenChange } = + usePerpsPaymentToken(); const { isLastUsed } = useLastUsedPaymentMethod(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + const isPerpsBalanceImplicitlySelected = + isPerpsDepositAndOrder && isPerpsBalanceSelected; const fiatPayment = useTransactionPayFiatPayment(); const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); + const isDedicatedSectionOwningSelection = + isPerpsBalanceImplicitlySelected || hasFiatPaymentSelected; const handleOtherAssetsPress = useCallback(() => { - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { + dismissOnSelectCount: 2, + }); }, [navigation]); const handlePreferredTokenPress = useCallback(() => { if (!preferredToken) { return; } - setPayToken({ + const target = { address: preferredToken.address, chainId: preferredToken.chainId, - }); + }; + if (isPerpsDepositAndOrder) { + onPerpsPaymentTokenChange(target); + } else { + setPayToken(target); + } navigation.goBack(); - }, [navigation, preferredToken, setPayToken]); + }, [ + isPerpsDepositAndOrder, + navigation, + onPerpsPaymentTokenChange, + preferredToken, + setPayToken, + ]); const preferredTokenBalance = useMemo( () => formatFiat(new BigNumber(preferredToken?.balanceUsd ?? '0')), @@ -100,8 +127,17 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { const rows: PayWithRowConfig[] = []; if (preferredToken) { + // When a dedicated section "owns" the selection (Perps balance is the + // implicit default in a perpsDepositAndOrder flow, OR a fiat payment + // method has been picked), the Crypto section's preferred-token row must + // not render a misleading checkmark, and the user-selected-token row is + // hidden below. When the user explicitly picks a crypto token via "Other + // assets" in a perps flow, `PerpsController` also stores it as + // `selectedPaymentToken`, and we honor that selection with a checkmark + // (handled by `isPerpsBalanceImplicitlySelected` being false in that + // case). const isPreferredTokenSelected = - !hasFiatPaymentSelected && + !isDedicatedSectionOwningSelection && isMatchingPayToken(selectedToken, preferredToken); rows.push({ @@ -126,7 +162,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { if ( isSelectedDistinctFromAutomatic && selectedTokenDisplay && - !hasFiatPaymentSelected + !isDedicatedSectionOwningSelection ) { rows.push({ id: 'crypto-selected-token', @@ -174,8 +210,8 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }, [ handleOtherAssetsPress, handlePreferredTokenPress, - hasFiatPaymentSelected, hasTokens, + isDedicatedSectionOwningSelection, isLastUsed, isSelectedDistinctFromAutomatic, preferredToken, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx new file mode 100644 index 00000000000..d72f2ae0e41 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx @@ -0,0 +1,243 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useNavigation } from '@react-navigation/native'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../../../constants/navigation/Routes'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perpsController'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { usePayWithPerpsSection } from './usePayWithPerpsSection'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { balance?: string }) => { + const translations: Record = { + 'confirm.pay_with_bottom_sheet.perps': 'Perps', + 'confirm.pay_with_bottom_sheet.perps_account': 'Perps account', + 'confirm.pay_with_bottom_sheet.add': 'Add', + 'confirm.pay_with_bottom_sheet.available_balance': `${ + params?.balance ?? '' + } available`, + }; + return translations[key] ?? key; + }, +})); +jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); +jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsTrading'); +jest.mock('../../useApprovalRequest'); +jest.mock('../../transactions/useTransactionMetadataRequest'); + +describe('usePayWithPerpsSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useNavigationMock = jest.mocked(useNavigation); + const useFiatFormatterMock = jest.mocked(useFiatFormatter); + const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePerpsTradingMock = jest.mocked(usePerpsTrading); + const useApprovalRequestMock = jest.mocked(useApprovalRequest); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + + const navigateMock = jest.fn(); + const goBackMock = jest.fn(); + const onPaymentTokenChangeMock = jest.fn(); + const depositWithConfirmationMock = jest.fn(); + const onRejectMock = jest.fn(); + const formatFiatMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + formatFiatMock.mockImplementation( + (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + ); + + useNavigationMock.mockReturnValue({ + navigate: navigateMock, + goBack: goBackMock, + } as never); + + useFiatFormatterMock.mockReturnValue(formatFiatMock as never); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + + useSelectorMock.mockImplementation((selector) => { + if (selector === selectPerpsAccountState) { + return { spendableBalance: '500' }; + } + return undefined; + }); + + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + } as never); + + usePerpsTradingMock.mockReturnValue({ + depositWithConfirmation: depositWithConfirmationMock.mockResolvedValue({ + result: Promise.resolve('ok'), + }), + } as never); + + useApprovalRequestMock.mockReturnValue({ + onReject: onRejectMock, + } as never); + }); + + it('returns null when the transaction type is not perpsDepositAndOrder', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.perpsDeposit, + txParams: {}, + } as never); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toBeNull(); + }); + + it('returns null when there is no transaction metadata', () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toBeNull(); + }); + + it('returns the perps section config with a single perps account row when the transaction type is perpsDepositAndOrder', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toEqual( + expect.objectContaining({ + id: 'perps', + title: 'Perps', + testID: 'pay-with-section-perps', + }), + ); + expect(result.current?.rows).toHaveLength(1); + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'perps-balance', + title: 'Perps account', + subtitle: '$500.00 available', + isSelected: true, + testID: 'pay-with-perps-section-balance-row', + }), + ); + }); + + it('marks the row as selected when perps balance is the active payment method', () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: true, + trailingElement: expect.any(Object), + }), + ); + }); + + it('marks the row as not selected when a crypto token is chosen instead', () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: false, + trailingElement: expect.any(Object), + }), + ); + }); + + it('treats a missing spendable balance as zero', () => { + useSelectorMock.mockImplementation((selector) => { + if (selector === selectPerpsAccountState) { + return null; + } + return undefined; + }); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0].subtitle).toBe('$0.00 available'); + }); + + it('selects perps balance as payment token and dismisses the sheet when the row is pressed', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPaymentTokenChangeMock).toHaveBeenCalledWith(null); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('rejects approval, triggers deposit confirmation, and navigates with perps header when Add is pressed', async () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => Promise } } + | undefined; + + await act(async () => { + await trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(depositWithConfirmationMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + }); + + it('does not navigate when deposit confirmation rejects', async () => { + depositWithConfirmationMock.mockRejectedValueOnce(new Error('user-cancel')); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => Promise } } + | undefined; + + await act(async () => { + await trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(depositWithConfirmationMock).toHaveBeenCalledTimes(1); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('keeps the result reference stable across renders when nothing changes', () => { + const { result, rerender } = renderHook(() => usePayWithPerpsSection()); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx new file mode 100644 index 00000000000..27e59426ab0 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useMemo } from 'react'; +import { Image } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../locales/i18n'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perpsController'; +import { PERPS_BALANCE_ICON_URI } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { + PayWithRowConfig, + PayWithSectionConfig, +} from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { hasTransactionType } from '../../../utils/transaction'; + +export const PAY_WITH_PERPS_SECTION_TEST_ID = 'pay-with-section-perps'; +export const PAY_WITH_PERPS_BALANCE_ROW_TEST_ID = + 'pay-with-perps-section-balance-row'; + +export function usePayWithPerpsSection(): PayWithSectionConfig | null { + const navigation = useNavigation(); + const transactionMeta = useTransactionMetadataRequest(); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + const perpsAccount = useSelector(selectPerpsAccountState); + const { onPaymentTokenChange } = usePerpsPaymentToken(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const { depositWithConfirmation } = usePerpsTrading(); + const { onReject } = useApprovalRequest(); + + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + + const balance = useMemo( + () => formatFiat(new BigNumber(perpsAccount?.spendableBalance ?? '0')), + [formatFiat, perpsAccount?.spendableBalance], + ); + + const handleSelect = useCallback(() => { + onPaymentTokenChange(null); + navigation.goBack(); + }, [navigation, onPaymentTokenChange]); + + const handleAdd = useCallback(async () => { + onReject(); + try { + await depositWithConfirmation(); + navigation.navigate( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + } catch { + // Deposit flow handles errors (e.g. user rejection or missing network). + } + }, [depositWithConfirmation, navigation, onReject]); + + return useMemo(() => { + if (!isPerpsDepositAndOrder) { + return null; + } + + const row: PayWithRowConfig = { + id: 'perps-balance', + icon: React.createElement(Image, { + source: { uri: PERPS_BALANCE_ICON_URI }, + style: { width: 24, height: 24 }, + }), + title: strings('confirm.pay_with_bottom_sheet.perps_account'), + subtitle: strings('confirm.pay_with_bottom_sheet.available_balance', { + balance, + }), + isSelected: isPerpsBalanceSelected, + trailingElement: ( + + ), + onPress: handleSelect, + testID: PAY_WITH_PERPS_BALANCE_ROW_TEST_ID, + }; + + return { + id: 'perps', + title: strings('confirm.pay_with_bottom_sheet.perps'), + testID: PAY_WITH_PERPS_SECTION_TEST_ID, + rows: [row], + }; + }, [ + balance, + handleAdd, + handleSelect, + isPerpsBalanceSelected, + isPerpsDepositAndOrder, + ]); +} diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts index 82205a1406e..8534c17efde 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts @@ -41,15 +41,18 @@ describe('useDismissOnPaymentChange', () => { useTransactionPayFiatPayment, ); const goBackMock = jest.fn(); + const isFocusedMock = jest.fn().mockReturnValue(true); const setPayTokenMock: jest.MockedFn< ReturnType['setPayToken'] > = jest.fn(); beforeEach(() => { jest.resetAllMocks(); + isFocusedMock.mockReturnValue(true); useNavigationMock.mockReturnValue({ goBack: goBackMock, + isFocused: isFocusedMock, } as never); useTransactionPayTokenMock.mockReturnValue({ @@ -268,4 +271,40 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); }); + + describe('focus guard (defers dismissal when an overlapping route is on top)', () => { + it('does not call goBack when the screen is not focused even if the pay token changes', () => { + isFocusedMock.mockReturnValue(false); + + const { rerender } = renderHook(() => useDismissOnPaymentChange()); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); + + it('latches when defeating an unfocused change, so it does not re-fire after re-focus', () => { + isFocusedMock.mockReturnValue(false); + + const { rerender } = renderHook(() => useDismissOnPaymentChange()); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + isFocusedMock.mockReturnValue(true); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts index 474bfe81974..09393fa2f4e 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts @@ -46,6 +46,11 @@ export function useDismissOnPaymentChange(): void { return; } + if (!navigation.isFocused()) { + isDismissingRef.current = true; + return; + } + isDismissingRef.current = true; navigation.goBack(); }, [navigation, payToken, selectedPaymentMethodId]); diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts index eb95e992cd1..e537d2c79b1 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -2,10 +2,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { PayWithSectionConfig } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; import { usePayWithFiatSection } from './sections/usePayWithFiatSection'; +import { usePayWithPerpsSection } from './sections/usePayWithPerpsSection'; import { usePayWithSections } from './usePayWithSections'; jest.mock('./sections/usePayWithCryptoSection'); jest.mock('./sections/usePayWithFiatSection'); +jest.mock('./sections/usePayWithPerpsSection'); const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { id: 'crypto', @@ -19,6 +21,18 @@ const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { ], }; +const PERPS_SECTION_MOCK: PayWithSectionConfig = { + id: 'perps', + title: 'Perps', + rows: [ + { + id: 'perps-balance', + icon: 'Perps', + title: 'Perps account', + }, + ], +}; + const BANK_CARD_SECTION_MOCK: PayWithSectionConfig = { id: 'bank-card', title: 'Bank and card', @@ -34,12 +48,14 @@ const BANK_CARD_SECTION_MOCK: PayWithSectionConfig = { describe('usePayWithSections', () => { const usePayWithCryptoSectionMock = jest.mocked(usePayWithCryptoSection); const usePayWithFiatSectionMock = jest.mocked(usePayWithFiatSection); + const usePayWithPerpsSectionMock = jest.mocked(usePayWithPerpsSection); beforeEach(() => { jest.resetAllMocks(); usePayWithCryptoSectionMock.mockReturnValue(null); usePayWithFiatSectionMock.mockReturnValue(null); + usePayWithPerpsSectionMock.mockReturnValue(null); }); it('returns empty sections array when no section is visible', () => { @@ -56,6 +72,14 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([CRYPTO_SECTION_MOCK]); }); + it('returns the visible perps section', () => { + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([PERPS_SECTION_MOCK]); + }); + it('returns the visible bank-card section when only bank-card is available', () => { usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -64,6 +88,18 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([BANK_CARD_SECTION_MOCK]); }); + it('returns perps section before crypto section when both are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + it('renders bank-card before crypto when both sections are visible', () => { usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -76,6 +112,20 @@ describe('usePayWithSections', () => { ]); }); + it('renders perps, bank-card, then crypto when all three sections are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + BANK_CARD_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + it('returns the same sections reference across renders', () => { const { result, rerender } = renderHook(() => usePayWithSections()); const first = result.current.sections; diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts index d8dac8eef97..8c83cf43648 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts @@ -1,20 +1,27 @@ import { useMemo } from 'react'; import { PayWithSectionConfig } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; -import { usePayWithCryptoSection, usePayWithFiatSection } from './sections'; +import { + usePayWithCryptoSection, + usePayWithFiatSection, + usePayWithPerpsSection, +} from './sections'; export interface UsePayWithSectionsResult { sections: PayWithSectionConfig[]; } export function usePayWithSections(): UsePayWithSectionsResult { + const perpsSection = usePayWithPerpsSection(); const bankCardSection = usePayWithFiatSection(); const cryptoSection = usePayWithCryptoSection(); return useMemo( () => ({ - sections: [bankCardSection, cryptoSection].filter(isPayWithSectionConfig), + sections: [perpsSection, bankCardSection, cryptoSection].filter( + isPayWithSectionConfig, + ), }), - [bankCardSection, cryptoSection], + [bankCardSection, cryptoSection, perpsSection], ); } diff --git a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts index 53e4f433cf7..cc0ff82b4d9 100644 --- a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts +++ b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.test.ts @@ -72,7 +72,7 @@ describe('useAddPopularNetwork', () => { build: jest.fn().mockReturnValue({ event: 'test' }), }), }), - addTraitsToUser: mockAddTraitsToUser, + identify: mockAddTraitsToUser, }); (useNetworkEnablement as jest.Mock).mockReturnValue({ enableNetwork: mockEnableNetwork, diff --git a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts index 429d97fbdce..7ccf9fad97c 100644 --- a/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts +++ b/app/components/hooks/useAddPopularNetwork/useAddPopularNetwork.ts @@ -36,7 +36,7 @@ interface UseAddPopularNetworkResult { */ export const useAddPopularNetwork = (): UseAddPopularNetworkResult => { const dispatch = useDispatch(); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const networkConfigurationByChainId = useSelector( selectEvmNetworkConfigurationsByChainId, ); @@ -116,7 +116,7 @@ export const useAddPopularNetwork = (): UseAddPopularNetworkResult => { ], }); - addTraitsToUser(addItemToChainIdList(hexChainId)); + identify(addItemToChainIdList(hexChainId)); networkClientId = addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] @@ -138,7 +138,7 @@ export const useAddPopularNetwork = (): UseAddPopularNetworkResult => { networkConfigurationByChainId, trackEvent, createEventBuilder, - addTraitsToUser, + identify, enableNetwork, dispatch, ], diff --git a/app/controllers/perps/PerpsController-method-action-types.ts b/app/controllers/perps/PerpsController-method-action-types.ts index 3e7ee2f260e..cdc4c733a41 100644 --- a/app/controllers/perps/PerpsController-method-action-types.ts +++ b/app/controllers/perps/PerpsController-method-action-types.ts @@ -895,6 +895,26 @@ export type PerpsControllerSaveMarketFilterPreferencesAction = { handler: PerpsController['saveMarketFilterPreferences']; }; +/** + * Get the user's max slippage tolerance in basis points. + * + * @returns The configured max slippage bps, or undefined if never set (callers should default to 300 bps / 3%). + */ +export type PerpsControllerGetMaxSlippageAction = { + type: `PerpsController:getMaxSlippage`; + handler: PerpsController['getMaxSlippage']; +}; + +/** + * Set the user's max slippage tolerance in basis points. + * + * @param bps - Max slippage in basis points (e.g. 300 = 3%). Clamped to 10–1000, snapped to step of 10. + */ +export type PerpsControllerSetMaxSlippageAction = { + type: `PerpsController:setMaxSlippage`; + handler: PerpsController['setMaxSlippage']; +}; + /** * Set the selected payment token for the Perps order/deposit flow. * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. @@ -1060,6 +1080,8 @@ export type PerpsControllerMethodActions = | PerpsControllerClearPendingTradeConfigurationAction | PerpsControllerGetMarketFilterPreferencesAction | PerpsControllerSaveMarketFilterPreferencesAction + | PerpsControllerGetMaxSlippageAction + | PerpsControllerSetMaxSlippageAction | PerpsControllerSetSelectedPaymentTokenAction | PerpsControllerResetSelectedPaymentTokenAction | PerpsControllerGetOrderBookGroupingAction diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts index d1313036d24..9ead43fc73c 100644 --- a/app/controllers/perps/PerpsController.test.ts +++ b/app/controllers/perps/PerpsController.test.ts @@ -5131,6 +5131,90 @@ describe('PerpsController', () => { expect(() => controller.stopMarketDataPreload()).not.toThrow(); }); + it('clears stale user data cache when the selected account changes', () => { + const selectedAddress = '0x2222222222222222222222222222222222222222'; + const staleAddress = '0x1111111111111111111111111111111111111111'; + const selectedAccount = { + address: selectedAddress, + type: 'eip155:eoa', + id: 'account-2', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Selected', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }; + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [selectedAccount]; + } + return undefined; + }); + const mockMessenger = createMockMessenger({ call: mockCall }); + const localInfrastructure = createMockInfrastructure(); + const localController = new TestablePerpsController({ + messenger: mockMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: localInfrastructure, + }); + localController.testMarkInitialized(); + localController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + localController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [], + orders: [], + accountState: null, + timestamp: Date.now(), + address: staleAddress, + }; + }); + + localController.startMarketDataPreload(); + + const selectedAccountChangeHandler = ( + mockMessenger.subscribe as jest.Mock + ).mock.calls.find( + ([event]) => event === 'AccountsController:selectedAccountChange', + )?.[1] as (() => void) | undefined; + const accountGroupChangeHandler = ( + mockMessenger.subscribe as jest.Mock + ).mock.calls.find( + ([event]) => + event === 'AccountTreeController:selectedAccountGroupChange', + )?.[1] as (() => void) | undefined; + + expect(selectedAccountChangeHandler).toEqual(expect.any(Function)); + expect(accountGroupChangeHandler).toBe(selectedAccountChangeHandler); + + selectedAccountChangeHandler?.(); + + expect(localController.state.cachedUserDataByProvider).toEqual({}); + expect(localInfrastructure.diskCache.removeItem).toHaveBeenCalledWith( + PERPS_DISK_CACHE_USER_DATA, + ); + + localController.stopMarketDataPreload(); + + expect(mockMessenger.unsubscribe).toHaveBeenCalledWith( + 'AccountsController:selectedAccountChange', + selectedAccountChangeHandler, + ); + expect(mockMessenger.unsubscribe).toHaveBeenCalledWith( + 'AccountTreeController:selectedAccountGroupChange', + selectedAccountChangeHandler, + ); + }); + it('hydrates market data from disk at construction time', () => { const diskMarkets = { providerNetworkKey: 'hyperliquid:mainnet', diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 92c2d4d2b60..22d90aabd8b 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -17,6 +17,7 @@ import { } from './constants/eventNames'; import { USDC_SYMBOL } from './constants/hyperLiquidConfig'; import { PerpsMeasurementName } from './constants/performanceMetrics'; +import type { SortOptionId } from './constants/perpsConfig'; import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, @@ -24,8 +25,8 @@ import { PERPS_DISK_CACHE_MARKETS, PERPS_DISK_CACHE_USER_DATA, buildProviderCacheKey, + MAX_SLIPPAGE_BOUNDS, } from './constants/perpsConfig'; -import type { SortOptionId } from './constants/perpsConfig'; import type { PerpsControllerMethodActions } from './PerpsController-method-action-types'; import { PERPS_ERROR_CODES } from './perpsErrorCodes'; import { AggregatedPerpsProvider } from './providers/AggregatedPerpsProvider'; @@ -120,7 +121,7 @@ import { LastTransactionResult, TransactionStatus, } from './types/transactionTypes'; -import { getSelectedEvmAccount } from './utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils'; import { ensureError } from './utils/errorUtils'; import { hydrateFromDiskSync, @@ -343,6 +344,9 @@ export type PerpsControllerState = { }; }; + // Max slippage tolerance in basis points (e.g. 300 = 3%). Global user preference. + maxSlippageBps?: number; + // Market filter preferences (network-independent) - includes both sorting and filtering options marketFilterPreferences: { optionId: SortOptionId; @@ -590,6 +594,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + maxSlippageBps: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, marketFilterPreferences: { includeInStateLogs: true, persist: true, @@ -739,6 +749,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshEligibility', 'resetFirstTimeUserState', 'resetSelectedPaymentToken', + 'getMaxSlippage', + 'setMaxSlippage', 'saveMarketFilterPreferences', 'saveOrderBookGrouping', 'savePendingTradeConfiguration', @@ -1155,11 +1167,7 @@ export class PerpsController extends BaseController< // Get current user address for validation let currentAddress: string | null = null; try { - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); currentAddress = evmAccount?.address ?? null; } catch { // Can't determine current account — trust the cache @@ -2216,11 +2224,7 @@ export class PerpsController extends BaseController< currentDepositId = depositId; // Get current account address via messenger (outside of update() for proper typing) - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const accountAddress = evmAccount?.address ?? 'unknown'; this.update((state) => { @@ -3089,13 +3093,9 @@ export class PerpsController extends BaseController< this.messenger.unsubscribe('PerpsController:stateChange', handler); }; - // Watch for account changes via AccountTreeController + // Watch for selected account changes and selected account group changes. const accountChangeHandler = (): void => { - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const currentAddress = evmAccount?.address ?? null; // If any cached entry belongs to a different account, clear all entries. @@ -3128,11 +3128,19 @@ export class PerpsController extends BaseController< } } }; + this.messenger.subscribe( + 'AccountsController:selectedAccountChange', + accountChangeHandler, + ); this.messenger.subscribe( 'AccountTreeController:selectedAccountGroupChange', accountChangeHandler, ); this.#accountChangeUnsubscribe = (): void => { + this.messenger.unsubscribe( + 'AccountsController:selectedAccountChange', + accountChangeHandler, + ); this.messenger.unsubscribe( 'AccountTreeController:selectedAccountGroupChange', accountChangeHandler, @@ -3333,11 +3341,7 @@ export class PerpsController extends BaseController< } // Get current user address - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); if (!evmAccount?.address) { return; } @@ -4816,6 +4820,39 @@ export class PerpsController extends BaseController< }); } + /** + * Get the user's max slippage tolerance in basis points. + * + * @returns The configured max slippage bps, or undefined if never set (callers should default to 300 bps / 3%). + */ + getMaxSlippage(): number | undefined { + return this.state.maxSlippageBps; + } + + /** + * Set the user's max slippage tolerance in basis points. + * + * @param bps - Max slippage in basis points (e.g. 300 = 3%). Clamped to 10–1000, snapped to step of 10. + */ + setMaxSlippage(bps: number): void { + // Reject non-finite input (NaN/Infinity) so it cannot reach the order + // path, where it would poison `getMaxSlippage` and produce a NaN limit + // price. `Math.max(..., NaN)` returns NaN and `??` does not catch it. + if (!Number.isFinite(bps)) { + return; + } + const clamped = Math.min( + MAX_SLIPPAGE_BOUNDS.MaxBps, + Math.max(MAX_SLIPPAGE_BOUNDS.MinBps, bps), + ); + const snapped = + Math.round(clamped / MAX_SLIPPAGE_BOUNDS.StepBps) * + MAX_SLIPPAGE_BOUNDS.StepBps; + this.update((state) => { + state.maxSlippageBps = snapped; + }); + } + /** * Set the selected payment token for the Perps order/deposit flow. * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. diff --git a/app/controllers/perps/constants/eventNames.ts b/app/controllers/perps/constants/eventNames.ts index fae07b1910e..54af92f8eeb 100644 --- a/app/controllers/perps/constants/eventNames.ts +++ b/app/controllers/perps/constants/eventNames.ts @@ -159,6 +159,11 @@ export const PERPS_EVENT_PROPERTY = { INITIAL_PAYMENT_METHOD: 'initial_payment_method', NEW_PAYMENT_METHOD: 'new_payment_method', + // Slippage properties + MAX_SLIPPAGE_PCT: 'max_slippage_pct', + MAX_SLIPPAGE_SOURCE: 'max_slippage_source', + ESTIMATED_SLIPPAGE_PCT: 'estimated_slippage_pct', + // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) ABSTRACTION_MODE: 'abstraction_mode', PREVIOUS_ABSTRACTION_MODE: 'previous_abstraction_mode', @@ -329,6 +334,14 @@ export const PERPS_EVENT_VALUE = { PAYMENT_METHOD_CHANGED: 'payment_method_changed', // Deposit + order (pay-with token) cancel CANCEL_TRADE_WITH_TOKEN: 'cancel_trade_with_token', + // Slippage interactions + SLIPPAGE_CONFIG_OPENED: 'slippage_config_opened', + SLIPPAGE_CONFIG_CHANGED: 'slippage_config_changed', + SLIPPAGE_LIMIT_BLOCKED_ORDER: 'slippage_limit_blocked_order', + }, + MAX_SLIPPAGE_SOURCE: { + DEFAULT: 'default', + USER_CONFIGURED: 'user_configured', }, ACTION_TYPE: { START_TRADING: 'start_trading', @@ -412,6 +425,7 @@ export const PERPS_EVENT_VALUE = { }, SETTING_TYPE: { LEVERAGE: 'leverage', + SLIPPAGE: 'slippage', }, SCREEN_NAME: { CONNECTION_ERROR: 'connection_error', diff --git a/app/controllers/perps/constants/perpsConfig.ts b/app/controllers/perps/constants/perpsConfig.ts index 07e13d1d4aa..bf6471b78ac 100644 --- a/app/controllers/perps/constants/perpsConfig.ts +++ b/app/controllers/perps/constants/perpsConfig.ts @@ -106,6 +106,16 @@ export const ORDER_SLIPPAGE_CONFIG = { DefaultLimitSlippageBps: 100, } as const; +/** + * Bounds and step for the user-configurable max slippage preference (basis points). + * Shared by the controller (`setMaxSlippage`) and UI (`slippageConfig.ts`). + */ +export const MAX_SLIPPAGE_BOUNDS = { + MinBps: 10, + MaxBps: 1000, + StepBps: 10, +} as const; + /** * Max order amount buffer to reduce "Insufficient margin" rejections from the exchange. * When the user selects 100% (slider or Max), we cap the order at (1 - this) of the @@ -135,6 +145,20 @@ export const PERFORMANCE_CONFIG = { // Prevents WS subscription churn during rapid market switching (#28141) CandleConnectDebounceMs: 500, + // Order-form slippage estimate throttle (milliseconds) + // Updates the estimated-slippage value derived from the live L2 order book + // no more than once per window. Aggressive enough to keep the row reactive + // while the user edits the amount, conservative enough to avoid re-render + // pressure on every book tick. + SlippageEstimateThrottleMs: 250, + + // Order-book levels sampled when estimating slippage + // Number of price levels (per side) walked by `calculateEstimatedSlippageBps` + // to fill the requested USD notional. Matches the L2 sample size used by the + // order-book panel and is enough depth for the typical order sizes we + // surface in the order form. + SlippageEstimateBookLevels: 10, + // Candle WS teardown delay (milliseconds) // When the last subscriber for a cacheKey unsubscribes, wait this long before // tearing down the WS. A subsequent subscribe inside the window cancels the diff --git a/app/controllers/perps/index.ts b/app/controllers/perps/index.ts index b0f9f138142..64d331b75b3 100644 --- a/app/controllers/perps/index.ts +++ b/app/controllers/perps/index.ts @@ -409,6 +409,7 @@ export { WITHDRAWAL_CONSTANTS, VALIDATION_THRESHOLDS, ORDER_SLIPPAGE_CONFIG, + MAX_SLIPPAGE_BOUNDS, PERFORMANCE_CONFIG, TP_SL_CONFIG, HYPERLIQUID_ORDER_LIMITS, diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 204bb9e8daf..74fbb053cd3 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -808,7 +808,7 @@ describe('HyperLiquidProvider', () => { isBuy: true, size: '0.1', orderType: 'market', - slippage: 0.02, // 2% slippage + maxSlippageBps: 200, // 2% } as OrderParams, }; @@ -817,11 +817,15 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(true); // Price is fetched from WebSocket cache (getCachedPrice) or REST API (allMids) as fallback - // Verify market orders use FrontendMarket TIF in edit operations + // Verify market orders use FrontendMarket TIF in edit operations, and + // that the user-configured cap (2% via maxSlippageBps: 200) actually + // moves the submitted limit price. BTC mock price is 50000, so a 2% buy + // buffer should produce a price of 51000. expect(mockClientService.getExchangeClient().modify).toHaveBeenCalledWith( expect.objectContaining({ order: expect.objectContaining({ t: { limit: { tif: 'FrontendMarket' } }, + p: expect.stringMatching(/^51000(\.0+)?$/), }), }), ); @@ -3610,13 +3614,55 @@ describe('HyperLiquidProvider', () => { size: '0.1', orderType: 'market', currentPrice: 50000, - slippage: 0.02, // 2% slippage + maxSlippageBps: 200, // 2% }; const result = await provider.placeOrder(orderParams); expect(result.success).toBe(true); - // Should use 2% slippage instead of default 1% + // 50000 * (1 + 0.02) = 51000 — verifies the user-configured cap reaches + // HyperLiquid as the buffered limit price (regression guard for the + // bps wiring fix). + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + p: expect.stringMatching(/^51000(\.0+)?$/), + }), + ], + }), + ); + }); + + it('normalizes the deprecated decimal `slippage` field to bps', async () => { + // Legacy publisher consumers may still call placeOrder with the + // deprecated decimal `slippage`. The provider must normalize it to + // the same submitted limit price as `maxSlippageBps: 200`. + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + slippage: 0.02, // 2% as decimal + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + p: expect.stringMatching(/^51000(\.0+)?$/), + }), + ], + }), + ); }); it('handles filled order response', async () => { diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 83b76c9ee01..ad444dde9d6 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -3757,18 +3757,24 @@ export class HyperLiquidProvider implements PerpsProvider { blocklistMarkets: this.#blocklistMarkets, }); - // 2. Calculate final position size with USD reconciliation + // Normalize the deprecated decimal `slippage` to bps once so both the + // price-staleness check and the limit-price calc see the same value. + const normalizedMaxSlippageBps = + params.maxSlippageBps ?? + (typeof params.slippage === 'number' + ? Math.round(params.slippage * BASIS_POINTS_DIVISOR) + : undefined); + const { finalPositionSize } = calculateFinalPositionSize({ usdAmount: params.usdAmount, size: params.size, currentPrice: effectivePrice, priceAtCalculation: params.priceAtCalculation, - maxSlippageBps: params.maxSlippageBps, + maxSlippageBps: normalizedMaxSlippageBps, szDecimals: assetInfo.szDecimals, leverage: params.leverage, }); - // 3. Calculate order price and formatted size const { orderPrice, formattedSize, formattedPrice } = calculateOrderPriceAndSize({ orderType: params.orderType, @@ -3776,7 +3782,7 @@ export class HyperLiquidProvider implements PerpsProvider { finalPositionSize, currentPrice: effectivePrice, limitPrice: params.price, - slippage: params.slippage, + maxSlippageBps: normalizedMaxSlippageBps, szDecimals: assetInfo.szDecimals, }); @@ -3969,35 +3975,21 @@ export class HyperLiquidProvider implements PerpsProvider { dexName: dexName ?? null, }); - // Calculate order parameters using the same logic as placeOrder - let orderPrice: number; - let formattedSize: string; - - if (params.newOrder.orderType === 'market') { - const positionSize = parseFloat(params.newOrder.size); - const slippage = - params.newOrder.slippage ?? - ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000; - orderPrice = params.newOrder.isBuy - ? currentPrice * (1 + slippage) - : currentPrice * (1 - slippage); - formattedSize = formatHyperLiquidSize({ - size: positionSize, - szDecimals: assetInfo.szDecimals, - }); - } else { - if (!params.newOrder.price) { - throw new Error(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); - } - orderPrice = parseFloat(params.newOrder.price); - formattedSize = formatHyperLiquidSize({ - size: parseFloat(params.newOrder.size), - szDecimals: assetInfo.szDecimals, - }); - } - - const formattedPrice = formatHyperLiquidPrice({ - price: orderPrice, + // Calculate order parameters using the same helper as placeOrder so the + // slippage rules stay in one place (bps → decimal, market-only, default). + // Accept the deprecated decimal `slippage` field too, normalizing to bps. + const normalizedMaxSlippageBps = + params.newOrder.maxSlippageBps ?? + (typeof params.newOrder.slippage === 'number' + ? Math.round(params.newOrder.slippage * BASIS_POINTS_DIVISOR) + : undefined); + const { formattedSize, formattedPrice } = calculateOrderPriceAndSize({ + orderType: params.newOrder.orderType, + isBuy: params.newOrder.isBuy, + finalPositionSize: parseFloat(params.newOrder.size), + currentPrice, + limitPrice: params.newOrder.price, + maxSlippageBps: normalizedMaxSlippageBps, szDecimals: assetInfo.szDecimals, }); const assetId = await this.#getAssetIdWithRepair({ diff --git a/app/controllers/perps/services/AccountService.ts b/app/controllers/perps/services/AccountService.ts index d3073872930..3b4003643ca 100644 --- a/app/controllers/perps/services/AccountService.ts +++ b/app/controllers/perps/services/AccountService.ts @@ -24,7 +24,7 @@ import type { } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { TransactionStatus } from '../types/transactionTypes'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; /** @@ -124,10 +124,8 @@ export class AccountService { const netAmount = Math.max(0, grossAmount - feeAmount); // Get current account address via messenger - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const evmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); const accountAddress = evmAccount?.address ?? 'unknown'; diff --git a/app/controllers/perps/services/DataLakeService.ts b/app/controllers/perps/services/DataLakeService.ts index 8e7035ea57a..a713357e7fb 100644 --- a/app/controllers/perps/services/DataLakeService.ts +++ b/app/controllers/perps/services/DataLakeService.ts @@ -9,7 +9,7 @@ import { import { PerpsTraceNames, PerpsTraceOperations } from '../types'; import type { PerpsPlatformDependencies } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; /** @@ -131,11 +131,7 @@ export class DataLakeService { try { const token = await this.#getBearerToken(); - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount || !token) { this.#deps.debugLogger.log('DataLake API: Missing requirements', { diff --git a/app/controllers/perps/services/DepositService.test.ts b/app/controllers/perps/services/DepositService.test.ts index 7b2dea0bdb1..64bd4182c4c 100644 --- a/app/controllers/perps/services/DepositService.test.ts +++ b/app/controllers/perps/services/DepositService.test.ts @@ -45,6 +45,8 @@ describe('DepositService', () => { const mockAssetId = 'eip155:42161/erc20:0xTokenAddress/default'; beforeEach(() => { + jest.clearAllMocks(); + mockProvider = createMockHyperLiquidProvider() as unknown as jest.Mocked; @@ -87,8 +89,6 @@ describe('DepositService', () => { } return value; }); - - jest.clearAllMocks(); }); afterEach(() => { @@ -114,6 +114,34 @@ describe('DepositService', () => { }); }); + it('uses the selected EVM account as the transaction sender', async () => { + const selectedAccount = { + ...mockEvmAccount, + address: '0x2222222222222222222222222222222222222222', + }; + const groupAccount = { + ...mockEvmAccount, + address: '0x3333333333333333333333333333333333333333', + }; + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [groupAccount]; + } + return undefined; + }); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.from).toBe(selectedAccount.address); + }); + it('generates unique deposit ID for tracking', async () => { await service.prepareTransaction({ provider: mockProvider, diff --git a/app/controllers/perps/services/DepositService.ts b/app/controllers/perps/services/DepositService.ts index 860964db75d..d1feadcf475 100644 --- a/app/controllers/perps/services/DepositService.ts +++ b/app/controllers/perps/services/DepositService.ts @@ -9,7 +9,7 @@ import type { PerpsTransactionParams, } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { generateDepositId } from '../utils/idUtils'; import { generateERC20TransferData } from '../utils/transferData'; @@ -76,12 +76,8 @@ export class DepositService { '0x0', ); - // Get EVM account from selected account group via messenger - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + // Get EVM account from selected account, falling back to the selected account group. + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount) { throw new Error( 'No EVM-compatible account found in selected account group', diff --git a/app/controllers/perps/services/HyperLiquidWalletService.test.ts b/app/controllers/perps/services/HyperLiquidWalletService.test.ts index ab052c0b9b5..89e6ae1264e 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.test.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.test.ts @@ -118,6 +118,60 @@ describe('HyperLiquidWalletService', () => { expect(typeof walletAdapter.getChainId).toBe('function'); }); + it('prefers the selected EVM account over the selected account group', async () => { + const selectedAccount = { + ...mockEvmAccount, + address: '0x2222222222222222222222222222222222222222', + }; + const groupAccount = { + ...mockEvmAccount, + address: '0x3333333333333333333333333333333333333333', + }; + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [groupAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + return undefined; + }); + + const selectedAdapter = service.createWalletAdapter(); + + expect(selectedAdapter.address).toBe(selectedAccount.address); + + await selectedAdapter.signTypedData({ + domain: { + name: 'HyperLiquid', + version: '1', + chainId: 42161, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + from: selectedAccount.address, + }), + 'V4', + ); + }); + describe('getChainId method', () => { it('should return mainnet chain ID', async () => { expect(walletAdapter.getChainId).toBeDefined(); @@ -386,7 +440,7 @@ describe('HyperLiquidWalletService', () => { }); await expect(service.getCurrentAccountId()).rejects.toThrow( - 'Store error', + 'NO_ACCOUNT_SELECTED', ); }); diff --git a/app/controllers/perps/services/HyperLiquidWalletService.ts b/app/controllers/perps/services/HyperLiquidWalletService.ts index 1f8dbcbbf8b..ee6688609db 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.ts @@ -12,7 +12,10 @@ import type { PerpsTypedMessageParams, } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils'; +import { + getSelectedEvmAccountDetailsFromMessenger, + getSelectedEvmAccountFromMessenger, +} from '../utils/accountUtils'; // Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this // service portable between mobile and the core monorepo. @@ -61,10 +64,8 @@ export class HyperLiquidWalletService { * @returns True for MetaMask hardware keyrings; false for software accounts. */ public isSelectedHardwareWallet(): boolean { - const selectedEvmAccount = findEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const selectedEvmAccount = getSelectedEvmAccountDetailsFromMessenger( + this.#messenger, ); if (!selectedEvmAccount || !hasProperty(selectedEvmAccount, 'metadata')) { return false; @@ -122,12 +123,8 @@ export class HyperLiquidWalletService { }) => Promise; getChainId?: () => Promise; } { - // Get current EVM account via DI accountTree - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + // Get current EVM account via DI messenger + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -152,10 +149,8 @@ export class HyperLiquidWalletService { }): Promise => { // Get FRESH account on every sign to handle account switches // This prevents race conditions where wallet adapter was created with old account - const currentEvmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentEvmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentEvmAccount?.address) { @@ -200,11 +195,7 @@ export class HyperLiquidWalletService { * @returns The CAIP account ID for the current EVM account. */ public async getCurrentAccountId(): Promise { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); diff --git a/app/controllers/perps/services/MYXWalletService.test.ts b/app/controllers/perps/services/MYXWalletService.test.ts index a271209a987..50c09d6c748 100644 --- a/app/controllers/perps/services/MYXWalletService.test.ts +++ b/app/controllers/perps/services/MYXWalletService.test.ts @@ -126,6 +126,47 @@ describe('MYXWalletService', () => { expect(address).toBe(mockEvmAccount.address); }); + it('uses the selected EVM account for signer address and signing', async () => { + const selectedAccount = { + ...mockEvmAccount, + address: '0x2222222222222222222222222222222222222222', + }; + const groupAccount = { + ...mockEvmAccount, + address: '0x3333333333333333333333333333333333333333', + }; + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [groupAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + return undefined; + }); + + const signer = service.createEthersSigner(); + + await expect(signer.getAddress()).resolves.toBe(selectedAccount.address); + await signer.signTypedData({ name: 'MYX' }, { Test: [] }, {}); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + from: selectedAccount.address, + }), + 'V4', + ); + }); + it('getAddress() throws when account disappears', async () => { const signer = service.createEthersSigner(); diff --git a/app/controllers/perps/services/MYXWalletService.ts b/app/controllers/perps/services/MYXWalletService.ts index ba3d9b2cd17..a2720fab7f7 100644 --- a/app/controllers/perps/services/MYXWalletService.ts +++ b/app/controllers/perps/services/MYXWalletService.ts @@ -26,7 +26,7 @@ import { import type { PerpsControllerMessenger } from '../PerpsController'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import type { PerpsPlatformDependencies } from '../types'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; export class MYXWalletService { #isTestnet: boolean; @@ -83,21 +83,15 @@ export class MYXWalletService { ) => Promise; provider: null; } { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } return { getAddress: async (): Promise => { - const currentAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -109,10 +103,8 @@ export class MYXWalletService { types: Record, value: Record, ): Promise => { - const currentAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -160,11 +152,7 @@ export class MYXWalletService { message: Record; }) => Promise; } { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } @@ -174,10 +162,8 @@ export class MYXWalletService { account: { address: evmAccount.address }, chain: { id: chainId }, signTypedData: async (args): Promise => { - const currentAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, ); if (!currentAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -207,11 +193,7 @@ export class MYXWalletService { } public getUserAddress(): Hex { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } @@ -223,11 +205,7 @@ export class MYXWalletService { } public async getCurrentAccountId(): Promise { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } diff --git a/app/controllers/perps/services/RewardsIntegrationService.ts b/app/controllers/perps/services/RewardsIntegrationService.ts index a74e231f58b..6717d5da976 100644 --- a/app/controllers/perps/services/RewardsIntegrationService.ts +++ b/app/controllers/perps/services/RewardsIntegrationService.ts @@ -5,7 +5,7 @@ import { import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { PerpsPlatformDependencies } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; @@ -63,11 +63,7 @@ export class RewardsIntegrationService { */ async calculateUserFeeDiscount(): Promise { try { - const evmAccount = getSelectedEvmAccount( - this.#messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount) { this.#deps.debugLogger.log( diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index e18262e518e..9b29cdee65b 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -159,12 +159,18 @@ export type OrderParams = { usdAmount?: string; // USD amount (primary source of truth, provider calculates size from this) priceAtCalculation?: number; // Price snapshot when size was calculated (for slippage validation) maxSlippageBps?: number; // Slippage tolerance in basis points (e.g., 100 = 1%, default if not provided) + /** + * @deprecated Use `maxSlippageBps` instead. Retained for one release so that + * existing publisher consumers (extension, core) that still pass slippage as + * a decimal (e.g. 0.03 for 3%) continue to work; the provider normalizes the + * value to basis points when `maxSlippageBps` is absent. + */ + slippage?: number; // Advanced order features takeProfitPrice?: string; // Take profit price stopLossPrice?: string; // Stop loss price clientOrderId?: string; // Optional client-provided order ID - slippage?: number; // Slippage tolerance for market orders (default: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000 = 3%) grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; // Override grouping (defaults: 'na' without TP/SL, 'normalTpsl' with TP/SL) currentPrice?: number; // Current market price (avoids extra API call if provided) leverage?: number; // Leverage to apply for the order (e.g., 10 for 10x leverage) diff --git a/app/controllers/perps/types/messenger.ts b/app/controllers/perps/types/messenger.ts index d5becee6797..5489121617e 100644 --- a/app/controllers/perps/types/messenger.ts +++ b/app/controllers/perps/types/messenger.ts @@ -2,6 +2,10 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, } from '@metamask/account-tree-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, @@ -32,6 +36,7 @@ export type PerpsControllerAllowedActions = | KeyringControllerSignTypedMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction + | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | AuthenticationController.AuthenticationControllerGetBearerTokenAction; @@ -40,6 +45,7 @@ export type PerpsControllerAllowedActions = */ export type PerpsControllerAllowedEvents = | RemoteFeatureFlagControllerStateChangeEvent + | AccountsControllerSelectedAccountChangeEvent | AccountTreeControllerSelectedAccountGroupChangeEvent; /** diff --git a/app/controllers/perps/utils/accountUtils.test.ts b/app/controllers/perps/utils/accountUtils.test.ts index ba604a1e290..86e3a6d647c 100644 --- a/app/controllers/perps/utils/accountUtils.test.ts +++ b/app/controllers/perps/utils/accountUtils.test.ts @@ -1,3 +1,5 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState } from '../types'; @@ -5,9 +7,128 @@ import { addSpotBalanceToAccountState, aggregateAccountStates, calculateWeightedReturnOnEquity, + getSelectedEvmAccountDetailsFromMessenger, + getSelectedEvmAccountFromMessenger, getSpotBalance, } from './accountUtils'; +type SelectedEvmAccountMessenger = Parameters< + typeof getSelectedEvmAccountFromMessenger +>[0]; + +const SELECTED_ADDRESS = '0x1111111111111111111111111111111111111111'; +const GROUP_ADDRESS = '0x2222222222222222222222222222222222222222'; +const NON_EVM_ADDRESS = 'bc1qselectedaccount'; + +function buildAccount( + address: string, + id: string, + type: InternalAccount['type'] = 'eip155:eoa', +): InternalAccount { + return { + address, + id, + type, + options: {}, + methods: [], + metadata: { + name: id, + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + scopes: ['eip155:0'], + } as InternalAccount; +} + +function buildMessenger( + call: (actionType: string) => InternalAccount | InternalAccount[], +): SelectedEvmAccountMessenger { + return { call }; +} + +describe('getSelectedEvmAccountFromMessenger', () => { + it('returns selected account details when requested', () => { + const selectedAccount = buildAccount(SELECTED_ADDRESS, 'selected'); + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountDetailsFromMessenger(messenger)).toBe( + selectedAccount, + ); + }); + + it('prefers the selected account over the first evm account in the selected group', () => { + const selectedAccount = buildAccount(SELECTED_ADDRESS, 'selected'); + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: SELECTED_ADDRESS, + }); + }); + + it('falls back to the selected account group when selected account lookup is unavailable', () => { + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + throw new Error('Selected account unavailable'); + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: GROUP_ADDRESS, + }); + }); + + it('falls back to the selected account group when the selected account is not evm', () => { + const selectedAccount = buildAccount( + NON_EVM_ADDRESS, + 'selected', + 'bip122:p2wpkh', + ); + const groupedAccount = buildAccount(GROUP_ADDRESS, 'grouped'); + const messenger = buildMessenger((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }); + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: GROUP_ADDRESS, + }); + }); +}); + describe('aggregateAccountStates', () => { const fallback: AccountState = { spendableBalance: PERPS_CONSTANTS.FallbackDataDisplay, diff --git a/app/controllers/perps/utils/accountUtils.ts b/app/controllers/perps/utils/accountUtils.ts index 747cdd7620b..2d50fffd2dc 100644 --- a/app/controllers/perps/utils/accountUtils.ts +++ b/app/controllers/perps/utils/accountUtils.ts @@ -37,6 +37,65 @@ export function getSelectedEvmAccount( return getEvmAccountFromAccountGroup(accounts); } +type SelectedEvmAccountMessenger = { + call( + actionType: + | 'AccountsController:getSelectedAccount' + | 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ): unknown; +}; + +function isAccountLike( + value: unknown, +): value is InternalAccount | PerpsInternalAccount { + const account = value as { address?: unknown; type?: unknown } | null; + + return ( + typeof value === 'object' && + value !== null && + typeof account?.address === 'string' && + typeof account.type === 'string' + ); +} + +export function getSelectedEvmAccountDetailsFromMessenger( + messenger: SelectedEvmAccountMessenger, +): InternalAccount | PerpsInternalAccount | undefined { + try { + const selectedAccount = messenger.call( + 'AccountsController:getSelectedAccount', + ); + if (isAccountLike(selectedAccount)) { + const evmAccount = findEvmAccount([selectedAccount]); + if (evmAccount) { + return evmAccount; + } + } + } catch { + // Fall back to the selected account group if the direct lookup is unavailable. + } + + try { + const selectedAccountGroup = messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + return Array.isArray(selectedAccountGroup) + ? (findEvmAccount(selectedAccountGroup.filter(isAccountLike)) ?? + undefined) + : undefined; + } catch { + return undefined; + } +} + +export function getSelectedEvmAccountFromMessenger( + messenger: SelectedEvmAccountMessenger, +): { address: string } | undefined { + const evmAccount = getSelectedEvmAccountDetailsFromMessenger(messenger); + + return evmAccount ? { address: evmAccount.address } : undefined; +} + export type ReturnOnEquityInput = { unrealizedPnl: string | number; returnOnEquity: string | number; diff --git a/app/controllers/perps/utils/orderCalculations.ts b/app/controllers/perps/utils/orderCalculations.ts index b5e70f85ce4..1073a2def4d 100644 --- a/app/controllers/perps/utils/orderCalculations.ts +++ b/app/controllers/perps/utils/orderCalculations.ts @@ -4,6 +4,7 @@ import { formatHyperLiquidPrice, formatHyperLiquidSize, } from './hyperLiquidAdapter'; +import { BASIS_POINTS_DIVISOR } from '../constants/hyperLiquidConfig'; import { MAX_ORDER_MARGIN_BUFFER, ORDER_SLIPPAGE_CONFIG, @@ -58,7 +59,10 @@ export type CalculateOrderPriceAndSizeParams = { finalPositionSize: number; currentPrice: number; limitPrice?: string; - slippage?: number; + // Max slippage in basis points (e.g. 300 = 3%). Only applied to market orders; + // limit orders use limitPrice directly. Falls back to ORDER_SLIPPAGE_CONFIG + // .DefaultMarketSlippageBps when omitted on a market order. + maxSlippageBps?: number; szDecimals: number; }; @@ -314,7 +318,7 @@ export function calculateOrderPriceAndSize( finalPositionSize, currentPrice, limitPrice, - slippage, + maxSlippageBps, szDecimals, } = params; @@ -322,9 +326,12 @@ export function calculateOrderPriceAndSize( let formattedSize: string; if (orderType === 'market') { - // Market orders: add slippage (3% conservative default) - const slippageValue = - slippage ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000; + // Market orders: apply slippage buffer to the live price so HyperLiquid + // receives a worst-case acceptable limit price. Falls back to the + // documented default if the caller does not provide one. + const effectiveBps = + maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; + const slippageValue = effectiveBps / BASIS_POINTS_DIVISOR; orderPrice = isBuy ? currentPrice * (1 + slippageValue) : currentPrice * (1 - slippageValue); diff --git a/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts b/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts index c853fba6c65..bc7bc10d9b4 100644 --- a/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts +++ b/app/core/Engine/controllers/analytics-controller/platform-adapter.test.ts @@ -1,5 +1,6 @@ -import { createPlatformAdapter } from './platform-adapter'; +import { createPlatformAdapter, normalizeProxyUrl } from './platform-adapter'; import { + createClient, type SegmentClient, DestinationPlugin, } from '@segment/analytics-react-native'; @@ -38,6 +39,145 @@ interface GlobalWithSegmentClient { segmentMockClient: SegmentClient; } +const mockCreateClient = createClient as jest.MockedFunction< + typeof createClient +>; + +// Realistic proxy URL shapes used by MetaMask (base64 write-key in query params). +// The actual values are redacted in .js.env; the structural pattern is: +// https://fn.segmentapis.com/v1/b?b=[=|==] +// DEV keys typically produce single `=` padding; PROD keys often produce `==`. +const DEV_PROXY_URL = + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1kZXYta2V5MTIzNA=='; +const PROD_PROXY_URL = + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1wcm9kLWtleUFCQ0Q='; +const MULTI_PARAM_URL = + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdC1rZXkxMjM=&v=2'; + +describe('normalizeProxyUrl', () => { + it('returns undefined when url is undefined', () => { + const result = normalizeProxyUrl(undefined); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when url is an empty string', () => { + const result = normalizeProxyUrl(''); + + expect(result).toBeUndefined(); + }); + + it('returns the URL unchanged when there is no base64 padding', () => { + const url = 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'; + + const result = normalizeProxyUrl(url); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'); + }); + + it('strips a single trailing = from the last query param', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ=', + ); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'); + }); + + it('strips double trailing == from the last query param', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ==', + ); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ'); + }); + + it('strips trailing = padding from a mid-URL query param followed by another param', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ==&v=2', + ); + + expect(result).toBe('https://fn.segmentapis.com/v1/b?b=dGVzdGtleQ&v=2'); + }); + + it('preserves = key-value separators in query params', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdGtleQ==', + ); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdGtleQ', + ); + }); + + it('strips padding from multiple params that each carry base64 values', () => { + const result = normalizeProxyUrl( + 'https://fn.segmentapis.com/v1/b?a=dGVzdA==&b=dGVzdGtleQ=', + ); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?a=dGVzdA&b=dGVzdGtleQ', + ); + }); + + it('returns the URL unchanged when it has no query string', () => { + const url = 'https://fn.segmentapis.com/v1/b'; + + const result = normalizeProxyUrl(url); + + expect(result).toBe('https://fn.segmentapis.com/v1/b'); + }); + + it('normalises the dev-environment proxy URL (double == padding)', () => { + const result = normalizeProxyUrl(DEV_PROXY_URL); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1kZXYta2V5MTIzNA', + ); + }); + + it('normalises the prod-environment proxy URL (single = padding)', () => { + const result = normalizeProxyUrl(PROD_PROXY_URL); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?b=dGVzdC1wcm9kLWtleUFCQ0Q', + ); + }); + + it('normalises a multi-param proxy URL preserving non-base64 params', () => { + const result = normalizeProxyUrl(MULTI_PARAM_URL); + + expect(result).toBe( + 'https://fn.segmentapis.com/v1/b?region=us-west&b=dGVzdC1rZXkxMjM&v=2', + ); + }); + + it('passes Segment validateURL regex after normalisation for dev URL', () => { + const result = normalizeProxyUrl(DEV_PROXY_URL) as string; + + // The Segment regex allows only [a-zA-Z0-9_.-] in query-param values. + // After normalisation no `=` padding should remain in any param value. + const queryString = result.split('?')[1] ?? ''; + const paramValues = queryString + .split('&') + .map((pair) => pair.split('=')[1]); + paramValues.forEach((value) => { + expect(value).toMatch(/^[a-zA-Z0-9_.-]+$/); + }); + }); + + it('passes Segment validateURL regex after normalisation for prod URL', () => { + const result = normalizeProxyUrl(PROD_PROXY_URL) as string; + + const queryString = result.split('?')[1] ?? ''; + const paramValues = queryString + .split('&') + .map((pair) => pair.split('=')[1]); + paramValues.forEach((value) => { + expect(value).toMatch(/^[a-zA-Z0-9_.-]+$/); + }); + }); +}); + describe('createPlatformAdapter', () => { beforeEach(() => { jest.clearAllMocks(); @@ -169,4 +309,24 @@ describe('createPlatformAdapter', () => { expect(adapter1).not.toBe(adapter2); }); }); + + describe('proxy URL normalisation', () => { + // babel-plugin-transform-inline-environment-variables bakes process.env.* + // at compile time, so env vars cannot be mutated at test runtime. The + // SEGMENT_PROXY_URL value is therefore always undefined in the Jest + // environment, which means createClient receives an undefined proxy. The + // normaliseProxyUrl unit tests above already prove the function handles + // all URL shapes. Here we verify the wiring: createClient receives the + // output of normalizeProxyUrl, whatever that resolves to. + it('passes the output of normalizeProxyUrl as the proxy config to createClient', () => { + mockCreateClient.mockClear(); + + createPlatformAdapter(); + + expect(mockCreateClient).toHaveBeenCalledTimes(1); + const calledConfig = mockCreateClient.mock.calls[0][0]; + // The proxy field is present in the config object (value depends on env). + expect(calledConfig).toHaveProperty('proxy'); + }); + }); }); diff --git a/app/core/Engine/controllers/analytics-controller/platform-adapter.ts b/app/core/Engine/controllers/analytics-controller/platform-adapter.ts index a80eb48da0c..f6135eae590 100644 --- a/app/core/Engine/controllers/analytics-controller/platform-adapter.ts +++ b/app/core/Engine/controllers/analytics-controller/platform-adapter.ts @@ -15,10 +15,31 @@ import { segmentPersistor } from '../../../../util/analytics/SegmentPersistor'; import Logger from '../../../../util/Logger'; import MetaMetricsPrivacySegmentPlugin from '../../../../util/analytics/privacySegmentPlugin'; +/** + * Strips trailing `=` padding from every query-param value in a URL. + * + * @segment/analytics-react-native ≥2.23.0 introduced a strict `validateURL` + * regex that only allows `[a-zA-Z0-9_.-]` in query-param values, which rejects + * the standard base64 `=` padding characters present in the Segment proxy write + * key. Stripping the padding is safe – base64 decoders always infer it from the + * data length, and the proxy server accepts both forms. + * + * TODO: remove once upstream fixes the regex to accept all RFC 3986 query chars. + * See: https://github.com/segmentio/analytics-react-native/pull/1157 + */ +export const normalizeProxyUrl = ( + url: string | undefined, +): string | undefined => { + if (!url) return undefined; + // Replace any run of `=` that is followed by `&` (next param) or end-of-string + // (end of query). This strips base64 padding without touching `=` separators. + return url.replace(/[=]+(?=&|$)/g, ''); +}; + const getSegmentClient = (): SegmentClient => { const config: Config = { writeKey: process.env.SEGMENT_WRITE_KEY as string, - proxy: process.env.SEGMENT_PROXY_URL as string, + proxy: normalizeProxyUrl(process.env.SEGMENT_PROXY_URL), debug: __DEV__, // Use custom persistor to bridge Segment SDK with app's storage system storePersistor: segmentPersistor, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index 1c4d393ddbf..8b2232f8a4c 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -170,6 +170,11 @@ export type RewardsControllerGetOptInStatusAction = { handler: RewardsController['getOptInStatus']; }; +export type RewardsControllerGetVipTierForAccountAction = { + type: `RewardsController:getVipTierForAccount`; + handler: RewardsController['getVipTierForAccount']; +}; + /** * Get perps fee discount for an account. * @@ -825,6 +830,7 @@ export type RewardsControllerMethodActions = | RewardsControllerGetHasAccountOptedInAction | RewardsControllerCheckOptInStatusAgainstCacheAction | RewardsControllerGetOptInStatusAction + | RewardsControllerGetVipTierForAccountAction | RewardsControllerGetPerpsDiscountForAccountAction | RewardsControllerGetPointsEventsAction | RewardsControllerGetPointsEventsIfChangedAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 6adb968bb4e..4296e3b4ee9 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3855,6 +3855,52 @@ describe('RewardsController', () => { }); }); + describe('getVipTierForAccount', () => { + it('returns null when disabled via isDisabled callback', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); + + const result = + await disabledController.getVipTierForAccount(CAIP_ACCOUNT_1); + + expect(result).toBeNull(); + }); + + it('returns null for accounts the controller has never seen (unhydrated)', async () => { + const result = await controller.getVipTierForAccount(CAIP_ACCOUNT_2); + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null when the account has no linked subscription (unhydrated)', async () => { + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: null, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + }, + isDisabled: () => false, + }); + + const result = await controller.getVipTierForAccount(CAIP_ACCOUNT_1); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + }); + describe('isRewardsFeatureEnabled', () => { it('returns true when not disabled', () => { const result = controller.isRewardsFeatureEnabled(); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 83fbf2136e1..d1baa4fcca0 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -491,6 +491,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOptInStatus', 'getPerpsTradingCampaignParticipantOutcome', 'getPerpsDiscountForAccount', + 'getVipTierForAccount', 'getPointsEvents', 'getPointsEventsIfChanged', 'getPointsEventsLastUpdated', @@ -1703,6 +1704,65 @@ export class RewardsController extends BaseController< } } + async #getVipFeesForSubscriptionId( + subscriptionId: string, + ): Promise { + // Deduplicate concurrent fetches: if there's already an in-flight + // request for this subscriptionId, await it instead of firing another. + let inFlight = this.#vipFeesFetchInFlight.get(subscriptionId); + if (!inFlight) { + inFlight = this.#withAuthRetry( + () => + this.messenger.call('RewardsDataService:getVipFees', subscriptionId), + subscriptionId, + ).then((vipFeeResponse): VipFeesResponseDto | 0 => { + // Tier-0 response: backend says no VIP fees — return sentinel 0 + // without caching so the next call re-checks. + if (!vipFeeResponse?.fees || vipFeeResponse.vipTier <= 0) { + return 0; + } + this.update((state) => { + // Promote the subscription's VIP flag so the rest of the app + // reflects the user's VIP status without waiting for a full refresh. + const subState = state.subscriptions[subscriptionId]; + if (subState) { + subState.features = { + ...subState.features, + vip: { enabled: true }, + }; + } + }); + return vipFeeResponse; + }); + this.#vipFeesFetchInFlight.set(subscriptionId, inFlight); + const cleanup = () => this.#vipFeesFetchInFlight.delete(subscriptionId); + inFlight.then(cleanup, cleanup); + } + + const result = await inFlight; + if (result === 0) return 0; + const feeResponse = result as VipFeesResponseDto; + if (!feeResponse.fees) return null; + return feeResponse; + } + + async getVipTierForAccount(account: CaipAccountId): Promise { + const rewardsEnabled = this.isRewardsFeatureEnabled(); + if (!rewardsEnabled) return null; + + const subscriptionId = this.getActualSubscriptionId(account); + if (!subscriptionId) return null; + + const subscription = this.state.subscriptions[subscriptionId]; + if (!subscription) return null; + + const vipFeesResponse = + await this.#getVipFeesForSubscriptionId(subscriptionId); + + if (!vipFeesResponse) return null; + return vipFeesResponse.vipTier; + } + /** * Get perps fee discount for an account. * @@ -1753,8 +1813,7 @@ export class RewardsController extends BaseController< ): Promise { if (!Number.isFinite(baseFeeBips) || baseFeeBips <= 0) return null; - const accountState = this.#getAccountState(account); - const subscriptionId = accountState?.subscriptionId; + const subscriptionId = this.getActualSubscriptionId(account); if (!subscriptionId) return null; const subscription = this.state.subscriptions[subscriptionId]; @@ -1768,57 +1827,26 @@ export class RewardsController extends BaseController< ) { builderFeeBipsRaw = cached.hyperliquidBuilderFeeBips; } else { - // Deduplicate concurrent fetches: if there's already an in-flight - // request for this subscriptionId, await it instead of firing another. - let inFlight = this.#vipFeesFetchInFlight.get(subscriptionId); - if (!inFlight) { - inFlight = this.#withAuthRetry( - () => - this.messenger.call( - 'RewardsDataService:getVipFees', - subscriptionId, - ), - subscriptionId, - ).then((vipFeeResponse): VipFeesResponseDto | 0 => { - // Tier-0 response: backend says no VIP fees — return sentinel 0 - // without caching so the next call re-checks. - if ( - !vipFeeResponse?.fees || - vipFeeResponse.vipTier <= 0 || - !vipFeeResponse.fees.hyperliquid?.builderFeeBips - ) { - return 0; - } - const rawBips = vipFeeResponse.fees.hyperliquid.builderFeeBips; - const next: VipPerpsFeesState = { - hyperliquidBuilderFeeBips: rawBips, - lastFetched: Date.now(), - }; - this.update((state) => { - state.vipPerpsFees[subscriptionId] = next; - // Promote the subscription's VIP flag so the rest of the app - // reflects the user's VIP status without waiting for a full refresh. - const subState = state.subscriptions[subscriptionId]; - if (subState) { - subState.features = { - ...subState.features, - vip: { enabled: true }, - }; - } - }); - return vipFeeResponse; - }); - this.#vipFeesFetchInFlight.set(subscriptionId, inFlight); - const cleanup = () => this.#vipFeesFetchInFlight.delete(subscriptionId); - inFlight.then(cleanup, cleanup); - } - try { - const result = await inFlight; - if (result === 0) return 0; - const feeResponse = result as VipFeesResponseDto; - if (!feeResponse.fees) return null; - builderFeeBipsRaw = feeResponse.fees.hyperliquid.builderFeeBips; + const vipFeeResponse = + await this.#getVipFeesForSubscriptionId(subscriptionId); + if (vipFeeResponse === 0) { + return 0; + } + if (!vipFeeResponse?.fees) { + return null; + } + if (!vipFeeResponse.fees.hyperliquid?.builderFeeBips) { + return 0; + } + builderFeeBipsRaw = vipFeeResponse.fees.hyperliquid.builderFeeBips; + const next: VipPerpsFeesState = { + hyperliquidBuilderFeeBips: builderFeeBipsRaw, + lastFetched: Date.now(), + }; + this.update((state) => { + state.vipPerpsFees[subscriptionId] = next; + }); } catch (error) { Logger.log( 'RewardsController: VIP fees fetch failed; returning no discount:', diff --git a/app/core/Engine/messengers/perps-controller-messenger/index.test.ts b/app/core/Engine/messengers/perps-controller-messenger/index.test.ts index ecadd1fdde8..08b6c159120 100644 --- a/app/core/Engine/messengers/perps-controller-messenger/index.test.ts +++ b/app/core/Engine/messengers/perps-controller-messenger/index.test.ts @@ -1,8 +1,30 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { getPerpsControllerMessenger } from '.'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +interface GetSelectedAccountAction { + type: 'AccountsController:getSelectedAccount'; + handler: () => InternalAccount; +} + describe('PerpsController Messenger', () => { + const selectedAccount: InternalAccount = { + id: 'selected-account-id', + address: '0x1111111111111111111111111111111111111111', + type: 'eip155:eoa' as const, + options: {}, + methods: [], + metadata: { + name: 'Selected Account', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + scopes: ['eip155:1'], + }; + it('returns an instance of the perps controller messenger', () => { const baseControllerMessenger = new ExtendedMessenger({ namespace: MOCK_ANY_NAMESPACE, @@ -25,4 +47,42 @@ describe('PerpsController Messenger', () => { expect(typeof result[method]).toBe('function'); }); }); + + it('delegates the selected account action to the perps controller messenger', () => { + const baseControllerMessenger = new ExtendedMessenger< + MockAnyNamespace, + GetSelectedAccountAction + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + baseControllerMessenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + () => selectedAccount, + ); + + const result = getPerpsControllerMessenger(baseControllerMessenger); + + expect(result.call('AccountsController:getSelectedAccount')).toBe( + selectedAccount, + ); + }); + + it('delegates required events to the perps controller messenger', () => { + const baseControllerMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const delegateSpy = jest.spyOn(baseControllerMessenger, 'delegate'); + + getPerpsControllerMessenger(baseControllerMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ]), + }), + ); + }); }); diff --git a/app/core/Engine/messengers/perps-controller-messenger/index.ts b/app/core/Engine/messengers/perps-controller-messenger/index.ts index 445e2262274..6f426f263dc 100644 --- a/app/core/Engine/messengers/perps-controller-messenger/index.ts +++ b/app/core/Engine/messengers/perps-controller-messenger/index.ts @@ -11,7 +11,8 @@ import { * * PerpsController uses the messenger for all cross-controller communication: * NetworkController, KeyringController, TransactionController, - * RemoteFeatureFlagController, AccountTreeController, AuthenticationController. + * RemoteFeatureFlagController, AccountsController, AccountTreeController, + * AuthenticationController. * The root messenger already registers actions for these controllers, * so the child messenger can call them through the parent. * @@ -40,11 +41,13 @@ export function getPerpsControllerMessenger( 'KeyringController:signTypedMessage', 'TransactionController:addTransaction', 'RemoteFeatureFlagController:getState', + 'AccountsController:getSelectedAccount', 'AccountTreeController:getAccountsFromSelectedAccountGroup', 'AuthenticationController:getBearerToken', ], events: [ 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', 'AccountTreeController:selectedAccountGroupChange', ], messenger, diff --git a/app/images/rewards/metamask-rewards-points-vip.svg b/app/images/rewards/metamask-rewards-points-vip.svg new file mode 100644 index 00000000000..3fbe48f220a --- /dev/null +++ b/app/images/rewards/metamask-rewards-points-vip.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/perps/perps-architecture.md b/docs/perps/perps-architecture.md index 73e4f0d95db..50199742038 100644 --- a/docs/perps/perps-architecture.md +++ b/docs/perps/perps-architecture.md @@ -502,6 +502,13 @@ const prices = useLivePrices({ symbols: allSymbols, throttleMs: 2000 }); // Charts: near real-time (100ms throttle) const prices = useLivePrices({ symbols: ['BTC'], throttleMs: 100 }); + +// Slippage estimator: sub-second so the row reflects the size the user is +// typing. Downstream useMemo keeps per-tick work cheap. +const { orderBook } = usePerpsLiveOrderBook({ + symbol, + throttleMs: PERFORMANCE_CONFIG.SlippageEstimateThrottleMs, +}); ``` 4. **Shared cache** ensures instant data availability for all subscribers diff --git a/docs/perps/perps-review-antipatterns.md b/docs/perps/perps-review-antipatterns.md index ecab07ee152..c12a5257c4e 100644 --- a/docs/perps/perps-review-antipatterns.md +++ b/docs/perps/perps-review-antipatterns.md @@ -68,7 +68,7 @@ All provider access must go through `AggregatedPerpsProvider` → `ProviderRoute Single `PerpsAlwaysOnProvider` at wallet root owns lifecycle. All `PerpsConnectionProvider` instances use `manageLifecycle={false}`. - **New `PerpsConnectionProvider` with lifecycle** — adding a `PerpsConnectionProvider` without `manageLifecycle={false}` creates reference-count bugs. Only `PerpsAlwaysOnProvider` manages connect/disconnect. -- **Unthrottled WS → setState** — every WS tick triggers state update. Must use `useLivePrices` with appropriate `throttleMs` (100ms for charts, 2s for lists, 10s for order forms). +- **Unthrottled WS → setState** — every WS tick triggers state update. Must use `useLivePrices` with appropriate `throttleMs` (100ms for charts, 2s for lists, 10s for order forms). Exception: subscriptions that must react to user form input within the same tick (e.g. the L2 order-book subscription in `usePerpsEstimatedSlippage`) can use a sub-second cadence via `PERFORMANCE_CONFIG.SlippageEstimateThrottleMs`; downstream `useMemo` must keep per-tick work cheap so the faster cadence does not cause render pressure. - **Per-component WS subscription** — creating a new WebSocket connection per component instead of using `PerpsStreamManager` shared subscriptions with reference counting. - **WS subscription leak** — subscribing on mount without unsubscribing on unmount or market switch. `PerpsStreamManager` handles ref counting but custom subscriptions must clean up. - **Stale data after async gap** — reading position/order state, awaiting something, then using the stale read. WS updates change state between awaits. Re-read after async boundaries. diff --git a/locales/languages/en.json b/locales/languages/en.json index 748ebceb013..35d6289839a 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1469,6 +1469,22 @@ "select_token_to_pay_with": "Please select a token to pay with before placing your order", "initializing": "Initializing order..." }, + "slippage": { + "config_title": "Set slippage", + "config_description": "Your transaction won't go through if the price shifts beyond this threshold.", + "set": "Set", + "cancel": "Cancel", + "custom": "Custom", + "use_custom_title": "Use custom slippage", + "row_format": "Est: {{est}}% / Max: {{value}}%", + "row_format_pending": "Est: -- / Max: {{value}}%", + "input_label": "Max slippage percentage", + "decrement_label": "Decrease slippage", + "increment_label": "Increase slippage", + "out_of_range": "Enter a value between {{min}}% and {{max}}%", + "slippage": "Slippage", + "exceeds_max": "Estimated slippage ({{est}}%) exceeds your max slippage ({{max}}%). Increase the cap or reduce the order size." + }, "price_deviation_warning": { "message": "Price has deviated too much from the spot price. New positions cannot be opened at this time." }, @@ -1924,6 +1940,10 @@ "pay_with": { "title": "Pay with", "content": "Choose which token or balance to use to pay for this trade. You can pay with your Perps balance or select another token from your wallet." + }, + "slippage": { + "title": "Estimated slippage", + "content": "Slippage is the difference between the expected price and the price at which your order is filled. Larger orders or low-liquidity markets may have higher slippage." } }, "connection": { @@ -7132,6 +7152,9 @@ "last_used": "Last used", "bank_and_card": "Bank and card", "crypto": "Crypto", + "perps": "Perps", + "perps_account": "Perps account", + "add": "Add", "available_balance": "{{balance}} available", "other_assets": "Other assets", "other_assets_description": "Select from your tokens" @@ -7430,6 +7453,9 @@ "title": "Bridge", "submitting_transaction": "Submitting", "fetching_quote": "Fetching quote", + "fee_includes": "Includes", + "fee_percentage": "{{feePercentage}}%", + "fee_percentage_meta_mask": "{{feePercentage}}% MM fee.", "fee_disclaimer": "Includes {{feePercentage}}% MetaMask fee.", "no_mm_fee": "No MM fee", "token_suspicious": "Suspicious", @@ -8457,6 +8483,7 @@ "main_title": "Rewards", "vip": { "bps_unit": "bps", + "badge_label": "VIP {{tier}}", "swaps_label": "Swaps", "perps_label": "Perps", "tier_benefits_title": "Tier benefits", diff --git a/package.json b/package.json index 38acd0c31e8..a0f876a885a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "coverage:analyze": "node scripts/coverage-analysis.js", "coverage:files": "node scripts/coverage-analysis.js --files", "setup": "yarn clean && node scripts/setup.mjs", - "skills": "bash scripts/skills-sync.sh", + "skills": "tsx scripts/skills-sync.mts", + "postinstall": "tsx scripts/skills-postinstall.mts", "setup:ci": "yarn clean:ios && yarn clean:android && node scripts/setup.mjs", "setup:github-ci": "node scripts/setup.mjs --build-on-github-ci", "eas": "eas", @@ -727,6 +728,7 @@ }, "lavamoat": { "allowScripts": { + "$root$": true, "ethereumjs-abi>ethereumjs-util>ethereum-cryptography>keccak": true, "@sentry/react-native>@sentry/cli": true, "@storybook/manager-webpack5>@storybook/core-common>webpack>watchpack>watchpack-chokidar2>chokidar>fsevents": false, diff --git a/scripts/skills-postinstall.mts b/scripts/skills-postinstall.mts new file mode 100644 index 00000000000..0a2d4537f69 --- /dev/null +++ b/scripts/skills-postinstall.mts @@ -0,0 +1,100 @@ +// Auto-update skills on `yarn install`. Best-effort: never fails the install. +// +// - Skipped on CI, or when SKILLS_SKIP_POSTINSTALL=1. +// - Override CI skip with SKILLS_FORCE_POSTINSTALL=1 (for CI jobs that +// actually need skills installed, e.g. agent-driven review bots). +// - Clones https://github.com/MetaMask/skills (public, no auth) into +// .skills-cache/metamask-skills if absent. +// - `git fetch + reset` to origin/main if present. +// - Leaves installation/domain selection to `yarn skills`, which reads +// .skills.local and SKILLS_DOMAINS. +// - All errors swallowed with a one-line warning. Engineers can run +// `yarn skills` manually for interactive feedback. + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { mkdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +if (process.env.SKILLS_SKIP_POSTINSTALL) { + process.exit(0); +} +if (process.env.CI && !process.env.SKILLS_FORCE_POSTINSTALL) { + process.exit(0); +} + +const CACHE_DIR = '.skills-cache/metamask-skills'; +const PUBLIC_REPO = 'https://github.com/MetaMask/skills.git'; + +function warn(msg: string): void { + process.stderr.write( + `skills postinstall: ${msg} (run \`yarn skills\` for details)\n`, + ); +} + +function run(cmd: string, args: string[]): SpawnSyncReturns { + return spawnSync(cmd, args, { stdio: 'ignore' }); +} + +function isGitDir(dir: string): boolean { + try { + return statSync(path.join(dir, '.git')).isDirectory(); + } catch { + return false; + } +} + +// Top-level guard: synchronous calls (mkdirSync, statSync, existsSync) can +// throw on EPERM, read-only filesystem, or unexpected file-vs-dir conflicts. +// Honor the "never fails the install" contract — swallow anything that +// escapes and exit 0. +try { + if (!isGitDir(CACHE_DIR)) { + mkdirSync(path.dirname(CACHE_DIR), { recursive: true }); + const clone = run('git', [ + 'clone', + '--depth', + '1', + '--branch', + 'main', + PUBLIC_REPO, + CACHE_DIR, + ]); + if (clone.status !== 0) { + warn('clone failed (offline?)'); + process.exit(0); + } + } else { + const fetch = run('git', [ + '-C', + CACHE_DIR, + 'fetch', + '--depth', + '1', + 'origin', + 'main', + ]); + if (fetch.status !== 0) { + warn('fetch failed (offline?)'); + process.exit(0); + } + const reset = run('git', [ + '-C', + CACHE_DIR, + 'reset', + '--hard', + 'origin/main', + ]); + if (reset.status !== 0) { + warn('reset failed'); + process.exit(0); + } + } + + // `yarn skills` performs installation with the selected Bash and honors + // .skills.local / SKILLS_DOMAINS. Postinstall only keeps the public cache + // available so that default path works without any local configuration. +} catch (e) { + const msg = e instanceof Error ? e.message : String(e); + warn(`unexpected error: ${msg}`); +} +process.exit(0); diff --git a/scripts/skills-sync.mts b/scripts/skills-sync.mts new file mode 100644 index 00000000000..f30900b92c9 --- /dev/null +++ b/scripts/skills-sync.mts @@ -0,0 +1,196 @@ +// Wrapper for `yarn skills`. Picks a multi-source-aware tools/sync from +// whichever skill repo is configured and delegates. +// +// Source configuration comes from env vars first, then .skills.local. +// Prefer the public MetaMask/skills sync CLI whenever it is available: +// 1. METAMASK_SKILLS_DIR/tools/sync +// 2. .skills-cache/metamask-skills/tools/sync (zero-config default) +// 3. CONSENSYS_SKILLS_DIR/tools/sync (private fallback when no public source exists) +// The public sync still walks every configured source. Cache fallback means +// `yarn skills` works out of the box after `yarn install` without any shell rc +// edit. + +import { spawnSync } from 'child_process'; +import { readFileSync, statSync } from 'fs'; +import path from 'path'; +import { parse } from 'dotenv'; + +const REPO = 'metamask-mobile'; +const CACHE_DIR = path.join(process.cwd(), '.skills-cache', 'metamask-skills'); +const CONFIG_FILE = path.join(process.cwd(), '.skills.local'); +const SOURCE_ENV_KEYS = [ + 'METAMASK_SKILLS_DIR', + 'CONSENSYS_SKILLS_DIR', +] as const; + +function syncIn(dir: string): string | null { + const candidate = path.join(dir, 'tools', 'sync'); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + // ignored + } + return null; +} + +function bashMajorVersion(candidate: string): number | null { + const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' }); + if (result.status !== 0) return null; + + const match = `${result.stdout}${result.stderr}`.match( + /GNU bash, version (\d+)\./u, + ); + return match ? Number(match[1]) : null; +} + +function pickBash(): string | null { + const candidates = [ + process.env.BASH, + 'bash', + '/opt/homebrew/bin/bash', + '/usr/local/bin/bash', + '/bin/bash', + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of new Set(candidates)) { + const major = bashMajorVersion(candidate); + if (major && major >= 4) { + return candidate; + } + } + + return null; +} + +type SkillSourceEnv = Record< + (typeof SOURCE_ENV_KEYS)[number], + string | undefined +>; +type SyncPick = { sync: string }; + +function expandLeadingTilde(value: string | undefined): string | undefined { + if (!value?.startsWith('~')) { + return value; + } + + if (!process.env.HOME) { + return value; + } + + if (value === '~') { + return process.env.HOME; + } + + if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { + return path.join(process.env.HOME, value.slice(2)); + } + + return value; +} + +function loadSkillSourceEnv(): SkillSourceEnv { + const env: SkillSourceEnv = { + METAMASK_SKILLS_DIR: process.env.METAMASK_SKILLS_DIR, + CONSENSYS_SKILLS_DIR: process.env.CONSENSYS_SKILLS_DIR, + }; + + try { + const localConfig = parse(readFileSync(CONFIG_FILE, 'utf8')); + for (const key of SOURCE_ENV_KEYS) { + if (!env[key]) { + env[key] = expandLeadingTilde(localConfig[key]); + } + } + } catch { + // ignored: .skills.local is optional + } + + return env; +} + +function pickSync(sourceEnv: SkillSourceEnv): SyncPick | null { + const publicSync = sourceEnv.METAMASK_SKILLS_DIR + ? syncIn(sourceEnv.METAMASK_SKILLS_DIR) + : null; + if (publicSync) { + return { sync: publicSync }; + } + + const cacheSync = syncIn(CACHE_DIR); + if (cacheSync) { + return { sync: cacheSync }; + } + + if (sourceEnv.CONSENSYS_SKILLS_DIR) { + const privateSync = syncIn(sourceEnv.CONSENSYS_SKILLS_DIR); + if (privateSync) { + return { sync: privateSync }; + } + } + + return null; +} + +const sourceEnv = loadSkillSourceEnv(); +const picked = pickSync(sourceEnv); +if (!picked) { + process.stderr.write( + [ + 'No skills source available.', + '', + 'The postinstall hook normally clones the public skills repo into', + '.skills-cache/metamask-skills automatically. If that did not happen', + '(e.g. you ran the wrapper before `yarn install`, or postinstall was', + 'skipped via CI / SKILLS_SKIP_POSTINSTALL), point at a clone manually', + 'in .skills.local:', + '', + ' git clone https://github.com/MetaMask/skills ~/dev/metamask/skills', + ' echo METAMASK_SKILLS_DIR=~/dev/metamask/skills >> .skills.local', + '', + 'Optional private overlay (Consensys internal, SSH required):', + ' git clone git@github.com:Consensys/skills.git ~/dev/Consensys/skills', + ' echo CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills >> .skills.local', + '', + 'Then re-run `yarn skills`.', + '', + ].join('\n'), + ); + process.exit(1); +} + +const env = { ...process.env }; +for (const key of SOURCE_ENV_KEYS) { + if (!env[key] && sourceEnv[key]) { + env[key] = sourceEnv[key]; + } +} +if (!env.METAMASK_SKILLS_DIR && syncIn(CACHE_DIR)) { + env.METAMASK_SKILLS_DIR = CACHE_DIR; +} + +const bash = pickBash(); +if (!bash) { + process.stderr.write( + [ + 'No supported Bash found.', + '', + '`yarn skills` requires Bash 4+ because the shared skills installer uses', + 'modern Bash features. macOS /bin/bash is 3.2.', + '', + 'Install a current Bash, then re-run `yarn skills`:', + ' brew install bash', + '', + ].join('\n'), + ); + process.exit(1); +} +if (bash.includes(path.sep)) { + env.PATH = `${path.dirname(bash)}${path.delimiter}${env.PATH ?? ''}`; +} + +const result = spawnSync( + bash, + [picked.sync, '--repo', REPO, '--target', process.cwd(), ...process.argv.slice(2)], + { stdio: 'inherit', env }, +); +process.exit(result.status === null ? 1 : result.status); diff --git a/scripts/skills-sync.sh b/scripts/skills-sync.sh deleted file mode 100755 index 46bc9549fce..00000000000 --- a/scripts/skills-sync.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Wrapper for `yarn skills`. Delegates to Consensys/skills/tools/sync. -# Lives here (not in package.json) to keep yarn's variable handling out of the way. -set -eu - -if [[ -z "${CONSENSYS_SKILLS_DIR:-}" ]]; then - cat >&2 <<'EOF' -CONSENSYS_SKILLS_DIR is not set. - -To set up (one time): - 1. Clone the source repo somewhere on your machine: - git clone git@github.com:Consensys/skills.git ~/path/to/skills - 2. Export the env var (add to your shell rc): - export CONSENSYS_SKILLS_DIR=~/path/to/skills - -Then re-run `yarn skills`. -EOF - exit 1 -fi - -if [[ ! -x "$CONSENSYS_SKILLS_DIR/tools/sync" ]]; then - cat >&2 < { +describe(SmokeSnaps('BIP-44 Snap Tests'), () => { it('can connect to BIP-44 snap', async () => { await withFixtures( {