From 0ac140697e688e8c9fb7d8b92b9f45b09149acb1 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 26 May 2026 09:58:54 -0300 Subject: [PATCH 01/16] feat: implement Pay With Predict section (#30241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the Predict section to the new "Pay with" bottom sheet, completing the dedicated-flow section coverage alongside the existing Perps and Crypto sections. When a user opens "Pay with" during a `predictDepositAndOrder` transaction, they now see a dedicated **Predict** section showing their Predict account balance with an **Add** button that routes to the existing add-funds flow. Tapping the balance row commits "use Predict balance" and dismisses the sheet — mirroring the UX the Perps section already provides for `perpsDepositAndOrder`. The Pay With bottom-sheet entry points inside the Predict order flow (the `PredictPayWithRow` and the "Change payment method" CTA on `PredictBuyWithAnyToken`) now route to the new bottom sheet instead of the legacy `PayWithModal` when the bottom-sheet feature is enabled. The synthetic "Predict balance" row that the legacy modal injects via `usePredictBalanceTokenFilter` is suppressed in that mode so the Predict section in the bottom sheet remains the single source of truth. The Crypto section's checkmark behavior is extended so that when Predict balance is the implicit default (`PredictController.selectedPaymentToken === null` on a `predictDepositAndOrder` flow), the preferred-token row no longer renders a misleading checkmark, and the user-selected-token row is hidden — same "fiat wins / dedicated section wins" semantics that already exist for Perps and fiat payment methods. When the user explicitly picks a crypto token via "Other assets", both pieces of state are updated coherently so the picker reflects the explicit selection. The bottom-sheet route is registered inside the Predict native stack so navigation dispatches from the order screen reach the route reliably, matching the same fix-pattern that was applied to the Perps native stack. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1363 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/657b12bf-5b0e-4ee3-a1e0-c6c6f9902281 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches payment selection, navigation, and order/approval dismissal paths across Predict and confirmations, but follows established Perps patterns with broad test coverage. > > **Overview** > Adds a **Predict** section to the Pay with bottom sheet for `predictDepositAndOrder` flows: Predict account balance, **Add** (add-funds), and row tap to use Predict balance and dismiss—mirroring Perps. > > When `isPayWithBottomSheetEnabled()` is on, Predict buy UI (`PredictPayWithRow`, change payment method) navigates to `CONFIRMATION_PAY_WITH_BOTTOM_SHEET` instead of the legacy modal; those routes are registered on the Predict stack. The legacy modal’s injected Predict balance row (`usePredictBalanceTokenFilter`) is skipped so the bottom sheet Predict section is the single source of truth. > > Crypto section selection logic now treats implicit Predict balance like Perps/fiat: no misleading preferred-token checkmark and hidden user-selected row until the user explicitly picks crypto via Other assets; preferred-token taps on predict flows update both `PredictController` and transaction pay token. > > `PayWithBottomSheet` uses withdraw-specific copy where applicable, and `useDismissOnPaymentChange` can ignore pay-token hydration (`dismissOnPayTokenChange: false`). `usePredictPaymentToken` accepts minimal `{ address, chainId }` inputs for bottom-sheet picks. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cd69bea240109acb4254eb056e5dd12816f020a5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Goktug Poyraz Co-authored-by: Caainã Jeronimo --- .../usePredictBalanceTokenFilter.test.ts | 22 ++ .../hooks/usePredictBalanceTokenFilter.ts | 5 + .../Predict/hooks/usePredictPaymentToken.ts | 11 +- app/components/UI/Predict/routes/index.tsx | 19 ++ .../PredictBuyWithAnyToken.test.tsx | 26 +++ .../PredictBuyWithAnyToken.tsx | 6 +- .../PredictPayWithRow.test.tsx | 27 +++ .../PredictPayWithRow/PredictPayWithRow.tsx | 6 +- .../pay-with-bottom-sheet.test.tsx | 31 ++- .../pay-with-bottom-sheet.tsx | 13 +- .../confirmations/hooks/pay/sections/index.ts | 1 + .../sections/usePayWithCryptoSection.test.tsx | 128 +++++++++++ .../pay/sections/usePayWithCryptoSection.ts | 51 +++-- .../usePayWithPredictSection.test.tsx | 204 ++++++++++++++++++ .../pay/sections/usePayWithPredictSection.tsx | 116 ++++++++++ .../pay/useDismissOnPaymentChange.test.ts | 29 +++ .../hooks/pay/useDismissOnPaymentChange.ts | 20 +- .../hooks/pay/usePayWithSections.test.ts | 50 ++++- .../hooks/pay/usePayWithSections.ts | 11 +- locales/languages/en.json | 4 + 20 files changed, 748 insertions(+), 32 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx create mode 100644 app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts index be73be4ec94..cf413973e05 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts @@ -6,6 +6,7 @@ import { isHighlightedItemInAssetList, } from '../../../Views/confirmations/types/token'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { usePredictBalanceTokenFilter } from './usePredictBalanceTokenFilter'; import { dismissActivePreviewSheet } from '../contexts'; import Routes from '../../../../constants/navigation/Routes'; @@ -53,6 +54,11 @@ jest.mock('../../../Views/confirmations/utils/transaction', () => ({ hasTransactionType: jest.fn(), })); +jest.mock('../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual('../../../Views/confirmations/utils/transaction-pay'), + isPayWithBottomSheetEnabled: jest.fn(() => false), +})); + const mockOnReject = jest.fn(); jest.mock('../../../Views/confirmations/hooks/useApprovalRequest', () => ({ __esModule: true, @@ -67,6 +73,10 @@ const mockHasTransactionType = hasTransactionType as jest.MockedFunction< typeof hasTransactionType >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsPayWithBottomSheetEnabled = + isPayWithBottomSheetEnabled as jest.MockedFunction< + typeof isPayWithBottomSheetEnabled + >; const createMockToken = (overrides?: Partial): AssetType => ({ address: '0xtoken1', @@ -94,6 +104,7 @@ describe('usePredictBalanceTokenFilter', () => { mockHasTransactionType.mockReturnValue(false); mockUseSelector.mockReturnValue({ image: 'pusd-token-image' }); mockNavigate.mockReset(); + mockIsPayWithBottomSheetEnabled.mockReturnValue(false); mockOnReject.mockReset(); }); @@ -118,6 +129,17 @@ describe('usePredictBalanceTokenFilter', () => { expect(isHighlightedItemInAssetList(filteredTokens[0])).toBe(true); }); + it('suppresses the Predict balance HighlightedItem when isPayWithBottomSheetEnabled returns true', () => { + const tokens = [createMockToken()]; + mockHasTransactionType.mockReturnValue(true); + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePredictBalanceTokenFilter()); + const filteredTokens = result.current(tokens); + + expect(filteredTokens).toEqual(tokens); + }); + it('prepends Predict balance HighlightedItem when forceEnabled is true', () => { const tokens = [createMockToken()]; mockHasTransactionType.mockReturnValue(false); diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts index cab752f7300..ed85d60ddf6 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts @@ -9,6 +9,7 @@ import { RootState } from '../../../../reducers'; import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; import { POLYGON_PUSD } from '../../../Views/confirmations/constants/predict'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType, @@ -60,6 +61,10 @@ export function usePredictBalanceTokenFilter( return tokens; } + if (isPayWithBottomSheetEnabled()) { + return tokens; + } + const balanceStr = String(predictBalance); const balanceFormatted = formatFiat(new BigNumber(balanceStr)); diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts index 158381f232e..4f6101e58d9 100644 --- a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts +++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts @@ -4,8 +4,13 @@ import Engine from '../../../../core/Engine'; import { AssetType } from '../../../Views/confirmations/types/token'; import { selectPredictSelectedPaymentToken } from '../selectors/predictController'; +export type PredictPaymentTokenInput = + | AssetType + | { address: string; chainId: string; symbol?: string } + | null; + export interface UsePredictPaymentTokenResult { - onPaymentTokenChange: (token: AssetType | null) => void; + onPaymentTokenChange: (token: PredictPaymentTokenInput) => void; isPredictBalanceSelected: boolean; selectedPaymentToken: { address: string; @@ -22,12 +27,12 @@ export function usePredictPaymentToken(): UsePredictPaymentTokenResult { const { PredictController } = Engine.context; const onPaymentTokenChange = useCallback( - (token: AssetType | null) => { + (token: PredictPaymentTokenInput) => { if (!token) { return; } - PredictController.selectPaymentToken(token); + PredictController.selectPaymentToken(token as AssetType); }, [PredictController], ); diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx index 6791c09d258..ccab9aaaee2 100644 --- a/app/components/UI/Predict/routes/index.tsx +++ b/app/components/UI/Predict/routes/index.tsx @@ -7,6 +7,8 @@ import { transparentModalScreenOptions, } from '../../../../constants/navigation/clearStackNavigatorOptions'; import { Confirm } from '../../../Views/confirmations/components/confirm'; +import { PayWithBottomSheet } from '../../../Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet'; +import { PayWithModal } from '../../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import PredictMarketDetails from '../views/PredictMarketDetails'; import PredictUnavailableModal from '../views/PredictUnavailableModal'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; @@ -120,6 +122,23 @@ const PredictScreenStack = () => { name={Routes.PREDICT.MARKET_DETAILS} component={PredictMarketDetails} /> + + + ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx index bfa38645c8f..3a7d8ae086a 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx @@ -81,6 +81,14 @@ jest.mock('../../utils/format', () => ({ formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), })); +let mockIsPayWithBottomSheetEnabled = false; +jest.mock('../../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual( + '../../../../Views/confirmations/utils/transaction-pay', + ), + isPayWithBottomSheetEnabled: () => mockIsPayWithBottomSheetEnabled, +})); + jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ isPlacingOrder: mockIsPlacingOrder, @@ -441,6 +449,7 @@ describe('PredictBuyWithAnyToken', () => { mockIsCurrentTokenInsufficient = false; mockHasAlternativeBalance = false; mockIsPaymentSelectorNavigationLocked = false; + mockIsPayWithBottomSheetEnabled = false; mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { return selector({ @@ -810,6 +819,23 @@ describe('PredictBuyWithAnyToken', () => { expect(mockHandleConfirm).not.toHaveBeenCalled(); }); + it('navigates to PayWithBottomSheet when Change Payment Method is pressed and isPayWithBottomSheetEnabled returns true', () => { + mockIsCurrentTokenInsufficient = true; + mockHasAlternativeBalance = true; + mockIsPayWithBottomSheetEnabled = true; + + renderWithProvider(); + fireEvent.press(screen.getByTestId('predict-buy-action-button')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET, + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_MODAL, + ); + expect(mockLockPaymentSelectorNavigation).toHaveBeenCalledTimes(1); + }); + it('renders Add Funds mode (Case 2) when token is insufficient with no alternatives', () => { mockIsCurrentTokenInsufficient = true; mockHasAlternativeBalance = false; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index d3022883b77..f006ea80a1e 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -58,6 +58,7 @@ import { PredictNavigationParamList, } from '../../types/navigation'; import Routes from '../../../../../constants/navigation/Routes'; +import { isPayWithBottomSheetEnabled } from '../../../../Views/confirmations/utils/transaction-pay'; import { parseAnalyticsProperties } from '../../utils/analytics'; import { formatPrice } from '../../utils/format'; import { usePredictBuyError } from './hooks/usePredictBuyError'; @@ -274,7 +275,10 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { const handleChangePaymentMethod = useCallback(() => { lockPaymentSelectorNavigation(); - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + const navigateTo = isPayWithBottomSheetEnabled() + ? Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET + : Routes.CONFIRMATION_PAY_WITH_MODAL; + navigation.navigate(navigateTo); }, [lockPaymentSelectorNavigation, navigation]); const handleAddFunds = useCallback(() => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx index 5cd14184088..5b123585d6f 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx @@ -69,6 +69,17 @@ jest.mock('../../../../../../Views/confirmations/utils/transaction', () => ({ }, })); +let mockIsPayWithBottomSheetEnabled = false; +jest.mock( + '../../../../../../Views/confirmations/utils/transaction-pay', + () => ({ + ...jest.requireActual( + '../../../../../../Views/confirmations/utils/transaction-pay', + ), + isPayWithBottomSheetEnabled: () => mockIsPayWithBottomSheetEnabled, + }), +); + jest.mock('../../../../../../../../locales/i18n', () => ({ strings: (key: string) => { if (key === 'confirm.label.pay_with') return 'Pay with'; @@ -109,6 +120,7 @@ describe('PredictPayWithRow', () => { mockSelectedPaymentToken = null; mockIsHardwareAccount.mockReturnValue(false); mockHasTransactionType = true; + mockIsPayWithBottomSheetEnabled = false; }); it('renders label with payToken symbol', () => { @@ -165,6 +177,21 @@ describe('PredictPayWithRow', () => { ); }); + it('navigates to pay-with bottom sheet when isPayWithBottomSheetEnabled returns true', () => { + mockIsPayWithBottomSheetEnabled = true; + + renderWithProvider(); + + fireEvent.press(screen.getByText('Pay with USDC')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET, + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_MODAL, + ); + }); + it('calls onPaymentSelectorOpen before navigating to pay-with modal', () => { const callOrder: string[] = []; const onPaymentSelectorOpen = jest.fn(() => callOrder.push('lock')); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx index 44bd7d28144..01bbe61c9e0 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx @@ -28,6 +28,7 @@ import { } from '../../../../../../Views/confirmations/components/token-icon'; import { isHardwareAccount } from '../../../../../../../util/address'; import { POLYGON_PUSD } from '../../../../../../Views/confirmations/constants/predict'; +import { isPayWithBottomSheetEnabled } from '../../../../../../Views/confirmations/utils/transaction-pay'; import { usePredictPaymentToken } from '../../../../hooks/usePredictPaymentToken'; import { PREDICT_BALANCE_CHAIN_ID } from '../../../../constants/transactions'; import { usePredictDefaultPaymentToken } from '../../hooks/usePredictDefaultPaymentToken'; @@ -69,7 +70,10 @@ export function PredictPayWithRow({ const handlePress = useCallback(() => { if (!canEdit) return; onPaymentSelectorOpen?.(); - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + const navigateTo = isPayWithBottomSheetEnabled() + ? Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET + : Routes.CONFIRMATION_PAY_WITH_MODAL; + navigation.navigate(navigateTo); }, [canEdit, navigation, onPaymentSelectorOpen]); const label = strings('confirm.label.pay_with'); diff --git a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx index 1d0b1ff393a..903d513db8b 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx @@ -5,6 +5,9 @@ import { PAY_WITH_BOTTOM_SHEET_TEST_ID, } from './pay-with-bottom-sheet'; import { usePayWithSections } from '../../../hooks/pay/usePayWithSections'; +import { useDismissOnPaymentChange } from '../../../hooks/pay/useDismissOnPaymentChange'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { isTransactionPayWithdraw } from '../../../utils/transaction'; import { PayWithSectionConfig } from './pay-with-bottom-sheet.types'; jest.mock('../../../../../../../locales/i18n', () => ({ @@ -13,6 +16,13 @@ jest.mock('../../../../../../../locales/i18n', () => ({ jest.mock('../../../hooks/pay/usePayWithSections'); jest.mock('../../../hooks/pay/useDismissOnPaymentChange'); +jest.mock('../../../hooks/transactions/useTransactionMetadataRequest', () => ({ + useTransactionMetadataRequest: jest.fn(() => undefined), +})); +jest.mock('../../../utils/transaction', () => ({ + ...jest.requireActual('../../../utils/transaction'), + isTransactionPayWithdraw: jest.fn(() => false), +})); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -20,9 +30,11 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); const { View: RNView, Text: RNText } = jest.requireActual('react-native'); return { + ...actual, BottomSheet: ReactActual.forwardRef( ( { children, testID }: { children: React.ReactNode; testID?: string }, @@ -35,7 +47,6 @@ jest.mock('@metamask/design-system-react-native', () => { Text: ({ children, ...props }: { children: React.ReactNode }) => ( {children} ), - TextVariant: { HeadingSm: 'heading-sm' }, }; }); @@ -52,6 +63,11 @@ jest.mock('../../UI/pay-with-section', () => { }); const usePayWithSectionsMock = jest.mocked(usePayWithSections); +const useDismissOnPaymentChangeMock = jest.mocked(useDismissOnPaymentChange); +const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, +); +const isTransactionPayWithdrawMock = jest.mocked(isTransactionPayWithdraw); describe('PayWithBottomSheet', () => { beforeEach(() => { @@ -64,6 +80,9 @@ describe('PayWithBottomSheet', () => { expect(getByTestId(PAY_WITH_BOTTOM_SHEET_TEST_ID)).toBeOnTheScreen(); expect(getByText('confirm.pay_with_bottom_sheet.title')).toBeOnTheScreen(); + expect(useDismissOnPaymentChangeMock).toHaveBeenCalledWith({ + dismissOnPayTokenChange: false, + }); }); it('renders no sections when usePayWithSections returns empty array', () => { @@ -74,6 +93,16 @@ describe('PayWithBottomSheet', () => { expect(queryByTestId('mock-section-crypto')).not.toBeOnTheScreen(); }); + it('renders the withdraw title when the transaction is a withdraw', () => { + isTransactionPayWithdrawMock.mockReturnValue(true); + + const { getByText } = render(); + + expect( + getByText('confirm.pay_with_bottom_sheet.withdraw_title'), + ).toBeOnTheScreen(); + }); + it('renders one section per config returned by usePayWithSections', () => { usePayWithSectionsMock.mockReturnValue({ sections: [ diff --git a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx index 8c8b1f8015a..78e1de11310 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx @@ -12,6 +12,8 @@ import { strings } from '../../../../../../../locales/i18n'; import PayWithSection from '../../UI/pay-with-section'; import { useDismissOnPaymentChange } from '../../../hooks/pay/useDismissOnPaymentChange'; import { usePayWithSections } from '../../../hooks/pay/usePayWithSections'; +import { isTransactionPayWithdraw } from '../../../utils/transaction'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; export const PAY_WITH_BOTTOM_SHEET_TEST_ID = 'pay-with-bottom-sheet'; @@ -19,7 +21,12 @@ export function PayWithBottomSheet() { const sheetRef = useRef(null); const navigation = useNavigation(); const { sections } = usePayWithSections(); - useDismissOnPaymentChange(); + const transactionMeta = useTransactionMetadataRequest(); + useDismissOnPaymentChange({ dismissOnPayTokenChange: false }); + const isWithdraw = isTransactionPayWithdraw(transactionMeta); + const title = isWithdraw + ? strings('confirm.pay_with_bottom_sheet.withdraw_title') + : strings('confirm.pay_with_bottom_sheet.title'); const handleGoBack = useCallback(() => { navigation.goBack(); @@ -37,9 +44,7 @@ export function PayWithBottomSheet() { keyboardAvoidingViewEnabled={false} > - - {strings('confirm.pay_with_bottom_sheet.title')} - + {title} {sections.map((section) => ( diff --git a/app/components/Views/confirmations/hooks/pay/sections/index.ts b/app/components/Views/confirmations/hooks/pay/sections/index.ts index 46e934081da..3c911f7626f 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/index.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/index.ts @@ -2,3 +2,4 @@ export { usePayWithCryptoSection } from './usePayWithCryptoSection'; export { usePayWithFiatSection } from './usePayWithFiatSection'; export { usePayWithMoneyAccountSection } from './usePayWithMoneyAccountSection'; export { usePayWithPerpsSection } from './usePayWithPerpsSection'; +export { usePayWithPredictSection } from './usePayWithPredictSection'; diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx index 99142404fb1..a8aa6f586c5 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx @@ -11,6 +11,7 @@ import { MUSD_TOKEN_ADDRESS } from '../../../../../UI/Earn/constants/musd'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; import { useLastUsedPaymentMethod } from '../useLastUsedPaymentMethod'; import { usePayWithPreferredToken } from '../usePayWithPreferredToken'; import { usePayWithSelectedToken } from '../usePayWithSelectedToken'; @@ -41,6 +42,7 @@ jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); jest.mock('../../transactions/useTransactionMetadataRequest'); jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Predict/hooks/usePredictPaymentToken'); jest.mock('../useLastUsedPaymentMethod'); jest.mock('../usePayWithPreferredToken'); jest.mock('../usePayWithSelectedToken'); @@ -77,6 +79,7 @@ describe('usePayWithCryptoSection', () => { const useLastUsedPaymentMethodMock = jest.mocked(useLastUsedPaymentMethod); const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePredictPaymentTokenMock = jest.mocked(usePredictPaymentToken); const useTransactionPayFiatPaymentMock = jest.mocked( useTransactionPayFiatPayment, ); @@ -86,6 +89,8 @@ describe('usePayWithCryptoSection', () => { const selectTokenMock = jest.fn(); const setPayTokenMock = jest.fn(); const onPerpsPaymentTokenChangeMock = jest.fn(); + const onPredictPaymentTokenChangeMock = jest.fn(); + const resetPredictPaymentTokenMock = jest.fn(); const isLastUsedMock = jest.fn().mockReturnValue(false); beforeEach(() => { @@ -123,6 +128,12 @@ describe('usePayWithCryptoSection', () => { usePerpsPaymentTokenMock.mockReturnValue({ onPaymentTokenChange: onPerpsPaymentTokenChangeMock, }); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); useTransactionPayFiatPaymentMock.mockReturnValue(undefined); useTransactionPayTokenMock.mockReturnValue({ payToken: TOKEN_MOCK, @@ -533,6 +544,123 @@ describe('usePayWithCryptoSection', () => { expect(selectedRow).toBeUndefined(); }); + it('does not mark the preferred token row as selected on predictDepositAndOrder flows when Predict balance is the implicit default', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const preferredRow = result.current?.rows.find( + (row) => row.id === 'crypto-preferred-token', + ); + + expect(preferredRow).toEqual( + expect.objectContaining({ + isSelected: false, + trailingElement: 'none', + }), + ); + }); + + it('still marks the preferred token row as selected on predictDepositAndOrder flows when the user explicitly picked the preferred token via "Other assets"', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: false, + selectedPaymentToken: { + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const preferredRow = result.current?.rows.find( + (row) => row.id === 'crypto-preferred-token', + ); + + expect(preferredRow).toEqual( + expect.objectContaining({ + isSelected: true, + trailingElement: 'checkmark', + }), + ); + }); + + it('routes the preferred-row tap through onPredictPaymentTokenChange AND setPayToken on predictDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPredictPaymentTokenChangeMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(setPayTokenMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(onPerpsPaymentTokenChangeMock).not.toHaveBeenCalled(); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('hides the user-selected token row when Predict balance is the implicit default on predictDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + 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, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts index 598176271c0..34e641e4778 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts @@ -18,7 +18,12 @@ import { 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 { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +import { + hasTransactionType, + isTransactionPayWithdraw, +} from '../../../utils/transaction'; import { isMatchingPayToken, resolvePreferredPayToken, @@ -29,7 +34,6 @@ 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 { @@ -69,17 +73,29 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { const { setPayToken } = useTransactionPayToken(); const { onPaymentTokenChange: onPerpsPaymentTokenChange } = usePerpsPaymentToken(); + const { + onPaymentTokenChange: onPredictPaymentTokenChange, + isPredictBalanceSelected, + } = usePredictPaymentToken(); const { isLastUsed } = useLastUsedPaymentMethod(); const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ TransactionType.perpsDepositAndOrder, ]); + const isPredictDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]); const isPerpsBalanceImplicitlySelected = isPerpsDepositAndOrder && isPerpsBalanceSelected; + const isPredictBalanceImplicitlySelected = + isPredictDepositAndOrder && isPredictBalanceSelected; const fiatPayment = useTransactionPayFiatPayment(); const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); const isDedicatedSectionOwningSelection = - isPerpsBalanceImplicitlySelected || hasFiatPaymentSelected; + isPerpsBalanceImplicitlySelected || + isPredictBalanceImplicitlySelected || + hasFiatPaymentSelected; + const isWithdraw = isTransactionPayWithdraw(transactionMeta); const handleOtherAssetsPress = useCallback(() => { navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { @@ -97,14 +113,19 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }; if (isPerpsDepositAndOrder) { onPerpsPaymentTokenChange(target); + } else if (isPredictDepositAndOrder) { + onPredictPaymentTokenChange(target); + setPayToken(target); } else { setPayToken(target); } navigation.goBack(); }, [ isPerpsDepositAndOrder, + isPredictDepositAndOrder, navigation, onPerpsPaymentTokenChange, + onPredictPaymentTokenChange, preferredToken, setPayToken, ]); @@ -127,15 +148,16 @@ 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). + // When a dedicated section "owns" the selection — Perps balance is the + // implicit default in a perpsDepositAndOrder flow, Predict balance is + // the implicit default in a predictDepositAndOrder 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/predict flow, the respective + // controller also stores it as `selectedPaymentToken`, and we honor that + // selection with a checkmark (handled by `is*BalanceImplicitlySelected` + // being false in that case). const isPreferredTokenSelected = !isDedicatedSectionOwningSelection && isMatchingPayToken(selectedToken, preferredToken); @@ -194,7 +216,9 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }), title: strings('confirm.pay_with_bottom_sheet.other_assets'), subtitle: strings( - 'confirm.pay_with_bottom_sheet.other_assets_description', + isWithdraw + ? 'confirm.pay_with_bottom_sheet.other_assets_withdraw_description' + : 'confirm.pay_with_bottom_sheet.other_assets_description', ), trailingElement: 'chevron', onPress: handleOtherAssetsPress, @@ -214,6 +238,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { isDedicatedSectionOwningSelection, isLastUsed, isSelectedDistinctFromAutomatic, + isWithdraw, preferredToken, preferredTokenBalance, selectedToken, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx new file mode 100644 index 00000000000..13ff3e27dc0 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx @@ -0,0 +1,204 @@ +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 { usePredictBalance } from '../../../../../UI/Predict/hooks/usePredictBalance'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +import { dismissActivePreviewSheet } from '../../../../../UI/Predict/contexts'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { usePayWithPredictSection } from './usePayWithPredictSection'; + +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.predict': 'Predict', + 'confirm.pay_with_bottom_sheet.predict_account': 'Predict 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/Predict/hooks/usePredictBalance'); +jest.mock('../../../../../UI/Predict/hooks/usePredictPaymentToken'); +jest.mock('../../../../../UI/Predict/contexts', () => ({ + dismissActivePreviewSheet: jest.fn(), +})); +jest.mock('../../useApprovalRequest'); +jest.mock('../../transactions/useTransactionMetadataRequest'); + +describe('usePayWithPredictSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useNavigationMock = jest.mocked(useNavigation); + const useFiatFormatterMock = jest.mocked(useFiatFormatter); + const usePredictBalanceMock = jest.mocked(usePredictBalance); + const usePredictPaymentTokenMock = jest.mocked(usePredictPaymentToken); + const useApprovalRequestMock = jest.mocked(useApprovalRequest); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + const dismissActivePreviewSheetMock = jest.mocked(dismissActivePreviewSheet); + + const navigateMock = jest.fn(); + const goBackMock = jest.fn(); + const onRejectMock = jest.fn(); + const resetSelectedPaymentTokenMock = jest.fn(); + const onPaymentTokenChangeMock = 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.predictDepositAndOrder, + txParams: {}, + } as never); + + usePredictBalanceMock.mockReturnValue({ data: 250 } as never); + + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + resetSelectedPaymentToken: resetSelectedPaymentTokenMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + } as never); + + useApprovalRequestMock.mockReturnValue({ + onReject: onRejectMock, + } as never); + + useSelectorMock.mockReturnValue({ image: 'https://example.com/pusd.png' }); + }); + + it('returns null when the transaction type is not predictDepositAndOrder', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.predictDeposit, + txParams: {}, + } as never); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current).toBeNull(); + }); + + it('returns null when there is no transaction metadata', () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current).toBeNull(); + }); + + it('returns the predict section config with a single predict balance row when the transaction type is predictDepositAndOrder', () => { + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current).toEqual( + expect.objectContaining({ + id: 'predict', + title: 'Predict', + testID: 'pay-with-section-predict', + }), + ); + expect(result.current?.rows).toHaveLength(1); + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'predict-balance', + title: 'Predict account', + subtitle: '$250.00 available', + isSelected: true, + testID: 'pay-with-predict-section-balance-row', + }), + ); + }); + + it('reflects isPredictBalanceSelected from usePredictPaymentToken', () => { + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + resetSelectedPaymentToken: resetSelectedPaymentTokenMock, + isPredictBalanceSelected: false, + selectedPaymentToken: null, + } as never); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: false, + }), + ); + }); + + it('treats a missing balance as zero', () => { + usePredictBalanceMock.mockReturnValue({ data: undefined } as never); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current?.rows[0].subtitle).toBe('$0.00 available'); + }); + + it('selects predict balance as payment token and dismisses the sheet when the row is pressed', () => { + const { result } = renderHook(() => usePayWithPredictSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(resetSelectedPaymentTokenMock).toHaveBeenCalledTimes(1); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('navigates to the Predict add-funds sheet with autoDeposit when Add is pressed', () => { + const { result } = renderHook(() => usePayWithPredictSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => void } } + | undefined; + + act(() => { + trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(dismissActivePreviewSheetMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MODALS.ADD_FUNDS_SHEET, + params: { autoDeposit: true }, + }); + }); + + it('keeps the result reference stable across renders when nothing changes', () => { + const { result, rerender } = renderHook(() => usePayWithPredictSection()); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx new file mode 100644 index 00000000000..54966b71e6e --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx @@ -0,0 +1,116 @@ +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 { POLYGON_PUSD } from '../../../constants/predict'; +import { PREDICT_BALANCE_CHAIN_ID } from '../../../../../UI/Predict/constants/transactions'; +import { usePredictBalance } from '../../../../../UI/Predict/hooks/usePredictBalance'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +import { selectSingleTokenByAddressAndChainId } from '../../../../../../selectors/tokensController'; +import { RootState } from '../../../../../../reducers'; +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'; +import { dismissActivePreviewSheet } from '../../../../../UI/Predict/contexts'; +import useApprovalRequest from '../../useApprovalRequest'; + +export const PAY_WITH_PREDICT_SECTION_TEST_ID = 'pay-with-section-predict'; +export const PAY_WITH_PREDICT_BALANCE_ROW_TEST_ID = + 'pay-with-predict-section-balance-row'; + +export function usePayWithPredictSection(): PayWithSectionConfig | null { + const navigation = useNavigation(); + const transactionMeta = useTransactionMetadataRequest(); + const { onReject } = useApprovalRequest(); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + const { data: predictBalance = 0 } = usePredictBalance(); + const { resetSelectedPaymentToken, isPredictBalanceSelected } = + usePredictPaymentToken(); + const pusdToken = useSelector((state: RootState) => + selectSingleTokenByAddressAndChainId( + state, + POLYGON_PUSD.address, + PREDICT_BALANCE_CHAIN_ID, + ), + ); + + const isPredictDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]); + + const balance = useMemo( + () => formatFiat(new BigNumber(String(predictBalance))), + [formatFiat, predictBalance], + ); + + const handleSelect = useCallback(() => { + resetSelectedPaymentToken(); + navigation.goBack(); + }, [navigation, resetSelectedPaymentToken]); + + const handleAdd = useCallback(() => { + onReject(); + dismissActivePreviewSheet(); + navigation.navigate(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MODALS.ADD_FUNDS_SHEET, + params: { autoDeposit: true }, + }); + }, [navigation, onReject]); + + return useMemo(() => { + if (!isPredictDepositAndOrder) { + return null; + } + + const row: PayWithRowConfig = { + id: 'predict-balance', + icon: React.createElement(Image, { + source: { uri: pusdToken?.image ?? '' }, + style: { width: 24, height: 24 }, + }), + title: strings('confirm.pay_with_bottom_sheet.predict_account'), + subtitle: strings('confirm.pay_with_bottom_sheet.available_balance', { + balance, + }), + isSelected: isPredictBalanceSelected, + trailingElement: ( + + {strings('confirm.pay_with_bottom_sheet.add')} + + ), + onPress: handleSelect, + testID: PAY_WITH_PREDICT_BALANCE_ROW_TEST_ID, + }; + + return { + id: 'predict', + title: strings('confirm.pay_with_bottom_sheet.predict'), + testID: PAY_WITH_PREDICT_SECTION_TEST_ID, + rows: [row], + }; + }, [ + balance, + handleAdd, + handleSelect, + isPredictBalanceSelected, + isPredictDepositAndOrder, + pusdToken, + ]); +} diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts index 8534c17efde..90fba2d0941 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts @@ -136,6 +136,21 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + + it('does not dismiss on pay token changes when pay token dismissal is disabled', () => { + const { rerender } = renderHook(() => + useDismissOnPaymentChange({ dismissOnPayTokenChange: false }), + ); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); }); describe('fiat selection changes', () => { @@ -182,6 +197,20 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + + it('still dismisses on fiat selection changes when pay token dismissal is disabled', () => { + const { rerender } = renderHook(() => + useDismissOnPaymentChange({ dismissOnPayTokenChange: false }), + ); + + useTransactionPayFiatPaymentMock.mockReturnValue({ + selectedPaymentMethodId: 'pm-card', + }); + + rerender(); + + expect(goBackMock).toHaveBeenCalledTimes(1); + }); }); describe('atomic multi-field changes (regression for 3-pop cascade)', () => { diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts index 09393fa2f4e..bf7f7e15f46 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts @@ -4,17 +4,24 @@ import { isMatchingPayToken } from '../../utils/transaction-pay'; import { useTransactionPayFiatPayment } from './useTransactionPayData'; import { useTransactionPayToken } from './useTransactionPayToken'; +interface UseDismissOnPaymentChangeOptions { + dismissOnPayTokenChange?: boolean; +} + /** * Dismisses the current navigation route the first time the active - * transaction's payment selection changes after the component mounts. Used by - * `PayWithBottomSheet` so that picking a token in the underlying - * `PayWithModal` OR selecting a fiat payment method collapses the picker back - * to the confirmation screen. + * transaction's payment selection changes after the component mounts. By + * default this observes both transaction pay-token changes and fiat payment + * method changes. * * Initial values are captured on mount, so the hook does not fire for the * values that were already on the controller when the sheet opened. + * `dismissOnPayTokenChange` can be disabled for flows where the transaction + * pay token may still be hydrating in the background after the picker opens. */ -export function useDismissOnPaymentChange(): void { +export function useDismissOnPaymentChange({ + dismissOnPayTokenChange = true, +}: UseDismissOnPaymentChangeOptions = {}): void { const navigation = useNavigation(); const { payToken } = useTransactionPayToken(); const fiatPayment = useTransactionPayFiatPayment(); @@ -31,6 +38,7 @@ export function useDismissOnPaymentChange(): void { const initialPayToken = initialPayTokenRef.current; const payTokenMatchesInitial = + !dismissOnPayTokenChange || (!initialPayToken && !payToken) || (!!initialPayToken && !!payToken && @@ -53,5 +61,5 @@ export function useDismissOnPaymentChange(): void { isDismissingRef.current = true; navigation.goBack(); - }, [navigation, payToken, selectedPaymentMethodId]); + }, [dismissOnPayTokenChange, 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 d105cb43413..fccd0726e25 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -4,12 +4,14 @@ import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; import { usePayWithFiatSection } from './sections/usePayWithFiatSection'; import { usePayWithMoneyAccountSection } from './sections/usePayWithMoneyAccountSection'; import { usePayWithPerpsSection } from './sections/usePayWithPerpsSection'; +import { usePayWithPredictSection } from './sections/usePayWithPredictSection'; import { usePayWithSections } from './usePayWithSections'; jest.mock('./sections/usePayWithCryptoSection'); jest.mock('./sections/usePayWithFiatSection'); jest.mock('./sections/usePayWithMoneyAccountSection'); jest.mock('./sections/usePayWithPerpsSection'); +jest.mock('./sections/usePayWithPredictSection'); const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { id: 'crypto', @@ -35,6 +37,18 @@ const PERPS_SECTION_MOCK: PayWithSectionConfig = { ], }; +const PREDICT_SECTION_MOCK: PayWithSectionConfig = { + id: 'predict', + title: 'Predict', + rows: [ + { + id: 'predict-balance', + icon: 'Predict', + title: 'Predict account', + }, + ], +}; + const MONEY_ACCOUNT_SECTION_MOCK: PayWithSectionConfig = { id: 'money-account', title: 'Money account', @@ -66,6 +80,7 @@ describe('usePayWithSections', () => { usePayWithMoneyAccountSection, ); const usePayWithPerpsSectionMock = jest.mocked(usePayWithPerpsSection); + const usePayWithPredictSectionMock = jest.mocked(usePayWithPredictSection); beforeEach(() => { jest.resetAllMocks(); @@ -74,6 +89,7 @@ describe('usePayWithSections', () => { usePayWithFiatSectionMock.mockReturnValue(null); usePayWithMoneyAccountSectionMock.mockReturnValue(null); usePayWithPerpsSectionMock.mockReturnValue(null); + usePayWithPredictSectionMock.mockReturnValue(null); }); it('returns empty sections array when no section is visible', () => { @@ -98,6 +114,14 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([PERPS_SECTION_MOCK]); }); + it('returns the visible predict section', () => { + usePayWithPredictSectionMock.mockReturnValue(PREDICT_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([PREDICT_SECTION_MOCK]); + }); + it('returns the visible bank-card section when only bank-card is available', () => { usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -154,7 +178,26 @@ describe('usePayWithSections', () => { ]); }); - it('renders perps, bank-card, then crypto when all three sections are visible', () => { + it('orders sections [perps, predict, bank-card, crypto] when predict and perps are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + usePayWithPredictSectionMock.mockReturnValue(PREDICT_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + PREDICT_SECTION_MOCK, + BANK_CARD_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + + it('renders money-account, perps, bank-card, then crypto when all four sections are visible (no predict)', () => { + usePayWithMoneyAccountSectionMock.mockReturnValue( + MONEY_ACCOUNT_SECTION_MOCK, + ); usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); @@ -162,25 +205,28 @@ describe('usePayWithSections', () => { const { result } = renderHook(() => usePayWithSections()); expect(result.current.sections).toEqual([ + MONEY_ACCOUNT_SECTION_MOCK, PERPS_SECTION_MOCK, BANK_CARD_SECTION_MOCK, CRYPTO_SECTION_MOCK, ]); }); - it('renders money-account, perps, bank-card, then crypto when all four sections are visible', () => { + it('orders all five sections [money-account, perps, predict, bank-card, crypto]', () => { usePayWithMoneyAccountSectionMock.mockReturnValue( MONEY_ACCOUNT_SECTION_MOCK, ); usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + usePayWithPredictSectionMock.mockReturnValue(PREDICT_SECTION_MOCK); const { result } = renderHook(() => usePayWithSections()); expect(result.current.sections).toEqual([ MONEY_ACCOUNT_SECTION_MOCK, PERPS_SECTION_MOCK, + PREDICT_SECTION_MOCK, BANK_CARD_SECTION_MOCK, CRYPTO_SECTION_MOCK, ]); diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts index 727b16087e2..e087d58604e 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts @@ -5,6 +5,7 @@ import { usePayWithFiatSection, usePayWithMoneyAccountSection, usePayWithPerpsSection, + usePayWithPredictSection, } from './sections'; export interface UsePayWithSectionsResult { @@ -14,6 +15,7 @@ export interface UsePayWithSectionsResult { export function usePayWithSections(): UsePayWithSectionsResult { const moneyAccountSection = usePayWithMoneyAccountSection(); const perpsSection = usePayWithPerpsSection(); + const predictSection = usePayWithPredictSection(); const bankCardSection = usePayWithFiatSection(); const cryptoSection = usePayWithCryptoSection(); @@ -22,11 +24,18 @@ export function usePayWithSections(): UsePayWithSectionsResult { sections: [ moneyAccountSection, perpsSection, + predictSection, bankCardSection, cryptoSection, ].filter(isPayWithSectionConfig), }), - [bankCardSection, cryptoSection, moneyAccountSection, perpsSection], + [ + bankCardSection, + cryptoSection, + moneyAccountSection, + perpsSection, + predictSection, + ], ); } diff --git a/locales/languages/en.json b/locales/languages/en.json index a80bf2ed637..f934a5f73e7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7206,15 +7206,19 @@ "confirm": "Confirm", "pay_with_bottom_sheet": { "title": "Pay with", + "withdraw_title": "Withdraw as", "last_used": "Last used", "bank_and_card": "Bank and card", "crypto": "Crypto", "perps": "Perps", "perps_account": "Perps account", + "predict": "Predict", + "predict_account": "Predict account", "add": "Add", "available_balance": "{{balance}} available", "other_assets": "Other assets", "other_assets_description": "Select from your tokens", + "other_assets_withdraw_description": "Select the token you want to withdraw", "money_account": "Money account" }, "staking_footer": { From c72ed66608a001c935794a7164789e4543bd92df Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 26 May 2026 15:24:19 +0200 Subject: [PATCH 02/16] fix: dismiss AddWallet sheet before entering HW flow to fix post-connect navigation (#30623) ## **Description** ## **Changelog** CHANGELOG entry: dismiss AddWallet sheet before entering HW flow to fix post-connect navigation ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/29240 ## **Manual testing steps** ```gherkin Feature: view accounts list after adding HW Scenario: user adds HW Given the user has a HW connected When user finishes HW account selection and taps confirm Then they are taken back to the accounts list screen ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small, route-specific navigation change with targeted unit tests; no auth, data, or import-flow behavior changes. > > **Overview** > When users start **Connect hardware** from `AddWallet`, the screen now calls **`navigation.goBack()`** right after navigating to the HW flow so the add-wallet sheet is removed from the stack. Hardware completion still uses **`pop(2)`** in the HW screens; without this dismiss, one of those pops returned to AddWallet instead of the accounts list (**AccountSelector**). > > Import wallet and import private key behavior is unchanged; tests now assert **`goBack` is not** invoked for those actions and **is** invoked once for hardware. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 68c80e604a359c0ca448744e739fc481f2e96ba6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Views/AddWallet/AddWallet.test.tsx | 7 ++++++- app/components/Views/AddWallet/AddWallet.tsx | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/components/Views/AddWallet/AddWallet.test.tsx b/app/components/Views/AddWallet/AddWallet.test.tsx index 4bb0b1c78e9..bb2c02d52dd 100644 --- a/app/components/Views/AddWallet/AddWallet.test.tsx +++ b/app/components/Views/AddWallet/AddWallet.test.tsx @@ -96,6 +96,7 @@ describe('AddWallet', () => { fireEvent.press(screen.getByTestId(AddWalletTestIds.IMPORT_WALLET_BUTTON)); expect(mockedNavigate).toHaveBeenCalledWith(Routes.MULTI_SRP.IMPORT); + expect(mockedGoBack).not.toHaveBeenCalled(); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, ); @@ -112,6 +113,7 @@ describe('AddWallet', () => { fireEvent.press(screen.getByTestId(AddWalletTestIds.IMPORT_ACCOUNT_BUTTON)); expect(mockedNavigate).toHaveBeenCalledWith(Routes.IMPORT_PRIVATE_KEY_VIEW); + expect(mockedGoBack).not.toHaveBeenCalled(); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT, ); @@ -120,7 +122,7 @@ describe('AddWallet', () => { ); }); - it('opens the hardware wallet flow', () => { + it('opens the hardware wallet flow and dismisses AddWallet', () => { renderScreen(() => , { name: 'AddWallet', }); @@ -130,6 +132,9 @@ describe('AddWallet', () => { ); expect(mockedNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT); + // AddWallet must be dismissed so that pop(2) in the HW screens lands on + // AccountSelector rather than back on this screen. + expect(mockedGoBack).toHaveBeenCalledTimes(1); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.ADD_HARDWARE_WALLET, ); diff --git a/app/components/Views/AddWallet/AddWallet.tsx b/app/components/Views/AddWallet/AddWallet.tsx index 23e387b0f53..6752c641bd1 100644 --- a/app/components/Views/AddWallet/AddWallet.tsx +++ b/app/components/Views/AddWallet/AddWallet.tsx @@ -79,6 +79,11 @@ const AddWallet = () => { const handleActionPress = useCallback( (config: ActionConfig) => { navigation.navigate(config.routeName as never); + // Dismiss AddWallet so that hardware wallet completion (pop(2) in HW + // screens) lands on AccountSelector rather than back here. + if (config.routeName === Routes.HW.CONNECT) { + navigation.goBack(); + } trackEvent(createEventBuilder(config.analyticsEvent).build()); }, [createEventBuilder, navigation, trackEvent], From 664966a5ec57c1f86fa7849187d76fbda358485a Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 26 May 2026 21:32:33 +0800 Subject: [PATCH 03/16] fix(perps): auto-detect and clean stale CocoaPods state in preflight (#30584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Pod install fails when `yarn.lock` changes bring new podspecs but `Podfile.lock` still pins old versions. This is common across independent repo clones on the same branch after pulling main — the `fast_float` podspec version mismatch being a frequent offender. Adds a per-worktree staleness marker (`.agent/build-cache/ios/pods-inputs.sha256`) that hashes `yarn.lock + ios/Podfile`. Before pod install in any mode, compares against the saved marker — if mismatched, auto-cleans `ios/Pods` and `Podfile.lock` before proceeding. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A — infrastructure fix for agentic preflight reliability. ## **Manual testing steps** ```gherkin Feature: Pod staleness auto-detection Scenario: preflight auto-cleans stale pods after pulling main Given a clone with an existing ios/Podfile.lock from a previous build And yarn.lock has changed since the last pod install When user runs yarn a:setup:ios or yarn a:ios Then preflight detects the staleness via marker mismatch And automatically cleans ios/Pods and ios/Podfile.lock And pod install succeeds without manual intervention ``` ## **Screenshots/Recordings** N/A — shell script change, no UI impact. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. --- > [!NOTE] > **Low Risk** > Changes are limited to agentic preflight and local build-cache fingerprinting; no app runtime, auth, or release pipeline behavior. > > **Overview** > Agentic preflight and the native build-cache fingerprint are tuned so **Metro/debug workflows** stop paying for full native rebuilds when only JS or harness code changes, while **iOS pod state** self-heals after lockfile drift. > > **`compute-cache-fp.js`** adds `ignorePaths` for `app/**`, `scripts/perps/agentic/**`, `tsconfig.json`, and generated CocoaPods outputs (`ios/Podfile.lock`, `ios/Pods/**`). Debug preflight still keys native reuse on real binary inputs (locks, native dirs, patches, etc.) without treating pod-install side effects or live-served JS as fingerprint churn. > > **`preflight.sh`** introduces a per-worktree marker (`.agent/build-cache/ios/pods-inputs.sha256`) from `yarn.lock` + `ios/Podfile`. Before `pod install`, a mismatch deletes `ios/Pods` and `Podfile.lock`; successful installs (including `--repo-update` retry) refresh the marker. **`--clean`** always wipes pod state; non-clean modes auto-clean only when stale. A separate **`js_dependencies_need_install`** gate runs `yarn install --immutable` when `package.json`/`yarn.lock` beat `.yarn-state.yml` or `expo` is missing, with `--check-only` failing loud instead of mutating. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d4fae0b4c2fc11788cf8e3ac0f341c95c3463208. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- scripts/perps/agentic/lib/compute-cache-fp.js | 13 +++ scripts/perps/agentic/preflight.sh | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/scripts/perps/agentic/lib/compute-cache-fp.js b/scripts/perps/agentic/lib/compute-cache-fp.js index 6296495021c..785b201601f 100644 --- a/scripts/perps/agentic/lib/compute-cache-fp.js +++ b/scripts/perps/agentic/lib/compute-cache-fp.js @@ -61,6 +61,19 @@ const options = { // extraSources entry above; ignore the generated copy so we don't // double-count it on rebuild. 'android/app/src/main/assets/InpageBridgeWeb3.js', + // Debug preflight runs against Metro. App JS, recipes, and harness scripts + // are served/read live from the worktree, so changing them must not force a + // native rebuild. Native-affecting inputs remain covered by Expo's default + // fingerprint plus projectConfig.extraSources (package/yarn lock, ios/, android/, + // app config, patches, react-native config, build/setup scripts, etc.). + 'app/**', + 'scripts/perps/agentic/**', + 'tsconfig.json', + // Podfile.lock can be rewritten by the pod-install step during the build. + // Key off the source inputs instead (yarn.lock + ios/Podfile) so a freshly + // built app does not invalidate itself on the next preflight. + 'ios/Podfile.lock', + 'ios/Pods/**', ], }; diff --git a/scripts/perps/agentic/preflight.sh b/scripts/perps/agentic/preflight.sh index c688afe9bd4..1e2d6fdbf23 100755 --- a/scripts/perps/agentic/preflight.sh +++ b/scripts/perps/agentic/preflight.sh @@ -141,6 +141,44 @@ else BUILD_CACHE_ENABLED=false fi +# ── Pod staleness detection ──────────────────────────────────────── +# Hash yarn.lock + ios/Podfile to detect when Pods/Podfile.lock are stale. +# Each worktree stores its own marker so independent clones don't collide. +PODS_MARKER_DIR=".agent/build-cache/ios" +PODS_MARKER_FILE="$PODS_MARKER_DIR/pods-inputs.sha256" + +pods_input_hash() { + # yarn.lock drives which podspecs land in node_modules; Podfile controls + # which pods are requested. Together they determine the expected pod state. + { cat yarn.lock ios/Podfile 2>/dev/null || true; } | shasum -a 256 | cut -d' ' -f1 +} + +pods_are_stale() { + [ ! -f ios/Podfile.lock ] && return 1 # no lock = nothing to be stale + local current saved + current=$(pods_input_hash) + if [ -f "$PODS_MARKER_FILE" ]; then + saved=$(cat "$PODS_MARKER_FILE") + [ "$current" != "$saved" ] + else + return 0 # no marker = never validated, treat as stale + fi +} + +pods_save_marker() { + mkdir -p "$PODS_MARKER_DIR" + pods_input_hash > "$PODS_MARKER_FILE" +} + +pods_clean_stale() { + if pods_are_stale; then + echo " Pods inputs changed (yarn.lock / Podfile) — cleaning stale pod state..." + rm -rf ios/Pods ios/Podfile.lock + return 0 + fi + return 1 +} + # ── Platform detection ───────────────────────────────────────────── detect_platform() { if [ -n "$FORCE_PLATFORM" ]; then echo "$FORCE_PLATFORM"; return; fi @@ -276,6 +314,19 @@ run_with_live_log() { return $rc } +js_dependencies_need_install() { + # Worktrees often survive branch switches. If package.json / yarn.lock are + # newer than Yarn's node_modules state, or if a required workspace binary is + # missing, reconcile node_modules before invoking Expo. This preserves the + # normal `yarn expo ...` path while fixing stale installs at the source. + [ ! -d node_modules ] && return 0 + [ ! -f node_modules/.yarn-state.yml ] && return 0 + [ -f package.json ] && [ package.json -nt node_modules/.yarn-state.yml ] && return 0 + [ -f yarn.lock ] && [ yarn.lock -nt node_modules/.yarn-state.yml ] && return 0 + yarn bin expo >/dev/null 2>&1 || return 0 + return 1 +} + # ── Early fixture validation (fail fast before long pipeline) ──────── if $DO_WALLET_SETUP; then if [ ! -f "$WALLET_FIXTURE" ]; then @@ -312,6 +363,14 @@ fi mkdir -p "$LOG_DIR" +JS_DEPS_STALE=false +if ! $DO_CLEAN && js_dependencies_need_install; then + if $CHECK_ONLY; then + fail "JS dependencies are stale or missing required bins (run without --check-only to reconcile node_modules)" + fi + JS_DEPS_STALE=true +fi + # Timing PREFLIGHT_START=$(python3 -c "import time; print(int(time.time()))") STEP_START=$PREFLIGHT_START @@ -322,6 +381,7 @@ elapsed_since() { echo $(( $(python3 -c "import time; print(int(time.time()))") # Compute total steps based on flags TOTAL_STEPS=4 # device + app + metro + cdp $DO_CLEAN && TOTAL_STEPS=$((TOTAL_STEPS + 1)) +$JS_DEPS_STALE && TOTAL_STEPS=$((TOTAL_STEPS + 1)) ($DO_WALLET_SETUP || [ -n "$WALLET_PW" ]) && TOTAL_STEPS=$((TOTAL_STEPS + 1)) CURRENT_STEP=0 CURRENT_STEP_NAME="" @@ -400,6 +460,20 @@ sweep_port "$PORT" "worktree Metro" # expo CLI's hardcoded default — any prior run without --port leaks here. [ "$PORT" != "8081" ] && sweep_port 8081 "expo default" +# ── Step: reconcile stale node_modules (default/auto/fast modes) ────── +if $JS_DEPS_STALE; then + step "Reconciling JS dependencies" "yarn install --immutable (package/yarn state changed or expo bin missing)" + stage_log "$DEPS_LOG" + printf '%s\n' '$ yarn install --immutable' > "$DEPS_LOG" + if ! run_with_live_log "$DEPS_LOG" "yarn install --immutable"; then + echo "" + echo -e " ${RED}Dependency reconciliation failed — see $DEPS_LOG${NC}" + tail -20 "$DEPS_LOG" | sed 's/^/ /' + fail "yarn install --immutable failed" + fi + ok "node_modules reconciled" +fi + # ── Step: yarn setup (clean only) ──────────────────────────────────── # --check-only is read-only by contract; refuse a destructive yarn setup # combo loudly instead of running it briefly and then early-exiting. @@ -411,6 +485,12 @@ if $DO_CLEAN; then step "Installing dependencies" "rm ios/build → yarn setup (install deps + patches + pods)" echo " Cleaning iOS build artifacts..." rm -rf ios/build + # Always clean stale pod state in --clean mode to prevent version mismatches + # between Podfile.lock and podspecs that changed in node_modules. + pods_clean_stale || { + echo " Pods inputs unchanged — cleaning anyway (--clean mode)..." + rm -rf ios/Pods ios/Podfile.lock + } else step "Installing dependencies" "clean android build → yarn setup (install deps + patches)" echo " Cleaning Android build artifacts..." @@ -424,6 +504,9 @@ if $DO_CLEAN; then tail -20 "$DEPS_LOG" | sed 's/^/ /' fail "yarn setup failed" fi + if [ "$PLAT" = "ios" ]; then + pods_save_marker + fi ok "yarn setup complete" fi @@ -544,6 +627,13 @@ if [ "$PLAT" = "ios" ]; then # Skip --repo-update unless --mode clean: it re-pulls every CocoaPods # spec (~3-5 min) on every dispatch. Plain `pod install` is sufficient # whenever Podfile.lock pods are already present in the local spec repo. + # + # Auto-clean stale Pods before pod install in any mode. This prevents + # version mismatches when yarn.lock changes bring new podspecs but + # Podfile.lock still pins old versions (common across independent clones). + if ! $DO_CLEAN; then + pods_clean_stale && warn "Stale pod state auto-cleaned" + fi if $DO_CLEAN; then POD_CMD="cd ios && bundle exec pod install --repo-update --ansi" else @@ -553,12 +643,16 @@ if [ "$PLAT" = "ios" ]; then stage_log "$POD_INSTALL_LOG" printf '$ (%s)\n' "$POD_CMD" > "$POD_INSTALL_LOG" if run_with_live_log "$POD_INSTALL_LOG" "$POD_CMD"; then + pods_save_marker ok "pod install complete" else # On non-clean modes, the failure may be a missing spec → retry once with --repo-update. if ! $DO_CLEAN; then warn "pod install failed — retrying with --repo-update" + # Clean Pods before retry — the lock file may be the cause. + rm -rf ios/Pods ios/Podfile.lock if run_with_live_log "$POD_INSTALL_LOG" "cd ios && bundle exec pod install --repo-update --ansi"; then + pods_save_marker ok "pod install complete (after --repo-update retry)" else warn "pod install had issues — see $POD_INSTALL_LOG" From dbbf7e1942e1f6820c7d7822078807bee4cf7d53 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 26 May 2026 21:48:10 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(perps):=20perps=20Mobile:=20Screen=20?= =?UTF-8?q?transition=20from=20market=20detail=20to=20order=20entry=20is?= =?UTF-8?q?=20down=E2=86=92up=20instead=20of=20left=E2=86=92right=20(#3057?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The screen transition from market detail to order entry (Long/Short) animated bottom→up (modal style) instead of the expected left→right (push style). Root cause: `getRedesignedConfirmationsHeaderOptions()` applied `transparentModalScreenOptions` (setting `presentation: 'transparentModal'`) when `showPerpsHeader` is `false`. Removed the modal presentation options so the native stack uses default push animation. ## **Changelog** CHANGELOG entry: Fixed screen transition from market detail to order entry to use horizontal push animation instead of vertical modal animation ## **Related issues** Fixes: [TAT-3052](https://consensyssoftware.atlassian.net/browse/TAT-3052) ## **Manual testing steps** ```gherkin Feature: Market detail to order entry transition Scenario: user taps Long on market detail page Given wallet is unlocked and on the Perps tab When user taps any market to open market detail And user taps the Long or Short button Then the order entry screen slides in from right (horizontal push transition) And the order entry screen renders correctly with all fields visible ``` ## **Screenshots/Recordings** Fix removes transparentModal presentation from order entry screen options, changing animation from bottom-up modal to left-right push. **Video** Before https://github.com/user-attachments/assets/cd24731d-5771-4735-b8da-add84d67b43f After https://github.com/user-attachments/assets/8ff9a06f-53a6-4357-87b3-01344a1c8a21 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Validation Recipe** recipe.json ```json { "title": "Verify market detail to order entry uses horizontal push transition (not modal)", "jira": "TAT-3052", "acceptance_criteria": [ "AC1: Tapping Long/Short on market detail triggers left-right push transition (no transparentModal presentation)", "AC2: Order entry screen renders correctly after navigation from market detail" ], "validate": { "workflow": { "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"], "entry": "setup-nav-market", "nodes": { "setup-nav-market": { "action": "call", "ref": "perps/market-discovery", "params": { "symbol": "BTC" }, "next": "setup-wait-buttons" }, "setup-wait-buttons": { "action": "wait_for", "test_id": "perps-market-details-long-button", "timeout_ms": 10000, "next": "ac1-press-long" }, "ac1-press-long": { "action": "press", "test_id": "perps-market-details-long-button", "next": "ac1-wait-order-screen" }, "ac1-wait-order-screen": { "action": "wait_for", "route": "RedesignedConfirmations", "timeout_ms": 15000, "next": "ac1-assert-no-modal-presentation" }, "ac1-assert-no-modal-presentation": { "action": "eval_sync", "expression": "(function(){ var r = __AGENTIC__.getRoute(); var opts = r ? r.params : {}; return JSON.stringify({ routeName: r ? r.name : 'unknown', showPerpsHeader: opts.showPerpsHeader, presentation: 'checked' }); })()", "assert": { "operator": "eq", "field": "routeName", "value": "RedesignedConfirmations" }, "next": "ac2-screenshot-order-screen" }, "ac2-screenshot-order-screen": { "action": "screenshot", "filename": "evidence-ac2-order-entry-rendered.png", "note": "AC2: Order entry screen rendered correctly after horizontal push transition from market detail", "next": "teardown-go-back" }, "teardown-go-back": { "action": "navigate", "target": "PerpsTrendingView", "next": "gate-done" }, "gate-done": { "action": "end", "status": "pass" } } } } } ``` ## **Recipe Workflow** workflow.mmd ```mermaid graph TD setup-nav-market["call perps/market-discovery"] --> setup-wait-buttons["wait_for long button"] setup-wait-buttons --> ac1-press-long["press Long button"] ac1-press-long --> ac1-wait-order-screen["wait_for RedesignedConfirmations"] ac1-wait-order-screen --> ac1-assert-no-modal-presentation["eval_sync: assert route"] ac1-assert-no-modal-presentation --> ac2-screenshot-order-screen["screenshot: order entry"] ac2-screenshot-order-screen --> teardown-go-back["navigate: PerpsTrendingView"] teardown-go-back --> gate-done["end: pass"] ``` [TAT-3052]: https://consensyssoftware.atlassian.net/browse/TAT-3052?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Small navigation options change for one Perps confirmation screen path, with unit tests and no auth or data handling impact. > > **Overview** > Fixes the **market detail → order entry** navigation so it uses a **horizontal push** transition instead of a **bottom-up modal** when `showPerpsHeader` is false (Long/Short from market detail). > > **`getRedesignedConfirmationsHeaderOptions`** no longer spreads **`transparentModalScreenOptions`** or sets a transparent **`contentStyle`** in that branch, so the native stack keeps default push presentation. The helper is **exported** and covered by new unit tests asserting no `presentation` property and expected header flags. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2df4ae172f2c01c4067995db137e047b05846f6f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- ...designedConfirmationsHeaderOptions.test.ts | 30 +++++++++++++++++++ app/components/UI/Perps/routes/index.tsx | 4 +-- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts diff --git a/app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts b/app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts new file mode 100644 index 00000000000..8c22f7794e3 --- /dev/null +++ b/app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts @@ -0,0 +1,30 @@ +import { getRedesignedConfirmationsHeaderOptions } from './index'; + +describe('getRedesignedConfirmationsHeaderOptions', () => { + it('returns push-style options without modal presentation when showPerpsHeader is false', () => { + const options = getRedesignedConfirmationsHeaderOptions({ + showPerpsHeader: false, + }); + + expect(options.headerShown).toBe(false); + expect(options.headerBackVisible).toBe(false); + expect(options).not.toHaveProperty('presentation'); + expect(options.contentStyle).toBeUndefined(); + }); + + it('returns header-visible options when showPerpsHeader is true', () => { + const options = getRedesignedConfirmationsHeaderOptions({ + showPerpsHeader: true, + }); + + expect(options.headerShown).toBe(true); + expect(options.headerBackVisible).toBe(false); + expect(options).not.toHaveProperty('presentation'); + }); + + it('defaults to showing perps header when no params provided', () => { + const options = getRedesignedConfirmationsHeaderOptions(); + + expect(options.headerShown).toBe(true); + }); +}); diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 56e07b5069c..6f993a975ac 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -62,7 +62,7 @@ const styles = StyleSheet.create({ }, }); -function getRedesignedConfirmationsHeaderOptions({ +export function getRedesignedConfirmationsHeaderOptions({ showPerpsHeader = CONFIRMATION_HEADER_CONFIG.DefaultShowPerpsHeader, }: PerpsNavigationParamList['RedesignedConfirmations'] = {}): NativeStackNavigationOptions { if (showPerpsHeader) { @@ -76,8 +76,6 @@ function getRedesignedConfirmationsHeaderOptions({ headerShown: false, title: '', headerBackVisible: false, - contentStyle: { backgroundColor: 'transparent' }, - ...transparentModalScreenOptions, }; } From 37005a8f78047b92a8594366fbb95a9424e0c8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Tue, 26 May 2026 14:55:27 +0100 Subject: [PATCH 05/16] chore: adds the QuickBuy main sheet with modular buy flow (#30512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Introduces the new QuickBuy amount/main sheet for Top Traders, buy path. - Rebuilds the Top Traders QuickBuy as a modular, host-agnostic flow - Introduces a compound QuickBuy API (`QuickBuy.Root`, `AmountScreen`, `Toolbar`, `Amount`, `Footer`) with shared context and a default `AmountScreen` recipe when hosts only render QuickBuy.Root. - Removes legacy QuickBuyBottomSheet UI and the deprecated QuickBuySheet code. Follow-up PRs: quote details, select quote, Sell mode, pay-with view, etc. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches bridge quote submission, Redux bridge state reset, and amount-selection analytics while changing the primary copy-trading buy UX; logic is heavily tested but end-to-end swap flows deserve manual QA. > > **Overview** > Replaces the monolithic **QuickBuyBottomSheet** with a **`QuickBuy/`** module: compound API (`QuickBuy.Root`, `AmountScreen`, `Toolbar`, `Amount`, `Footer`), **`QuickBuyProvider`** / **`useQuickBuyContext`**, and **`TraderPositionQuickBuy`** mapping social `Position` → **`QuickBuyTarget`**. > > The buy sheet UX shifts from **USD preset chips** and a token-detail **header** to an amount-first layout: **toolbar**, large fiat/crypto **amount** (optional toggle), **exchange-rate tag**, **percentage slider** (0–100% of balance), simplified **pay-with** row, and updated **confirm** styling. **`useQuickBuyBottomSheet`** becomes **`useQuickBuyController`**; analytics move to **`useQuickBuyAnalytics`** (slider vs custom input, bridge reset on unmount). Quote wiring in **`useQuickBuyQuotes`** tolerates non-array **`quoteRequest`**. > > The old **`QuickBuyBottomSheet/`** tree (header, footer presets, expanded quote breakdown) is deleted; **`TraderPositionView`** now mounts **`TraderPositionQuickBuy`**. Broad unit test coverage for new primitives and hooks. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2fae60d08481db6cc786c420dc94b2ff67c67161. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../TraderPositionView.test.tsx | 12 +- .../TraderPositionView/TraderPositionView.tsx | 4 +- .../components/QuickBuy/QuickBuyAmount.tsx | 43 ++ .../QuickBuy/QuickBuyAmountScreen.tsx | 54 ++ .../QuickBuyBanners.test.tsx | 0 .../QuickBuyBanners.tsx | 0 .../QuickBuyBottomSheetSkeleton.test.tsx | 34 ++ .../QuickBuy/QuickBuyBottomSheetSkeleton.tsx | 77 +++ .../QuickBuy/QuickBuyConfirmButton.test.tsx | 68 +++ .../QuickBuyConfirmButton.tsx | 31 +- .../components/QuickBuy/QuickBuyContext.tsx | 60 +++ .../components/QuickBuy/QuickBuyRoot.test.tsx | 266 ++++++++++ .../components/QuickBuy/QuickBuyRoot.tsx | 128 +++++ .../QuickBuySheet.test.tsx} | 181 +++---- .../QuickBuy/TraderPositionQuickBuy.test.tsx | 147 ++++++ .../QuickBuy/TraderPositionQuickBuy.tsx | 67 +++ .../components/QuickBuyActionFooter.tsx | 158 ++++++ .../components/QuickBuyAmountSection.test.tsx | 145 ++++++ .../components/QuickBuyAmountSection.tsx | 145 ++++++ .../components/QuickBuyPercentageSlider.tsx | 164 +++++++ .../components/QuickBuyRateTag.test.tsx | 38 ++ .../QuickBuy/components/QuickBuyRateTag.tsx | 65 +++ .../QuickBuy/components/QuickBuyToolbar.tsx | 29 ++ .../components/QuickBuy/features.ts | 11 + .../getBridgeTokenImageSource.test.ts | 53 ++ .../getBridgeTokenImageSource.ts | 0 .../hooks/useQuickBuyAnalytics.test.ts | 303 ++++++++++++ .../QuickBuy/hooks/useQuickBuyAnalytics.ts | 139 ++++++ .../hooks/useQuickBuyController.ts} | 377 +++++++------- .../hooks}/useQuickBuyQuotes.ts | 46 +- .../hooks}/useQuickBuySetup.ts | 13 +- .../hooks}/useSourceTokenOptions.ts | 22 +- .../components/QuickBuy/index.ts | 28 ++ .../components/QuickBuy/quickBuy.ts | 13 + .../QuickBuy/sourceTokenCandidates.test.ts | 137 ++++++ .../sourceTokenCandidates.ts | 0 .../components/QuickBuy/types.ts | 60 +++ .../components/QuickBuy/useQuickBuyContext.ts | 14 + .../useQuickBuyController.test.ts} | 248 ++++++++-- .../useQuickBuyQuotes.test.ts | 2 +- .../useQuickBuySetup.test.ts | 2 +- .../useSourceTokenOptions.test.ts | 2 +- .../QuickBuy/utils/formatExchangeRate.test.ts | 48 ++ .../QuickBuy/utils/formatExchangeRate.ts | 33 ++ .../QuickBuy/utils/getMetamaskFeePercent.ts | 26 + .../QuickBuyAmountInput.test.tsx | 134 ----- .../QuickBuyAmountInput.tsx | 94 ---- .../QuickBuyBottomSheet.tsx | 261 ---------- .../QuickBuyBottomSheetSkeleton.tsx | 139 ------ .../QuickBuyFooter.test.tsx | 241 --------- .../QuickBuyBottomSheet/QuickBuyFooter.tsx | 461 ------------------ .../QuickBuyHeader.test.tsx | 139 ------ .../QuickBuyBottomSheet/QuickBuyHeader.tsx | 68 --- .../QuickBuyBottomSheet/SourceTokenPicker.tsx | 152 ------ .../components/QuickBuyBottomSheet/index.ts | 2 - .../analytics/socialLeaderboardEvents.ts | 1 + locales/languages/en.json | 7 +- 57 files changed, 3143 insertions(+), 2049 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmount.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/QuickBuyBanners.test.tsx (100%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/QuickBuyBanners.tsx (100%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/QuickBuyConfirmButton.tsx (68%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx => QuickBuy/QuickBuySheet.test.tsx} (66%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/getBridgeTokenImageSource.ts (100%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet/useQuickBuyBottomSheet.ts => QuickBuy/hooks/useQuickBuyController.ts} (68%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy/hooks}/useQuickBuyQuotes.ts (89%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy/hooks}/useQuickBuySetup.ts (89%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy/hooks}/useSourceTokenOptions.ts (90%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/sourceTokenCandidates.ts (100%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts => QuickBuy/useQuickBuyController.test.ts} (79%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/useQuickBuyQuotes.test.ts (99%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/useQuickBuySetup.test.ts (98%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/useSourceTokenOptions.test.ts (99%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index c6e184b3c44..5f7b5950a30 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -96,13 +96,11 @@ jest.mock('../../../../core/ClipboardManager', () => ({ setString: jest.fn().mockResolvedValue(undefined), })); -// Pressing buy mounts QuickBuyBottomSheet. Jest's global mock for design-system -// `BottomSheet` (see app/util/test/testSetup.js) invokes `onOpenBottomSheet`'s -// callback synchronously, so `QuickBuyBottomSheetContent` mounts in the same turn -// and runs `useQuickBuyBottomSheet` (bridge selectors, device version compare, -// NetworkController, …). This file intentionally uses a minimal Redux store, so -// we stub the sheet here. -jest.mock('./components/QuickBuyBottomSheet', () => ({ +// Pressing buy mounts TraderPositionQuickBuy (`QuickBuy.Root`). Jest's global mock +// for design-system `BottomSheet` (see app/util/test/testSetup.js) can mount +// QuickBuy provider/controller (bridge selectors, NetworkController, …). This +// file intentionally uses a minimal Redux store, so we stub the sheet here. +jest.mock('./components/QuickBuy', () => ({ __esModule: true, default: () => null, })); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index 8332c87685d..e2f96bfcdb7 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -33,7 +33,7 @@ import { IconName as ComponentLibraryIconName } from '../../../../component-libr import ClipboardManager from '../../../../core/ClipboardManager'; import { TraderPositionViewSelectorsIDs } from './TraderPositionView.testIds'; import { useTheme } from '../../../../util/theme'; -import QuickBuyBottomSheet from './components/QuickBuyBottomSheet'; +import TraderPositionQuickBuy from './components/QuickBuy'; import TraderPositionHeader from './components/TraderPositionHeader'; import TraderTokenInfoRow from './components/TraderTokenInfoRow'; import TraderPositionChartSection from './components/TraderPositionChartSection'; @@ -374,7 +374,7 @@ const TraderPositionView = () => { - { + const { + amountDisplayMode, + features, + usdAmount, + target, + estimatedReceiveAmount, + sourceBalanceFiat, + isQuoteLoading, + hiddenInputRef, + formattedExchangeRate, + handleAmountAreaPress, + handleAmountChange, + handleToggleAmountDisplay, + } = useQuickBuyContext(); + + return ( + } + /> + ); +}; + +export default QuickBuyAmount; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx new file mode 100644 index 00000000000..b0c13bbc1d7 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; +import { + Box, + BoxAlignItems, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../../locales/i18n'; +import QuickBuyAmount from './QuickBuyAmount'; +import QuickBuyActionFooter from './components/QuickBuyActionFooter'; +import QuickBuyToolbar from './components/QuickBuyToolbar'; +import { useQuickBuyContext } from './useQuickBuyContext'; + +const AnimatedScrollView = Animated.createAnimatedComponent( + GestureHandlerScrollView, +); + +/** + * Default amount-first buy layout (Figma Swap For You). + */ +const QuickBuyAmountScreen: React.FC = () => { + const tw = useTailwind(); + const { isUnsupportedChain } = useQuickBuyContext(); + + if (isUnsupportedChain) { + return ( + + + {strings('social_leaderboard.quick_buy.unsupported_chain')} + + + ); + } + + return ( + <> + + + + + + > + ); +}; + +export default QuickBuyAmountScreen; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx similarity index 100% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.test.tsx rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx similarity index 100% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.tsx rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx new file mode 100644 index 00000000000..4a1988d087b --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import QuickBuyBottomSheetSkeleton from './QuickBuyBottomSheetSkeleton'; + +describe('QuickBuyBottomSheetSkeleton', () => { + it('renders the loading container', () => { + render(); + expect(screen.getByTestId('quick-buy-content-loading')).toBeOnTheScreen(); + }); + + it('renders the slider skeleton', () => { + render(); + expect(screen.getByTestId('quick-buy-skeleton-slider')).toBeOnTheScreen(); + }); + + it('renders the pay-with pill skeleton', () => { + render(); + expect(screen.getByTestId('quick-buy-skeleton-pay-with')).toBeOnTheScreen(); + }); + + it('renders the confirm-button skeleton', () => { + render(); + expect( + screen.getByTestId('quick-buy-skeleton-confirm-button'), + ).toBeOnTheScreen(); + }); + + it('does not render the old USD preset buttons', () => { + render(); + expect( + screen.queryByTestId('quick-buy-skeleton-preset-20'), + ).not.toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx new file mode 100644 index 00000000000..af7de1c0cf4 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; +import { strings } from '../../../../../../../locales/i18n'; + +const QuickBuyBottomSheetSkeleton: React.FC = () => { + const tw = useTailwind(); + + return ( + + {/* Amount area — mirrors QuickBuyAmountSection pt-6 pb-4 */} + + {/* Primary amount */} + + {/* Secondary amount / rate tag */} + + {/* Available balance */} + + + + {/* Footer area — mirrors QuickBuyActionFooter px-4 pb-4 */} + + {/* Slider — mirrors pt-2 pb-3 */} + + + + + {/* Pay with row */} + + + {strings('social_leaderboard.quick_buy.pay_with')} + + + + + {/* Confirm button */} + + + + ); +}; + +export default QuickBuyBottomSheetSkeleton; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx new file mode 100644 index 00000000000..b9f0342b5b7 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import QuickBuyConfirmButton from './QuickBuyConfirmButton'; + +const defaultProps = { + state: 'idle' as const, + label: 'Confirm', + hasValidAmount: false, + isDisabled: false, + onPress: jest.fn(), + testID: 'confirm-button', +}; + +describe('QuickBuyConfirmButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the label in idle state', () => { + render(); + expect(screen.getByText('Confirm')).toBeOnTheScreen(); + }); + + it('does not render the label in loading state', () => { + render(); + expect(screen.queryByText('Confirm')).not.toBeOnTheScreen(); + }); + + it('does not render the label in success state', () => { + render(); + expect(screen.queryByText('Confirm')).not.toBeOnTheScreen(); + }); + + it('calls onPress when tapped in idle state with a valid amount', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('confirm-button')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not call onPress when state is loading', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('confirm-button')); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('does not call onPress when isDisabled is true', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('confirm-button')); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyConfirmButton.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.tsx similarity index 68% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyConfirmButton.tsx rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.tsx index 6d7f026d576..b897af0fc8f 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyConfirmButton.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.tsx @@ -10,6 +10,7 @@ import Animated, { withTiming, useAnimatedStyle, } from 'react-native-reanimated'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../../util/theme'; import Icon, { IconName, @@ -25,8 +26,9 @@ const styles = StyleSheet.create({ borderRadius: 12, alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }, - disabled: { + inactive: { opacity: 0.5, }, label: { @@ -38,6 +40,7 @@ const styles = StyleSheet.create({ interface QuickBuyConfirmButtonProps { state: ConfirmButtonState; label: string; + hasValidAmount: boolean; isDisabled: boolean; onPress: () => void; testID?: string; @@ -46,10 +49,12 @@ interface QuickBuyConfirmButtonProps { const QuickBuyConfirmButton: React.FC = ({ state, label, + hasValidAmount, isDisabled, onPress, testID, }) => { + const tw = useTailwind(); const { colors } = useTheme(); const checkScale = useSharedValue(0); @@ -62,12 +67,24 @@ const QuickBuyConfirmButton: React.FC = ({ transform: [{ scale: checkScale.value }], })); + // Use design-system ButtonPrimary token equivalents: + const activeContainerStyle = tw.style('bg-icon-default'); + const activeLabelStyle = tw.style('text-primary-inverse'); + + const labelColor = hasValidAmount + ? (activeLabelStyle.color as string) + : colors.text.alternative; + + const showInactiveStyle = isDisabled && state === 'idle'; + return ( = ({ activeOpacity={0.8} > {state === 'loading' && ( - + )} {state === 'success' && ( )} {state === 'idle' && ( - - {label} - + {label} )} ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx new file mode 100644 index 00000000000..bdbeb1a4da6 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx @@ -0,0 +1,60 @@ +import React, { createContext } from 'react'; +import { + useQuickBuyController, + type UseQuickBuyControllerResult, +} from './hooks/useQuickBuyController'; +import type { + QuickBuyAnalyticsContext, + QuickBuyFeatures, + QuickBuyScreen, + QuickBuyTarget, +} from './types'; + +export interface QuickBuyContextValue extends UseQuickBuyControllerResult { + target: QuickBuyTarget; + features: QuickBuyFeatures; + analyticsContext?: QuickBuyAnalyticsContext; + onClose: () => void; + activeScreen: QuickBuyScreen; + setActiveScreen: React.Dispatch>; +} + +export const QuickBuyContext = createContext(null); + +interface QuickBuyProviderProps { + target: QuickBuyTarget; + onClose: () => void; + features: QuickBuyFeatures; + analyticsContext?: QuickBuyAnalyticsContext; + activeScreen: QuickBuyScreen; + setActiveScreen: React.Dispatch>; + children: React.ReactNode; +} + +export const QuickBuyProvider: React.FC = ({ + target, + onClose, + features, + analyticsContext, + activeScreen, + setActiveScreen, + children, +}) => { + const controller = useQuickBuyController(target, onClose, analyticsContext); + + const value: QuickBuyContextValue = { + ...controller, + target, + features, + analyticsContext, + onClose, + activeScreen, + setActiveScreen, + }; + + return ( + + {children} + + ); +}; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx new file mode 100644 index 00000000000..c3cbd2be43c --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx @@ -0,0 +1,266 @@ +import React from 'react'; +import { act, screen } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import QuickBuyRoot from './QuickBuyRoot'; +import { useQuickBuyContext } from './useQuickBuyContext'; +import { + useQuickBuyController, + type UseQuickBuyControllerResult, +} from './hooks/useQuickBuyController'; +import { useQuickBuySetup } from './hooks/useQuickBuySetup'; +import { positionToQuickBuyTarget } from './types'; +import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features'; +import type { Position } from '@metamask/social-controllers'; + +jest.mock('./hooks/useQuickBuyController', () => ({ + useQuickBuyController: jest.fn(), +})); + +jest.mock('./hooks/useQuickBuySetup', () => ({ + useQuickBuySetup: jest.fn(), +})); + +let storedOnOpenCallback: (() => void) | undefined; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactMock = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return { + ...actual, + BottomSheet: ReactMock.forwardRef( + ( + { + children, + onClose, + }: { + children: unknown; + onClose?: () => void; + }, + ref: unknown, + ) => { + ReactMock.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: (cb: () => void) => { + storedOnOpenCallback = cb; + }, + })); + return ReactMock.createElement( + View, + { testID: 'mock-bottom-sheet', onTouchEnd: onClose }, + children, + ); + }, + ), + }; +}); + +jest.mock('./components/QuickBuyToolbar', () => { + const ReactMock = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement(Text, { testID: 'mock-toolbar' }, 'toolbar'), + }; +}); + +jest.mock('./QuickBuyAmount', () => { + const ReactMock = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement( + Text, + { testID: 'mock-amount-section' }, + 'amount-section', + ), + }; +}); + +jest.mock('./components/QuickBuyActionFooter', () => { + const ReactMock = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement(Text, { testID: 'mock-action-footer' }, 'footer'), + }; +}); + +jest.mock('./QuickBuyBottomSheetSkeleton', () => { + const ReactMock = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement( + Text, + { testID: 'mock-skeleton' }, + 'quick-buy-content-loading', + ), + }; +}); + +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockCreateRef = () => ({ current: null }); + +const buildHookResult = ( + overrides: Partial = {}, +): UseQuickBuyControllerResult => ({ + hiddenInputRef: mockCreateRef() as never, + destToken: undefined, + isSetupLoading: false, + isUnsupportedChain: false, + sourceToken: undefined, + sourceChainId: '0x1', + sourceTokenOptions: [], + selectedSourceToken: undefined, + isSourcePickerOpen: false, + setIsSourcePickerOpen: jest.fn(), + setSelectedSourceToken: jest.fn(), + amountDisplayMode: 'fiat', + usdAmount: '', + sliderPercent: 0, + maxSpendUsd: 0, + formattedExchangeRate: undefined, + metamaskFeePercent: 0, + estimatedReceiveAmount: undefined, + sourceBalanceFiat: '$0.00', + sourceBalanceDisplay: undefined, + formattedNetworkFee: '-', + formattedSlippage: '-', + formattedMinimumReceived: '-', + formattedPriceImpact: '-', + totalAmountUsd: '$0', + isQuoteLoading: false, + isSubmittingTx: false, + isTotalLoading: false, + isHardwareSolanaBlocked: false, + priceImpactViewData: { + textColor: TextColor.TextAlternative, + icon: undefined, + title: 'bridge.price_impact_info_title', + description: 'bridge.price_impact_info_description', + }, + isPriceImpactError: false, + buttonError: null, + hasValidAmount: false, + isConfirmDisabled: true, + confirmButtonState: 'idle', + getButtonLabel: () => 'social_leaderboard.trader_position.buy', + handleClose: jest.fn(), + handleSliderChange: jest.fn(), + handleAmountAreaPress: jest.fn(), + handleAmountChange: jest.fn(), + handleToggleAmountDisplay: jest.fn(), + handleConfirm: jest.fn(), + ...overrides, +}); + +const createPosition = (overrides: Partial = {}): Position => + ({ + chain: 'base', + tokenAddress: '0x1234567890123456789012345678901234567890', + tokenSymbol: 'PEPE', + tokenName: 'Pepe', + positionAmount: 1000, + boughtUsd: 500, + soldUsd: 0, + realizedPnl: 0, + costBasis: 500, + trades: [], + lastTradeAt: 0, + currentValueUSD: 900, + pnlValueUsd: 400, + pnlPercent: 80, + ...overrides, + }) as Position; + +describe('QuickBuyRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + storedOnOpenCallback = undefined; + (useQuickBuyController as jest.Mock).mockReturnValue(buildHookResult()); + (useQuickBuySetup as jest.Mock).mockReturnValue({ + chainId: '0x1', + destToken: undefined, + isLoading: false, + isUnsupportedChain: false, + }); + }); + + it('renders default AmountScreen content when no children are passed', () => { + renderWithProvider( + , + ); + + act(() => { + storedOnOpenCallback?.(); + }); + + expect(screen.getByTestId('mock-toolbar')).toBeOnTheScreen(); + expect(screen.getByTestId('mock-amount-section')).toBeOnTheScreen(); + expect(screen.getByTestId('mock-action-footer')).toBeOnTheScreen(); + }); + + it('shows unsupported chain message without amount flow', () => { + (useQuickBuyController as jest.Mock).mockReturnValue( + buildHookResult({ isUnsupportedChain: true }), + ); + + renderWithProvider( + , + ); + + act(() => { + storedOnOpenCallback?.(); + }); + + expect( + screen.getByText('social_leaderboard.quick_buy.unsupported_chain'), + ).toBeOnTheScreen(); + expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen(); + }); +}); + +describe('useQuickBuyContext guard', () => { + it('throws when useQuickBuyContext is called outside QuickBuy.Root', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const ContextProbe = () => { + useQuickBuyContext(); + return null; + }; + + expect(() => renderWithProvider()).toThrow( + 'QuickBuy compound components must be rendered within QuickBuy.Root', + ); + + consoleError.mockRestore(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx new file mode 100644 index 00000000000..3827a9fb44b --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx @@ -0,0 +1,128 @@ +import { + BottomSheet, + type BottomSheetRef, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import React, { useEffect, useRef, useState } from 'react'; +import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; +import { useSelector } from 'react-redux'; +import { selectIsSubmittingTx } from '../../../../../../core/redux/slices/bridge'; +import QuickBuyAmountScreen from './QuickBuyAmountScreen'; +import { QuickBuyProvider } from './QuickBuyContext'; +import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features'; +import QuickBuyBottomSheetSkeleton from './QuickBuyBottomSheetSkeleton'; +import type { + QuickBuyAnalyticsContext, + QuickBuyFeatures, + QuickBuyRootProps, + QuickBuyScreen, + QuickBuyTarget, +} from './types'; + +export type { QuickBuyRootProps } from './types'; + +const AnimatedScrollView = Animated.createAnimatedComponent( + GestureHandlerScrollView, +); + +function renderActiveScreen( + activeScreen: QuickBuyScreen, + children: React.ReactNode | undefined, +): React.ReactNode { + if (children !== undefined && children !== null) { + return children; + } + + switch (activeScreen) { + case 'amount': + default: + return ; + } +} + +interface QuickBuyRootInnerProps { + target: QuickBuyTarget; + onClose: () => void; + features: QuickBuyFeatures; + analyticsContext?: QuickBuyAnalyticsContext; + children?: React.ReactNode; +} + +const QuickBuyRootInner: React.FC = ({ + target, + onClose, + features, + analyticsContext, + children, +}) => { + const tw = useTailwind(); + const bottomSheetRef = useRef(null); + const [isContentReady, setIsContentReady] = useState(false); + const [activeScreen, setActiveScreen] = useState('amount'); + const isSubmittingTx = useSelector(selectIsSubmittingTx); + + useEffect(() => { + bottomSheetRef.current?.onOpenBottomSheet(() => { + setIsContentReady(true); + }); + }, []); + + return ( + + {isContentReady ? ( + + {renderActiveScreen(activeScreen, children)} + + ) : ( + + + + )} + + ); +}; + +/** + * Compound Quick Buy root — bottom sheet, provider, and screen routing. + */ +const QuickBuyRoot: React.FC = ({ + isVisible, + target, + onClose, + features = TOP_TRADERS_QUICK_BUY_FEATURES, + analyticsContext, + children, +}) => { + if (!isVisible || !target) { + return null; + } + + return ( + + {children} + + ); +}; + +export default QuickBuyRoot; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx similarity index 66% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx index f7c8a71dcac..7788729b931 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx @@ -3,19 +3,28 @@ import { act, fireEvent, screen } from '@testing-library/react-native'; import { TextColor } from '@metamask/design-system-react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import type { Position } from '@metamask/social-controllers'; -import QuickBuyBottomSheet from './QuickBuyBottomSheet'; +import { QuickBuy } from './quickBuy'; import { - useQuickBuyBottomSheet, - type UseQuickBuyBottomSheetResult, -} from './useQuickBuyBottomSheet'; -import { useQuickBuySetup } from './useQuickBuySetup'; - -// Mock the heavy hook so we can control all rendered state -jest.mock('./useQuickBuyBottomSheet', () => ({ - useQuickBuyBottomSheet: jest.fn(), + useQuickBuyController, + type UseQuickBuyControllerResult, +} from './hooks/useQuickBuyController'; +import { useQuickBuySetup } from './hooks/useQuickBuySetup'; +import { positionToQuickBuyTarget } from './types'; +import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features'; + +const mockControllerState: { + getResult: () => UseQuickBuyControllerResult; +} = { + getResult: () => { + throw new Error('QuickBuy controller mock not initialized'); + }, +}; + +jest.mock('./hooks/useQuickBuyController', () => ({ + useQuickBuyController: jest.fn(), })); -jest.mock('./useQuickBuySetup', () => ({ +jest.mock('./hooks/useQuickBuySetup', () => ({ useQuickBuySetup: jest.fn(), })); @@ -59,29 +68,17 @@ jest.mock('@metamask/design-system-react-native', () => { }); // Mock sub-components so their own dep trees don't pollute these tests -jest.mock('./QuickBuyHeader', () => { +jest.mock('./components/QuickBuyToolbar', () => { const ReactMock = jest.requireActual('react'); const { Text } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ - position, - marketCap, - }: { - position: Position; - marketCap?: number; - }) => - ReactMock.createElement( - Text, - { testID: 'mock-header' }, - marketCap != null - ? `${position.tokenSymbol}:${marketCap}` - : position.tokenSymbol, - ), + default: () => + ReactMock.createElement(Text, { testID: 'mock-toolbar' }, 'toolbar'), }; }); -jest.mock('./QuickBuyAmountInput', () => { +jest.mock('./components/QuickBuyAmountSection', () => { const ReactMock = jest.requireActual('react'); const { Text } = jest.requireActual('react-native'); return { @@ -89,19 +86,28 @@ jest.mock('./QuickBuyAmountInput', () => { default: () => ReactMock.createElement( Text, - { testID: 'mock-amount-input' }, - 'amount-input', + { testID: 'mock-amount-section' }, + 'amount-section', ), }; }); -jest.mock('./QuickBuyFooter', () => { +jest.mock('./components/QuickBuyActionFooter', () => { const ReactMock = jest.requireActual('react'); - const { Text } = jest.requireActual('react-native'); + const { Text, TouchableOpacity } = jest.requireActual('react-native'); return { __esModule: true, default: () => - ReactMock.createElement(Text, { testID: 'mock-footer' }, 'footer'), + ReactMock.createElement( + TouchableOpacity, + { + testID: 'quick-buy-confirm-button', + onPress: () => { + mockControllerState.getResult().handleConfirm(); + }, + }, + ReactMock.createElement(Text, null, 'confirm'), + ), }; }); @@ -165,8 +171,8 @@ jest.mock('../../../../../../../locales/i18n', () => ({ const mockCreateRef = () => ({ current: null }); const buildHookResult = ( - overrides: Partial = {}, -): UseQuickBuyBottomSheetResult => ({ + overrides: Partial = {}, +): UseQuickBuyControllerResult => ({ hiddenInputRef: mockCreateRef() as never, destToken: undefined, isSetupLoading: false, @@ -179,8 +185,13 @@ const buildHookResult = ( setIsSourcePickerOpen: jest.fn(), setSelectedSourceToken: jest.fn(), usdAmount: '', + sliderPercent: 0, + maxSpendUsd: 0, + formattedExchangeRate: undefined, + metamaskFeePercent: 0, estimatedReceiveAmount: undefined, - sourceBalanceFiat: undefined, + sourceBalanceFiat: '$0.00', + sourceBalanceDisplay: undefined, formattedNetworkFee: '-', formattedSlippage: '-', formattedMinimumReceived: '-', @@ -203,9 +214,11 @@ const buildHookResult = ( confirmButtonState: 'idle', getButtonLabel: () => 'social_leaderboard.trader_position.buy', handleClose: jest.fn(), - handlePresetPress: jest.fn(), + handleSliderChange: jest.fn(), handleAmountAreaPress: jest.fn(), handleAmountChange: jest.fn(), + handleToggleAmountDisplay: jest.fn(), + amountDisplayMode: 'fiat', handleConfirm: jest.fn(), ...overrides, }); @@ -229,11 +242,20 @@ const createPosition = (overrides: Partial = {}): Position => ...overrides, }) as Position; -describe('QuickBuyBottomSheet', () => { +const setMockQuickBuyController = ( + overrides: Partial = {}, +) => { + mockControllerState.getResult = () => buildHookResult(overrides); + (useQuickBuyController as jest.Mock).mockImplementation(() => + mockControllerState.getResult(), + ); +}; + +describe('QuickBuy.Root', () => { beforeEach(() => { jest.clearAllMocks(); storedOnOpenCallback = undefined; - (useQuickBuyBottomSheet as jest.Mock).mockReturnValue(buildHookResult()); + setMockQuickBuyController(); (useQuickBuySetup as jest.Mock).mockReturnValue({ chainId: '0x1', destToken: undefined, @@ -249,9 +271,10 @@ describe('QuickBuyBottomSheet', () => { describe('outer gate', () => { it('renders nothing when isVisible is false', () => { renderWithProvider( - , ); @@ -261,9 +284,10 @@ describe('QuickBuyBottomSheet', () => { it('mounts the inner sheet when visible with a valid position', () => { renderWithProvider( - , ); @@ -273,57 +297,48 @@ describe('QuickBuyBottomSheet', () => { }); describe('inner sheet', () => { - it('renders the header with the position token symbol', () => { + it('renders the toolbar after deferred content becomes ready', () => { renderWithProvider( - , ); - expect(screen.getByTestId('mock-header')).toBeOnTheScreen(); - expect(screen.getByText('PEPE')).toBeOnTheScreen(); - }); - - it('forwards the marketCap prop to the header', () => { - renderWithProvider( - , - ); + act(() => { + storedOnOpenCallback?.(); + }); - expect(screen.getByText('PEPE:2300000')).toBeOnTheScreen(); + expect(screen.getByTestId('mock-toolbar')).toBeOnTheScreen(); }); it('renders the skeleton body before deferred content becomes ready', () => { renderWithProvider( - , ); - // storedOnOpenCallback is not called — isContentReady stays false - expect(screen.getByTestId('mock-header')).toBeOnTheScreen(); expect(screen.getByTestId('mock-skeleton')).toBeOnTheScreen(); - expect(screen.queryByTestId('mock-amount-input')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('mock-footer')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('mock-toolbar')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen(); }); it('shows an unsupported chain message instead of the buy flow', () => { - (useQuickBuyBottomSheet as jest.Mock).mockReturnValue( - buildHookResult({ isUnsupportedChain: true }), - ); + setMockQuickBuyController({ isUnsupportedChain: true }); renderWithProvider( - , ); @@ -334,19 +349,21 @@ describe('QuickBuyBottomSheet', () => { expect( screen.getByText('social_leaderboard.quick_buy.unsupported_chain'), ).toBeOnTheScreen(); - expect(screen.queryByTestId('mock-amount-input')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('mock-footer')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('mock-toolbar')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen(); + expect( + screen.queryByTestId('quick-buy-confirm-button'), + ).not.toBeOnTheScreen(); }); it('renders the amount input, footer details, and sticky confirm button for a supported chain', () => { - (useQuickBuyBottomSheet as jest.Mock).mockReturnValue( - buildHookResult({ isUnsupportedChain: false }), - ); + setMockQuickBuyController({ isUnsupportedChain: false }); renderWithProvider( - , ); @@ -354,21 +371,19 @@ describe('QuickBuyBottomSheet', () => { storedOnOpenCallback?.(); }); - expect(screen.getByTestId('mock-amount-input')).toBeOnTheScreen(); - expect(screen.getByTestId('mock-footer')).toBeOnTheScreen(); + expect(screen.getByTestId('mock-amount-section')).toBeOnTheScreen(); expect(screen.getByTestId('quick-buy-confirm-button')).toBeOnTheScreen(); }); it('calls handleConfirm from the sticky confirm button', () => { const handleConfirm = jest.fn(); - (useQuickBuyBottomSheet as jest.Mock).mockReturnValue( - buildHookResult({ isUnsupportedChain: false, handleConfirm }), - ); + setMockQuickBuyController({ isUnsupportedChain: false, handleConfirm }); renderWithProvider( - , ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx new file mode 100644 index 00000000000..2c34eb255c0 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import TraderPositionQuickBuy from './TraderPositionQuickBuy'; +import type { Position } from '@metamask/social-controllers'; + +const mockQuickBuyRoot = jest.fn((_props: unknown) => null); + +jest.mock('./quickBuy', () => ({ + QuickBuy: { + Root: (props: unknown) => mockQuickBuyRoot(props), + }, +})); + +jest.mock('./features', () => ({ + TOP_TRADERS_QUICK_BUY_FEATURES: { tradeModes: ['buy'] }, +})); + +jest.mock('./types', () => ({ + positionToQuickBuyTarget: (p: Position) => ({ + tokenAddress: p.tokenAddress, + tokenSymbol: p.tokenSymbol, + tokenName: p.tokenName, + chain: p.chain, + }), +})); + +const mockPosition: Position = { + tokenAddress: '0xtoken', + tokenSymbol: 'TKN', + tokenName: 'Token', + chain: '0x1', +} as unknown as Position; + +describe('TraderPositionQuickBuy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalled(); + }); + + it('passes null target when position is null', () => { + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ target: null }), + ); + }); + + it('maps position to QuickBuyTarget', () => { + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + tokenAddress: '0xtoken', + tokenSymbol: 'TKN', + tokenName: 'Token', + chain: '0x1', + }, + }), + ); + }); + + it('passes analyticsContext when at least one analytics prop is defined', () => { + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ + analyticsContext: { + traderAddress: '0xtrader', + marketCap: 1000000, + source: 'leaderboard', + }, + }), + ); + }); + + it('passes undefined analyticsContext when no analytics props are provided', () => { + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ analyticsContext: undefined }), + ); + }); + + it('passes only defined analytics props in context', () => { + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ + analyticsContext: { + traderAddress: '0xtrader', + marketCap: undefined, + source: undefined, + }, + }), + ); + }); + + it('forwards isVisible and onClose to QuickBuy.Root', () => { + const onClose = jest.fn(); + render( + , + ); + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ isVisible: false, onClose }), + ); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx new file mode 100644 index 00000000000..490c14aa946 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx @@ -0,0 +1,67 @@ +import type { Position } from '@metamask/social-controllers'; +import React, { useMemo } from 'react'; +import type { QuickBuySheetSource } from '../../../analytics'; +import { QuickBuy } from './quickBuy'; +import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features'; +import { positionToQuickBuyTarget } from './types'; + +export interface TraderPositionQuickBuyProps { + isVisible: boolean; + position: Position | null; + onClose: () => void; + traderAddress?: string; + marketCap?: number; + source?: QuickBuySheetSource; +} + +/** + * Top Traders adapter — maps social `Position` to `QuickBuyTarget` and + * bundles leaderboard analytics into `analyticsContext`. + */ +const TraderPositionQuickBuy: React.FC = ({ + position, + isVisible, + onClose, + traderAddress, + marketCap, + source, +}) => { + // Memoise on primitive fields so the target reference stays stable while + // the underlying position doesn't change. Without this, every parent + // re-render produces a new target object, which destabilises `destToken` + // inside `useQuickBuySetup`, which in turn re-triggers `useQuickBuyQuotes`' + // fetch effect — aborting in-flight quotes before they resolve and leaving + // the spinner stuck on. + // Stabilise the derived `target` reference so it doesn't destabilise the + // `destToken` memo inside `useQuickBuySetup` (which would in turn re-trigger + // `useQuickBuyQuotes`' fetch effect and abort in-flight quotes). + // + // `position` is reference-stable upstream — it's either a nav-param value or + // the cached result of `useTraderPosition` — so memoising on it is enough. + // If a future caller starts allocating a fresh `Position` on every render, + // switch to primitive-field deps (tokenAddress, tokenSymbol, tokenName, chain). + const target = useMemo( + () => (position ? positionToQuickBuyTarget(position) : null), + [position], + ); + + const analyticsContext = useMemo(() => { + const hasAny = + traderAddress !== undefined || + marketCap !== undefined || + source !== undefined; + return hasAny ? { traderAddress, marketCap, source } : undefined; + }, [traderAddress, marketCap, source]); + + return ( + + ); +}; + +export default TraderPositionQuickBuy; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx new file mode 100644 index 00000000000..aead18acdbd --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Text, + TextVariant, + TextColor, + AvatarToken, + AvatarTokenSize, + Icon, + IconColor, + IconName, + IconSize, + BadgeWrapper, + BadgeWrapperPosition, + BadgeNetwork, +} from '@metamask/design-system-react-native'; +import { TouchableOpacity } from 'react-native'; +import { strings } from '../../../../../../../../locales/i18n'; +import QuickBuyConfirmButton from '../QuickBuyConfirmButton'; +import QuickBuyBanners from '../QuickBuyBanners'; +import { useQuickBuyContext } from '../useQuickBuyContext'; +import { QuickBuyPercentageSlider } from './QuickBuyPercentageSlider'; +import { getNetworkImageSource } from '../../../../../../../util/networks'; +import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource'; + +const QuickBuyActionFooter: React.FC = () => { + const { + sliderPercent, + maxSpendUsd, + handleSliderChange, + confirmButtonState, + getButtonLabel, + hasValidAmount, + isConfirmDisabled, + handleConfirm, + metamaskFeePercent, + isHardwareSolanaBlocked, + isPriceImpactError, + priceImpactViewData, + formattedPriceImpact, + sourceToken, + sourceChainId, + sourceBalanceFiat, + features, + } = useQuickBuyContext(); + + const isPriceImpactWarning = + !isPriceImpactError && !!priceImpactViewData.icon; + + const networkImage = sourceChainId + ? getNetworkImageSource({ chainId: sourceChainId }) + : undefined; + + return ( + + {/* Slider — reduced top padding to tighten gap with the amount section */} + + + + + {/* Pay with row */} + + + {strings('social_leaderboard.quick_buy.pay_with')} + + + + + {sourceToken ? ( + networkImage ? ( + } + > + + + ) : ( + + ) + ) : null} + + {sourceToken + ? sourceBalanceFiat + ? `${sourceToken.symbol} (${sourceBalanceFiat})` + : sourceToken.symbol + : '—'} + + {features.payWithSheet ? ( + + ) : null} + + + + + + + + + {metamaskFeePercent > 0 ? ( + + + {strings('social_leaderboard.quick_buy.includes_mm_fee', { + fee: metamaskFeePercent, + })} + + + ) : null} + + ); +}; + +export default QuickBuyActionFooter; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx new file mode 100644 index 00000000000..8ab1691edbe --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx @@ -0,0 +1,145 @@ +import React, { createRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { TextInput } from 'react-native'; +import QuickBuyAmountSection from './QuickBuyAmountSection'; + +const defaultProps = { + amountDisplayMode: 'fiat' as const, + fiatCryptoToggleEnabled: false, + usdAmount: '', + destSymbol: 'ETH', + estimatedReceiveAmount: undefined, + availableBalanceFiat: '$0.00', + isQuoteLoading: false, + hiddenInputRef: createRef(), + onAmountAreaPress: jest.fn(), + onAmountChange: jest.fn(), + onToggleAmountDisplay: jest.fn(), +}; + +describe('QuickBuyAmountSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the fiat amount as primary in fiat mode', () => { + render( + , + ); + expect(screen.getByText('$50')).toBeOnTheScreen(); + }); + + it('shows $0 placeholder when usdAmount is empty', () => { + render(); + expect(screen.getByText('$0')).toBeOnTheScreen(); + }); + + it('renders the crypto amount as primary in crypto mode', () => { + render( + , + ); + expect(screen.getByText('0.025 ETH')).toBeOnTheScreen(); + }); + + it('shows 0 crypto placeholder when estimatedReceiveAmount is undefined', () => { + render( + , + ); + expect(screen.getByText('0 ETH')).toBeOnTheScreen(); + }); + + it('replaces the secondary label with an ActivityIndicator when isQuoteLoading', () => { + render( + , + ); + expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen(); + // Secondary label is replaced by spinner — crypto label should NOT be present + expect(screen.queryByText('0 ETH')).not.toBeOnTheScreen(); + }); + + it('shows the secondary label when NOT loading', () => { + render(); + expect(screen.getByText('0 ETH')).toBeOnTheScreen(); + }); + + it('shows the toggle button when fiatCryptoToggleEnabled', () => { + render(); + expect( + screen.getByTestId('quick-buy-toggle-amount-display'), + ).toBeOnTheScreen(); + }); + + it('hides the toggle button when fiatCryptoToggleEnabled is false', () => { + render( + , + ); + expect( + screen.queryByTestId('quick-buy-toggle-amount-display'), + ).not.toBeOnTheScreen(); + }); + + it('calls onToggleAmountDisplay when toggle is pressed', () => { + const onToggleAmountDisplay = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('quick-buy-toggle-amount-display')); + expect(onToggleAmountDisplay).toHaveBeenCalledTimes(1); + }); + + it('calls onAmountAreaPress when the area is pressed', () => { + const onAmountAreaPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('quick-buy-amount-area')); + expect(onAmountAreaPress).toHaveBeenCalledTimes(1); + }); + + it('shows available balance when provided', () => { + render( + , + ); + expect(screen.getByText(/\$1,234.56/)).toBeOnTheScreen(); + }); + + it('shows locale-formatted zero available when balance is zero', () => { + render( + , + ); + expect(screen.getByText(/\$0\.00/)).toBeOnTheScreen(); + expect(screen.getByText(/available/)).toBeOnTheScreen(); + }); + + it('renders a rateTag node when provided without error', () => { + render(>} />); + expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx new file mode 100644 index 00000000000..bb9fcebb9c6 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { + StyleSheet, + TextInput, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import { + Box, + Text, + TextVariant, + TextColor, + FontWeight, + BoxAlignItems, + BoxJustifyContent, + BoxFlexDirection, + Icon, + IconColor, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../../locales/i18n'; +import type { QuickBuyAmountDisplayMode } from '../types'; + +const styles = StyleSheet.create({ + amountText: { fontSize: 48, lineHeight: 52 }, + hiddenInput: { position: 'absolute', opacity: 0, height: 0 }, +}); + +interface QuickBuyAmountSectionProps { + amountDisplayMode: QuickBuyAmountDisplayMode; + fiatCryptoToggleEnabled: boolean; + usdAmount: string; + destSymbol: string; + estimatedReceiveAmount: string | undefined; + availableBalanceFiat: string; + isQuoteLoading: boolean; + hiddenInputRef: React.RefObject; + onAmountAreaPress: () => void; + onAmountChange: (text: string) => void; + onToggleAmountDisplay: () => void; + rateTag?: React.ReactNode; +} + +const QuickBuyAmountSection: React.FC = ({ + amountDisplayMode, + fiatCryptoToggleEnabled, + usdAmount, + destSymbol, + estimatedReceiveAmount, + availableBalanceFiat, + isQuoteLoading, + hiddenInputRef, + onAmountAreaPress, + onAmountChange, + onToggleAmountDisplay, + rateTag, +}) => { + const fiatAmountLabel = usdAmount ? `$${usdAmount}` : '$0'; + const cryptoAmountLabel = estimatedReceiveAmount + ? `${estimatedReceiveAmount} ${destSymbol}` + : `0 ${destSymbol}`; + + const isCryptoPrimary = amountDisplayMode === 'crypto'; + const primaryLabel = isCryptoPrimary ? cryptoAmountLabel : fiatAmountLabel; + const secondaryLabel = isCryptoPrimary ? fiatAmountLabel : cryptoAmountLabel; + + return ( + + + + {primaryLabel} + + + {rateTag} + + {isQuoteLoading ? ( + + ) : ( + + + {secondaryLabel} + + {fiatCryptoToggleEnabled ? ( + + + + ) : null} + + )} + + + {strings('social_leaderboard.quick_buy.available_balance', { + amount: availableBalanceFiat, + })} + + + + + + ); +}; + +export default QuickBuyAmountSection; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx new file mode 100644 index 00000000000..4437855e0df --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { AccessibilityActionEvent, LayoutChangeEvent } from 'react-native'; +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +const HANDLE_SIZE = 24; +const MARKER_SIZE = 4; +const PERCENTAGE_STEP = 25; +export const SNAP_POINTS = [0, 25, 50, 75, 100]; + +export function snapToPercentageStep(value: number): number { + const snappedValue = Math.round(value / PERCENTAGE_STEP) * PERCENTAGE_STEP; + return Math.max(0, Math.min(100, snappedValue)); +} + +interface QuickBuyPercentageSliderProps { + value: number; + onValueChange: (value: number) => void; + disabled?: boolean; + testID?: string; +} + +export function QuickBuyPercentageSlider({ + value, + onValueChange, + disabled = false, + testID = 'quick-buy-percentage-slider', +}: QuickBuyPercentageSliderProps) { + const tw = useTailwind(); + const sliderWidth = useSharedValue(0); + const translateX = useSharedValue(0); + const widthRef = useRef(0); + + const updatePosition = useCallback( + (nextValue: number, width = widthRef.current) => { + const snappedValue = snapToPercentageStep(nextValue); + translateX.value = (snappedValue / 100) * width; + }, + [translateX], + ); + + const updateValueFromPosition = useCallback( + (position: number, width: number) => { + if (width === 0 || disabled) return; + const clampedPosition = Math.max(0, Math.min(position, width)); + const nextValue = snapToPercentageStep((clampedPosition / width) * 100); + updatePosition(nextValue, width); + if (nextValue !== value) { + onValueChange(nextValue); + } + }, + [disabled, onValueChange, updatePosition, value], + ); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout; + widthRef.current = width; + sliderWidth.value = width; + updatePosition(value, width); + }, + [sliderWidth, updatePosition, value], + ); + + useEffect(() => { + updatePosition(value); + }, [updatePosition, value]); + + const progressStyle = useAnimatedStyle(() => ({ + width: translateX.value, + })); + + const handleStyle = useAnimatedStyle(() => { + const handleOffset = Math.max( + 0, + Math.min( + translateX.value - HANDLE_SIZE / 2, + sliderWidth.value - HANDLE_SIZE, + ), + ); + return { transform: [{ translateX: handleOffset }] }; + }); + + const gesture = Gesture.Simultaneous( + Gesture.Tap().onEnd((event) => { + runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); + }), + Gesture.Pan().onUpdate((event) => { + runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); + }), + ); + + const handleAccessibilityAction = useCallback( + (event: AccessibilityActionEvent) => { + const nextValue = + event.nativeEvent.actionName === 'increment' + ? snapToPercentageStep(value + PERCENTAGE_STEP) + : snapToPercentageStep(value - PERCENTAGE_STEP); + if (nextValue !== value) { + onValueChange(nextValue); + } + }, + [onValueChange, value], + ); + + return ( + + + + + + {SNAP_POINTS.map((snapPoint) => ( + + ))} + + + + + ); +} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx new file mode 100644 index 00000000000..584faac1543 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import QuickBuyRateTag from './QuickBuyRateTag'; + +describe('QuickBuyRateTag', () => { + it('renders nothing when label is undefined', () => { + render(); + expect(screen.queryByTestId('quick-buy-rate-tag')).not.toBeOnTheScreen(); + }); + + it('renders nothing when label is empty', () => { + render(); + expect(screen.queryByTestId('quick-buy-rate-tag')).not.toBeOnTheScreen(); + }); + + it('renders the label inside a non-pressable container when onPress is not provided', () => { + render(); + expect(screen.getByTestId('quick-buy-rate-tag')).toBeOnTheScreen(); + expect(screen.getByText('1 ETH = 1000 USDC')).toBeOnTheScreen(); + expect( + screen.queryByTestId('quick-buy-rate-tag-pressable'), + ).not.toBeOnTheScreen(); + }); + + it('renders a pressable wrapper when onPress is provided', () => { + render(); + expect( + screen.getByTestId('quick-buy-rate-tag-pressable'), + ).toBeOnTheScreen(); + }); + + it('invokes onPress when pressed', () => { + const onPress = jest.fn(); + render(); + fireEvent.press(screen.getByTestId('quick-buy-rate-tag-pressable')); + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx new file mode 100644 index 00000000000..a5dcfaa2008 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Text, + TextVariant, + TextColor, + Icon, + IconColor, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; + +interface QuickBuyRateTagProps { + label: string | undefined; + onPress?: () => void; +} + +const QuickBuyRateTag: React.FC = ({ + label, + onPress, +}) => { + if (!label) return null; + + const content = ( + + + {label} + + + + ); + + return ( + + {onPress ? ( + + {content} + + ) : ( + content + )} + + ); +}; + +export default QuickBuyRateTag; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx new file mode 100644 index 00000000000..f65e69543e1 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../../locales/i18n'; + +const QuickBuyToolbar: React.FC = () => ( + + + + {strings('social_leaderboard.quick_buy.buy_mode')} + + + +); + +export default QuickBuyToolbar; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts new file mode 100644 index 00000000000..af4bc499363 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts @@ -0,0 +1,11 @@ +import type { QuickBuyFeatures } from './types'; + +/** Top Traders — buy-only amount sheet. */ +export const TOP_TRADERS_QUICK_BUY_FEATURES: QuickBuyFeatures = { + tradeModes: ['buy'], + quoteDetails: false, + selectQuote: false, + payWithSheet: false, + highPriceImpactModal: false, + fiatCryptoToggle: true, +}; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts new file mode 100644 index 00000000000..11567abbdd9 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts @@ -0,0 +1,53 @@ +import { getBridgeTokenImageSource } from './getBridgeTokenImageSource'; +import { getAssetImageUrl } from '../../../../../UI/Bridge/hooks/useAssetMetadata/utils'; +import type { BridgeToken } from '../../../../../UI/Bridge/types'; + +jest.mock('../../../../../UI/Bridge/hooks/useAssetMetadata/utils', () => ({ + getAssetImageUrl: jest.fn(), +})); + +const mockGetAssetImageUrl = getAssetImageUrl as jest.MockedFunction< + typeof getAssetImageUrl +>; + +const baseToken: BridgeToken = { + address: '0xabc', + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', +}; + +describe('getBridgeTokenImageSource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the token image URI when token.image is set', () => { + const token = { ...baseToken, image: 'https://cdn.example.com/eth.png' }; + expect(getBridgeTokenImageSource(token)).toEqual({ + uri: 'https://cdn.example.com/eth.png', + }); + expect(mockGetAssetImageUrl).not.toHaveBeenCalled(); + }); + + it('returns the CDN fallback URI when token.image is absent but getAssetImageUrl resolves', () => { + const token = { ...baseToken }; + mockGetAssetImageUrl.mockReturnValue('https://cdn.example.com/0xabc.png'); + + expect(getBridgeTokenImageSource(token)).toEqual({ + uri: 'https://cdn.example.com/0xabc.png', + }); + expect(mockGetAssetImageUrl).toHaveBeenCalledWith( + token.address, + token.chainId, + ); + }); + + it('returns undefined when token.image is absent and getAssetImageUrl returns null', () => { + const token = { ...baseToken }; + mockGetAssetImageUrl.mockReturnValue(undefined); + + expect(getBridgeTokenImageSource(token)).toBeUndefined(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/getBridgeTokenImageSource.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.ts similarity index 100% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/getBridgeTokenImageSource.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts new file mode 100644 index 00000000000..4a486c8256d --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts @@ -0,0 +1,303 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useDispatch } from 'react-redux'; +import { MetaMetricsEvents } from '../../../../../../../core/Analytics'; +import { + SocialLeaderboardEventProperties, + SocialLeaderboardEventValues, +} from '../../../../analytics'; +import { useQuickBuyAnalytics } from './useQuickBuyAnalytics'; + +const mockDispatch = jest.fn(); +const mockTrack = jest.fn(); +const mockResetBridgeState = jest.fn(() => ({ type: 'bridge/reset' })); +const mockBridgeControllerResetState = jest.fn(); + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); + +jest.mock('../../../../analytics', () => ({ + SocialLeaderboardEventProperties: { + TRADER_ADDRESS: 'trader_address', + CAIP19: 'caip19', + DISMISS_STAGE: 'dismiss_stage', + AMOUNT_USD: 'amount_usd', + AMOUNT_SELECTION_METHOD: 'amount_selection_method', + PAY_WITH_TOKEN: 'pay_with_token', + }, + SocialLeaderboardEventValues: { + DISMISS_STAGE: { + TOKEN_DETAIL: 'token_detail', + AMOUNT_SELECTION: 'amount_selection', + CONFIRMATION: 'confirmation', + }, + AMOUNT_SELECTION_METHOD: { + CUSTOM_INPUT: 'custom_input', + SLIDER: 'slider', + }, + }, + useSocialLeaderboardAnalytics: () => ({ track: mockTrack }), +})); + +jest.mock('../../../../../../../core/Analytics', () => ({ + MetaMetricsEvents: { + SOCIAL_QUICK_BUY_DISMISSED: 'SOCIAL_QUICK_BUY_DISMISSED', + SOCIAL_QUICK_BUY_AMOUNT_SELECTED: 'SOCIAL_QUICK_BUY_AMOUNT_SELECTED', + SOCIAL_QUICK_BUY_TRADE_SUBMITTED: 'SOCIAL_QUICK_BUY_TRADE_SUBMITTED', + SOCIAL_QUICK_BUY_TRADE_COMPLETED: 'SOCIAL_QUICK_BUY_TRADE_COMPLETED', + }, +})); + +jest.mock('../../../../../../../core/redux/slices/bridge', () => ({ + resetBridgeState: () => mockResetBridgeState(), +})); + +jest.mock('../../../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + resetState: () => mockBridgeControllerResetState(), + }, + }, + }, +})); + +const TRADER = '0xTrader'; +const CAIP19 = 'eip155:1/erc20:0xtoken'; + +describe('useQuickBuyAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + }); + + describe('trackAmountSelected', () => { + it('fires AMOUNT_SELECTED with correct properties', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19)); + + act(() => { + result.current.trackAmountSelected( + 25, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + 'ETH', + ); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, + expect.objectContaining({ + [SocialLeaderboardEventProperties.TRADER_ADDRESS]: TRADER, + [SocialLeaderboardEventProperties.CAIP19]: CAIP19, + [SocialLeaderboardEventProperties.AMOUNT_USD]: 25, + [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]: + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]: 'ETH', + }), + ); + }); + + it('includes slider_percent when provided', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19)); + + act(() => { + result.current.trackAmountSelected( + 50, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER, + undefined, + 25, + ); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, + expect.objectContaining({ slider_percent: 25 }), + ); + }); + + it('does not include slider_percent when not provided', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19)); + + act(() => { + result.current.trackAmountSelected( + 50, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + ); + }); + + const call = mockTrack.mock.calls[0][1]; + expect(call).not.toHaveProperty('slider_percent'); + }); + + it('is a no-op when traderAddress is empty', () => { + const { result } = renderHook(() => useQuickBuyAnalytics('', CAIP19)); + + act(() => { + result.current.trackAmountSelected( + 25, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + ); + }); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('is a no-op when caip19 is empty', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, '')); + + act(() => { + result.current.trackAmountSelected( + 25, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + ); + }); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('prefers analyticsContext.traderAddress over the hook arg', () => { + const { result } = renderHook(() => + useQuickBuyAnalytics(TRADER, CAIP19, { traderAddress: '0xOverride' }), + ); + + act(() => { + result.current.trackAmountSelected( + 10, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + ); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, + expect.objectContaining({ + [SocialLeaderboardEventProperties.TRADER_ADDRESS]: '0xOverride', + }), + ); + }); + }); + + describe('trackTradeSubmitted / trackTradeCompleted', () => { + it('fires TRADE_SUBMITTED with provided props', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19)); + + act(() => { + result.current.trackTradeSubmitted({ foo: 'bar' }); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, + { foo: 'bar' }, + ); + }); + + it('fires TRADE_COMPLETED with provided props', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19)); + + act(() => { + result.current.trackTradeCompleted({ tx: '0xhash' }); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, + { tx: '0xhash' }, + ); + }); + }); + + describe('markTradeSubmitted', () => { + it('updates dismissStageRef to CONFIRMATION and sets tradeSubmittedRef', () => { + const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19)); + + act(() => { + result.current.markTradeSubmitted(); + }); + + expect(result.current.refs.tradeSubmittedRef.current).toBe(true); + expect(result.current.refs.dismissStageRef.current).toBe( + SocialLeaderboardEventValues.DISMISS_STAGE.CONFIRMATION, + ); + }); + }); + + describe('unmount — DISMISSED event', () => { + it('fires DISMISSED on unmount when trade was not submitted', () => { + const { unmount } = renderHook(() => + useQuickBuyAnalytics(TRADER, CAIP19), + ); + + unmount(); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, + expect.objectContaining({ + [SocialLeaderboardEventProperties.TRADER_ADDRESS]: TRADER, + [SocialLeaderboardEventProperties.CAIP19]: CAIP19, + [SocialLeaderboardEventProperties.DISMISS_STAGE]: + SocialLeaderboardEventValues.DISMISS_STAGE.TOKEN_DETAIL, + }), + ); + }); + + it('includes amount_usd in DISMISSED event when an amount was selected', () => { + const { result, unmount } = renderHook(() => + useQuickBuyAnalytics(TRADER, CAIP19), + ); + + act(() => { + result.current.trackAmountSelected( + 42, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + ); + }); + + mockTrack.mockClear(); + unmount(); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, + expect.objectContaining({ + [SocialLeaderboardEventProperties.AMOUNT_USD]: 42, + }), + ); + }); + + it('does NOT fire DISMISSED when trade was submitted', () => { + const { result, unmount } = renderHook(() => + useQuickBuyAnalytics(TRADER, CAIP19), + ); + + act(() => { + result.current.markTradeSubmitted(); + }); + + mockTrack.mockClear(); + unmount(); + + expect(mockTrack).not.toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, + expect.anything(), + ); + }); + + it('dispatches resetBridgeState on unmount', () => { + const { unmount } = renderHook(() => + useQuickBuyAnalytics(TRADER, CAIP19), + ); + + unmount(); + + expect(mockDispatch).toHaveBeenCalled(); + expect(mockResetBridgeState).toHaveBeenCalled(); + }); + + it('calls BridgeController.resetState on unmount', () => { + const { unmount } = renderHook(() => + useQuickBuyAnalytics(TRADER, CAIP19), + ); + + unmount(); + + expect(mockBridgeControllerResetState).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts new file mode 100644 index 00000000000..dbe4081ceaa --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { MetaMetricsEvents } from '../../../../../../../core/Analytics'; +import { resetBridgeState } from '../../../../../../../core/redux/slices/bridge'; +import Engine from '../../../../../../../core/Engine'; +import { + SocialLeaderboardEventProperties, + SocialLeaderboardEventValues, + useSocialLeaderboardAnalytics, +} from '../../../../analytics'; +import type { QuickBuyAnalyticsContext } from '../types'; + +type QuickBuyDismissStage = + (typeof SocialLeaderboardEventValues.DISMISS_STAGE)[keyof typeof SocialLeaderboardEventValues.DISMISS_STAGE]; + +type AmountSelectionMethod = + (typeof SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD)[keyof typeof SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD]; + +export interface QuickBuyAnalyticsRefs { + dismissStageRef: React.MutableRefObject; + tradeSubmittedRef: React.MutableRefObject; + lastTrackedAmountRef: React.MutableRefObject; + lastInputMethodRef: React.MutableRefObject; + submitStartedAtRef: React.MutableRefObject; +} + +export function useQuickBuyAnalytics( + traderAddress: string, + caip19: string, + analyticsContext?: QuickBuyAnalyticsContext, +): { + refs: QuickBuyAnalyticsRefs; + trackAmountSelected: ( + amountUsd: number, + method: AmountSelectionMethod, + payWithToken?: string, + sliderPercent?: number, + ) => void; + trackTradeSubmitted: (props: Record) => void; + trackTradeCompleted: (props: Record) => void; + markTradeSubmitted: () => void; +} { + const dispatch = useDispatch(); + const { track } = useSocialLeaderboardAnalytics(); + + const dismissStageRef = useRef( + SocialLeaderboardEventValues.DISMISS_STAGE.TOKEN_DETAIL, + ); + const tradeSubmittedRef = useRef(false); + const lastTrackedAmountRef = useRef(''); + const lastInputMethodRef = useRef( + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + ); + const submitStartedAtRef = useRef(null); + + const resolvedTraderAddress = + analyticsContext?.traderAddress ?? traderAddress; + + useEffect( + () => () => { + dispatch(resetBridgeState()); + if (Engine.context.BridgeController?.resetState) { + Engine.context.BridgeController.resetState(); + } + if (!tradeSubmittedRef.current && resolvedTraderAddress && caip19) { + const numeric = Number(lastTrackedAmountRef.current); + track(MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, { + [SocialLeaderboardEventProperties.TRADER_ADDRESS]: + resolvedTraderAddress, + [SocialLeaderboardEventProperties.CAIP19]: caip19, + [SocialLeaderboardEventProperties.DISMISS_STAGE]: + dismissStageRef.current, + [SocialLeaderboardEventProperties.AMOUNT_USD]: + Number.isFinite(numeric) && numeric > 0 ? numeric : undefined, + }); + } + }, + [dispatch, resolvedTraderAddress, caip19, track], + ); + + const trackAmountSelected = useCallback( + ( + amountUsd: number, + method: AmountSelectionMethod, + payWithToken?: string, + sliderPercent?: number, + ) => { + if (!resolvedTraderAddress || !caip19) return; + lastTrackedAmountRef.current = String(amountUsd); + lastInputMethodRef.current = method; + track(MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, { + [SocialLeaderboardEventProperties.TRADER_ADDRESS]: + resolvedTraderAddress, + [SocialLeaderboardEventProperties.CAIP19]: caip19, + [SocialLeaderboardEventProperties.AMOUNT_USD]: amountUsd, + [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]: method, + [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]: payWithToken, + ...(sliderPercent != null ? { slider_percent: sliderPercent } : {}), + }); + dismissStageRef.current = + SocialLeaderboardEventValues.DISMISS_STAGE.AMOUNT_SELECTION; + }, + [resolvedTraderAddress, caip19, track], + ); + + const trackTradeSubmitted = useCallback( + (props: Record) => { + track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, props); + }, + [track], + ); + + const trackTradeCompleted = useCallback( + (props: Record) => { + track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, props); + }, + [track], + ); + + const markTradeSubmitted = useCallback(() => { + tradeSubmittedRef.current = true; + dismissStageRef.current = + SocialLeaderboardEventValues.DISMISS_STAGE.CONFIRMATION; + }, []); + + return { + refs: { + dismissStageRef, + tradeSubmittedRef, + lastTrackedAmountRef, + lastInputMethodRef, + submitStartedAtRef, + }, + trackAmountSelected, + trackTradeSubmitted, + trackTradeCompleted, + markTradeSubmitted, + }; +} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts similarity index 68% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index 18ae61263b0..8367caf6b86 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -2,30 +2,39 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { playSuccessNotification, playErrorNotification, -} from '../../../../../../util/haptics'; +} from '../../../../../../../util/haptics'; import { TextInput } from 'react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; -import type { Position } from '@metamask/social-controllers'; +import type { + QuickBuyAmountDisplayMode, + QuickBuyAnalyticsContext, + QuickBuyTarget, +} from '../types'; +import { useQuickBuyAnalytics } from './useQuickBuyAnalytics'; +import { formatExchangeRate } from '../utils/formatExchangeRate'; +import { getMetamaskFeePercent } from '../utils/getMetamaskFeePercent'; +import { snapToPercentageStep } from '../components/QuickBuyPercentageSlider'; import type { Hex } from '@metamask/utils'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import { selectDefaultSourceToken } from '../../../utils/tokenSelection'; +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { selectDefaultSourceToken } from '../../../../utils/tokenSelection'; import { useQuickBuySetup } from './useQuickBuySetup'; import { useSourceTokenOptions } from './useSourceTokenOptions'; import { useQuickBuyQuotes } from './useQuickBuyQuotes'; -import { isGaslessQuote } from '../../../../../UI/Bridge/utils/isGaslessQuote'; +import { isGaslessQuote } from '../../../../../../UI/Bridge/utils/isGaslessQuote'; +import { formatCurrency } from '../../../../../../UI/Bridge/utils/currencyUtils'; +import { selectCurrentCurrency } from '../../../../../../../selectors/currencyRateController'; import { isNumberValue, dotAndCommaDecimalFormatter, -} from '../../../../../../util/number'; +} from '../../../../../../../util/number/bigint'; import { isNonEvmChainId } from '@metamask/bridge-controller'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog -import { useGasFeeEstimates } from '../../../../confirmations/hooks/gas/useGasFeeEstimates'; +import { useGasFeeEstimates } from '../../../../../confirmations/hooks/gas/useGasFeeEstimates'; import { setSourceAmount, setSourceToken, setDestToken, - resetBridgeState, selectIsSubmittingTx, selectDestAddress, selectSlippage, @@ -34,51 +43,36 @@ import { selectIsSolanaSourced, selectBridgeFeatureFlags, setIsSubmittingTx, -} from '../../../../../../core/redux/slices/bridge'; -import { useLatestBalance } from '../../../../../UI/Bridge/hooks/useLatestBalance'; -import useIsInsufficientBalance from '../../../../../UI/Bridge/hooks/useInsufficientBalance'; -import { useHasSufficientGas } from '../../../../../UI/Bridge/hooks/useHasSufficientGas'; -import { useIsNetworkFeeUnavailable } from '../../../../../UI/Bridge/hooks/useIsNetworkFeeUnavailable'; -import { useInitialSlippage } from '../../../../../UI/Bridge/hooks/useInitialSlippage'; -import { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData'; +} from '../../../../../../../core/redux/slices/bridge'; +import { useLatestBalance } from '../../../../../../UI/Bridge/hooks/useLatestBalance'; +import useIsInsufficientBalance from '../../../../../../UI/Bridge/hooks/useInsufficientBalance'; +import { useHasSufficientGas } from '../../../../../../UI/Bridge/hooks/useHasSufficientGas'; +import { useIsNetworkFeeUnavailable } from '../../../../../../UI/Bridge/hooks/useIsNetworkFeeUnavailable'; +import { useInitialSlippage } from '../../../../../../UI/Bridge/hooks/useInitialSlippage'; +import { usePriceImpactViewData } from '../../../../../../UI/Bridge/hooks/usePriceImpactViewData'; import { parsePriceImpact, exceedsPriceImpactErrorThreshold, -} from '../../../../../UI/Bridge/utils/getPriceImpactViewData'; -import { selectShouldUseSmartTransaction } from '../../../../../../selectors/smartTransactionsController'; -import { useRefreshSmartTransactionsLiveness } from '../../../../../hooks/useRefreshSmartTransactionsLiveness'; -import { useIsGasIncludedSTXSendBundleSupported } from '../../../../../UI/Bridge/hooks/useIsGasIncludedSTXSendBundleSupported'; -import { useRecipientInitialization } from '../../../../../UI/Bridge/hooks/useRecipientInitialization'; -import { selectSourceWalletAddress } from '../../../../../../selectors/bridge'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; -import { isHardwareAccount } from '../../../../../../util/address'; -import Engine from '../../../../../../core/Engine'; -import Routes from '../../../../../../constants/navigation/Routes'; -import { strings } from '../../../../../../../locales/i18n'; -import { calcTokenValue } from '../../../../../../util/transactions'; -import Logger from '../../../../../../util/Logger'; -import { buildSocialLoggerErrorOptions } from '../../../../../../util/social/socialServiceTelemetry'; +} from '../../../../../../UI/Bridge/utils/getPriceImpactViewData'; +import { selectShouldUseSmartTransaction } from '../../../../../../../selectors/smartTransactionsController'; +import { useRefreshSmartTransactionsLiveness } from '../../../../../../hooks/useRefreshSmartTransactionsLiveness'; +import { useIsGasIncludedSTXSendBundleSupported } from '../../../../../../UI/Bridge/hooks/useIsGasIncludedSTXSendBundleSupported'; +import { useRecipientInitialization } from '../../../../../../UI/Bridge/hooks/useRecipientInitialization'; +import { selectSourceWalletAddress } from '../../../../../../../selectors/bridge'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../../../../../util/address'; +import Engine from '../../../../../../../core/Engine'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../../locales/i18n'; +import { calcTokenValue } from '../../../../../../../util/transactions'; +import Logger from '../../../../../../../util/Logger'; +import { buildSocialLoggerErrorOptions } from '../../../../../../../util/social/socialServiceTelemetry'; import { SocialLeaderboardEventProperties, SocialLeaderboardEventValues, - useSocialLeaderboardAnalytics, - type QuickBuySheetSource, -} from '../../../analytics'; -import { MetaMetricsEvents } from '../../../../../../core/Analytics'; -import { chainNameToId } from '../../../utils/chainMapping'; -import { toAssetId } from '../../../../../UI/Bridge/hooks/useAssetMetadata/utils'; - -type QuickBuyDismissStage = - (typeof SocialLeaderboardEventValues.DISMISS_STAGE)[keyof typeof SocialLeaderboardEventValues.DISMISS_STAGE]; - -export interface QuickBuyAnalyticsContext { - /** Wallet address of the trader being copied. Required for analytics. */ - traderAddress?: string; - /** Destination-token market cap; passes through on sheet-viewed. */ - marketCap?: number; - /** Surface that opened the sheet. */ - source?: QuickBuySheetSource; -} +} from '../../../../analytics'; +import { chainNameToId } from '../../../../utils/chainMapping'; +import { toAssetId } from '../../../../../../UI/Bridge/hooks/useAssetMetadata/utils'; export type QuickBuyButtonError = | 'insufficient_balance' @@ -91,7 +85,7 @@ const BUTTON_ERROR_LABELS: Record = { no_quotes: 'social_leaderboard.quick_buy.no_quotes', }; -export interface UseQuickBuyBottomSheetResult { +export interface UseQuickBuyControllerResult { // refs hiddenInputRef: React.RefObject; // setup @@ -109,9 +103,15 @@ export interface UseQuickBuyBottomSheetResult { React.SetStateAction >; // amount + amountDisplayMode: QuickBuyAmountDisplayMode; usdAmount: string; + sliderPercent: number; + maxSpendUsd: number; + formattedExchangeRate: string | undefined; + metamaskFeePercent: number; estimatedReceiveAmount: string | undefined; - sourceBalanceFiat: string | undefined; + sourceBalanceFiat: string; + sourceBalanceDisplay: string | undefined; formattedNetworkFee: string; formattedSlippage: string; formattedMinimumReceived: string; @@ -133,51 +133,46 @@ export interface UseQuickBuyBottomSheetResult { getButtonLabel: () => string; // handlers handleClose: () => void; - handlePresetPress: (preset: string) => void; + handleSliderChange: (percent: number) => void; handleAmountAreaPress: () => void; handleAmountChange: (text: string) => void; + handleToggleAmountDisplay: () => void; handleConfirm: () => Promise; } -export function useQuickBuyBottomSheet( - position: Position, +export function useQuickBuyController( + target: QuickBuyTarget, onClose: () => void, analyticsContext?: QuickBuyAnalyticsContext, -): UseQuickBuyBottomSheetResult { +): UseQuickBuyControllerResult { const hiddenInputRef = useRef(null); const dispatch = useDispatch(); const navigation = useNavigation(); - const { track } = useSocialLeaderboardAnalytics(); - // Stable refs so analytics callbacks don't capture stale context across - // unmount cleanups. The cleanup effect below reads these to fire the - // dismissed event without re-binding on each amount change. const traderAddress = analyticsContext?.traderAddress ?? ''; const caip19 = useMemo(() => { - const caipChainId = chainNameToId(position.chain); + const caipChainId = chainNameToId(target.chain); if (!caipChainId) return ''; - return toAssetId(position.tokenAddress, caipChainId) ?? ''; - }, [position.chain, position.tokenAddress]); + return toAssetId(target.tokenAddress, caipChainId) ?? ''; + }, [target.chain, target.tokenAddress]); + + const { + refs: { lastInputMethodRef, lastTrackedAmountRef, submitStartedAtRef }, + trackAmountSelected, + trackTradeSubmitted, + trackTradeCompleted, + markTradeSubmitted, + } = useQuickBuyAnalytics(traderAddress, caip19, analyticsContext); const [usdAmount, setUsdAmount] = useState(''); + // Fiat-first: every input path (slider, hidden TextInput, amount-area press) + // edits the USD amount, so the primary label must default to fiat as well. + // The user can swap to crypto display via the toggle once a quote is available. + const [amountDisplayMode, setAmountDisplayMode] = + useState('fiat'); + const [sliderPercent, setSliderPercent] = useState(0); + const lastSnappedSliderPercentRef = useRef(0); const [txPhase, setTxPhase] = useState<'idle' | 'success'>('idle'); - // Marks where the current usdAmount value came from. Reset to 'preset' when - // a chip is pressed, otherwise 'custom_input' on each keystroke. Used to - // disambiguate the analytics method without re-firing on every keystroke. - const lastInputMethodRef = useRef<'preset' | 'custom_input'>('custom_input'); - // Last usdAmount we already emitted analytics for; prevents duplicate - // events when redux state churns and effects re-run. - const lastTrackedAmountRef = useRef(''); - // Tracks the highest dismiss-stage the user reached so the cleanup effect - // can attach the right `dismiss_stage` to the dismissed event. - const dismissStageRef = useRef( - SocialLeaderboardEventValues.DISMISS_STAGE.TOKEN_DETAIL, - ); - // Cleared once the trade is submitted (success path) so we don't double- - // count dismissed events on top of completed events. - const tradeSubmittedRef = useRef(false); - // Captures the trade timer so trade-completed can compute execution_time_ms. - const submitStartedAtRef = useRef(null); const isSubmittingTx = useSelector(selectIsSubmittingTx); const walletAddress = useSelector(selectSourceWalletAddress); @@ -187,6 +182,7 @@ export function useQuickBuyBottomSheet( const isNonEvmNonEvmBridge = useSelector(selectIsNonEvmNonEvmBridge); const isSolanaSourced = useSelector(selectIsSolanaSourced); const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); + const currentCurrency = useSelector(selectCurrentCurrency); const selectedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, ); @@ -200,7 +196,7 @@ export function useQuickBuyBottomSheet( destToken, isLoading: isSetupLoading, isUnsupportedChain, - } = useQuickBuySetup(position); + } = useQuickBuySetup(target); const { options: sourceTokenOptions } = useSourceTokenOptions(destChainId); const [selectedSourceToken, setSelectedSourceToken] = useState< @@ -328,7 +324,10 @@ export function useQuickBuyBottomSheet( const num = parseFloat(amount); if (isNaN(num)) return '-'; const floored = Math.floor(num * 1e8) / 1e8; - const formatted = floored.toFixed(8).replace(/\.?0+$/, '') || '0'; + const formatted = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 8, + useGrouping: false, + }).format(floored); return `${formatted} ${symbol}`; }, [activeQuote, destToken]); @@ -376,78 +375,111 @@ export function useQuickBuyBottomSheet( const hasDestinationPicker = isEvmNonEvmBridge || isNonEvmNonEvmBridge; const isDestinationAddressMissing = hasDestinationPicker && !destAddress; - // Cleanup bridge state on unmount, and emit `Quick Buy Dismissed` whenever - // the sheet closes without a successful submission. - useEffect( - () => () => { - dispatch(resetBridgeState()); - if (Engine.context.BridgeController?.resetState) { - Engine.context.BridgeController.resetState(); - } - if (!tradeSubmittedRef.current && traderAddress && caip19) { - const numeric = Number(lastTrackedAmountRef.current); - track(MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, { - [SocialLeaderboardEventProperties.TRADER_ADDRESS]: traderAddress, - [SocialLeaderboardEventProperties.CAIP19]: caip19, - [SocialLeaderboardEventProperties.DISMISS_STAGE]: - dismissStageRef.current, - [SocialLeaderboardEventProperties.AMOUNT_USD]: - Number.isFinite(numeric) && numeric > 0 ? numeric : undefined, - }); - } - }, - [dispatch, traderAddress, caip19, track], + const sourceBalanceFiatUsd = useMemo(() => { + if ( + !latestSourceBalance?.displayBalance || + !sourceToken?.currencyExchangeRate + ) { + return 0; + } + const balance = parseFloat(latestSourceBalance.displayBalance); + if (!Number.isFinite(balance)) return 0; + const fiat = balance * sourceToken.currencyExchangeRate; + return Number.isFinite(fiat) && fiat > 0 ? fiat : 0; + }, [latestSourceBalance?.displayBalance, sourceToken?.currencyExchangeRate]); + + const sourceBalanceFiat = useMemo( + () => formatCurrency(sourceBalanceFiatUsd, currentCurrency), + [sourceBalanceFiatUsd, currentCurrency], + ); + + const sourceBalanceDisplay = useMemo(() => { + if (!latestSourceBalance?.displayBalance || !sourceToken?.symbol) { + return undefined; + } + const balance = parseFloat(latestSourceBalance.displayBalance); + if (isNaN(balance)) return undefined; + const formatted = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 6, + useGrouping: false, + }).format(balance); + return `${formatted} ${sourceToken.symbol}`; + }, [latestSourceBalance?.displayBalance, sourceToken?.symbol]); + + const maxSpendUsd = sourceBalanceFiatUsd; + + const formattedExchangeRate = useMemo( + () => formatExchangeRate(destToken, sourceToken), + [destToken, sourceToken], + ); + + const metamaskFeePercent = useMemo( + () => getMetamaskFeePercent(activeQuote), + [activeQuote], ); const handleClose = useCallback(() => { onClose(); }, [onClose]); - const handlePresetPress = useCallback( - (preset: string) => { - lastInputMethodRef.current = 'preset'; - setUsdAmount(preset); - const numericPreset = Number(preset); - const presetValue = - numericPreset === 20 || - numericPreset === 50 || - numericPreset === 100 || - numericPreset === 250 - ? numericPreset - : undefined; - lastTrackedAmountRef.current = preset; - if (traderAddress && caip19) { - track(MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, { - [SocialLeaderboardEventProperties.TRADER_ADDRESS]: traderAddress, - [SocialLeaderboardEventProperties.CAIP19]: caip19, - [SocialLeaderboardEventProperties.AMOUNT_USD]: numericPreset, - [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]: - SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.PRESET, - [SocialLeaderboardEventProperties.PRESET_VALUE]: presetValue, - [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]: - sourceToken?.symbol, - }); + const handleSliderChange = useCallback( + (percent: number) => { + const snapped = snapToPercentageStep(percent); + if (snapped === lastSnappedSliderPercentRef.current) { + return; + } + + lastSnappedSliderPercentRef.current = snapped; + setSliderPercent(snapped); + if (maxSpendUsd <= 0) { + setUsdAmount(''); + return; + } + const nextUsd = + snapped === 0 ? '' : ((maxSpendUsd * snapped) / 100).toFixed(2); + setUsdAmount(nextUsd); + lastInputMethodRef.current = + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER; + const numericUsd = Number(nextUsd); + if (snapped > 0 && Number.isFinite(numericUsd) && numericUsd > 0) { + trackAmountSelected( + numericUsd, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER, + sourceToken?.symbol, + snapped, + ); } - dismissStageRef.current = - SocialLeaderboardEventValues.DISMISS_STAGE.AMOUNT_SELECTION; }, - [traderAddress, caip19, sourceToken?.symbol, track], + [maxSpendUsd, sourceToken?.symbol, trackAmountSelected, lastInputMethodRef], ); const handleAmountAreaPress = useCallback(() => { + // Ensure the user always types in fiat so the keyboard digits match what + // they see. Crypto display mode is view-only; switch back on input focus. + setAmountDisplayMode('fiat'); hiddenInputRef.current?.focus(); }, []); - const handleAmountChange = useCallback((text: string) => { - lastInputMethodRef.current = 'custom_input'; - const cleaned = dotAndCommaDecimalFormatter(text).replace(/[^0-9.]/g, ''); - const normalized = cleaned.startsWith('.') ? `0${cleaned}` : cleaned; - const parts = normalized.split('.'); - if (parts.length > 2) return; - if (parts.length === 2 && parts[1].length > 2) return; - setUsdAmount(normalized); + const handleToggleAmountDisplay = useCallback(() => { + setAmountDisplayMode((mode) => (mode === 'fiat' ? 'crypto' : 'fiat')); }, []); + const handleAmountChange = useCallback( + (text: string) => { + lastInputMethodRef.current = + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT; + const cleaned = dotAndCommaDecimalFormatter(text).replace(/[^0-9.]/g, ''); + const normalized = cleaned.startsWith('.') ? `0${cleaned}` : cleaned; + const parts = normalized.split('.'); + if (parts.length > 2) return; + if (parts.length === 2 && parts[1].length > 2) return; + setUsdAmount(normalized); + lastSnappedSliderPercentRef.current = 0; + setSliderPercent(0); + }, + [lastInputMethodRef], + ); + // Debounced track for custom amount entries — fires once after the user // stops typing for 500ms, so we don't emit on every keystroke. useEffect(() => { @@ -455,25 +487,22 @@ export function useQuickBuyBottomSheet( if (!usdAmount) return; const numeric = Number(usdAmount); if (!Number.isFinite(numeric) || numeric <= 0) return; - if (lastTrackedAmountRef.current === usdAmount) return; + if (lastTrackedAmountRef.current === String(numeric)) return; const handle = setTimeout(() => { - lastTrackedAmountRef.current = usdAmount; - if (traderAddress && caip19) { - track(MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, { - [SocialLeaderboardEventProperties.TRADER_ADDRESS]: traderAddress, - [SocialLeaderboardEventProperties.CAIP19]: caip19, - [SocialLeaderboardEventProperties.AMOUNT_USD]: numeric, - [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]: - SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, - [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]: - sourceToken?.symbol, - }); - } - dismissStageRef.current = - SocialLeaderboardEventValues.DISMISS_STAGE.AMOUNT_SELECTION; + trackAmountSelected( + numeric, + SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, + sourceToken?.symbol, + ); }, 500); return () => clearTimeout(handle); - }, [usdAmount, traderAddress, caip19, sourceToken?.symbol, track]); + }, [ + usdAmount, + sourceToken?.symbol, + trackAmountSelected, + lastInputMethodRef, + lastTrackedAmountRef, + ]); const handleConfirm = useCallback(async () => { if (!activeQuote || !walletAddress) return; @@ -486,7 +515,7 @@ export function useQuickBuyBottomSheet( : undefined; const submittedTraderAddress = traderAddress; const submittedCaip19 = caip19; - const submittedAssetName = destToken?.symbol ?? position.tokenSymbol; + const submittedAssetName = destToken?.symbol ?? target.tokenSymbol; const submittedPayWith = sourceToken?.symbol; // Shared by the SUBMITTED + COMPLETED (success / failure) events. Built @@ -504,11 +533,9 @@ export function useQuickBuyBottomSheet( : null; if (tradeBaseProps) { - track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, tradeBaseProps); + trackTradeSubmitted(tradeBaseProps); } - tradeSubmittedRef.current = true; - dismissStageRef.current = - SocialLeaderboardEventValues.DISMISS_STAGE.CONFIRMATION; + markTradeSubmitted(); submitStartedAtRef.current = Date.now(); const elapsedMs = () => @@ -529,7 +556,7 @@ export function useQuickBuyBottomSheet( ? ((submitResult as { hash?: string }).hash as string) : undefined; if (tradeBaseProps) { - track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, { + trackTradeCompleted({ ...tradeBaseProps, [SocialLeaderboardEventProperties.AMOUNT_TOKEN]: amountToken, [SocialLeaderboardEventProperties.TX_HASH]: txHash, @@ -549,7 +576,7 @@ export function useQuickBuyBottomSheet( surface: 'quick_buy', operation: 'submit_tx', extraMessage: 'Error submitting QuickBuy tx', - source: 'useQuickBuyBottomSheet', + source: 'useQuickBuyController', error, extraTags: { sourceChainId: sourceToken?.chainId ?? 'unknown', @@ -559,7 +586,7 @@ export function useQuickBuyBottomSheet( ); await playErrorNotification(); if (tradeBaseProps) { - track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, { + trackTradeCompleted({ ...tradeBaseProps, [SocialLeaderboardEventProperties.AMOUNT_TOKEN]: amountToken, [SocialLeaderboardEventProperties.EXECUTION_TIME_MS]: elapsedMs(), @@ -583,22 +610,14 @@ export function useQuickBuyBottomSheet( traderAddress, caip19, destToken?.symbol, - position.tokenSymbol, + target.tokenSymbol, sourceToken?.symbol, - track, + trackTradeSubmitted, + trackTradeCompleted, + markTradeSubmitted, + submitStartedAtRef, ]); - const sourceBalanceFiat = useMemo(() => { - if ( - !latestSourceBalance?.displayBalance || - !sourceToken?.currencyExchangeRate - ) - return undefined; - const balance = parseFloat(latestSourceBalance.displayBalance); - if (isNaN(balance)) return undefined; - return `$${(balance * sourceToken.currencyExchangeRate).toFixed(2)}`; - }, [latestSourceBalance?.displayBalance, sourceToken?.currencyExchangeRate]); - const hasError = Boolean(quoteFetchError || isNoQuotesAvailable); const hasValidAmount = Boolean(usdAmount && Number(usdAmount) > 0); const hasQuoteRequestableAmount = useMemo(() => { @@ -684,10 +703,13 @@ export function useQuickBuyBottomSheet( } const getButtonLabel = useCallback(() => { + if (!hasValidAmount) { + return strings('social_leaderboard.trader_position.buy'); + } if (buttonError) return strings(BUTTON_ERROR_LABELS[buttonError]); if (isSubmittingTx) return strings('bridge.submitting_transaction'); return strings('social_leaderboard.trader_position.buy'); - }, [buttonError, isSubmittingTx]); + }, [buttonError, hasValidAmount, isSubmittingTx]); return { hiddenInputRef, @@ -701,9 +723,15 @@ export function useQuickBuyBottomSheet( isSourcePickerOpen, setIsSourcePickerOpen, setSelectedSourceToken, + amountDisplayMode, usdAmount, + sliderPercent, + maxSpendUsd, + formattedExchangeRate, + metamaskFeePercent, estimatedReceiveAmount, sourceBalanceFiat, + sourceBalanceDisplay, formattedNetworkFee, formattedSlippage, formattedMinimumReceived, @@ -721,9 +749,10 @@ export function useQuickBuyBottomSheet( confirmButtonState, getButtonLabel, handleClose, - handlePresetPress, + handleSliderChange, handleAmountAreaPress, handleAmountChange, + handleToggleAmountDisplay, handleConfirm, }; } diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts similarity index 89% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts index 64feb896662..e90292cd3a0 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts @@ -6,35 +6,36 @@ import { isNonEvmChainId, selectBridgeQuotes as selectBridgeQuotesBase, SortOrder, + type BridgeAppState, type GenericQuoteRequest, type L1GasFees, type NonEvmFees, type QuoteResponse, } from '@metamask/bridge-controller'; -import type { RootState } from '../../../../../../reducers'; -import Engine from '../../../../../../core/Engine'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import { fromTokenMinimalUnit } from '../../../../../../util/number'; -import { areAddressesEqual } from '../../../../../../util/address'; -import { calcTokenValue } from '../../../../../../util/transactions'; -import { analytics } from '../../../../../../util/analytics/analytics'; -import { selectRemoteFeatureFlags } from '../../../../../../selectors/featureFlagController'; +import type { RootState } from '../../../../../../../reducers'; +import Engine from '../../../../../../../core/Engine'; +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { fromTokenMinimalUnit } from '../../../../../../../util/number/bigint'; +import { areAddressesEqual } from '../../../../../../../util/address'; +import { calcTokenValue } from '../../../../../../../util/transactions'; +import { analytics } from '../../../../../../../util/analytics/analytics'; +import { selectRemoteFeatureFlags } from '../../../../../../../selectors/featureFlagController'; import { selectDestAddress, selectSlippage, -} from '../../../../../../core/redux/slices/bridge'; +} from '../../../../../../../core/redux/slices/bridge'; import { selectGasIncludedQuoteParams, selectSourceWalletAddress, -} from '../../../../../../selectors/bridge'; -import { getDecimalChainId } from '../../../../../../util/networks'; -import Logger from '../../../../../../util/Logger'; -import { buildSocialLoggerErrorOptions } from '../../../../../../util/social/socialServiceTelemetry'; +} from '../../../../../../../selectors/bridge'; +import { getDecimalChainId } from '../../../../../../../util/networks'; +import Logger from '../../../../../../../util/Logger'; +import { buildSocialLoggerErrorOptions } from '../../../../../../../util/social/socialServiceTelemetry'; import { SocialLeaderboardEventProperties, useSocialLeaderboardAnalytics, -} from '../../../analytics'; -import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +} from '../../../../analytics'; +import { MetaMetricsEvents } from '../../../../../../../core/Analytics'; export type QuickBuyQuote = QuoteResponse & L1GasFees & NonEvmFees; @@ -330,15 +331,16 @@ export function useQuickBuyQuotes({ } : {}; - const controllerFields = { + const existingQuoteRequest = metadataDeps.bridgeController.quoteRequest; + const quoteRequestBase = Array.isArray(existingQuoteRequest) + ? (existingQuoteRequest[0] ?? {}) + : (existingQuoteRequest ?? {}); + + const controllerFields: BridgeAppState = { ...metadataDeps.bridgeController, quotes: rawQuotes, - quoteRequest: [ - { - ...(metadataDeps.bridgeController.quoteRequest?.[0] ?? {}), - ...quoteRequestPatch, - }, - ], + // selectBridgeQuotes destructures quoteRequest as an array at runtime. + quoteRequest: [{ ...quoteRequestBase, ...quoteRequestPatch }], gasFeeEstimatesByChainId: metadataDeps.gasFeeEstimatesByChainId, ...metadataDeps.multichainAssetsRates, ...metadataDeps.tokenRates, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuySetup.ts similarity index 89% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuySetup.ts index 20e5e92521e..618ef250289 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuySetup.ts @@ -6,11 +6,11 @@ import { formatChainIdToHex, isNonEvmChainId, } from '@metamask/bridge-controller'; -import type { Position } from '@metamask/social-controllers'; -import { useAssetMetadata } from '../../../../../UI/Bridge/hooks/useAssetMetadata'; -import { chainNameToId } from '../../../utils/chainMapping'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import { selectIsBridgeEnabledSourceFactory } from '../../../../../../core/redux/slices/bridge'; +import type { QuickBuyTarget } from '../types'; +import { useAssetMetadata } from '../../../../../../UI/Bridge/hooks/useAssetMetadata'; +import { chainNameToId } from '../../../../utils/chainMapping'; +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { selectIsBridgeEnabledSourceFactory } from '../../../../../../../core/redux/slices/bridge'; export interface QuickBuySetupResult { /** The destination chain ID (hex or CAIP) for this position's chain */ @@ -30,8 +30,9 @@ export interface QuickBuySetupResult { * Source token selection is handled separately by useSourceTokenOptions. */ export const useQuickBuySetup = ( - position: Position | null, + target: QuickBuyTarget | null, ): QuickBuySetupResult => { + const position = target; const isBridgeEnabledSource = useSelector(selectIsBridgeEnabledSourceFactory); // Destination chain from the position — hex for EVM, CAIP for non-EVM diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useSourceTokenOptions.ts similarity index 90% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useSourceTokenOptions.ts index fc4e8f5d9bb..d9e76e1c844 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useSourceTokenOptions.ts @@ -4,20 +4,20 @@ import { CaipChainId, Hex } from '@metamask/utils'; import { formatUnits } from 'ethers/lib/utils'; import { isSolanaChainId } from '@metamask/bridge-controller'; import { SolScope } from '@metamask/keyring-api'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import type { RootState } from '../../../../../../reducers'; -import { selectAccountsByChainId } from '../../../../../../selectors/accountTrackerController'; -import { selectSelectedInternalAccountByScope } from '../../../../../../selectors/multichainAccounts/accounts'; -import { selectTokensBalances } from '../../../../../../selectors/tokenBalancesController'; -import { selectTokenMarketData } from '../../../../../../selectors/tokenRatesController'; -import { selectCurrencyRates } from '../../../../../../selectors/currencyRateController'; +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import type { RootState } from '../../../../../../../reducers'; +import { selectAccountsByChainId } from '../../../../../../../selectors/accountTrackerController'; +import { selectSelectedInternalAccountByScope } from '../../../../../../../selectors/multichainAccounts/accounts'; +import { selectTokensBalances } from '../../../../../../../selectors/tokenBalancesController'; +import { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController'; +import { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController'; import { selectMultichainBalances, selectMultichainAssetsRates, -} from '../../../../../../selectors/multichain/multichain'; -import { getSourceTokenCandidates } from './sourceTokenCandidates'; -import { toChecksumAddress } from '../../../../../../util/address'; -import { EVM_SCOPE } from '../../../../../UI/Earn/constants/networks'; +} from '../../../../../../../selectors/multichain/multichain'; +import { getSourceTokenCandidates } from '../sourceTokenCandidates'; +import { toChecksumAddress } from '../../../../../../../util/address'; +import { EVM_SCOPE } from '../../../../../../UI/Earn/constants/networks'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts new file mode 100644 index 00000000000..43e2c94340b --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts @@ -0,0 +1,28 @@ +/** Compound API — prefer `QuickBuy.Root`, `QuickBuy.AmountScreen`, etc. */ +export { QuickBuy } from './quickBuy'; + +export type { QuickBuyRootProps } from './types'; +export type { QuickBuyContextValue } from './QuickBuyContext'; +export type { QuickBuySheetProps } from './types'; +export type { TraderPositionQuickBuyProps } from './TraderPositionQuickBuy'; +export type { + QuickBuyTarget, + QuickBuyFeatures, + QuickBuyTradeMode, + QuickBuyScreen, + QuickBuyAnalyticsContext, +} from './types'; + +export { TOP_TRADERS_QUICK_BUY_FEATURES } from './features'; +export { positionToQuickBuyTarget } from './types'; + +/** Top Traders host adapter */ +export { default as TraderPositionQuickBuy } from './TraderPositionQuickBuy'; +export { default } from './TraderPositionQuickBuy'; + +/** Named convenience exports — same components as `QuickBuy.*` */ +export { default as QuickBuyRoot } from './QuickBuyRoot'; +export { default as QuickBuyAmountScreen } from './QuickBuyAmountScreen'; +export { default as QuickBuyAmount } from './QuickBuyAmount'; +export { default as QuickBuyToolbar } from './components/QuickBuyToolbar'; +export { default as QuickBuyFooter } from './components/QuickBuyActionFooter'; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts new file mode 100644 index 00000000000..d656f1fe914 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts @@ -0,0 +1,13 @@ +import QuickBuyAmount from './QuickBuyAmount'; +import QuickBuyAmountScreen from './QuickBuyAmountScreen'; +import QuickBuyRoot from './QuickBuyRoot'; +import QuickBuyActionFooter from './components/QuickBuyActionFooter'; +import QuickBuyToolbar from './components/QuickBuyToolbar'; + +export const QuickBuy = { + Root: QuickBuyRoot, + AmountScreen: QuickBuyAmountScreen, + Toolbar: QuickBuyToolbar, + Amount: QuickBuyAmount, + Footer: QuickBuyActionFooter, +} as const; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts new file mode 100644 index 00000000000..1521650b5f9 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts @@ -0,0 +1,137 @@ +import { SolScope } from '@metamask/keyring-api'; +import { NETWORK_CHAIN_ID } from '../../../../../../util/networks/customNetworks'; +import { getSourceTokenCandidates, getTokenKey } from './sourceTokenCandidates'; +import type { BridgeToken } from '../../../../../UI/Bridge/types'; + +const mockNativeToken = (chainId: string): BridgeToken => + ({ + address: '0x0000000000000000000000000000000000000000', + chainId, + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }) as unknown as BridgeToken; + +jest.mock('../../../../../UI/Bridge/utils/tokenUtils', () => ({ + getNativeSourceToken: jest.fn((chainId: string) => mockNativeToken(chainId)), +})); + +jest.mock( + '../../../../../UI/Bridge/constants/default-swap-dest-tokens', + () => ({ + DefaultSwapDestTokens: { + 'eip155:1/erc20:usdc': { + symbol: 'USDC', + address: '0xusdc', + chainId: '0x1', + decimals: 6, + name: 'USD Coin', + }, + 'eip155:137/erc20:usdc_matic': { + symbol: 'USDC', + address: '0xusdc_matic', + chainId: '0x89', + decimals: 6, + name: 'USD Coin (Polygon)', + }, + 'eip155:1/erc20:eth': { + symbol: 'WETH', + address: '0xweth', + chainId: '0x1', + decimals: 18, + name: 'Wrapped Ether', + }, + }, + Bip44TokensForDefaultPairs: { + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + symbol: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1', + decimals: 6, + name: 'USD Coin', + }, + }, + }), +); + +jest.mock('../../../../../../constants/bridge', () => ({ + ETH_USDT_ADDRESS: '0xdac17f958d2ee523a2206206994597c13d831ec7', +})); + +describe('getTokenKey', () => { + it('returns address:chainId with address lowercased', () => { + const token = { + address: '0xABCD', + chainId: '0x1', + } as unknown as BridgeToken; + + expect(getTokenKey(token)).toBe('0xabcd:0x1'); + }); + + it('handles already lowercase addresses', () => { + const token = { + address: '0xabcd', + chainId: '0x89', + } as unknown as BridgeToken; + + expect(getTokenKey(token)).toBe('0xabcd:0x89'); + }); +}); + +describe('getSourceTokenCandidates', () => { + it('returns an array of BridgeToken candidates', () => { + const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET); + expect(Array.isArray(candidates)).toBe(true); + expect(candidates.length).toBeGreaterThan(0); + }); + + it('includes stablecoin candidates (USDC, USDT)', () => { + const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET); + const symbols = candidates.map((t) => t.symbol); + expect(symbols).toContain('USDC'); + expect(symbols).toContain('USDT'); + }); + + it('includes native SOL for Solana mainnet', () => { + const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET); + const solTokens = candidates.filter((t) => t.chainId === SolScope.Mainnet); + expect(solTokens.length).toBeGreaterThan(0); + }); + + it('includes native token for all major EVM chains', () => { + const candidates = getSourceTokenCandidates(undefined); + const chainIds = candidates.map((t) => t.chainId); + expect(chainIds).toContain(NETWORK_CHAIN_ID.MAINNET); + expect(chainIds).toContain(NETWORK_CHAIN_ID.BASE); + }); + + it('does not duplicate native token when destChainId is already in NATIVE_TOKEN_CHAIN_IDS', () => { + const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET); + const mainnetNatives = candidates.filter( + (t) => + t.chainId === NETWORK_CHAIN_ID.MAINNET && + t.address === '0x0000000000000000000000000000000000000000', + ); + // Should appear exactly once from the NATIVE_TOKEN_CHAIN_IDS loop + expect(mainnetNatives.length).toBe(1); + }); + + it('adds native token for a novel EVM destChainId not in NATIVE_TOKEN_CHAIN_IDS', () => { + const novelChain = '0x12345'; + const candidates = getSourceTokenCandidates(novelChain); + const novelNatives = candidates.filter((t) => t.chainId === novelChain); + expect(novelNatives.length).toBeGreaterThan(0); + }); + + it('does NOT add native token for a non-EVM (non-0x) destChainId', () => { + const candidates = getSourceTokenCandidates(SolScope.Mainnet); + // Solana mainnet natives are added explicitly, not through the EVM branch + const solNatives = candidates.filter((t) => t.chainId === SolScope.Mainnet); + // Exactly one SOL native (added in the explicit push, not duplicated) + expect(solNatives.length).toBe(1); + }); + + it('handles undefined destChainId without error', () => { + expect(() => getSourceTokenCandidates(undefined)).not.toThrow(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/sourceTokenCandidates.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.ts similarity index 100% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/sourceTokenCandidates.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts new file mode 100644 index 00000000000..35fcbc24742 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import type { Position } from '@metamask/social-controllers'; +import type { QuickBuySheetSource } from '../../../analytics'; + +/** Host-agnostic trade target — maps from social `Position` via adapter. */ +export interface QuickBuyTarget { + tokenAddress: string; + tokenSymbol: string; + tokenName: string; + chain: string; +} + +export type QuickBuyTradeMode = 'buy' | 'sell'; + +/** Which amount is shown as the large primary value in the amount section. */ +export type QuickBuyAmountDisplayMode = 'fiat' | 'crypto'; + +export type QuickBuyScreen = + | 'amount' + | 'quoteDetails' + | 'selectQuote' + | 'payWith'; + +/** Feature flags for optional flow pieces (enabled per consumer). */ +export interface QuickBuyFeatures { + tradeModes: QuickBuyTradeMode[]; + quoteDetails: boolean; + selectQuote: boolean; + payWithSheet: boolean; + highPriceImpactModal: boolean; + fiatCryptoToggle: boolean; +} + +export interface QuickBuyAnalyticsContext { + traderAddress?: string; + marketCap?: number; + source?: QuickBuySheetSource; +} + +export interface QuickBuySheetProps { + isVisible: boolean; + target: QuickBuyTarget | null; + onClose: () => void; + features?: QuickBuyFeatures; + analyticsContext?: QuickBuyAnalyticsContext; + children?: ReactNode; +} + +/** Same contract as `QuickBuySheetProps` — props for `QuickBuy.Root`. */ +export type QuickBuyRootProps = QuickBuySheetProps; + +/** Maps a social leaderboard position into a portable QuickBuy target. */ +export function positionToQuickBuyTarget(position: Position): QuickBuyTarget { + return { + tokenAddress: position.tokenAddress, + tokenSymbol: position.tokenSymbol, + tokenName: position.tokenName, + chain: position.chain, + }; +} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts new file mode 100644 index 00000000000..cf443a36051 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; +import { QuickBuyContext, type QuickBuyContextValue } from './QuickBuyContext'; + +export function useQuickBuyContext(): QuickBuyContextValue { + const context = useContext(QuickBuyContext); + + if (!context) { + throw new Error( + 'QuickBuy compound components must be rendered within QuickBuy.Root', + ); + } + + return context; +} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts similarity index 79% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index 1a877a6669e..9108d6b2563 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -1,11 +1,12 @@ import { renderHook, act } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import type { Position } from '@metamask/social-controllers'; -import { useQuickBuyBottomSheet } from './useQuickBuyBottomSheet'; +import { useQuickBuyController } from './hooks/useQuickBuyController'; +import { positionToQuickBuyTarget } from './types'; import { selectDefaultSourceToken } from '../../../utils/tokenSelection'; -import { useQuickBuySetup } from './useQuickBuySetup'; -import { useSourceTokenOptions } from './useSourceTokenOptions'; -import { useQuickBuyQuotes } from './useQuickBuyQuotes'; +import { useQuickBuySetup } from './hooks/useQuickBuySetup'; +import { useSourceTokenOptions } from './hooks/useSourceTokenOptions'; +import { useQuickBuyQuotes } from './hooks/useQuickBuyQuotes'; import { useLatestBalance } from '../../../../../UI/Bridge/hooks/useLatestBalance'; import useIsInsufficientBalance from '../../../../../UI/Bridge/hooks/useInsufficientBalance'; import { useHasSufficientGas } from '../../../../../UI/Bridge/hooks/useHasSufficientGas'; @@ -22,6 +23,7 @@ import { } from '../../../../../../core/redux/slices/bridge'; import { selectSourceWalletAddress } from '../../../../../../selectors/bridge'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; +import { selectCurrentCurrency } from '../../../../../../selectors/currencyRateController'; import { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData'; import { TextColor } from '@metamask/design-system-react-native'; import type { BridgeToken } from '../../../../../UI/Bridge/types'; @@ -44,15 +46,33 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: jest.fn() }), })); -jest.mock('./useQuickBuySetup', () => ({ +const mockTrackAmountSelected = jest.fn(); + +jest.mock('./hooks/useQuickBuyAnalytics', () => ({ + useQuickBuyAnalytics: () => ({ + refs: { + dismissStageRef: { current: 'amount_selection' }, + tradeSubmittedRef: { current: false }, + lastTrackedAmountRef: { current: '' }, + lastInputMethodRef: { current: 'custom_input' }, + submitStartedAtRef: { current: null }, + }, + trackAmountSelected: mockTrackAmountSelected, + trackTradeSubmitted: jest.fn(), + trackTradeCompleted: jest.fn(), + markTradeSubmitted: jest.fn(), + }), +})); + +jest.mock('./hooks/useQuickBuySetup', () => ({ useQuickBuySetup: jest.fn(), })); -jest.mock('./useSourceTokenOptions', () => ({ +jest.mock('./hooks/useSourceTokenOptions', () => ({ useSourceTokenOptions: jest.fn(), })); -jest.mock('./useQuickBuyQuotes', () => ({ +jest.mock('./hooks/useQuickBuyQuotes', () => ({ useQuickBuyQuotes: jest.fn(), })); @@ -141,6 +161,10 @@ jest.mock('../../../../../../selectors/accountsController', () => ({ selectSelectedInternalAccountFormattedAddress: jest.fn(), })); +jest.mock('../../../../../../selectors/currencyRateController', () => ({ + selectCurrentCurrency: jest.fn(), +})); + jest.mock('../../../../../../util/address', () => ({ isHardwareAccount: jest.fn(() => false), })); @@ -233,6 +257,7 @@ const setupDefaultMocks = () => { ( selectSelectedInternalAccountFormattedAddress as unknown as jest.Mock ).mockReturnValue('0xWALLET'); + (selectCurrentCurrency as unknown as jest.Mock).mockReturnValue('USD'); (usePriceImpactViewData as jest.Mock).mockReturnValue({ textColor: TextColor.TextAlternative, icon: undefined, @@ -282,7 +307,7 @@ const setupDefaultMocks = () => { ).mockResolvedValue(undefined); }; -describe('useQuickBuyBottomSheet', () => { +describe('useQuickBuyController', () => { beforeEach(() => { jest.clearAllMocks(); setupDefaultMocks(); @@ -295,7 +320,10 @@ describe('useQuickBuyBottomSheet', () => { describe('handleAmountChange', () => { it('accepts valid numeric input', () => { const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { @@ -307,7 +335,10 @@ describe('useQuickBuyBottomSheet', () => { it('normalizes a leading decimal without digits', () => { const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { @@ -320,7 +351,10 @@ describe('useQuickBuyBottomSheet', () => { it('normalizes a leading decimal with digits', () => { const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { @@ -330,26 +364,100 @@ describe('useQuickBuyBottomSheet', () => { expect(result.current.usdAmount).toBe('0.5'); expect(result.current.hasValidAmount).toBe(true); }); + + it('resets slider percent when the user types a custom amount', () => { + (useLatestBalance as jest.Mock).mockReturnValue({ + displayBalance: '100', + atomicBalance: '100000000', + }); + const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 }); + (useSourceTokenOptions as jest.Mock).mockReturnValue({ + options: [sourceWithRate], + }); + + const { result } = renderHook(() => + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), + ); + + act(() => { + result.current.handleSliderChange(50); + }); + + expect(result.current.sliderPercent).toBe(50); + + act(() => { + result.current.handleAmountChange('25'); + }); + + expect(result.current.usdAmount).toBe('25'); + expect(result.current.sliderPercent).toBe(0); + }); }); - describe('handlePresetPress', () => { - it('sets usdAmount to the preset value', () => { + describe('handleSliderChange', () => { + it('sets usdAmount from slider percent of available balance', () => { + (useLatestBalance as jest.Mock).mockReturnValue({ + displayBalance: '100', + atomicBalance: '100000000', + }); + const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 }); + (useSourceTokenOptions as jest.Mock).mockReturnValue({ + options: [sourceWithRate], + }); + const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { - result.current.handlePresetPress('50'); + result.current.handleSliderChange(50); }); - expect(result.current.usdAmount).toBe('50'); + expect(result.current.sliderPercent).toBe(50); + expect(Number(result.current.usdAmount)).toBeGreaterThan(0); + }); + + it('tracks amount selected once when the snapped percent is unchanged', () => { + (useLatestBalance as jest.Mock).mockReturnValue({ + displayBalance: '100', + atomicBalance: '100000000', + }); + const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 }); + (useSourceTokenOptions as jest.Mock).mockReturnValue({ + options: [sourceWithRate], + }); + + const { result } = renderHook(() => + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), + ); + + act(() => { + result.current.handleSliderChange(48); + result.current.handleSliderChange(49); + result.current.handleSliderChange(51); + }); + + expect(result.current.sliderPercent).toBe(50); + expect(mockTrackAmountSelected).toHaveBeenCalledTimes(1); }); }); describe('getButtonLabel', () => { it('returns the buy label when all conditions are normal', () => { const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); expect(result.current.getButtonLabel()).toBe( @@ -361,9 +469,16 @@ describe('useQuickBuyBottomSheet', () => { (useIsInsufficientBalance as jest.Mock).mockReturnValue(true); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); + act(() => { + result.current.handleAmountChange('20'); + }); + expect(result.current.buttonError).toBe('insufficient_balance'); expect(result.current.getButtonLabel()).toBe('bridge.insufficient_funds'); }); @@ -372,9 +487,16 @@ describe('useQuickBuyBottomSheet', () => { (useHasSufficientGas as jest.Mock).mockReturnValue(false); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); + act(() => { + result.current.handleAmountChange('20'); + }); + expect(result.current.buttonError).toBe('insufficient_gas'); expect(result.current.getButtonLabel()).toBe('bridge.insufficient_gas'); @@ -401,9 +523,16 @@ describe('useQuickBuyBottomSheet', () => { }); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); + act(() => { + result.current.handleAmountChange('20'); + }); + expect(result.current.buttonError).toBe('insufficient_balance'); expect(result.current.getButtonLabel()).toBe('bridge.insufficient_funds'); expect(useHasSufficientGas).toHaveBeenCalledWith({ @@ -414,7 +543,12 @@ describe('useQuickBuyBottomSheet', () => { describe('quoteOverride wiring', () => { it('passes null to useIsInsufficientBalance when there is no active quote', () => { - renderHook(() => useQuickBuyBottomSheet(createPosition(), jest.fn())); + renderHook(() => + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), + ); expect(useIsInsufficientBalance).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -434,7 +568,12 @@ describe('useQuickBuyBottomSheet', () => { isActiveQuoteForCurrentTokenPair: true, }); - renderHook(() => useQuickBuyBottomSheet(createPosition(), jest.fn())); + renderHook(() => + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), + ); expect(useIsInsufficientBalance).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -447,7 +586,10 @@ describe('useQuickBuyBottomSheet', () => { describe('isConfirmDisabled', () => { it('is disabled when usdAmount is empty', () => { const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); expect(result.current.isConfirmDisabled).toBe(true); @@ -455,7 +597,10 @@ describe('useQuickBuyBottomSheet', () => { it('is disabled when amount is valid and there is no active quote', () => { const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { @@ -480,7 +625,10 @@ describe('useQuickBuyBottomSheet', () => { }); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { @@ -503,7 +651,10 @@ describe('useQuickBuyBottomSheet', () => { }); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); act(() => { @@ -534,11 +685,11 @@ describe('useQuickBuyBottomSheet', () => { (useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState); const props = { - position: createPosition(), + target: positionToQuickBuyTarget(createPosition()), onClose: jest.fn(), }; const { result, rerender } = renderHook( - ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose), + ({ target, onClose }) => useQuickBuyController(target, onClose), { initialProps: props, }, @@ -579,11 +730,11 @@ describe('useQuickBuyBottomSheet', () => { (useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState); const props = { - position: createPosition(), + target: positionToQuickBuyTarget(createPosition()), onClose: jest.fn(), }; const { result, rerender } = renderHook( - ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose), + ({ target, onClose }) => useQuickBuyController(target, onClose), { initialProps: props, }, @@ -628,11 +779,11 @@ describe('useQuickBuyBottomSheet', () => { (useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState); const props = { - position: createPosition(), + target: positionToQuickBuyTarget(createPosition()), onClose: jest.fn(), }; const { result, rerender } = renderHook( - ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose), + ({ target, onClose }) => useQuickBuyController(target, onClose), { initialProps: props }, ); @@ -677,11 +828,11 @@ describe('useQuickBuyBottomSheet', () => { })); const props = { - position: createPosition(), + target: positionToQuickBuyTarget(createPosition()), onClose: jest.fn(), }; const { result } = renderHook( - ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose), + ({ target, onClose }) => useQuickBuyController(target, onClose), { initialProps: props }, ); @@ -704,7 +855,10 @@ describe('useQuickBuyBottomSheet', () => { }); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); // createPosition uses chain 'base' → destChainId '0x1' in default mock, @@ -828,7 +982,10 @@ describe('useQuickBuyBottomSheet', () => { it('calls the onClose prop', () => { const onClose = jest.fn(); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), onClose), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + onClose, + ), ); act(() => { @@ -860,7 +1017,10 @@ describe('useQuickBuyBottomSheet', () => { const onClose = jest.fn(); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), onClose), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + onClose, + ), ); await act(async () => { @@ -887,7 +1047,10 @@ describe('useQuickBuyBottomSheet', () => { }); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); await act(async () => { @@ -915,7 +1078,10 @@ describe('useQuickBuyBottomSheet', () => { }); const { result } = renderHook(() => - useQuickBuyBottomSheet(createPosition(), jest.fn()), + useQuickBuyController( + positionToQuickBuyTarget(createPosition()), + jest.fn(), + ), ); await act(async () => { @@ -929,10 +1095,10 @@ describe('useQuickBuyBottomSheet', () => { feature: 'social', surface: 'quick_buy', operation: 'submit_tx', - source: 'useQuickBuyBottomSheet', + source: 'useQuickBuyController', }), extras: expect.objectContaining({ - message: 'Error submitting QuickBuy tx at useQuickBuyBottomSheet', + message: 'Error submitting QuickBuy tx at useQuickBuyController', }), }), ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts similarity index 99% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.test.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts index 97ba3b2587d..3b112fb69fe 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts @@ -5,7 +5,7 @@ import { MetaMetricsEvents } from '../../../../../../core/Analytics'; import { useQuickBuyQuotes, QUICK_BUY_QUOTE_DEBOUNCE_MS, -} from './useQuickBuyQuotes'; +} from './hooks/useQuickBuyQuotes'; import type { BridgeToken } from '../../../../../UI/Bridge/types'; import { selectDestAddress, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuySetup.test.ts similarity index 98% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.test.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuySetup.test.ts index e9a7126a376..023db282b49 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuySetup.test.ts @@ -6,7 +6,7 @@ import { AssetType, } from '../../../../../UI/Bridge/hooks/useAssetMetadata'; import { selectIsBridgeEnabledSourceFactory } from '../../../../../../core/redux/slices/bridge'; -import { useQuickBuySetup } from './useQuickBuySetup'; +import { useQuickBuySetup } from './hooks/useQuickBuySetup'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useSourceTokenOptions.test.ts similarity index 99% rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.test.ts rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useSourceTokenOptions.test.ts index 26f79f57b02..6bc4581fd77 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useSourceTokenOptions.test.ts @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { toChecksumAddress } from '../../../../../../util/address'; import type { BridgeToken } from '../../../../../UI/Bridge/types'; import { getSourceTokenCandidates } from './sourceTokenCandidates'; -import { useSourceTokenOptions } from './useSourceTokenOptions'; +import { useSourceTokenOptions } from './hooks/useSourceTokenOptions'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts new file mode 100644 index 00000000000..2eb34c2d16c --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts @@ -0,0 +1,48 @@ +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { MINIMUM_DISPLAY_THRESHOLD } from '../../../../../../../util/number/bigint'; +import { formatExchangeRate } from './formatExchangeRate'; + +const createToken = ( + symbol: string, + currencyExchangeRate: number, +): BridgeToken => + ({ + symbol, + currencyExchangeRate, + }) as BridgeToken; + +describe('formatExchangeRate', () => { + it('returns undefined when exchange rates are missing', () => { + expect(formatExchangeRate(undefined, createToken('ETH', 3000))).toBe( + undefined, + ); + expect(formatExchangeRate(createToken('PEPE', 1), undefined)).toBe( + undefined, + ); + }); + + it('formats large rates without spurious fraction digits', () => { + const result = formatExchangeRate( + createToken('ETH', 3000), + createToken('USDC', 1), + ); + + expect(result).toBe('1 ETH = 3000 USDC'); + }); + + it('uses the dust threshold for sub-micro meme-coin rates', () => { + const destPepe = createToken('PEPE', 0.000008); + const sourceEth = createToken('ETH', 3000); + + const result = formatExchangeRate(destPepe, sourceEth); + + expect(result).toBe(`1 PEPE = < ${MINIMUM_DISPLAY_THRESHOLD} ETH`); + }); + + it('formats mid-range rates up to five decimal places', () => { + const dest = createToken('TOKEN', 0.05); + const source = createToken('USDC', 1); + + expect(formatExchangeRate(dest, source)).toBe('1 TOKEN = 0.05 USDC'); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts new file mode 100644 index 00000000000..08713ec7aa8 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts @@ -0,0 +1,33 @@ +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { formatAmountWithThreshold } from '../../../../../../../util/number/bigint'; + +/** Matches social leaderboard token amount formatting (see `formatTokenAmount`). */ +const MAX_RATE_DECIMAL_PLACES = 5; + +/** + * Formats a human-readable exchange rate between destination and source tokens. + * Example: "1 ETH = 4,381 USDC" + * + * Very small positive rates use the shared dust threshold (`< 0.00001`). + */ +export function formatExchangeRate( + destToken: BridgeToken | undefined, + sourceToken: BridgeToken | undefined, +): string | undefined { + if (!destToken?.currencyExchangeRate || !sourceToken?.currencyExchangeRate) { + return undefined; + } + + const destUsd = destToken.currencyExchangeRate; + const sourceUsd = sourceToken.currencyExchangeRate; + if (destUsd <= 0 || sourceUsd <= 0) return undefined; + + const sourcePerDest = destUsd / sourceUsd; + if (sourcePerDest <= 0) return undefined; + + const formattedAmount = String( + formatAmountWithThreshold(sourcePerDest, MAX_RATE_DECIMAL_PLACES), + ); + + return `1 ${destToken.symbol} = ${formattedAmount} ${sourceToken.symbol}`; +} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts new file mode 100644 index 00000000000..86546f3aaca --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts @@ -0,0 +1,26 @@ +import { BRIDGE_MM_FEE_RATE } from '@metamask/bridge-controller'; +import { isNullOrUndefined } from '@metamask/utils'; + +/** + * Resolves the MM fee % from quote metadata, falling back to the default bridge rate. + * + * `quoteBpsFee` is not yet reflected in the bridge-controller TS types but is + * returned by the API at runtime — see the same @ts-expect-error pattern in + * BridgeViewFooter.tsx. We widen `metabridge` to `unknown` so any real quote + * is accepted, then extract the field via a safe runtime cast. + */ +export function getMetamaskFeePercent( + activeQuote: + | { quote?: { feeData?: { metabridge?: unknown } } } + | null + | undefined, +): number { + const metabridge = activeQuote?.quote?.feeData?.metabridge as + | { quoteBpsFee?: number } + | undefined; + const quoteBpsFee = metabridge?.quoteBpsFee; + if (!isNullOrUndefined(quoteBpsFee)) { + return quoteBpsFee / 100; + } + return BRIDGE_MM_FEE_RATE; +} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx deleted file mode 100644 index 3f8f68e5e49..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; -import { TextInput } from 'react-native'; -import { fireEvent, screen } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import type { Position } from '@metamask/social-controllers'; -import QuickBuyAmountInput from './QuickBuyAmountInput'; - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -const mockPosition = { - tokenSymbol: 'BTC', -} as unknown as Position; - -const { mockTheme } = jest.requireActual('../../../../../../util/theme'); -const mockColors = { text: { alternative: mockTheme.colors.text.alternative } }; - -const createHiddenInputRef = () => - React.createRef() as unknown as React.RefObject; - -const defaultProps = { - usdAmount: '', - position: mockPosition, - estimatedReceiveAmount: undefined as string | undefined, - isQuoteLoading: false, - hasValidAmount: false, - hiddenInputRef: createHiddenInputRef(), - onAmountAreaPress: jest.fn(), - onAmountChange: jest.fn(), - colors: mockColors, -}; - -describe('QuickBuyAmountInput', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders the zero state when no amount has been entered', () => { - const { UNSAFE_queryByType } = renderWithProvider( - , - ); - const { ActivityIndicator } = jest.requireActual('react-native'); - - expect(screen.getByText('$0')).toBeOnTheScreen(); - expect(screen.getByText('0 BTC')).toBeOnTheScreen(); - expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull(); - }); - - it('renders the entered USD amount', () => { - renderWithProvider( - , - ); - - expect(screen.getByText('$50')).toBeOnTheScreen(); - }); - - it('renders a leading decimal without dropping the zero prefix', () => { - renderWithProvider( - , - ); - - expect(screen.getByText('$0.')).toBeOnTheScreen(); - expect(screen.queryByText('$.')).not.toBeOnTheScreen(); - }); - - it('renders a leading decimal with digits without dropping the zero prefix', () => { - renderWithProvider( - , - ); - - expect(screen.getByText('$0.5')).toBeOnTheScreen(); - expect(screen.queryByText('$.5')).not.toBeOnTheScreen(); - }); - - it('shows a loading spinner while fetching a quote for a valid amount', () => { - const { UNSAFE_getByType } = renderWithProvider( - , - ); - - const { ActivityIndicator } = jest.requireActual('react-native'); - expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); - expect(screen.queryByText('0 BTC')).toBeNull(); - }); - - it('renders the estimated receive amount with the token symbol', () => { - renderWithProvider( - , - ); - - expect(screen.getByText('1.23 BTC')).toBeOnTheScreen(); - }); - - it('falls back to "0 ${symbol}" when there is no receive amount and not loading', () => { - renderWithProvider( - , - ); - - expect(screen.getByText('0 BTC')).toBeOnTheScreen(); - }); - - it('fires onAmountAreaPress when the amount area is tapped', () => { - const onAmountAreaPress = jest.fn(); - renderWithProvider( - , - ); - - fireEvent.press(screen.getByTestId('quick-buy-amount-area')); - expect(onAmountAreaPress).toHaveBeenCalledTimes(1); - }); - - it('fires onAmountChange when typing into the hidden input', () => { - const onAmountChange = jest.fn(); - renderWithProvider( - , - ); - - fireEvent.changeText(screen.getByTestId('quick-buy-amount-input'), '42'); - expect(onAmountChange).toHaveBeenCalledWith('42'); - }); -}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx deleted file mode 100644 index 97378ca1dcc..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import { - StyleSheet, - TextInput, - TouchableOpacity, - ActivityIndicator, -} from 'react-native'; -import { - Box, - Text, - TextVariant, - TextColor, - FontWeight, - BoxAlignItems, - BoxJustifyContent, -} from '@metamask/design-system-react-native'; -import type { Position } from '@metamask/social-controllers'; - -const styles = StyleSheet.create({ - amountText: { fontSize: 48, lineHeight: 50 }, - hiddenInput: { position: 'absolute', opacity: 0, height: 0 }, -}); - -interface QuickBuyAmountInputProps { - usdAmount: string; - position: Position; - estimatedReceiveAmount: string | undefined; - isQuoteLoading: boolean; - hasValidAmount: boolean; - hiddenInputRef: React.RefObject; - onAmountAreaPress: () => void; - onAmountChange: (text: string) => void; - colors: { text: { alternative: string } }; -} - -const QuickBuyAmountInput: React.FC = ({ - usdAmount, - position, - estimatedReceiveAmount, - isQuoteLoading, - hasValidAmount, - hiddenInputRef, - onAmountAreaPress, - onAmountChange, - colors, -}) => ( - - - - {`$${usdAmount || '0'}`} - - - {isQuoteLoading && hasValidAmount ? ( - - ) : ( - - {estimatedReceiveAmount - ? `${estimatedReceiveAmount} ${position.tokenSymbol}` - : `0 ${position.tokenSymbol}`} - - )} - - {/* Hidden TextInput for keyboard capture */} - - - -); - -export default QuickBuyAmountInput; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx deleted file mode 100644 index 9a8f50b63c6..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { - BottomSheet, - Box, - BoxAlignItems, - Text, - TextColor, - TextVariant, - type BottomSheetRef, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import type { Position } from '@metamask/social-controllers'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -// `react-native-gesture-handler` ScrollView is required for scrolling on -// Android inside a gesture-handler-managed BottomSheet. -import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler'; -import Animated from 'react-native-reanimated'; -import { useSelector } from 'react-redux'; -import { strings } from '../../../../../../../locales/i18n'; -import { selectIsSubmittingTx } from '../../../../../../core/redux/slices/bridge'; -import { useTheme } from '../../../../../../util/theme'; -import QuickBuyAmountInput from './QuickBuyAmountInput'; -import QuickBuyBanners from './QuickBuyBanners'; -import QuickBuyBottomSheetSkeleton from './QuickBuyBottomSheetSkeleton'; -import QuickBuyConfirmButton from './QuickBuyConfirmButton'; -import QuickBuyFooter from './QuickBuyFooter'; -import QuickBuyHeader from './QuickBuyHeader'; -import { useQuickBuyBottomSheet } from './useQuickBuyBottomSheet'; - -export interface QuickBuyBottomSheetProps { - isVisible: boolean; - position: Position | null; - onClose: () => void; - /** Wallet address of the trader being copied; required for analytics. */ - traderAddress?: string; - /** Destination-token market cap (in user currency); forwarded for analytics. */ - marketCap?: number; - /** Surface that opened the sheet; forwarded for analytics. */ - source?: 'notification' | 'profile_position' | 'leaderboard'; -} - -interface InnerProps { - position: Position; - onClose: () => void; - traderAddress?: string; - marketCap?: number; - source?: 'notification' | 'profile_position' | 'leaderboard'; -} - -const AnimatedScrollView = Animated.createAnimatedComponent( - GestureHandlerScrollView, -); - -/** - * Heavy subtree — deferred until after the open animation so its hook - * tree (bridge quotes, balances, rewards, metadata) does not starve the - * JS thread while the sheet is animating in. - */ -const QuickBuyBottomSheetContent: React.FC = ({ - position, - onClose, - traderAddress, - marketCap, - source, -}) => { - const tw = useTailwind(); - const { colors } = useTheme(); - const { - hiddenInputRef, - isUnsupportedChain, - sourceToken, - sourceChainId, - sourceTokenOptions, - selectedSourceToken, - isSourcePickerOpen, - setIsSourcePickerOpen, - setSelectedSourceToken, - usdAmount, - estimatedReceiveAmount, - sourceBalanceFiat, - formattedNetworkFee, - formattedSlippage, - formattedMinimumReceived, - formattedPriceImpact, - totalAmountUsd, - isQuoteLoading, - isTotalLoading, - isHardwareSolanaBlocked, - priceImpactViewData, - isPriceImpactError, - hasValidAmount, - isConfirmDisabled, - confirmButtonState, - getButtonLabel, - handlePresetPress, - handleAmountAreaPress, - handleAmountChange, - handleConfirm, - } = useQuickBuyBottomSheet(position, onClose, { - traderAddress, - marketCap, - source, - }); - - return ( - <> - {isUnsupportedChain ? ( - - - {strings('social_leaderboard.quick_buy.unsupported_chain')} - - - ) : ( - <> - - - - - - - - - - - - > - )} - > - ); -}; - -/** - * Lightweight shell — opens the sheet immediately with just a placeholder - * so the animation runs on an idle JS thread. The heavy content tree is - * mounted after the sheet reports its open animation has finished. - */ -const QuickBuyBottomSheetInner: React.FC = ({ - position, - onClose, - traderAddress, - marketCap, - source, -}) => { - const tw = useTailwind(); - const bottomSheetRef = useRef(null); - const [isContentReady, setIsContentReady] = useState(false); - const isSubmittingTx = useSelector(selectIsSubmittingTx); - - useEffect(() => { - bottomSheetRef.current?.onOpenBottomSheet(() => { - setIsContentReady(true); - }); - }, []); - - const handleClose = useCallback(() => { - bottomSheetRef.current?.onCloseBottomSheet(); - }, []); - - return ( - - - {isContentReady ? ( - - ) : ( - - - - )} - - ); -}; - -/** - * Outer gate component — only mounts the inner sheet when visible. - * This prevents the bridge hooks from running on an empty Redux state, - * which causes reselect stability warnings. - */ -const QuickBuyBottomSheet: React.FC = ({ - isVisible, - position, - onClose, - traderAddress, - marketCap, - source, -}) => { - if (!isVisible || !position) return null; - return ( - - ); -}; - -export default QuickBuyBottomSheet; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx deleted file mode 100644 index 6fab22a0971..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - Text, - TextVariant, - TextColor, - Button, - ButtonVariant, - ButtonBaseSize, - BoxAlignItems, - BoxFlexDirection, - BoxJustifyContent, -} from '@metamask/design-system-react-native'; -import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; -import { strings } from '../../../../../../../locales/i18n'; - -const USD_PRESETS = ['1', '20', '50', '100']; - -interface QuickBuySkeletonRowProps { - label: string; - valueWidth: number; - showTokenBadge?: boolean; - showTrailingIcon?: boolean; - showInfoIcon?: boolean; -} - -const QuickBuySkeletonRow: React.FC = ({ - label, - valueWidth, - showTokenBadge = false, - showTrailingIcon = false, - showInfoIcon = false, -}) => { - const tw = useTailwind(); - - return ( - - - - {label} - - {showInfoIcon ? ( - - ) : null} - - - {showTokenBadge ? ( - - ) : null} - - {showTrailingIcon ? ( - - ) : null} - - - ); -}; - -const QuickBuyBottomSheetSkeleton: React.FC = () => { - const tw = useTailwind(); - - return ( - - - - - - - - - {USD_PRESETS.map((preset) => ( - - undefined} - isDisabled - isFullWidth - testID={`quick-buy-skeleton-preset-${preset}`} - > - {`$${preset}`} - - - ))} - - - - - - - - - - - - - - - - ); -}; - -export default QuickBuyBottomSheetSkeleton; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx deleted file mode 100644 index 302f468b157..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react-native'; -import { - TextColor, - IconColor, - IconName, -} from '@metamask/design-system-react-native'; -import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import type { Hex } from '@metamask/utils'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import QuickBuyFooter from './QuickBuyFooter'; - -jest.mock('./SourceTokenPicker', () => { - const ReactMock = jest.requireActual('react'); - const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); - return ({ - options, - onSelect, - }: { - options: BridgeToken[]; - onSelect: (token: BridgeToken) => void; - }) => - ReactMock.createElement( - View, - { testID: 'mock-source-token-picker' }, - options.map((token: BridgeToken) => - ReactMock.createElement( - TouchableOpacity, - { - key: token.symbol, - testID: `picker-option-${token.symbol}`, - onPress: () => onSelect(token), - }, - ReactMock.createElement(Text, null, token.symbol), - ), - ), - ); -}); - -jest.mock('../../../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(() => 0), -})); - -jest.mock('../../../../../UI/Rewards/components/RewardPointsAnimation', () => { - const ReactMock = jest.requireActual('react'); - const { Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ value }: { value: number }) => - ReactMock.createElement( - Text, - { testID: 'mock-rewards-animation' }, - `${value} pts`, - ), - RewardAnimationState: { - Loading: 'loading', - ErrorState: 'error', - Idle: 'idle', - }, - }; -}); - -jest.mock( - '../../../../../UI/Rewards/components/AddRewardsAccount/AddRewardsAccount', - () => { - const ReactMock = jest.requireActual('react'); - const { Text } = jest.requireActual('react-native'); - return ({ testID }: { testID?: string }) => - ReactMock.createElement( - Text, - { testID: testID ?? 'mock-add-rewards-account' }, - 'Add Rewards', - ); - }, -); - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -const { mockTheme } = jest.requireActual('../../../../../../util/theme'); -const mockColors = { icon: { alternative: mockTheme.colors.icon.alternative } }; - -const createSourceToken = (overrides: Partial = {}): BridgeToken => - ({ - address: '0x0000000000000000000000000000000000000000', - chainId: '0x1' as Hex, - decimals: 18, - symbol: 'ETH', - name: 'Ethereum', - image: 'https://example.com/eth.png', - currencyExchangeRate: 2000, - balance: '1.0', - balanceFiat: '$2000.00', - tokenFiatAmount: 2000, - ...overrides, - }) as BridgeToken; - -const defaultProps = { - usdAmount: '', - formattedNetworkFee: '-', - formattedSlippage: '-', - formattedMinimumReceived: '-', - formattedPriceImpact: '-', - priceImpactViewData: { - textColor: TextColor.TextAlternative, - icon: undefined, - title: 'bridge.price_impact_info_title', - description: 'bridge.price_impact_info_description', - }, - totalAmountUsd: '$0', - sourceToken: createSourceToken(), - sourceChainId: '0x1' as Hex, - sourceTokenOptions: [createSourceToken()], - selectedSourceToken: createSourceToken(), - isSourcePickerOpen: false, - setIsSourcePickerOpen: jest.fn(), - setSelectedSourceToken: jest.fn(), - sourceBalanceFiat: '$2000.00', - isTotalLoading: false, - onPresetPress: jest.fn(), - colors: mockColors, -}; - -describe('QuickBuyFooter', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('preset buttons', () => { - it('renders all four presets and calls onPresetPress when tapped', () => { - const onPresetPress = jest.fn(); - renderWithProvider( - , - ); - - expect(screen.getByTestId('quick-buy-preset-1')).toBeOnTheScreen(); - expect(screen.getByTestId('quick-buy-preset-20')).toBeOnTheScreen(); - expect(screen.getByTestId('quick-buy-preset-50')).toBeOnTheScreen(); - expect(screen.getByTestId('quick-buy-preset-100')).toBeOnTheScreen(); - fireEvent.press(screen.getByTestId('quick-buy-preset-50')); - expect(onPresetPress).toHaveBeenCalledWith('50'); - }); - }); - - describe('pay-with row', () => { - it('renders the SourceTokenPicker when isSourcePickerOpen is true', () => { - renderWithProvider( - , - ); - - expect(screen.getByTestId('mock-source-token-picker')).toBeOnTheScreen(); - }); - - it('calls setSelectedSourceToken when a picker option is selected', () => { - const setSelectedSourceToken = jest.fn(); - const setIsSourcePickerOpen = jest.fn(); - const usdcToken = createSourceToken({ symbol: 'USDC' }); - - renderWithProvider( - , - ); - - fireEvent.press(screen.getByTestId('picker-option-USDC')); - - expect(setSelectedSourceToken).toHaveBeenCalledWith(usdcToken); - expect(setIsSourcePickerOpen).toHaveBeenCalledWith(false); - }); - }); - - describe('total row', () => { - it('shows skeleton when isTotalLoading is true', () => { - renderWithProvider( - , - ); - - expect(screen.getByTestId('skeleton-view')).toBeOnTheScreen(); - expect(screen.queryByText('$20.50')).toBeNull(); - }); - - it('shows the total value when isTotalLoading is false', () => { - renderWithProvider( - , - ); - - expect(screen.getByText('$20.50')).toBeOnTheScreen(); - }); - }); - - describe('price impact row', () => { - it('renders the formatted percentage without an icon when impact is safe', () => { - renderWithProvider(); - - // safe impact starts collapsed; expand to reveal the subrow - fireEvent.press(screen.getByTestId('quick-buy-total-row')); - - expect(screen.getByTestId('quick-buy-price-impact')).toBeOnTheScreen(); - }); - - it('auto-expands the total breakdown when severity icon is set', () => { - renderWithProvider( - , - ); - - // No tap on Total — should already be expanded - expect(screen.getByTestId('quick-buy-price-impact')).toBeOnTheScreen(); - expect(screen.getByText('6.00%')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx deleted file mode 100644 index 5626e91565b..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx +++ /dev/null @@ -1,461 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { type LayoutChangeEvent, TouchableOpacity, View } from 'react-native'; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - Text, - TextVariant, - TextColor, - Button, - ButtonVariant, - ButtonBaseSize, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - AvatarToken, - AvatarTokenSize, - BadgeWrapper, - BadgeWrapperPosition, - BadgeNetwork, - Icon as IconDS, - IconSize as IconSizeDS, -} from '@metamask/design-system-react-native'; -import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; -import Icon, { - IconName, - IconSize, -} from '../../../../../../component-library/components/Icons/Icon'; -import { getNetworkImageSource } from '../../../../../../util/networks'; -import type { Hex } from '@metamask/utils'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import type { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData'; -import SourceTokenPicker from './SourceTokenPicker'; -import { getBridgeTokenImageSource } from './getBridgeTokenImageSource'; -import { strings } from '../../../../../../../locales/i18n'; - -const USD_PRESETS = ['1', '20', '50', '100']; - -const PAY_WITH_ANIMATION_DURATION_MS = 220; -const PAY_WITH_ANIMATION_EASING = Easing.out(Easing.cubic); - -interface QuickBuyFooterProps { - usdAmount: string; - formattedNetworkFee: string; - formattedSlippage: string; - formattedMinimumReceived: string; - formattedPriceImpact: string; - priceImpactViewData: ReturnType; - totalAmountUsd: string; - sourceToken: BridgeToken | undefined; - sourceChainId: Hex | undefined; - sourceTokenOptions: BridgeToken[]; - selectedSourceToken: BridgeToken | undefined; - isSourcePickerOpen: boolean; - setIsSourcePickerOpen: React.Dispatch>; - setSelectedSourceToken: React.Dispatch< - React.SetStateAction - >; - sourceBalanceFiat: string | undefined; - isTotalLoading: boolean; - onPresetPress: (preset: string) => void; - colors: { icon: { alternative: string } }; -} - -const QuickBuyFooter: React.FC = ({ - usdAmount, - formattedNetworkFee, - formattedSlippage, - formattedMinimumReceived, - formattedPriceImpact, - priceImpactViewData, - totalAmountUsd, - sourceToken, - sourceChainId, - sourceTokenOptions, - selectedSourceToken, - isSourcePickerOpen, - setIsSourcePickerOpen, - setSelectedSourceToken, - sourceBalanceFiat, - isTotalLoading, - onPresetPress, - colors, -}) => { - const tw = useTailwind(); - const isPriceImpactSafe = !priceImpactViewData.icon; - const [isTotalExpanded, setIsTotalExpanded] = useState(!isPriceImpactSafe); - - // Animate the source picker's real `height` so React Native's layout system - // re-measures the parent BottomSheet on each frame; this lets the whole sheet - // grow/shrink smoothly instead of jumping. - const pickerHeight = useSharedValue(0); - const measuredPickerHeight = useRef(0); - - const animatedPickerStyle = useAnimatedStyle(() => ({ - height: pickerHeight.value, - })); - - const handlePickerLayout = useCallback( - (event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - if (height <= 0 || height === measuredPickerHeight.current) return; - measuredPickerHeight.current = height; - if (isSourcePickerOpen) { - pickerHeight.value = withTiming(height, { - duration: PAY_WITH_ANIMATION_DURATION_MS, - easing: PAY_WITH_ANIMATION_EASING, - }); - } - }, - [isSourcePickerOpen, pickerHeight], - ); - - useEffect(() => { - pickerHeight.value = withTiming( - isSourcePickerOpen ? measuredPickerHeight.current : 0, - { - duration: PAY_WITH_ANIMATION_DURATION_MS, - easing: PAY_WITH_ANIMATION_EASING, - }, - ); - }, [isSourcePickerOpen, pickerHeight]); - - // Animate the Total fee-breakdown the same way. `isTotalExpanded` can start - // `true` (when price impact is unsafe) so we use an `initialized` ref to - // commit the first measurement without an animation; subsequent toggles run - // through `withTiming` so the parent BottomSheet grows smoothly. - const totalBreakdownHeight = useSharedValue(0); - const measuredTotalBreakdownHeight = useRef(0); - const totalBreakdownInitialized = useRef(false); - - const animatedTotalBreakdownStyle = useAnimatedStyle(() => ({ - height: totalBreakdownHeight.value, - })); - - const handleTotalBreakdownLayout = useCallback( - (event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - if (height <= 0 || height === measuredTotalBreakdownHeight.current) - return; - measuredTotalBreakdownHeight.current = height; - if (!totalBreakdownInitialized.current) { - totalBreakdownInitialized.current = true; - totalBreakdownHeight.value = isTotalExpanded ? height : 0; - return; - } - if (isTotalExpanded) { - totalBreakdownHeight.value = withTiming(height, { - duration: PAY_WITH_ANIMATION_DURATION_MS, - easing: PAY_WITH_ANIMATION_EASING, - }); - } - }, - [isTotalExpanded, totalBreakdownHeight], - ); - - useEffect(() => { - if (!totalBreakdownInitialized.current) return; - totalBreakdownHeight.value = withTiming( - isTotalExpanded ? measuredTotalBreakdownHeight.current : 0, - { - duration: PAY_WITH_ANIMATION_DURATION_MS, - easing: PAY_WITH_ANIMATION_EASING, - }, - ); - }, [isTotalExpanded, totalBreakdownHeight]); - - const handleSourcePickerToggle = useCallback(() => { - setIsSourcePickerOpen((prev) => !prev); - }, [setIsSourcePickerOpen]); - - const handleSourceTokenSelect = useCallback( - (token: BridgeToken) => { - setSelectedSourceToken(token); - setIsSourcePickerOpen(false); - }, - [setSelectedSourceToken, setIsSourcePickerOpen], - ); - - return ( - - {/* Preset pills */} - - - {USD_PRESETS.map((preset) => ( - - onPresetPress(preset)} - isFullWidth - testID={`quick-buy-preset-${preset}`} - > - {`$${preset}`} - - - ))} - - - - {/* Footer details */} - - - {/* Pay with card (tap to expand source picker inline) */} - - - - - {strings('social_leaderboard.quick_buy.pay_with')} - - - - ) : null - } - > - - - - {sourceToken?.symbol ?? ''} - - {sourceBalanceFiat && ( - - {`(${sourceBalanceFiat})`} - - )} - - - - - - - - - - - - - {/* Total row (tap to expand fee breakdown) */} - - setIsTotalExpanded((prev) => !prev)} - testID="quick-buy-total-row" - > - - - - {strings('social_leaderboard.quick_buy.total')} - - - - {isTotalLoading ? ( - - ) : ( - - {totalAmountUsd} - - )} - - - - {/* Expanded fee breakdown (subsection of Total) */} - - - - - - {strings('social_leaderboard.quick_buy.network_fee')} - - - {formattedNetworkFee} - - - - - {strings('social_leaderboard.quick_buy.slippage')} - - - {formattedSlippage} - - - - - {strings('social_leaderboard.quick_buy.minimum_received')} - - - {formattedMinimumReceived} - - - - - {strings('social_leaderboard.quick_buy.price_impact')} - - - {priceImpactViewData.icon && ( - - )} - - {formattedPriceImpact} - - - - - - - - - - - ); -}; - -export default QuickBuyFooter; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx deleted file mode 100644 index b5027b30903..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react-native'; -import type { Position } from '@metamask/social-controllers'; -import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import QuickBuyHeader from './QuickBuyHeader'; - -jest.mock('../../../components/PositionTokenAvatar', () => { - const ReactMock = jest.requireActual('react'); - const { Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ position }: { position: Position }) => - ReactMock.createElement( - Text, - { testID: 'mock-position-token-avatar' }, - position.tokenSymbol, - ), - }; -}); - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: (key: string, params?: Record) => - params ? `${key}:${JSON.stringify(params)}` : key, -})); - -const createPosition = (overrides: Partial = {}): Position => - ({ - chain: 'base', - tokenAddress: '0x1234567890123456789012345678901234567890', - tokenSymbol: 'PEPE', - tokenName: 'Pepe', - positionAmount: 1000, - boughtUsd: 500, - soldUsd: 0, - realizedPnl: 0, - costBasis: 500, - trades: [], - lastTradeAt: 0, - currentValueUSD: 900, - pnlValueUsd: 400, - pnlPercent: 80, - ...overrides, - }) as Position; - -describe('QuickBuyHeader', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders the title with the token symbol', () => { - renderWithProvider( - , - ); - - expect( - screen.getByText('social_leaderboard.quick_buy.title:{"symbol":"PEPE"}'), - ).toBeOnTheScreen(); - }); - - it('renders the position token avatar', () => { - renderWithProvider( - , - ); - - expect(screen.getByTestId('mock-position-token-avatar')).toBeOnTheScreen(); - }); - - describe('market cap subtitle', () => { - it('renders only the label when marketCap is undefined', () => { - renderWithProvider( - , - ); - - expect( - screen.getByText('social_leaderboard.quick_buy.market_cap_label'), - ).toBeOnTheScreen(); - }); - - it('renders the formatted market cap with the label when provided in millions', () => { - renderWithProvider( - , - ); - - expect( - screen.getByText('$2.3M social_leaderboard.quick_buy.market_cap_label'), - ).toBeOnTheScreen(); - }); - - it('renders the formatted market cap with the label when provided in thousands', () => { - renderWithProvider( - , - ); - - expect( - screen.getByText('$25K social_leaderboard.quick_buy.market_cap_label'), - ).toBeOnTheScreen(); - }); - - it('treats a zero marketCap as a present value and prefixes "$0"', () => { - renderWithProvider( - , - ); - - expect( - screen.getByText('$0 social_leaderboard.quick_buy.market_cap_label'), - ).toBeOnTheScreen(); - }); - }); - - describe('close button', () => { - it('calls onClose when the close button is pressed', () => { - const onClose = jest.fn(); - renderWithProvider( - , - ); - - fireEvent.press(screen.getByTestId('quick-buy-close-button')); - expect(onClose).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx deleted file mode 100644 index 048fdcb7c2d..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { - Box, - Text, - TextVariant, - TextColor, - FontWeight, - ButtonIcon, - ButtonIconSize, - IconName as DsIconName, - BoxFlexDirection, - BoxAlignItems, -} from '@metamask/design-system-react-native'; -import type { Position } from '@metamask/social-controllers'; -import { strings } from '../../../../../../../locales/i18n'; -import { formatCompactUsd } from '../../../../../UI/Rewards/utils/formatUtils'; -import PositionTokenAvatar from '../../../components/PositionTokenAvatar'; - -interface QuickBuyHeaderProps { - position: Position; - marketCap?: number; - onClose: () => void; -} - -const QuickBuyHeader: React.FC = ({ - position, - marketCap, - onClose, -}) => ( - - - - - - - {strings('social_leaderboard.quick_buy.title', { - symbol: position.tokenSymbol, - })} - - - {marketCap != null - ? `${formatCompactUsd(marketCap)} ${strings('social_leaderboard.quick_buy.market_cap_label')}` - : strings('social_leaderboard.quick_buy.market_cap_label')} - - - - -); - -export default QuickBuyHeader; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx deleted file mode 100644 index dbaa0b8d278..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { - AvatarToken, - AvatarTokenSize, - BadgeNetwork, - BadgeWrapper, - BadgeWrapperPosition, - Box, - BoxAlignItems, - BoxFlexDirection, - BoxJustifyContent, - FontWeight, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import React, { useCallback } from 'react'; -import { TouchableOpacity } from 'react-native'; -import Icon, { - IconName, - IconSize, -} from '../../../../../../component-library/components/Icons/Icon'; -import { getNetworkImageSource } from '../../../../../../util/networks'; -import { useTheme } from '../../../../../../util/theme'; -import type { BridgeToken } from '../../../../../UI/Bridge/types'; -import { getBridgeTokenImageSource } from './getBridgeTokenImageSource'; -import { getTokenKey } from './sourceTokenCandidates'; - -interface SourceTokenPickerProps { - options: BridgeToken[]; - selectedToken: BridgeToken | undefined; - onSelect: (token: BridgeToken) => void; -} - -/** - * Inline dropdown list of source token options. - * Renders directly inside the parent bottom sheet — no nested sheets. - */ -const SourceTokenPicker: React.FC = ({ - options, - selectedToken, - onSelect, -}) => { - const { colors } = useTheme(); - const selectedKey = selectedToken ? getTokenKey(selectedToken) : undefined; - - const handleSelect = useCallback( - (token: BridgeToken) => { - onSelect(token); - }, - [onSelect], - ); - - return ( - - {options.map((item) => { - const key = getTokenKey(item); - const isSelected = key === selectedKey; - - return ( - handleSelect(item)} - testID={`source-token-option-${item.symbol}-${item.chainId}`} - > - - - - - } - > - - - - - - {item.symbol} - - {item.name && ( - - {item.name} - - )} - - - - - - {item.balanceFiat && ( - - {item.balanceFiat} - - )} - - {isSelected && ( - - )} - - - - ); - })} - - ); -}; - -export default SourceTokenPicker; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts deleted file mode 100644 index 4fa828e0e55..00000000000 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './QuickBuyBottomSheet'; -export type { QuickBuyBottomSheetProps } from './QuickBuyBottomSheet'; diff --git a/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts b/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts index 82f68fd7e18..16d3367c905 100644 --- a/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts +++ b/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts @@ -46,6 +46,7 @@ export const SocialLeaderboardEventValues = { AMOUNT_SELECTION_METHOD: { PRESET: 'preset', CUSTOM_INPUT: 'custom_input', + SLIDER: 'slider', }, DISMISS_STAGE: { TOKEN_DETAIL: 'token_detail', diff --git a/locales/languages/en.json b/locales/languages/en.json index f934a5f73e7..80d5dee6d42 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1098,6 +1098,8 @@ "title": "Buy {{symbol}}", "market_cap_label": "Market cap", "pay_with": "Pay with", + "buy_mode": "Buy", + "with": "with", "total": "Total", "network_fee": "Network fee", "slippage": "Slippage", @@ -1107,7 +1109,10 @@ "no_quotes": "No quotes available for this token", "loading": "Loading...", "unavailable": "Swap unavailable", - "unsupported_chain": "This chain is not supported for Quick Buy yet" + "unsupported_chain": "This chain is not supported for Quick Buy yet", + "available_balance": "{{amount}} available", + "toggle_amount_display": "Switch between token and dollar amount", + "includes_mm_fee": "Includes {{fee}}% MM fee" } }, "perps": { From d63e32926e7fa83bbe99d11c0adf63447275ec9c Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 26 May 2026 16:01:17 +0200 Subject: [PATCH 06/16] fix: remove Save button from trader notifications bottom sheet (#30632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The per-trader notifications bottom sheet had two related problems: 1. The **Save** button was inconsistent with every other notification setting in the app, which auto-save on toggle. It also added a confirmation step nobody asked for. 2. Removing the Save button surfaced an existing **snap-back bug** in `useNotificationPreferences`: the optimistic overlay was being dropped too eagerly, so a background refetch landing during the in-flight PUT would briefly flip the Switch back to the pre-PUT value. **Root cause of the snap-back.** `updatePreferencesSection` writes the new value to the react-query cache synchronously via `setQueryData(...)`. That makes `remoteSocialAI === overlay` on the very next render, the drop effect fires, and the overlay is gone before the PUT has reached the server. Any subsequent background refetch (focus, interval, invalidation) returning stale data then overwrites the cache and snaps the Switch back. **Fix.** Gate the overlay drop on a `pendingWrites` counter inside `useNotificationPreferences`. The overlay can only clear once **both** conditions hold: the PUT has actually settled, AND the remote matches. This lets the bottom sheet bind the Switch directly to `isTraderNotificationEnabled(traderId)` — no local state, no `userTouchedRef`, no open/close gymnastics. **Bottom sheet cleanup.** With the hook fixed, `TraderNotificationsBottomSheet` reads straight from the hook. The Save button, its translation key, and its test id are removed. Each tap calls `toggleTraderNotification` immediately and the Switch reflects the optimistic value (or the rolled-back value on PUT failure) without flicker. ## **Changelog** CHANGELOG entry: Fixed an issue where the per-trader notifications toggle could briefly snap back to its previous value while saving, and removed the redundant Save button in favor of immediate save. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: per-trader notifications bottom sheet Scenario: user toggles a trader notification off Given the user is on a trader profile And the per-trader notifications bottom sheet is open And the toggle is currently on When the user taps the toggle Then the toggle flips to off immediately And the toggle does not snap back during the network round-trip And closing and reopening the sheet still shows the toggle as off Scenario: PUT fails mid-flight Given the user has just toggled the per-trader notification When the backend rejects the PUT Then the toggle rolls back to its previous value And reopening the sheet still shows the rolled-back value Scenario: push notifications are disabled globally Given push notifications are off When the user opens the bottom sheet Then the per-trader toggle is disabled And tapping it does not fire a PUT ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI and preference-sync changes in Social Leaderboard notifications; no auth or payment paths, with regression tests added. > > **Overview** > Removes the **Save** step from the per-trader notifications bottom sheet so toggles **persist immediately** via `toggleTraderNotification`, matching other notification settings. > > The sheet no longer keeps local draft state: the switch reads **`isTraderNotificationEnabled`** from `useNotificationPreferences` and calls **`toggleTraderNotification`** on change (with existing push-off guard and switch haptics). > > **`useNotificationPreferences`** adds a **`pendingWrites`** counter so the optimistic overlay is cleared only when no PUT is in flight **and** remote data has caught up—preventing the toggle from snapping back if React Query briefly shows stale data mid-request. > > Tests cover in-flight overlay stability and updated bottom-sheet behavior; **`SAVE_BUTTON`** test id and **`social_leaderboard.trader_notifications.save`** copy are removed. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 336d32e69594fbb16ad4c4ce7e79c2085248e441. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../hooks/useNotificationPreferences.test.ts | 62 ++++++ .../hooks/useNotificationPreferences.ts | 16 +- .../TraderNotificationsBottomSheet.test.tsx | 210 +++++------------- .../TraderNotificationsBottomSheet.testIds.ts | 1 - .../TraderNotificationsBottomSheet.tsx | 72 ++---- locales/languages/en.json | 3 +- locales/languages/fr.json | 3 +- 7 files changed, 155 insertions(+), 212 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts index 30838bfa479..f121b57600a 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts @@ -446,6 +446,68 @@ describe('useNotificationPreferences', () => { ); }); + it('keeps the optimistic overlay while a PUT is in flight even if the query data momentarily reports the pre-PUT value (no snap-back)', async () => { + // Simulate a stale refetch landing between the optimistic cache write + // and the PUT resolving: the second render of the hook receives query + // data that no longer matches the optimistic overlay. + const remoteWithMute = buildRemote({ + socialAI: { + ...DEFAULT_SOCIAL_AI_PREFERENCES, + mutedTraderProfileIds: ['trader-x'], + }, + }); + const remoteWithoutMute = buildRemote(); + + mockUseQuery.mockReturnValue( + makeQueryResult({ data: remoteWithoutMute }), + ); + + let resolvePut: () => void = () => undefined; + const putPromise = new Promise((resolve) => { + resolvePut = resolve; + }); + mockCall.mockImplementation(async (action: string) => { + if (action === GET_ACTION) return remoteWithoutMute; + if (action === PUT_ACTION) return putPromise; + return undefined; + }); + + const { result, rerender } = renderHook(() => + useNotificationPreferences(), + ); + + act(() => { + result.current.toggleTraderNotification('trader-x'); + }); + + expect(result.current.isTraderNotificationEnabled('trader-x')).toBe( + false, + ); + + mockUseQuery.mockReturnValue( + makeQueryResult({ data: remoteWithoutMute }), + ); + rerender({}); + + expect(result.current.isTraderNotificationEnabled('trader-x')).toBe( + false, + ); + + await act(async () => { + resolvePut(); + await putPromise; + }); + + mockUseQuery.mockReturnValue(makeQueryResult({ data: remoteWithMute })); + rerender({}); + + await waitFor(() => { + expect(result.current.isTraderNotificationEnabled('trader-x')).toBe( + false, + ); + }); + }); + it('rolls back from the optimistic cache update when the PUT fails', async () => { mockUseQuery.mockReturnValue(makeQueryResult({ data: buildRemote() })); mockCall.mockImplementation(async (action: string) => { diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts index a87d248064f..145ead040d7 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts @@ -90,6 +90,11 @@ export const useNotificationPreferences = const [overlay, setOverlay] = useState( undefined, ); + // Number of in-flight PUTs. The overlay is only allowed to drop once this + // is 0, so a refetch landing mid-flight cannot snap the UI back to the + // pre-PUT value via the react-query cache. Bumped synchronously in + // applyChange (before await) and decremented when the PUT settles. + const [pendingWrites, setPendingWrites] = useState(0); const [persistError, setPersistError] = useState(null); const remoteSocialAI: SocialAIPreference = @@ -144,6 +149,7 @@ export const useNotificationPreferences = const nextSocialAI = updater(currentSocialAIRef.current); currentSocialAIRef.current = nextSocialAI; setOverlay(nextSocialAI); + setPendingWrites((count) => count + 1); setPersistError(null); try { @@ -160,16 +166,22 @@ export const useNotificationPreferences = setPersistError(toErrorMessage(err)); } return; + } finally { + setPendingWrites((count) => Math.max(0, count - 1)); } }, [enqueuePersist, hasNotificationPreferences], ); useEffect(() => { - if (overlay && hasRemoteCaughtUp(overlay, remoteSocialAI)) { + if ( + overlay && + pendingWrites === 0 && + hasRemoteCaughtUp(overlay, remoteSocialAI) + ) { setOverlay(undefined); } - }, [overlay, remoteSocialAI]); + }, [overlay, pendingWrites, remoteSocialAI]); const setPushNotificationsEnabled = useCallback( (value: boolean) => diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx index 58dd99dc3cb..3b1a1b5736a 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx @@ -1,10 +1,9 @@ -import React, { useRef, useEffect } from 'react'; -import { Platform } from 'react-native'; -import { screen, fireEvent, act } from '@testing-library/react-native'; +import React, { useRef, useEffect, useState } from 'react'; +import { Platform, Pressable } from 'react-native'; +import { screen, fireEvent } from '@testing-library/react-native'; import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller/notification-services'; import { ImpactFeedbackStyle, - ImpactMoment, playImpact, } from '../../../../../../util/haptics'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; @@ -231,16 +230,6 @@ describe('TraderNotificationsBottomSheet', () => { ), ).toBeOnTheScreen(); }); - - it('renders the save button', () => { - renderOpenedSheet(); - - expect( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ).toBeOnTheScreen(); - }); }); describe('toggle', () => { @@ -270,7 +259,7 @@ describe('TraderNotificationsBottomSheet', () => { expect(toggle.props.value).toBe(false); }); - it('flips the toggle value locally but does NOT call toggleTraderNotification immediately', () => { + it('calls toggleTraderNotification immediately when the toggle is changed', () => { renderOpenedSheet({ traderId: 'trader-1', isTraderNotificationEnabled: () => true, @@ -282,11 +271,66 @@ describe('TraderNotificationsBottomSheet', () => { false, ); - expect(mockToggleTraderNotification).not.toHaveBeenCalled(); + expect(mockToggleTraderNotification).toHaveBeenCalledTimes(1); + expect(mockToggleTraderNotification).toHaveBeenCalledWith('trader-1'); + }); + + it('mirrors the hook value on every render so the optimistic overlay (or rollback) drives the Switch directly', () => { + const Controllable: React.FC = () => { + const ref = useRef(null); + const [hookEnabled, setHookEnabled] = useState(true); + mockIsTraderNotificationEnabled.mockImplementation(() => hookEnabled); + useEffect(() => { + ref.current?.onOpenBottomSheet(); + }, []); + return ( + <> + + ref.current?.onOpenBottomSheet()} + /> + setHookEnabled(false)} + /> + setHookEnabled(true)} + /> + > + ); + }; + + renderWithProvider(); + + expect( + screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE) + .props.value, + ).toBe(true); + + fireEvent.press(screen.getByTestId('hook-flip-off')); expect( screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE) .props.value, ).toBe(false); + + fireEvent.press( + screen.getByTestId( + TraderNotificationsBottomSheetSelectorsIDs.CLOSE_BUTTON, + ), + ); + fireEvent.press(screen.getByTestId('hook-flip-on')); + fireEvent.press(screen.getByTestId('reopen')); + + expect( + screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE) + .props.value, + ).toBe(true); }); it('disables the toggle when push notifications are off', () => { @@ -356,93 +400,6 @@ describe('TraderNotificationsBottomSheet', () => { }); }); - describe('save button', () => { - it('calls toggleTraderNotification when the toggle was changed before saving', () => { - renderOpenedSheet({ - traderId: 'trader-1', - isTraderNotificationEnabled: () => true, - }); - - fireEvent( - screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE), - 'valueChange', - false, - ); - - act(() => { - fireEvent.press( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ); - }); - - expect(mockToggleTraderNotification).toHaveBeenCalledWith('trader-1'); - }); - - it('does not call toggleTraderNotification when the toggle was not changed before saving', () => { - renderOpenedSheet({ - traderId: 'trader-1', - isTraderNotificationEnabled: () => true, - }); - - act(() => { - fireEvent.press( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ); - }); - - expect(mockToggleTraderNotification).not.toHaveBeenCalled(); - }); - - it('does not call toggleTraderNotification when the toggle was changed and then reverted before saving', () => { - renderOpenedSheet({ - traderId: 'trader-1', - isTraderNotificationEnabled: () => true, - }); - - fireEvent( - screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE), - 'valueChange', - false, - ); - fireEvent( - screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE), - 'valueChange', - true, - ); - - act(() => { - fireEvent.press( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ); - }); - - expect(mockToggleTraderNotification).not.toHaveBeenCalled(); - }); - - it('closes the sheet and calls onDismiss when save is pressed', () => { - const mockOnDismiss = jest.fn(); - - renderOpenedSheet({ onDismiss: mockOnDismiss }); - - act(() => { - fireEvent.press( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ); - }); - - expect(mockOnDismiss).toHaveBeenCalledTimes(1); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - }); - describe('haptic feedback', () => { const originalPlatform = Platform.OS; @@ -450,53 +407,6 @@ describe('TraderNotificationsBottomSheet', () => { Platform.OS = originalPlatform; }); - it('fires a medium impact when pressing save', () => { - Platform.OS = 'ios'; - renderOpenedSheet({ - traderId: 'trader-1', - isTraderNotificationEnabled: () => true, - }); - - fireEvent( - screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE), - 'valueChange', - false, - ); - mockImpactAsync.mockClear(); - mockPlayImpact.mockClear(); - - act(() => { - fireEvent.press( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ); - }); - - expect(mockPlayImpact).toHaveBeenCalledTimes(1); - expect(mockPlayImpact).toHaveBeenCalledWith(ImpactMoment.PrimaryCTA); - }); - - it('fires a medium impact when pressing save even if the value did not change', () => { - Platform.OS = 'ios'; - renderOpenedSheet({ - traderId: 'trader-1', - isTraderNotificationEnabled: () => true, - }); - - act(() => { - fireEvent.press( - screen.getByTestId( - TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON, - ), - ); - }); - - expect(mockPlayImpact).toHaveBeenCalledTimes(1); - expect(mockPlayImpact).toHaveBeenCalledWith(ImpactMoment.PrimaryCTA); - expect(mockToggleTraderNotification).not.toHaveBeenCalled(); - }); - it('fires a light impact when toggling the local switch on Android', () => { Platform.OS = 'android'; renderOpenedSheet({ diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts index 9efa1d49064..89a1576d024 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts @@ -3,5 +3,4 @@ export const TraderNotificationsBottomSheetSelectorsIDs = { CLOSE_BUTTON: 'trader-notifications-bottom-sheet-close-button', TOGGLE: 'trader-notifications-bottom-sheet-toggle', MANAGE_TRADERS_ROW: 'trader-notifications-bottom-sheet-manage-traders-row', - SAVE_BUTTON: 'trader-notifications-bottom-sheet-save-button', }; diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx index 5150c1339cd..eea568b6bab 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback, useEffect, useState } from 'react'; +import React, { forwardRef, useCallback } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -17,16 +17,12 @@ import { IconColor, } from '@metamask/design-system-react-native'; import BottomSheet from '../../../../../../component-library/components/BottomSheets/BottomSheet/BottomSheet'; -import BottomSheetFooter from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter'; import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; -import { ButtonVariants } from '../../../../../../component-library/components/Buttons/Button/Button.types'; import { strings } from '../../../../../../../locales/i18n'; import Routes from '../../../../../../constants/navigation/Routes'; import { fireSwitchHaptic, ImpactFeedbackStyle, - playImpact, - ImpactMoment, } from '../../../../../../util/haptics'; import { useNotificationPreferences } from '../../../NotificationPreferences/hooks'; import AllowPushNotificationsRow from '../../../NotificationPreferences/components/AllowPushNotificationsRow'; @@ -54,9 +50,6 @@ const TraderNotificationsBottomSheet = forwardRef< isTraderNotificationEnabled, toggleTraderNotification, } = useNotificationPreferences(); - const [localEnabled, setLocalEnabled] = useState(() => - isTraderNotificationEnabled(traderId), - ); const tw = useTailwind(); const navigation = useNavigation(); @@ -66,14 +59,11 @@ const TraderNotificationsBottomSheet = forwardRef< const pushNotificationsOff = !hasNotificationPreferences || !preferences.pushNotificationsEnabled; - // Snapshot the remote value each time the sheet opens so the toggle - // always starts from the authoritative server state. - useEffect(() => { - if (isVisible) { - setLocalEnabled(isTraderNotificationEnabled(traderId)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isVisible]); + // The hook is the single source of truth: it serves an optimistic overlay + // that flips instantly on tap, drops only once the remote catches up, and + // rolls back on failed PUTs. Reading it directly avoids stale local state + // surviving across open/close cycles. + const enabled = isTraderNotificationEnabled(traderId); const handleManageTradersPress = useCallback(() => { sheetRef.current?.onCloseBottomSheet(() => { @@ -97,23 +87,15 @@ const TraderNotificationsBottomSheet = forwardRef< }); }, [hasNotificationPreferences, navigation, sheetRef]); - // Only persist when the user explicitly confirms with Save. - // If the local draft differs from the remote value, issue one toggle call. - // Save is a deliberate primary-action commit, so always fire the haptic - // — including when the value didn't change — to acknowledge the press. - const handleSave = useCallback(() => { - playImpact(ImpactMoment.PrimaryCTA); - if (localEnabled !== isTraderNotificationEnabled(traderId)) { - toggleTraderNotification(traderId); + const handleToggle = useCallback(() => { + if (pushNotificationsOff) { + return; } - closeSheet(); - }, [ - closeSheet, - isTraderNotificationEnabled, - localEnabled, - toggleTraderNotification, - traderId, - ]); + // Subordinate switch: rely on iOS UISwitch's native tick on iOS, + // fire a Light impact only on Android where there is none. + fireSwitchHaptic(ImpactFeedbackStyle.Light); + toggleTraderNotification(traderId); + }, [pushNotificationsOff, toggleTraderNotification, traderId]); if (!isVisible) { return null; @@ -149,16 +131,8 @@ const TraderNotificationsBottomSheet = forwardRef< 'social_leaderboard.trader_notifications.allow_push_notifications_desc', { traderName }, )} - value={localEnabled} - onValueChange={(next: boolean) => { - if (pushNotificationsOff) { - return; - } - // Subordinate switch: rely on iOS UISwitch's native tick on iOS, - // fire a Light impact only on Android where there is none. - fireSwitchHaptic(ImpactFeedbackStyle.Light); - setLocalEnabled(next); - }} + value={enabled} + onValueChange={handleToggle} disabled={pushNotificationsOff} toggleTestID={TraderNotificationsBottomSheetSelectorsIDs.TOGGLE} /> @@ -175,7 +149,7 @@ const TraderNotificationsBottomSheet = forwardRef< flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Between} - twClassName="px-4 py-4" + twClassName="px-4 py-4 mb-4" > - - ); }); diff --git a/locales/languages/en.json b/locales/languages/en.json index 80d5dee6d42..d39f9c2c5d0 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1047,8 +1047,7 @@ "title": "Notifications from {{traderName}}", "allow_push_notifications": "Allow push notifications", "allow_push_notifications_desc": "Get notified on {{traderName}}'s trading activity", - "manage_traders": "Manage traders you follow", - "save": "Save" + "manage_traders": "Manage traders you follow" }, "trader_notifications_setup": { "description": "Get real-time alerts on trades from the traders you follow", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 56b32046d3d..6844f0add51 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -1045,8 +1045,7 @@ "title": "Notifications de {{traderName}}", "allow_push_notifications": "Autoriser les notifications push", "allow_push_notifications_desc": "Recevez des notifications concernant l’activité de trading de {{traderName}}", - "manage_traders": "Gérer les traders que vous suivez", - "save": "Sauvegarder" + "manage_traders": "Gérer les traders que vous suivez" }, "trader_notifications_setup": { "description": "Recevez des alertes en temps réel sur les transactions des traders que vous suivez", From e4b7792bd03786de6ab7e44073a1b6b35e0635d0 Mon Sep 17 00:00:00 2001 From: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com> Date: Tue, 26 May 2026 16:55:02 +0200 Subject: [PATCH 07/16] test: added project name to the browserstack configuration (#30635) ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Test-only config for remote E2E; no app runtime, auth, or production paths affected. > > **Overview** > Adds **`projectName`** to BrowserStack **`bstack:options`** in `BrowserStackConfigBuilder`, using the same naming as **`buildName`** (`BROWSERSTACK_BUILD_NAME` or `` `${cwdBasename} ${platform}` ``). Sessions should group under the correct project in the BrowserStack dashboard alongside existing build/session labels. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 86337d298052cec308d13fd134f95bbb401c8055. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../providers/browserstack/BrowserStackConfigBuilder.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts index 761563a419e..4f866f98240 100644 --- a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts +++ b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts @@ -86,6 +86,9 @@ export class BrowserStackConfigBuilder { osVersion: device.osVersion, platformName, deviceOrientation: device.orientation, + projectName: + process.env.BROWSERSTACK_BUILD_NAME || + `${projectName} ${platformName}`, buildName: process.env.BROWSERSTACK_BUILD_NAME || `${projectName} ${platformName}`, From 35ec102ea50b90c5dc8afbf68675cd95b3e31126 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 26 May 2026 16:55:47 +0200 Subject: [PATCH 08/16] fix: update icon for chart switch button (#30525) ## **Description** Update icon for switch chart type. ## **Changelog** CHANGELOG entry: update icon for line chart ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3260 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Single icon swap in chart UI with no logic, API, or data-handling changes. > > **Overview** > When the advanced chart is in **candlestick** mode, the chart-type toggle now shows the **`Diagram`** icon instead of **`TrendUp`**, so the control better matches switching to a **line** chart. Behavior, labels, and the candlestick icon are unchanged. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6e552665af217dde860e59f4a1a7d42209a91959. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx index 29f0dbdb01a..d72761fa18e 100644 --- a/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx +++ b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx @@ -152,7 +152,7 @@ const TimeRangeSelector: React.FC = ({ > {chartType === ChartType.Candles ? ( From 09c627dffd123db571ddf369d2296cf796077ccf Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Tue, 26 May 2026 10:57:52 -0400 Subject: [PATCH 09/16] chore(rewards): vip tier view rework (#30564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Rework Rewards tier view ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Rewards VIP presentation and i18n only; no API, auth, or payment logic changes. > > **Overview** > Reworks the **Rewards VIP Tiers** screen from a flat, always-visible fee grid into a **card-style list** of expandable rows. > > **`RewardsVipTiersView`** now drops **tier 0** from the list (`tier > 0`), wraps rows in a rounded **section** container, uses taller loading skeletons, and passes **`isLast`** for row separators. > > **`VipTierRow`** is the main UX change: the header shows **tier name**, optional **points threshold** (inline, not under the name), and an **up/down chevron**; **revenue share, swap/perps fees, and referral points** live in a collapsible panel with **Reanimated** fade/layout. The **current** tier starts expanded and re-expands when the user’s tier changes; other tiers toggle on press. Status icons and always-visible fee columns are removed; **completed** tiers (except current/next) are dimmed. > > Copy updates: **`tier_thresholds`** uses “points” instead of “total”; English adds **`swap_fees_label`**, **`perps_fees_label`**, **`referral_points_label`**, and expands **`revenue_share_label`**. Locale JSON files align **`tier_thresholds`** across languages. Tests cover filtering, expand/collapse, and the new detail fields. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e1915eced40c7b37f0ebec3e2b4be84bcda66dd9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Views/RewardsVipTiersView.test.tsx | 23 +- .../UI/Rewards/Views/RewardsVipTiersView.tsx | 7 +- .../components/Vip/VipTierRow.test.tsx | 43 ++- .../UI/Rewards/components/Vip/VipTierRow.tsx | 263 +++++++++++------- locales/languages/de.json | 2 +- locales/languages/el.json | 2 +- locales/languages/en.json | 7 +- locales/languages/es.json | 2 +- locales/languages/fr.json | 2 +- locales/languages/hi.json | 2 +- locales/languages/id.json | 2 +- locales/languages/ja.json | 2 +- locales/languages/ko.json | 2 +- locales/languages/pt.json | 2 +- locales/languages/ru.json | 2 +- locales/languages/tl.json | 2 +- locales/languages/tr.json | 2 +- locales/languages/vi.json | 2 +- locales/languages/zh.json | 2 +- 19 files changed, 240 insertions(+), 131 deletions(-) diff --git a/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx index 4df4325fa9f..05df5fdafab 100644 --- a/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx @@ -87,8 +87,13 @@ jest.mock('@metamask/design-system-react-native', () => { IconDefault: 'default', SuccessDefault: 'success', }, - IconName: { Check: 'Check', CheckBold: 'CheckBold' }, - IconSize: { Sm: 'sm', Md: 'md' }, + IconName: { + ArrowDown: 'ArrowDown', + ArrowUp: 'ArrowUp', + Check: 'Check', + CheckBold: 'CheckBold', + }, + IconSize: { Sm: 'sm', Md: 'md', Lg: 'lg' }, Skeleton, }; }); @@ -134,15 +139,19 @@ jest.mock('../../../../../locales/i18n', () => ({ default: { locale: 'en-US' }, strings: jest.fn((key: string, params?: Record) => { if (key === 'rewards.vip.tier_thresholds' && params) { - return `${params.points} total`; + return `${params.points} points`; } if (key === 'rewards.vip.bps_value' && params) { return `${params.bps} bps`; } const t: Record = { 'rewards.vip.tiers_title': 'Tiers', + 'rewards.vip.revenue_share_label': 'Revenue share', + 'rewards.vip.swap_fees_label': 'Swap fees', 'rewards.vip.swaps_label': 'Swaps', + 'rewards.vip.perps_fees_label': 'Perps fees', 'rewards.vip.perps_label': 'Perps', + 'rewards.vip.referral_points_label': 'Referral points', 'rewards.vip.error_title': 'Error', 'rewards.vip.error_description': 'Error description', 'rewards.vip.retry_button': 'Retry', @@ -281,12 +290,14 @@ describe('RewardsVipTiersView', () => { }); }); - it('renders one row per tier returned by the backend', () => { - const { getByTestId, getByText } = render(); + it('renders one row per VIP tier returned by the backend', () => { + const { getByTestId, getByText, queryByText } = render( + , + ); expect(getByTestId(REWARDS_VIP_TIERS_VIEW_TEST_IDS.ROOT)).toBeOnTheScreen(); expect(getByTestId(REWARDS_VIP_TIERS_VIEW_TEST_IDS.LIST)).toBeOnTheScreen(); - expect(getByText('Default')).toBeOnTheScreen(); + expect(queryByText('Default')).toBeNull(); expect(getByText('Gold Fox 3')).toBeOnTheScreen(); expect(getByText('Tiers')).toBeOnTheScreen(); expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({ diff --git a/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx b/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx index b2292d5206e..c0fda6d81c5 100644 --- a/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx @@ -60,7 +60,7 @@ const RewardsVipTiersView: React.FC = () => { const showSkeleton = (!hasAttemptedFetch || isLoading) && !dashboard; const showError = hasError && !dashboard; - const tiers = dashboard?.tiers ?? []; + const tiers = dashboard?.tiers.filter((tier) => tier.tier > 0) ?? []; const nextTierId = dashboard?.nextTier?.id; return ( @@ -82,7 +82,7 @@ const RewardsVipTiersView: React.FC = () => { testID={REWARDS_VIP_TIERS_VIEW_TEST_IDS.SKELETON} > {[0, 1, 2, 3, 4].map((i) => ( - + ))} ) : showError ? ( @@ -97,7 +97,7 @@ const RewardsVipTiersView: React.FC = () => { ) : ( {tiers.map((tier) => ( @@ -105,6 +105,7 @@ const RewardsVipTiersView: React.FC = () => { key={tier.id} tier={tier} isNext={tier.id === nextTierId} + isLast={tier.id === tiers[tiers.length - 1]?.id} /> ))} diff --git a/app/components/UI/Rewards/components/Vip/VipTierRow.test.tsx b/app/components/UI/Rewards/components/Vip/VipTierRow.test.tsx index d4bc0d9b19b..cccb31bc768 100644 --- a/app/components/UI/Rewards/components/Vip/VipTierRow.test.tsx +++ b/app/components/UI/Rewards/components/Vip/VipTierRow.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import VipTierRow, { VIP_TIER_ROW_TEST_IDS } from './VipTierRow'; import { VIP_GOLD_BACKGROUND_MUTED } from './Vip.constants'; @@ -15,15 +15,16 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string, params?: Record) => { if (key === 'rewards.vip.tier_thresholds' && params) { - return `${params.points} total`; + return `${params.points} points`; } if (key === 'rewards.vip.bps_value' && params) { return `${params.bps} bps`; } const t: Record = { - 'rewards.vip.revenue_share_label': 'Rev share', - 'rewards.vip.swaps_label': 'Swaps', - 'rewards.vip.perps_label': 'Perps', + 'rewards.vip.revenue_share_label': 'Revenue share', + 'rewards.vip.swap_fees_label': 'Swap fees', + 'rewards.vip.perps_fees_label': 'Perps fees', + 'rewards.vip.referral_points_label': 'Referral points', }; return t[key] ?? key; }, @@ -43,6 +44,14 @@ const baseTier = { }; describe('VipTierRow', () => { + it('opens current tier details by default', () => { + const { getByTestId } = render(); + + expect( + getByTestId(`${VIP_TIER_ROW_TEST_IDS.DETAILS}-${baseTier.id}`), + ).toBeOnTheScreen(); + }); + it('renders name, points threshold, and fees for a non-base tier', () => { const { getByText, getByTestId } = render(); @@ -51,8 +60,12 @@ describe('VipTierRow', () => { getByTestId(`${VIP_TIER_ROW_TEST_IDS.CONTAINER}-${baseTier.id}`), ).toHaveStyle({ backgroundColor: VIP_GOLD_BACKGROUND_MUTED }); expect(getByTestId(VIP_TIER_ROW_TEST_IDS.THRESHOLDS)).toHaveTextContent( - /750k total/, + /750k points/, ); + expect(getByText('Revenue share')).toBeOnTheScreen(); + expect(getByText('Swap fees')).toBeOnTheScreen(); + expect(getByText('Perps fees')).toBeOnTheScreen(); + expect(getByText('Referral points')).toBeOnTheScreen(); expect( getByTestId(VIP_TIER_ROW_TEST_IDS.REVENUE_SHARE_FEE), ).toHaveTextContent(/1.5%/); @@ -62,6 +75,24 @@ describe('VipTierRow', () => { expect(getByTestId(VIP_TIER_ROW_TEST_IDS.PERPS_FEE)).toHaveTextContent( /4 bps/, ); + expect( + getByTestId(VIP_TIER_ROW_TEST_IDS.REFERRAL_POINTS), + ).toHaveTextContent(/20%/); + }); + + it('toggles tier details from the title row', () => { + const tier = { ...baseTier, status: 'upcoming' as const }; + const { getByTestId, queryByTestId } = render(); + + expect( + queryByTestId(`${VIP_TIER_ROW_TEST_IDS.DETAILS}-${tier.id}`), + ).toBeNull(); + + fireEvent.press(getByTestId(`${VIP_TIER_ROW_TEST_IDS.HEADER}-${tier.id}`)); + + expect( + getByTestId(`${VIP_TIER_ROW_TEST_IDS.DETAILS}-${tier.id}`), + ).toBeOnTheScreen(); }); it('hides the thresholds row for tiers 0 and 1', () => { diff --git a/app/components/UI/Rewards/components/Vip/VipTierRow.tsx b/app/components/UI/Rewards/components/Vip/VipTierRow.tsx index 6bb9a7b501b..67ae8761794 100644 --- a/app/components/UI/Rewards/components/Vip/VipTierRow.tsx +++ b/app/components/UI/Rewards/components/Vip/VipTierRow.tsx @@ -1,4 +1,10 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { Pressable } from 'react-native'; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, +} from 'react-native-reanimated'; import { Box, BoxAlignItems, @@ -23,142 +29,199 @@ import { export const VIP_TIER_ROW_TEST_IDS = { CONTAINER: 'vip-tier-row', - CHECK: 'vip-tier-row-check', + HEADER: 'vip-tier-row-header', + CHEVRON: 'vip-tier-row-chevron', NAME: 'vip-tier-row-name', THRESHOLDS: 'vip-tier-row-thresholds', + DETAILS: 'vip-tier-row-details', REVENUE_SHARE_FEE: 'vip-tier-row-revenue-share-fee', SWAPS_FEE: 'vip-tier-row-swaps-fee', PERPS_FEE: 'vip-tier-row-perps-fee', + REFERRAL_POINTS: 'vip-tier-row-referral-points', } as const; interface VipTierRowProps { tier: VipTierDto; isNext?: boolean; + isLast?: boolean; } const isCurrent = (status: string): boolean => status === 'current'; -const isUpcoming = (status: string): boolean => status === 'upcoming'; +const isCompleted = (status: string): boolean => status === 'completed'; -const currentTierIconStyle = { - color: VIP_GOLD_TEXT_DEFAULT, -}; +const rowPressableStyle = { width: '100%' as const }; const currentTierContainerStyle = { backgroundColor: VIP_GOLD_BACKGROUND_MUTED, }; -const VipTierRow: React.FC = ({ tier, isNext = false }) => { +const currentTierTextStyle = { + color: VIP_GOLD_TEXT_DEFAULT, +}; + +const VIP_TIER_ROW_ANIMATION_DURATION_MS = 180; +const vipTierRowLayoutTransition = LinearTransition.duration( + VIP_TIER_ROW_ANIMATION_DURATION_MS, +); +const vipTierDetailsEntering = FadeIn.duration( + VIP_TIER_ROW_ANIMATION_DURATION_MS, +); +const vipTierDetailsExiting = FadeOut.duration( + VIP_TIER_ROW_ANIMATION_DURATION_MS, +); + +interface VipTierDetailRowProps { + label: string; + value: string; + testID: string; +} + +const VipTierDetailRow: React.FC = ({ + label, + value, + testID, +}) => ( + + + {label} + + + {value} + + +); + +const VipTierRow: React.FC = ({ + tier, + isNext = false, + isLast = false, +}) => { const current = isCurrent(tier.status); - // Only the current tier and the immediately-upcoming tier render in the - // primary text/icon color; completed (previous) tiers and further-out - // upcoming tiers are dimmed. - const dim = !current && !isNext; + const [expanded, setExpanded] = useState(current); + const dim = isCompleted(tier.status) && !current && !isNext; const textColor = dim ? TextColor.TextAlternative : TextColor.TextDefault; - const feeColor = dim ? TextColor.TextAlternative : TextColor.TextDefault; + const pointsColor = current + ? TextColor.TextDefault + : TextColor.TextAlternative; const iconColor = current ? undefined : IconColor.IconAlternative; - const iconStyle = current ? currentTierIconStyle : {}; const revenueSharePercentage = tier.revenueShareBps !== undefined ? formatNumber(tier.revenueShareBps / 100, 2) : tier.revenueShareBps; + const referralPointsPercentage = formatNumber( + tier.referralCarryoverBps / 100, + 2, + ); + + useEffect(() => { + setExpanded(current); + }, [current, tier.id]); return ( - - - - - {tier.name} - - {tier.tier > 1 ? ( - - {strings('rewards.vip.tier_thresholds', { - points: formatCompactValue(tier.pointsRequirement), - })} - - ) : null} - - - - {strings('rewards.vip.revenue_share_label')} - - - {`${revenueSharePercentage}%`} - - - setExpanded((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded }} + style={rowPressableStyle} + testID={`${VIP_TIER_ROW_TEST_IDS.HEADER}-${tier.id}`} > - - {strings('rewards.vip.swaps_label')} - - - {strings('rewards.vip.bps_value', { bps: tier.swapsBps })} - - - - - {strings('rewards.vip.perps_label')} - - + + {tier.name} + + + {tier.tier > 1 ? ( + + {strings('rewards.vip.tier_thresholds', { + points: formatCompactValue(tier.pointsRequirement), + })} + + ) : null} + + + + + + {expanded ? ( + - {strings('rewards.vip.bps_value', { bps: tier.perpsBps })} - - + + + + + + + + ) : null} - + ); }; diff --git a/locales/languages/de.json b/locales/languages/de.json index 82ced6ad637..b9671597c52 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/el.json b/locales/languages/el.json index 448e7bb1e14..2eca9808b12 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/en.json b/locales/languages/en.json index d39f9c2c5d0..f729bdc1f5b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8555,7 +8555,10 @@ "swaps_label": "Swaps", "perps_label": "Perps", "points_label": "Points", - "revenue_share_label": "Rev share", + "revenue_share_label": "Revenue share", + "swap_fees_label": "Swap fees", + "perps_fees_label": "Perps fees", + "referral_points_label": "Referral points", "points_from_referrals_label": "Points from referrals", "referrals_label": "VIP Referrals", "tier_benefits_title": "Tier benefits", @@ -8568,7 +8571,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps" }, "referral_title": "Referrals", diff --git a/locales/languages/es.json b/locales/languages/es.json index 15b1f83a409..d7cf0f5f494 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 6844f0add51..ec622290c77 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8462,7 +8462,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index a615affb1cb..9d3583ef86c 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/id.json b/locales/languages/id.json index 03eb157bb9f..eb17831e0ce 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 79939c70d26..cc65518bb9c 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index fa59271cfce..e5d91b1190d 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 9e2659db944..edeae8523a3 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 20288952102..91adf56abf0 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 6a3b1cc468e..3928cea9fbd 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index bdf8154120e..01998d871a8 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 5cbf553de61..bcd9617b361 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index cf6922afeba..27cf579eea9 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8463,7 +8463,7 @@ "error_description": "Check your connection and try again.", "retry_button": "Retry", "tiers_title": "Tiers", - "tier_thresholds": "{{points}} total", + "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", "equity_rebate_label": "Equity rebate", "equity_rebate_header": "Equity rebate: {{value}}%", From 8bfb7ff827317b5c5957776bf510e0976da6cd68 Mon Sep 17 00:00:00 2001 From: Battambang Date: Tue, 26 May 2026 17:21:54 +0200 Subject: [PATCH 10/16] feat: disable smart account on gas fees sponsored network (#30429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This pull request refines how the application handles "revoke delegation" transactions, particularly in the context of gas fee sponsorship. The changes ensure that "revoke delegation" transactions are not incorrectly marked as gas-fee sponsored and that UI components, fee calculations, and alerts reflect this logic accurately. Additionally, the logic for determining gas fee sponsorship is centralized for consistency across the codebase. **Gas Fee Sponsorship Logic Updates:** * Introduced and used a centralized `shouldApplyGasFeeSponsorship` utility function to determine if a transaction should be treated as gas-fee sponsored, replacing scattered checks throughout the codebase. This ensures that "revoke delegation" transactions are never considered sponsored, even if the flag is set. * Updated the import and usage of sponsorship-related helpers in `TransactionDetails` and related components to use the new centralized logic. **UI and Fee Calculation Adjustments:** * Modified UI components (e.g., `GasFeesDetailsRow`, `TransactionDetailsNetworkFeeRow`) and their tests to ensure that "Paid by MetaMask" is not shown for "revoke delegation" transactions, and that network fee calculations display correctly for these transactions. * Updated fee calculation hooks and their tests to use the new sponsorship logic, ensuring correct fee calculation for "revoke delegation" transactions. **Alert and Confirmation Flow Enhancements:** * Enhanced the insufficient balance alert logic to properly handle "revoke delegation" transactions, ensuring users are notified when they have insufficient funds for these transactions, even if sponsorship flags are set. * Updated the transaction confirmation flow to clear the `isGasFeeSponsored` flag for "revoke delegation" transactions when gasless is supported, preventing incorrect UI indications post-confirmation. **Testing and Utility Improvements:** * Added and updated tests throughout the codebase to verify the new logic for "revoke delegation" transactions, including utility function tests and component behavior. These changes collectively ensure that "revoke delegation" transactions are handled consistently and correctly with respect to gas fee sponsorship across the application. ## **Changelog** CHANGELOG entry: disabled smart account on gas fees sponsored network ## **Related issues** Fixes: user not able to disable Smart Account on gas fees sponsored networks ## **Manual testing steps** 1. Go to the Smart Account properties of an account which has his 7702 delegation enabled on a gas fees sponsored network 2. Toggle off the flag regarding the network to disable the delegation 3. The transaction "Account update" dialog pops up, confirm, the transaction is executed and the delegation is disabled 4. The flag for this network is disabled in the Smart Account properties of the account. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches transaction confirm, publish hooks, and fee/sponsorship logic for a security-adjacent flow (7702 delegation); behavior is well-tested but spans UI and Engine publish paths. > > **Overview** > **Revoke delegation** transactions are excluded from gas-fee sponsorship even when `isGasFeeSponsored` is set, so users can turn off Smart Account (7702) on sponsored networks without misleading “Paid by MetaMask” UI or wrong zero-fee behavior. > > New helpers in `transaction.ts` (`isRevokeDelegationTransaction`, `isTransactionMarkedAsGasFeeSponsored`, `shouldApplyGasFeeSponsorship`) replace scattered `isGasFeeSponsored && isGaslessSupported` checks in confirmation hooks, fee rows, and activity/bridge transaction details. Confirm clears the sponsored flag on submit for revoke delegation; insufficient-balance alerts still apply for those txs. > > **Publish path:** `Delegation7702PublishHook` and transaction-controller init skip the 7702 relay hook for `revokeDelegation` (must publish as top-level `setCode`), while smart transactions can still run for that type. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d2793a8424382dda8b0fd25fcc3f505a8950f4db. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../TransactionDetails.test.tsx | 25 ++++++ .../TransactionDetails/TransactionDetails.tsx | 4 +- .../TransactionDetails/index.js | 8 +- .../TransactionDetails/index.test.tsx | 14 ++++ ...ansaction-details-network-fee-row.test.tsx | 12 +++ .../transaction-details-network-fee-row.tsx | 1 + .../gas-fee-details-row.test.tsx | 20 +++++ .../gas-fee-details-row.tsx | 11 +-- .../useInsufficientBalanceAlert.test.ts | 32 ++++++++ .../alerts/useInsufficientBalanceAlert.ts | 18 +++-- .../hooks/gas/useFeeCalculations.test.ts | 29 +++++++ .../hooks/gas/useFeeCalculations.ts | 17 ++-- .../useTransactionConfirm.test.ts | 29 +++++++ .../transactions/useTransactionConfirm.ts | 11 ++- .../confirmations/utils/transaction.test.ts | 81 +++++++++++++++++++ .../Views/confirmations/utils/transaction.ts | 27 +++++++ .../transaction-controller-init.test.ts | 42 ++++++++++ .../transaction-controller-init.ts | 3 + .../hooks/delegation-7702-publish.test.ts | 19 +++++ .../hooks/delegation-7702-publish.ts | 6 ++ 20 files changed, 382 insertions(+), 27 deletions(-) diff --git a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.test.tsx b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.test.tsx index 32c8f7b3523..ac859129cda 100644 --- a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.test.tsx +++ b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.test.tsx @@ -3,6 +3,7 @@ import { BridgeTransactionDetails } from './TransactionDetails'; import { TransactionMeta, TransactionStatus, + TransactionType, } from '@metamask/transaction-controller'; import Routes from '../../../../../constants/navigation/Routes'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; @@ -273,4 +274,28 @@ describe('BridgeTransactionDetails', () => { expect(getByTestId('paid-by-metamask')).toBeOnTheScreen(); }); + + it('does not show "Paid by MetaMask" for a sponsored revoke delegation transaction', () => { + mockIsHardwareAccount.mockReturnValue(false); + + const sponsoredRevokeDelegationTx = { + ...mockEVMTx, + isGasFeeSponsored: true, + type: TransactionType.revokeDelegation, + } as TransactionMeta; + + const { queryByTestId } = renderScreen( + () => ( + + ), + { + name: Routes.BRIDGE.BRIDGE_TRANSACTION_DETAILS, + }, + { state: mockState }, + ); + + expect(queryByTestId('paid-by-metamask')).not.toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx index 6d8112ae252..98e82b711bc 100644 --- a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx +++ b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx @@ -45,6 +45,7 @@ import TagColored, { } from '../../../../../component-library/components-temp/TagColored'; // import { renderShortAddress } from '../../../../../util/address'; import { isHardwareAccount } from '../../../../../util/address'; +import { isTransactionMarkedAsGasFeeSponsored } from '../../../../Views/confirmations/utils/transaction'; const styles = StyleSheet.create({ detailRow: { @@ -401,7 +402,8 @@ export const BridgeTransactionDetails = ( {strings('bridge_transaction_details.total_gas_fee')} - {evmTxMeta?.isGasFeeSponsored && !isHardwareWallet ? ( + {isTransactionMarkedAsGasFeeSponsored(evmTxMeta) && + !isHardwareWallet ? ( ) : ( <> diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.js b/app/components/UI/TransactionElement/TransactionDetails/index.js index fdd740f4bac..f7c0e7d5a12 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.js +++ b/app/components/UI/TransactionElement/TransactionDetails/index.js @@ -49,7 +49,10 @@ import { selectTransactions, } from '../../../../selectors/transactionController'; import { getGlobalEthQuery } from '../../../../util/networks/global-network'; -import { hasGasFeeTokenSelected } from '../../../Views/confirmations/utils/transaction'; +import { + hasGasFeeTokenSelected, + isTransactionMarkedAsGasFeeSponsored, +} from '../../../Views/confirmations/utils/transaction'; import Avatar, { AvatarSize, AvatarVariant, @@ -480,7 +483,8 @@ class TransactionDetails extends PureComponent { transactionType={updatedTransactionDetails.transactionType} chainId={chainId} isGasFeeSponsored={ - transactionObject.isGasFeeSponsored && !isHardwareWallet + isTransactionMarkedAsGasFeeSponsored(transactionObject) && + !isHardwareWallet } /> diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx b/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx index 4e677958653..bf08f00530a 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx +++ b/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx @@ -9,6 +9,7 @@ import { createStackNavigator } from '@react-navigation/stack'; import { mockNetworkState } from '../../../../util/test/network'; import type { NetworkState } from '@metamask/network-controller'; import { isHardwareAccount } from '../../../../util/address'; +import { TransactionType } from '@metamask/transaction-controller'; const Stack = createStackNavigator(); const mockEthQuery = { @@ -532,4 +533,17 @@ describe('TransactionDetails', () => { expect(screen.queryByTestId('paid-by-metamask')).not.toBeOnTheScreen(); expect(screen.queryByText('Paid by MetaMask')).not.toBeOnTheScreen(); }); + + it('does not show "Paid by MetaMask" for revoke delegation even when isGasFeeSponsored is true', () => { + renderComponent({ + state: initialState, + transactionObj: { + isGasFeeSponsored: true, + type: TransactionType.revokeDelegation, + }, + }); + + expect(screen.queryByTestId('paid-by-metamask')).not.toBeOnTheScreen(); + expect(screen.queryByText('Paid by MetaMask')).not.toBeOnTheScreen(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx index 9e6054ed30a..0676c858530 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx @@ -76,4 +76,16 @@ describe('TransactionDetailsNetworkFeeRow', () => { const { getByText } = render(); expect(getByText(`$${CALCULATED_FEE_MOCK}`)).toBeDefined(); }); + + it('renders calculated network fee for revoke delegation fallback', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + type: TransactionType.revokeDelegation, + } as unknown as TransactionMeta, + }); + + const { getByText } = render(); + + expect(getByText(`$${CALCULATED_FEE_MOCK}`)).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx index 0dda62e9c26..c50b767487d 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx @@ -16,6 +16,7 @@ const FALLBACK_TYPES = [ TransactionType.predictClaim, TransactionType.predictWithdraw, TransactionType.musdClaim, + TransactionType.revokeDelegation, ]; export function TransactionDetailsNetworkFeeRow() { diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx index ba8a8076819..fa7a27a7184 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx @@ -15,6 +15,7 @@ import { GasFeeEstimateType, SimulationData, TransactionStatus, + TransactionType, } from '@metamask/transaction-controller'; import { useSelectedGasFeeToken } from '../../../../hooks/gas/useGasFeeToken'; import { useIsGaslessSupported } from '../../../../hooks/gas/useIsGaslessSupported'; @@ -341,6 +342,25 @@ describe('GasFeesDetailsRow', () => { expect(queryByText('ETH')).toBeNull(); }); + it('shows network fee when sponsored transaction is revoke delegation', async () => { + const clonedStakingDepositConfirmationState = + createStateWithSimulationData(); + clonedStakingDepositConfirmationState.engine.backgroundState.TransactionController.transactions[0].isGasFeeSponsored = true; + clonedStakingDepositConfirmationState.engine.backgroundState.TransactionController.transactions[0].type = + TransactionType.revokeDelegation; + + const { getByText, queryByTestId } = renderWithProvider( + , + { + state: clonedStakingDepositConfirmationState, + }, + ); + + expect(queryByTestId('paid-by-metamask')).toBeNull(); + expect(getByText('$0.34')).toBeDefined(); + expect(getByText('ETH')).toBeDefined(); + }); + it('does not show MetaMask fee info when metaMaskFee is 0x0', () => { const mockToken = { ...GAS_FEE_TOKEN_MOCK, diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx index e49dded14dd..0daea63d873 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx @@ -45,6 +45,7 @@ import useNetworkInfo from '../../../../hooks/useNetworkInfo'; import TagColored, { TagColor, } from '../../../../../../../component-library/components-temp/TagColored'; +import { shouldApplyGasFeeSponsorship } from '../../../../utils/transaction'; const PaidByMetaMask = () => ( { trackTooltipClickedEvent({ diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index 8c9e4c2a286..96f92d7a785 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -381,6 +381,38 @@ describe('useInsufficientBalanceAlert', () => { const { result } = renderHook(() => useInsufficientBalanceAlert()); expect(result.current).toEqual([]); }); + + it('returns alert when transaction is revoke delegation', () => { + useIsGaslessSupportedMock.mockReturnValue({ + isSmartTransaction: true, + isSupported: true, + pending: false, + }); + const txWithGasFeeSponsored = { + ...mockTransaction, + isGasFeeSponsored: true, + type: TransactionType.revokeDelegation, + }; + mockUseTransactionMetadataRequest.mockReturnValue(txWithGasFeeSponsored); + + const { result } = renderHook(() => useInsufficientBalanceAlert()); + + expect(result.current).toEqual([ + { + action: { + label: `Buy ${mockNativeCurrency}`, + callback: expect.any(Function), + }, + isBlocking: true, + field: RowAlertKey.EstimatedFee, + key: AlertKeys.InsufficientBalance, + message: `Insufficient ${mockNativeCurrency} balance`, + title: 'Insufficient Balance', + severity: Severity.Danger, + skipConfirmation: true, + }, + ]); + }); }); describe('isQuotesLoading', () => { diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index 3ca5186c9dc..e164f70b472 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -10,7 +10,10 @@ import { useConfirmActions } from '../useConfirmActions'; import { useConfirmationContext } from '../../context/confirmation-context'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; import { TransactionType } from '@metamask/transaction-controller'; -import { hasTransactionType } from '../../utils/transaction'; +import { + hasTransactionType, + shouldApplyGasFeeSponsorship, +} from '../../utils/transaction'; import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; @@ -54,12 +57,8 @@ export const useInsufficientBalanceAlert = ({ return []; } - const { - selectedGasFeeToken, - isGasFeeSponsored, - gasFeeTokens, - excludeNativeTokenForFee, - } = transactionMetadata; + const { selectedGasFeeToken, gasFeeTokens, excludeNativeTokenForFee } = + transactionMetadata; const isGasFeeTokensEmpty = gasFeeTokens?.length === 0; @@ -67,7 +66,10 @@ export const useInsufficientBalanceAlert = ({ const isGaslessCheckComplete = !isGaslessCheckPending; // Transaction is sponsored only if it's marked as sponsored AND gasless is supported - const isSponsoredTransaction = isGasFeeSponsored && isGaslessSupported; + const isSponsoredTransaction = shouldApplyGasFeeSponsorship({ + transactionMeta: transactionMetadata, + isGaslessSupported, + }); // Simulation is complete if it's disabled, or if enabled and gasFeeTokens is loaded const isSimulationComplete = !isSimulationEnabled || Boolean(gasFeeTokens); diff --git a/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts b/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts index 8ed751d6fef..1543c4a68c3 100644 --- a/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts @@ -1,4 +1,5 @@ import { Hex } from '@metamask/utils'; +import { TransactionType } from '@metamask/transaction-controller'; import { cloneDeep } from 'lodash'; import { decimalToHex } from '../../../../../util/conversions'; import { isTestNet } from '../../../../../util/networks'; @@ -90,6 +91,34 @@ describe('useFeeCalculations', () => { expect(result.current.calculateGasEstimate).toBeDefined(); }); + it('returns fee calculations when sponsored transaction is revoke delegation', () => { + mockUseIsGaslessSupported.mockReturnValue({ + isSupported: true, + isSmartTransaction: false, + pending: false, + }); + + const { result } = renderHookWithProvider( + () => + useFeeCalculations({ + ...transactionMeta, + isGasFeeSponsored: true, + type: TransactionType.revokeDelegation, + }), + { + state: stakingDepositConfirmationState, + }, + ); + + expect(result.current.estimatedFeeFiat).toBe('$0.34'); + expect(result.current.estimatedFeeNative).toBe('0.0001'); + expect(result.current.estimatedFeeFiatPrecise).toBe('0.337875011'); + expect(result.current.preciseNativeFeeInHex).toBe('0x5572e9c22d00'); + expect(result.current.maxFeeFiat).toBe('$0.86'); + expect(result.current.maxFeeNative).toBe('0.0002'); + expect(result.current.calculateGasEstimate).toBeDefined(); + }); + it('returns correct fee calculations when gas is sponsored but gasless not supported', () => { mockUseIsGaslessSupported.mockReturnValue({ isSupported: false, diff --git a/app/components/Views/confirmations/hooks/gas/useFeeCalculations.ts b/app/components/Views/confirmations/hooks/gas/useFeeCalculations.ts index e7158088183..f07ea82994f 100644 --- a/app/components/Views/confirmations/hooks/gas/useFeeCalculations.ts +++ b/app/components/Views/confirmations/hooks/gas/useFeeCalculations.ts @@ -11,6 +11,7 @@ import { selectShowFiatInTestnets } from '../../../../../selectors/settings'; import { isTestNet } from '../../../../../util/networks'; import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; import { calculateGasEstimate, getFeesFromHex } from '../../utils/gas'; +import { shouldApplyGasFeeSponsorship } from '../../utils/transaction'; import { addHexes, decimalToHex, @@ -101,6 +102,10 @@ export const useFeeCalculations = ( ?.estimatedBaseFee; const { isSupported: isGaslessSupported } = useIsGaslessSupported(); + const isGasFeeSponsored = shouldApplyGasFeeSponsorship({ + transactionMeta, + isGaslessSupported, + }); const txParamsGasPrice = transactionMeta.txParams?.gasPrice ?? HEX_ZERO; const receiptGasPriceHex = txReceipt?.effectiveGasPrice; @@ -146,13 +151,9 @@ export const useFeeCalculations = ( [estimatedBaseFee, layer1GasFee, getFeesFromHexCallback], ); - const isSponsorshipEnabledForTx = Boolean( - transactionMeta?.isGasFeeSponsored && isGaslessSupported, - ); - // Estimated fee const estimatedFees = useMemo(() => { - if (isSponsorshipEnabledForTx) { + if (isGasFeeSponsored) { return { currentCurrencyFee: fiatFormatter(new BigNumber('0')), preciseCurrentCurrencyFee: '0', @@ -176,13 +177,13 @@ export const useFeeCalculations = ( supportsEIP1559, txParamsGasPrice, receiptGasPriceHex, - isSponsorshipEnabledForTx, + isGasFeeSponsored, fiatFormatter, ]); // Max fee const maxFee = useMemo(() => { - if (isSponsorshipEnabledForTx) { + if (isGasFeeSponsored) { return HEX_ZERO; } return addHexes( @@ -200,7 +201,7 @@ export const useFeeCalculations = ( txParamsGasPrice, transactionMeta.txParams.gas, transactionMeta.layer1GasFee, - isSponsorshipEnabledForTx, + isGasFeeSponsored, ]); const { diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index abbf4693a5f..f2cbed20b9b 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -464,6 +464,35 @@ describe('useTransactionConfirm', () => { }); }); + it('clears isGasFeeSponsored for revoke delegation when gasless is supported', async () => { + useIsGaslessSupportedMock.mockReturnValue({ + isSmartTransaction: true, + isSupported: true, + pending: false, + }); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + chainId: CHAIN_ID_MOCK, + origin: ORIGIN_METAMASK, + txParams: {}, + type: TransactionType.revokeDelegation, + isGasFeeSponsored: true, + } as unknown as TransactionMeta); + + const { result } = renderHook(); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), { + txMeta: expect.objectContaining({ + isGasFeeSponsored: false, + }), + }); + }); + it('clears isGasFeeSponsored even without selectedGasFeeToken', async () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index d679b062828..e8c767d796f 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -12,7 +12,10 @@ import { useNetworkEnablement } from '../../../../hooks/useNetworkEnablement/use import { isHardwareAccount } from '../../../../../util/address'; import { createProjectLogger } from '@metamask/utils'; import { useSelectedGasFeeToken } from '../gas/useGasFeeToken'; -import { hasTransactionType } from '../../utils/transaction'; +import { + hasTransactionType, + shouldApplyGasFeeSponsorship, +} from '../../utils/transaction'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupportedSmartTransactions'; import { cloneDeep } from 'lodash'; @@ -109,8 +112,10 @@ export function useTransactionConfirm() { // Ensure the persisted `isGasFeeSponsored` flag reflects whether gasless // is actually supported (e.g. HW wallets don't support gasless, so the // flag must be cleared so the activity list does not show "Paid by MetaMask"). - updatedMetadata.isGasFeeSponsored = - isGaslessSupported && transactionMetadata?.isGasFeeSponsored; + updatedMetadata.isGasFeeSponsored = shouldApplyGasFeeSponsorship({ + transactionMeta: transactionMetadata, + isGaslessSupported, + }); if (isGaslessSupportedSTX) { handleSmartTransaction(updatedMetadata); diff --git a/app/components/Views/confirmations/utils/transaction.test.ts b/app/components/Views/confirmations/utils/transaction.test.ts index bdb9c40fc5d..9d566e6ec22 100644 --- a/app/components/Views/confirmations/utils/transaction.test.ts +++ b/app/components/Views/confirmations/utils/transaction.test.ts @@ -11,8 +11,11 @@ import { getSeverity, hasGasFeeTokenSelected, hasTransactionType, + isRevokeDelegationTransaction, + isTransactionMarkedAsGasFeeSponsored, isTransactionPayWithdraw, parseStandardTokenTransactionData, + shouldApplyGasFeeSponsorship, } from './transaction'; import { abiERC721, @@ -247,6 +250,84 @@ describe('hasGasFeeTokenSelected', () => { }); }); +describe('isRevokeDelegationTransaction', () => { + it('returns true for revoke delegation transaction', () => { + const txMeta = { + type: TransactionType.revokeDelegation, + } as TransactionMeta; + + expect(isRevokeDelegationTransaction(txMeta)).toBe(true); + }); + + it('returns false for undefined transaction', () => { + expect(isRevokeDelegationTransaction(undefined)).toBe(false); + }); +}); + +describe('shouldApplyGasFeeSponsorship', () => { + it('returns true when gas sponsorship is supported and transaction is sponsored', () => { + const txMeta = { + isGasFeeSponsored: true, + type: TransactionType.simpleSend, + } as TransactionMeta; + + expect( + shouldApplyGasFeeSponsorship({ + transactionMeta: txMeta, + isGaslessSupported: true, + }), + ).toBe(true); + }); + + it('returns false when gasless is not supported', () => { + const txMeta = { + isGasFeeSponsored: true, + type: TransactionType.simpleSend, + } as TransactionMeta; + + expect( + shouldApplyGasFeeSponsorship({ + transactionMeta: txMeta, + isGaslessSupported: false, + }), + ).toBe(false); + }); + + it('returns false for sponsored revoke delegation transaction', () => { + const txMeta = { + isGasFeeSponsored: true, + type: TransactionType.revokeDelegation, + } as TransactionMeta; + + expect( + shouldApplyGasFeeSponsorship({ + transactionMeta: txMeta, + isGaslessSupported: true, + }), + ).toBe(false); + }); +}); + +describe('isTransactionMarkedAsGasFeeSponsored', () => { + it('returns true when a transaction is marked as gas fee sponsored', () => { + const txMeta = { + isGasFeeSponsored: true, + type: TransactionType.simpleSend, + } as TransactionMeta; + + expect(isTransactionMarkedAsGasFeeSponsored(txMeta)).toBe(true); + }); + + it('returns false for a revoke delegation transaction', () => { + const txMeta = { + isGasFeeSponsored: true, + type: TransactionType.revokeDelegation, + } as TransactionMeta; + + expect(isTransactionMarkedAsGasFeeSponsored(txMeta)).toBe(false); + }); +}); + describe('isTransactionPayWithdraw', () => { it.each([TransactionType.predictWithdraw, TransactionType.perpsWithdraw])( 'returns true for %s transaction type', diff --git a/app/components/Views/confirmations/utils/transaction.ts b/app/components/Views/confirmations/utils/transaction.ts index 18aa9a2dfb1..934fa9e49ce 100644 --- a/app/components/Views/confirmations/utils/transaction.ts +++ b/app/components/Views/confirmations/utils/transaction.ts @@ -164,6 +164,33 @@ export function hasGasFeeTokenSelected( return Boolean(transactionMeta?.selectedGasFeeToken); } +export function isRevokeDelegationTransaction( + transactionMeta: TransactionMeta | undefined, +): boolean { + return transactionMeta?.type === TransactionType.revokeDelegation; +} + +export function isTransactionMarkedAsGasFeeSponsored( + transactionMeta: TransactionMeta | undefined, +): boolean { + return Boolean( + transactionMeta?.isGasFeeSponsored && + !isRevokeDelegationTransaction(transactionMeta), + ); +} + +export function shouldApplyGasFeeSponsorship({ + transactionMeta, + isGaslessSupported, +}: { + transactionMeta: TransactionMeta | undefined; + isGaslessSupported: boolean; +}): boolean { + return ( + isGaslessSupported && isTransactionMarkedAsGasFeeSponsored(transactionMeta) + ); +} + export function getSeverity(status: TransactionStatus): Severity { switch (status) { case TransactionStatus.confirmed: diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 19274eebe12..ce97a834171 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -685,6 +685,48 @@ describe('Transaction Controller Init', () => { expect(mockDelegation7702Hook).not.toHaveBeenCalled(); }); + it('skips Delegation7702PublishHook for revoke delegation transactions', async () => { + selectShouldUseSmartTransactionMock.mockReturnValue(false); + isSendBundleSupportedMock.mockResolvedValue(false); + + const hooks = testConstructorOption('hooks'); + const result = await hooks?.publish?.({ + ...MOCK_TRANSACTION_META, + chainId: '0x13', + type: TransactionType.revokeDelegation, + isGasFeeSponsored: true, + }); + + expect(Delegation7702PublishHookMock).not.toHaveBeenCalled(); + expect(mockDelegation7702Hook).not.toHaveBeenCalled(); + expect(result).toEqual({ transactionHash: undefined }); + }); + + it('keeps Smart Transactions eligible for revoke delegation transactions', async () => { + submitSmartTransactionHookMock.mockResolvedValue({ + transactionHash: '0xsmarthash', + }); + + const hooks = testConstructorOption('hooks'); + const result = await hooks?.publish?.({ + ...MOCK_TRANSACTION_META, + chainId: '0x13', + type: TransactionType.revokeDelegation, + isGasFeeSponsored: true, + }); + + expect(Delegation7702PublishHookMock).not.toHaveBeenCalled(); + expect(mockDelegation7702Hook).not.toHaveBeenCalled(); + expect(submitSmartTransactionHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactionMeta: expect.objectContaining({ + type: TransactionType.revokeDelegation, + }), + }), + ); + expect(result?.transactionHash).toBe('0xsmarthash'); + }); + it('falls back to Delegation7702PublishHook when smart transactions are disabled', async () => { selectShouldUseSmartTransactionMock.mockReturnValue(false); const hooks = testConstructorOption('hooks'); diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index e4757003edd..15214b60022 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -257,6 +257,8 @@ async function publishHook({ } const { isExternalSign } = transactionMeta; + const isRevokeDelegation = + transactionMeta.type === TransactionType.revokeDelegation; const keyringSupports7702 = await accountSupports7702( transactionMeta.txParams?.from, @@ -265,6 +267,7 @@ async function publishHook({ if ( keyringSupports7702 && + !isRevokeDelegation && (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) ) { const hook = new Delegation7702PublishHook({ diff --git a/app/util/transactions/hooks/delegation-7702-publish.test.ts b/app/util/transactions/hooks/delegation-7702-publish.test.ts index 30ecfb7de96..32d7d053c7d 100644 --- a/app/util/transactions/hooks/delegation-7702-publish.test.ts +++ b/app/util/transactions/hooks/delegation-7702-publish.test.ts @@ -194,6 +194,25 @@ describe('Delegation 7702 Publish Hook', () => { }); describe('returns empty result if', () => { + it('transaction type is revokeDelegation', async () => { + const result = await hookClass.getHook()( + { + ...TRANSACTION_META_MOCK, + type: TransactionType.revokeDelegation, + isGasFeeSponsored: true, + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + selectedGasFeeToken: GAS_FEE_TOKEN_MOCK.tokenAddress, + }, + SIGNED_TX_MOCK, + ); + + expect(result).toEqual({ + transactionHash: undefined, + }); + expect(isAtomicBatchSupportedMock).not.toHaveBeenCalled(); + expect(submitRelayTransactionMock).not.toHaveBeenCalled(); + }); + it('atomic batch is not supported', async () => { const result = await hookClass.getHook()( TRANSACTION_META_MOCK, diff --git a/app/util/transactions/hooks/delegation-7702-publish.ts b/app/util/transactions/hooks/delegation-7702-publish.ts index f72ef687448..01f1f6353e9 100644 --- a/app/util/transactions/hooks/delegation-7702-publish.ts +++ b/app/util/transactions/hooks/delegation-7702-publish.ts @@ -8,6 +8,7 @@ import { PublishHook, PublishHookResult, TransactionMeta, + TransactionType, decodeAuthorizationSignature, } from '@metamask/transaction-controller'; import { Hex, createProjectLogger } from '@metamask/utils'; @@ -107,6 +108,11 @@ export class Delegation7702PublishHook { transactionMeta: TransactionMeta, _signedTx: string, ): Promise { + if (transactionMeta.type === TransactionType.revokeDelegation) { + log('Skipping: revokeDelegation must publish as top-level setCode'); + return EMPTY_RESULT; + } + const { chainId, gasFeeTokens, selectedGasFeeToken, txParams } = transactionMeta; From 9c167529274e498bb490c0b0fc3353590b874eb4 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 26 May 2026 17:48:14 +0200 Subject: [PATCH 11/16] feat: Add dev auto-unlock password support (#30599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR introduces a developer feature that automatically unlocks an existing wallet after app or Metro refresh in development mode, eliminating the need to manually enter the password on every refresh. ## Changes ### Core Implementation - **New environment variable**: `DEV_AUTO_UNLOCK_PASSWORD` - Optional password for auto-unlock in dev mode - **Modified**: `app/store/sagas/index.ts` - Added `tryDevAutoUnlock()` function that attempts to unlock the wallet using the configured password - Modified `requestAuthOnAppStart` saga to try dev auto-unlock before falling back to biometric authentication - Auto-unlock only activates when: - `METAMASK_ENVIRONMENT` is set to `"dev"` - `DEV_AUTO_UNLOCK_PASSWORD` is configured - A vault exists (wallet was previously created) - The wallet is currently locked ### Utility Function - **New**: `app/util/environment.ts::getDevAutoUnlockPassword()` - Safely retrieves the dev auto-unlock password - Returns `undefined` in non-dev environments for security - Returns `undefined` if password is empty or not set ### Documentation - **Updated**: `.js.env.example` - Added documentation for the new `DEV_AUTO_UNLOCK_PASSWORD` variable ### Testing - **Updated**: `app/store/sagas/sagas.test.ts` - Added tests for dev auto-unlock success path - Added tests for fallback to normal authentication when password not configured - Added tests for fallback when no vault exists - Added proper mocking and cleanup for the new functionality - **Updated**: `app/util/environment.test.ts` - Added comprehensive tests for `getDevAutoUnlockPassword()` - Tests cover dev environment, non-dev environment, and empty password scenarios ## How to Use 1. Set `METAMASK_ENVIRONMENT="dev"` in your `.js.env` (typically already set for dev) 2. Add `export DEV_AUTO_UNLOCK_PASSWORD="your-wallet-password"` to your `.js.env` 3. Restart Metro bundler 4. The app will now automatically unlock after refresh **Note**: The password must match your actual wallet password for this to work. ## Security Considerations - ✅ Only works when `METAMASK_ENVIRONMENT="dev"` - ✅ Returns `undefined` in all non-dev environments - ✅ The `.js.env` file is gitignored and never committed - ✅ `.js.env.example` shows the variable commented out by default - ✅ Does not skip biometric authentication in production builds ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes app-start wallet unlock behavior, but only when `METAMASK_ENVIRONMENT` is dev and a local env password is set; misconfiguration could weaken dev machines if secrets leak from `.js.env`. > > **Overview** > Adds an optional **dev-only** auto-unlock path so engineers can skip re-entering the wallet password after Metro or app refresh when a vault already exists. > > Developers can set `DEV_AUTO_UNLOCK_PASSWORD` in local `.js.env` (documented in `.js.env.example`). `getDevAutoUnlockPassword()` only returns that value when `METAMASK_ENVIRONMENT` is `"dev"` and the password is non-empty; otherwise it is ignored. > > On startup, `requestAuthOnAppStart` checks that password first: if configured and `KeyringController` is locked but has a vault, it calls `Authentication.unlockWallet` with the password and **skips** the usual seedless/biometric `tryBiometricUnlock` path. If auto-unlock is not configured, there is no vault, or unlock fails, behavior stays the same (biometric flow or login reset). Foreground/lock-screen auth is unchanged. > > Unit tests cover the env helper and saga branches (success, missing password, no vault). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5afd84ed8d3cbe932c2fd8b9ab272a469cd7b7fd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .js.env.example | 4 ++ app/store/sagas/index.ts | 12 ++++++ app/store/sagas/sagas.test.ts | 66 ++++++++++++++++++++++++++++++++ app/util/environment.test.ts | 72 ++++++++++++++++++++++++++++++++++- app/util/environment.ts | 11 ++++++ 5 files changed, 164 insertions(+), 1 deletion(-) diff --git a/.js.env.example b/.js.env.example index 30f0ada448b..4c6d0867c2c 100644 --- a/.js.env.example +++ b/.js.env.example @@ -54,6 +54,10 @@ export METAMASK_ENVIRONMENT="dev" # Build type: "main" or "flask" or "beta" export METAMASK_BUILD_TYPE="main" +# Optional: automatically unlock an existing wallet after app/Metro refresh in dev. +# Only used when METAMASK_ENVIRONMENT="dev"; must match the wallet password. +# export DEV_AUTO_UNLOCK_PASSWORD="" + # Optional: enable Ramps debug dashboard bridge in __DEV__ (WebSocket + fetch instrumentation). # See app/components/UI/Ramp/debug/README.md # export RAMPS_DEBUG_DASHBOARD="true" diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 8d3a73eff3c..ecc3e12f6f6 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -53,6 +53,7 @@ import { watchMarketingAttributionOnClearOnboarding, watchMarketingAttributionOnConsentChange, } from './marketingAttribution'; +import { getDevAutoUnlockPassword } from '../../util/environment'; /** * Safety ceiling: if `MainNavigator` never mounts (e.g. the user stays on @@ -251,6 +252,17 @@ export function* appLockStateMachine() { */ export function* requestAuthOnAppStart() { try { + const devAutoUnlockPassword = getDevAutoUnlockPassword(); + if (devAutoUnlockPassword) { + const { KeyringController } = Engine.context; + if (!KeyringController.isUnlocked() && KeyringController.state?.vault) { + yield call(Authentication.unlockWallet, { + password: devAutoUnlockPassword, + }); + return; + } + } + yield call(tryBiometricUnlock); } catch (_) { // If authentication fails, navigate to login screen diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index eebe7e9307d..8b2fd121079 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -32,6 +32,7 @@ import Authentication from '../../core/Authentication'; import AppConstants from '../../core/AppConstants'; import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; import { providerErrors } from '@metamask/rpc-errors'; +import { getDevAutoUnlockPassword } from '../../util/environment'; const mockNavigate = jest.fn(); const mockReset = jest.fn(); @@ -97,6 +98,11 @@ jest.mock('../../core/Engine', () => ({ }, KeyringController: { isUnlocked: jest.fn().mockReturnValue(false), + state: { + vault: undefined, + keyrings: [], + isUnlocked: false, + }, }, SnapController: { updateRegistry: jest.fn(), @@ -153,6 +159,10 @@ jest.mock('../../core/Authentication', () => ({ }, })); +jest.mock('../../util/environment', () => ({ + getDevAutoUnlockPassword: jest.fn(), +})); + jest.mock('../../core/LockManagerService', () => ({ __esModule: true, default: { @@ -185,6 +195,18 @@ const defaultMockState = { banners: {}, }; +beforeEach(() => { + (getDevAutoUnlockPassword as jest.Mock).mockReturnValue(undefined); + (Engine.context.KeyringController.isUnlocked as jest.Mock).mockReturnValue( + false, + ); + Engine.context.KeyringController.state = { + vault: undefined, + keyrings: [], + isUnlocked: false, + }; +}); + describe('requestAuthOnAppStart', () => { beforeEach(() => { jest.clearAllMocks(); @@ -226,6 +248,50 @@ describe('requestAuthOnAppStart', () => { routes: [{ name: Routes.ONBOARDING.LOGIN }], }); }); + + it('uses dev auto-unlock password in dev when the wallet has a vault and is locked', async () => { + (getDevAutoUnlockPassword as jest.Mock).mockReturnValue('test-password'); + Engine.context.KeyringController.state = { + vault: 'mock-vault', + keyrings: [], + isUnlocked: false, + }; + + await expectSaga(requestAuthOnAppStart).run(); + + expect(Authentication.unlockWallet).toHaveBeenCalledWith({ + password: 'test-password', + }); + expect( + Authentication.checkIsSeedlessPasswordOutdated, + ).not.toHaveBeenCalled(); + }); + + it('falls back to normal app-start authentication when dev auto-unlock is not configured', async () => { + Engine.context.KeyringController.state = { + vault: 'mock-vault', + keyrings: [], + isUnlocked: false, + }; + + await expectSaga(requestAuthOnAppStart).run(); + + expect(Authentication.unlockWallet).toHaveBeenCalledWith(); + expect(Authentication.unlockWallet).not.toHaveBeenCalledWith({ + password: 'test-password', + }); + }); + + it('falls back to normal app-start authentication when no vault exists', async () => { + (getDevAutoUnlockPassword as jest.Mock).mockReturnValue('test-password'); + + await expectSaga(requestAuthOnAppStart).run(); + + expect(Authentication.unlockWallet).toHaveBeenCalledWith(); + expect(Authentication.unlockWallet).not.toHaveBeenCalledWith({ + password: 'test-password', + }); + }); }); describe('authStateMachine', () => { diff --git a/app/util/environment.test.ts b/app/util/environment.test.ts index 8f837897958..1542d0b6534 100644 --- a/app/util/environment.test.ts +++ b/app/util/environment.test.ts @@ -1,4 +1,4 @@ -import { isProduction } from './environment'; +import { getDevAutoUnlockPassword, isProduction } from './environment'; const originalMetamaskEnvironment = process.env.METAMASK_ENVIRONMENT; @@ -42,3 +42,73 @@ describe('isProduction', () => { expect(isProduction()).toBe(false); }); }); + +describe('getDevAutoUnlockPassword', () => { + const originalDevAutoUnlockPassword = process.env.DEV_AUTO_UNLOCK_PASSWORD; + + afterEach(() => { + Object.defineProperty(process.env, 'METAMASK_ENVIRONMENT', { + value: originalMetamaskEnvironment, + writable: true, + enumerable: true, + configurable: true, + }); + Object.defineProperty(process.env, 'DEV_AUTO_UNLOCK_PASSWORD', { + value: originalDevAutoUnlockPassword, + writable: true, + enumerable: true, + configurable: true, + }); + }); + + it('returns the password in dev', () => { + Object.defineProperty(process.env, 'METAMASK_ENVIRONMENT', { + value: 'dev', + writable: true, + enumerable: true, + configurable: true, + }); + Object.defineProperty(process.env, 'DEV_AUTO_UNLOCK_PASSWORD', { + value: 'test-password', + writable: true, + enumerable: true, + configurable: true, + }); + + expect(getDevAutoUnlockPassword()).toBe('test-password'); + }); + + it('returns undefined outside dev', () => { + Object.defineProperty(process.env, 'METAMASK_ENVIRONMENT', { + value: 'production', + writable: true, + enumerable: true, + configurable: true, + }); + Object.defineProperty(process.env, 'DEV_AUTO_UNLOCK_PASSWORD', { + value: 'test-password', + writable: true, + enumerable: true, + configurable: true, + }); + + expect(getDevAutoUnlockPassword()).toBeUndefined(); + }); + + it('returns undefined when password is empty', () => { + Object.defineProperty(process.env, 'METAMASK_ENVIRONMENT', { + value: 'dev', + writable: true, + enumerable: true, + configurable: true, + }); + Object.defineProperty(process.env, 'DEV_AUTO_UNLOCK_PASSWORD', { + value: '', + writable: true, + enumerable: true, + configurable: true, + }); + + expect(getDevAutoUnlockPassword()).toBeUndefined(); + }); +}); diff --git a/app/util/environment.ts b/app/util/environment.ts index 11c1df95755..dbef5ee8e9a 100644 --- a/app/util/environment.ts +++ b/app/util/environment.ts @@ -18,3 +18,14 @@ export const getE2EMockOAuthEmailForQaMock = (): string | undefined => { const email = process.env.E2E_MOCK_OAUTH_EMAIL; return typeof email === 'string' && email.length > 0 ? email : undefined; }; + +export const getDevAutoUnlockPassword = (): string | undefined => { + const password = process.env.DEV_AUTO_UNLOCK_PASSWORD; + if (process.env.METAMASK_ENVIRONMENT !== 'dev') { + return undefined; + } + + return typeof password === 'string' && password.length > 0 + ? password + : undefined; +}; From 62a5d6320317ed741074bb579759c6cb36316863 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Tue, 26 May 2026 12:02:35 -0400 Subject: [PATCH 12/16] fix(rewards): transparent background of rewards modals (#30562) ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-1321 Fix rewards modal to have transparent background ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Navigation presentation-only change in the Rewards tab; no auth, data, or business logic touched. > > **Overview** > Rewards stack **transparent modals** were showing the default navigator `cardStyle` background instead of letting the underlying Rewards UI show through. > > In `RewardsHome`, each rewards modal screen now sets **`cardStyle: { backgroundColor: 'transparent' }`** alongside `presentation: 'transparentModal'` (bottom sheet, claim sheet, opt-in account group, end-of-season claim). The opt-in screen drops the shared **`clearStackNavigatorOptionsWithTransitionAnimation`** spread in favor of that explicit transparent card style, matching the select sheet pattern already used there. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c8009296d7cb77f0fa33a4d52fa0d6b35fe72da1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Nav/Main/MainNavigator.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index d27beb29974..a989439d17f 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -333,12 +333,18 @@ const RewardsHome = () => { { options={{ headerShown: false, presentation: 'transparentModal', - ...clearStackNavigatorOptionsWithTransitionAnimation, + cardStyle: { backgroundColor: 'transparent' }, }} /> Date: Tue, 26 May 2026 18:05:31 +0200 Subject: [PATCH 13/16] feat: add ambient price color A/B test for Token Details page (#30323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implements the **Ambient Price Color A/B test** ([ASSETS-3205](https://consensyssoftware.atlassian.net/browse/ASSETS-3205)) for the Token Details page. Robinhood's token/stock detail page adapts its full UI chrome to green or red based on price performance. The hypothesis is that ambient price signaling creates contextual urgency that increases CTA engagement and downstream swap/buy conversion. - **Control (A):** static green sticky buttons regardless of price performance - **Treatment (B):** Ambient price signaling — sticky Swap/Buy buttons, timeframe pills, back arrow, line chart, percentage change text, and chart type toggle adapt to green (positive) or orange-red `#FF5C16` (negative) based on price change for the selected timeframe - Feature flag: `assetsASSETS3205AbtestAmbientPriceColor` (LaunchDarkly, string variants: `control` / `treatment`) - Candlestick chart always shows two colors: green for up-candles, orange-red for down-candles (independent of overall price direction) ## **Analytics Integration** This A/B test automatically enriches analytics events with variant information for funnel analysis: **Enriched Events:** - `Token Details Opened` — Primary entry point, includes `sticky_buttons_shown` - `Token Details CTA Clicked` — Tracks Swap/Buy button engagement - `Swap Page Viewed` — Funnel 1 entry point (via `useAnalytics().trackEvent`) - `Swap Completed` — Conversion metric for swap funnel - `On-ramp Purchase Submitted` — Funnel 2 entry point (via `analytics.trackEvent`) - `On-ramp Purchase Completed` — Conversion metric for on-ramp funnel ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [ASSETS-3205](https://consensyssoftware.atlassian.net/browse/ASSETS-3205) https://www.figma.com/design/iqPZeL5rVg7tWGmacjIeyH/Token-details?node-id=7970-15626&m=dev ## **Manual testing steps** ```gherkin Feature: Ambient Price Color A/B Test Scenario: Control variant shows default static styling Given the feature flag "assetsASSETS3205AbtestAmbientPriceColor" is set to "control" When user navigates to a Token Details page Then all UI elements (buttons, pills, back arrow) render with default static green styling And no ambient price coloring is applied regardless of price direction Scenario: Treatment variant shows green ambient color for positive price Given the feature flag is set to "treatment" When user navigates to a token with positive price change Then the back arrow, line chart, timeframe pills, percentage change text, chart type toggle, and sticky buttons are all green And candlestick chart shows green up-candles and orange-red down-candles Scenario: Treatment variant shows orange-red ambient color for negative price Given the feature flag is set to "treatment" When user navigates to a token with negative price change Then the back arrow, line chart, timeframe pills, percentage change text, chart type toggle, and sticky buttons are all orange-red (#FF5C16) And candlestick chart shows green up-candles and orange-red down-candles Scenario: Legacy chart fallback also adopts ambient color Given the feature flag is set to "treatment" And OHLCV data is unavailable so the legacy chart renders When user views the Token Details page Then the legacy chart line and timeframe pills also adopt the ambient color Scenario: Events include A/B test variant property Given the feature flag is set to "treatment" or "control" When user triggers any of: Token Details Opened, Token Details CTA Clicked, Swap Page Viewed, Swap Completed, On-ramp Purchase Submitted, On-ramp Purchase Completed Then each event payload includes the "ab_test_variant" property matching the assigned variant Scenario: Dark and light mode compatibility Given the feature flag is set to "treatment" When user views Token Details in dark mode or light mode Then the ambient colors render correctly in both modes And selected pill text uses the correct inverse color ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/e3a3fe27-b235-492e-806c-5839ab1d1144 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [ASSETS-3205]: https://consensyssoftware.atlassian.net/browse/ASSETS-3205?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [ASSETS-3205]: https://consensyssoftware.atlassian.net/browse/ASSETS-3205?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Touches conversion CTAs, chart WebView HTML when colors change, and gated UI (header back, sticky footer) until price direction resolves; fetch timeouts alter failure behavior. > > **Overview** > Adds an **ambient price color** A/B test on Token Details (`assetsASSETS3205AbtestAmbientPriceColor`): **control** keeps today’s static green chrome; **treatment** tints chart line, % change, timeframe pills, chart-type toggle, legacy nav buttons, back arrow, and sticky Buy/Swap CTAs **green or orange-red** (`#FF5C16`) from the selected timeframe’s price direction. > > Charts report direction via `onPriceDirectionChange` / `useAmbientColor` through `Price` → `AssetOverviewContent`, with guards so advanced OHLCV does not override **legacy fallback**. `AdvancedChart` / template gain `lineColorOverride` and related overrides; treatment delays the advanced chart until direction is known and **hides the sticky footer** until then. > > Also adds **3s fetch timeouts** for OHLCV and historical prices, registers the experiment for analytics and bridge submit attribution, and expands unit tests. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a2fe29e7a1a03236de61cc69b67eba366dc846ed. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../ChartNavigationButton.styles.tsx | 5 +- .../ChartNavigationButton.tsx | 18 +- .../Price/Price.advanced.test.tsx | 279 ++++++++++++++++++ .../UI/AssetOverview/Price/Price.advanced.tsx | 111 +++++-- .../UI/AssetOverview/Price/Price.legacy.tsx | 47 ++- .../UI/AssetOverview/Price/Price.tsx | 2 + .../AssetOverview/PriceChart/PriceChart.tsx | 8 +- .../UI/Charts/AdvancedChart/AdvancedChart.tsx | 23 +- .../AdvancedChart/AdvancedChart.types.ts | 7 + .../AdvancedChart/AdvancedChartTemplate.ts | 20 +- .../AdvancedChart/TimeRangeSelector.tsx | 34 ++- .../__tests__/AdvancedChart.test.tsx | 9 + .../__tests__/TimeRangeSelector.test.tsx | 49 +++ .../UI/Charts/AdvancedChart/useOHLCVChart.ts | 14 +- .../AdvancedChart/webview/chartLogic.js | 17 +- .../AdvancedChart/webview/chartLogicString.ts | 14 +- .../TokenDetails/Views/TokenDetails.test.tsx | 161 +++++++++- .../UI/TokenDetails/Views/TokenDetails.tsx | 51 +++- .../components/AssetOverviewContent.tsx | 8 + .../TokenDetailsInlineHeader.test.tsx | 82 ++++- .../components/TokenDetailsInlineHeader.tsx | 27 +- .../TokenDetailsStickyFooter.test.tsx | 107 ++++++- .../components/TokenDetailsStickyFooter.tsx | 31 +- .../TokenDetails/components/abTestConfig.ts | 37 +++ .../hooks/useTokenHistoricalPrices.ts | 16 +- app/util/analytics/abTestAnalyticsRegistry.ts | 6 +- .../bridge/hooks/useSubmitBridgeTx.test.tsx | 75 ++++- app/util/bridge/hooks/useSubmitBridgeTx.ts | 17 ++ 28 files changed, 1156 insertions(+), 119 deletions(-) diff --git a/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.styles.tsx b/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.styles.tsx index 9a77c3f21cd..0d6dd490627 100644 --- a/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.styles.tsx +++ b/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.styles.tsx @@ -5,15 +5,16 @@ const styleSheet = (params: { theme: Theme; vars: { selected: boolean; + selectedColor?: string; }; }) => { const { theme, - vars: { selected }, + vars: { selected, selectedColor }, } = params; const { colors } = theme; const finalBackgroundColor = selected - ? colors.background.muted + ? (selectedColor ?? colors.background.muted) : 'transparent'; /** Matches {@link TimeRangeSelector} segment Pressables: `py-1`, `px-4`, `rounded-lg`, `flex-1`, `bg-muted` when selected. */ return StyleSheet.create({ diff --git a/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx b/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx index cc33cec08c8..168fe1ccc81 100644 --- a/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx +++ b/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx @@ -11,20 +11,34 @@ interface ChartNavigationButtonProps { onPress: () => void; label: string; selected: boolean; + /** Override background color for the selected state (A/B test). */ + selectedColor?: string; } const ChartNavigationButton = ({ onPress, label, selected, + selectedColor, }: ChartNavigationButtonProps) => { - const { styles } = useStyles(styleSheet, { selected }); + const { styles } = useStyles(styleSheet, { selected, selectedColor }); + + const getTextColor = () => { + if (selected && selectedColor) { + return TextColor.Inverse; + } + if (!selected && selectedColor) { + return selectedColor; + } + return selected ? TextColor.Default : TextColor.Alternative; + }; + return ( {label} diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx index 9e719c623d5..ca5b04818a8 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx @@ -1049,4 +1049,283 @@ describe('PriceAdvanced', () => { ); }); }); + + describe('ambient color logic', () => { + it('returns undefined when useAmbientColor is false', () => { + const { queryByTestId } = render( + , + ); + + // When useAmbientColor is false, ambientColor should be undefined + // This means we won't render the skeleton and will render the chart directly + expect(queryByTestId('mock-advanced-chart')).toBeOnTheScreen(); + }); + + it('returns success green when displayDiff is null (no data)', () => { + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + ...ohlcvPaddingThree, + { time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 }, + { time: 2000, open: 100, high: 106, low: 100, close: 105, volume: 1 }, + ], + isLoading: true, // Still loading, so displayDiff will be null + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + const { getByTestId } = render( + , + ); + + // When displayDiff is null, should default to positive (success green) + // The chart should still render because we default to success green + expect(getByTestId('mock-advanced-chart')).toBeOnTheScreen(); + }); + + it('returns success green when displayDiff is positive', () => { + // OHLCV data: reference close = 100, current price = 105 + // displayDiff = 105 - 100 = 5 (positive) + const { getByTestId } = render( + , + ); + + // Should render chart with success green color + expect(getByTestId('mock-advanced-chart')).toBeOnTheScreen(); + const chart = getByTestId('mock-advanced-chart'); + expect(chart.props.lineColorOverride).toBeTruthy(); + // In light mode, should use LIGHT_MODE_SUCCESS_GREEN + }); + + it('returns AMBIENT_NEGATIVE_COLOR when displayDiff is negative', () => { + // Mock OHLCV data with negative price movement + // For 1D range: visibleFromMs = lastBarTime - 86400000ms (24 hours) + // lastBarTime = 100000000, visibleFromMs = 13600000 + // First visible candle at time 20000000 has close=100 + // Last candle has close=95 + // displayDiff = 95 - 100 = -5 (negative) + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + { time: 1000000, open: 90, high: 91, low: 89, close: 90, volume: 1 }, + { time: 2000000, open: 90, high: 91, low: 89, close: 91, volume: 1 }, + { time: 3000000, open: 91, high: 92, low: 90, close: 92, volume: 1 }, + { + time: 20000000, + open: 100, + high: 101, + low: 99, + close: 100, + volume: 1, + }, // First in visible range + { + time: 100000000, + open: 100, + high: 100, + low: 95, + close: 95, + volume: 1, + }, // Last bar + ], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + const { getByTestId } = render( + , + ); + + // Should render chart with negative color (#FF5C16) + expect(getByTestId('mock-advanced-chart')).toBeOnTheScreen(); + const chart = getByTestId('mock-advanced-chart'); + // eslint-disable-next-line @metamask/design-tokens/color-no-hex + expect(chart.props.lineColorOverride).toBe('#FF5C16'); + }); + + it('calls onPriceDirectionChange with true for positive displayDiff', () => { + const mockOnPriceDirectionChange = jest.fn(); + + render( + , + ); + + // Should call callback with true for positive price diff + expect(mockOnPriceDirectionChange).toHaveBeenCalledWith(true); + }); + + it('calls onPriceDirectionChange with false for negative displayDiff', () => { + const mockOnPriceDirectionChange = jest.fn(); + + // Mock OHLCV data with negative price movement + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + { time: 1000000, open: 90, high: 91, low: 89, close: 90, volume: 1 }, + { time: 2000000, open: 90, high: 91, low: 89, close: 91, volume: 1 }, + { time: 3000000, open: 91, high: 92, low: 90, close: 92, volume: 1 }, + { + time: 20000000, + open: 100, + high: 101, + low: 99, + close: 100, + volume: 1, + }, // First in visible range + { + time: 100000000, + open: 100, + high: 100, + low: 95, + close: 95, + volume: 1, + }, // Last bar + ], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + render( + , + ); + + // Should call callback with false for negative price diff + expect(mockOnPriceDirectionChange).toHaveBeenCalledWith(false); + }); + + it('does not call onPriceDirectionChange when falling back to legacy', () => { + const mockOnPriceDirectionChange = jest.fn(); + + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + { time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 }, + ], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + render( + , + ); + + // Should not call callback when falling back to legacy (insufficient data) + expect(mockOnPriceDirectionChange).not.toHaveBeenCalled(); + }); + + it('calls onPriceDirectionChange exactly once when OHLCV data is sufficient (>= 5 bars)', () => { + const mockOnPriceDirectionChange = jest.fn(); + + // Sufficient OHLCV data (5 bars total) + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + ...ohlcvPaddingThree, // 3 bars + { time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 }, + { time: 2000, open: 100, high: 106, low: 100, close: 105, volume: 1 }, + ], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + render( + , + ); + + // Should call callback exactly once with OHLCV-based direction + expect(mockOnPriceDirectionChange).toHaveBeenCalledTimes(1); + expect(mockOnPriceDirectionChange).toHaveBeenCalledWith(true); // positive price + }); + + it('does not call onPriceDirectionChange when OHLCV data is insufficient (< 5 bars) - legacy handles it', () => { + const mockOnPriceDirectionChange = jest.fn(); + + // Insufficient OHLCV data (4 bars total) - should fallback to legacy + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + { time: 100, open: 90, high: 91, low: 89, close: 90, volume: 1 }, + { time: 200, open: 90, high: 91, low: 89, close: 91, volume: 1 }, + { time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 }, + { time: 2000, open: 100, high: 106, low: 100, close: 105, volume: 1 }, + ], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + render( + , + ); + + // PriceAdvanced should NOT call callback (guarded by shouldFallbackToLegacy) + // PriceLegacy will call it instead when !isLoading + expect(mockOnPriceDirectionChange).not.toHaveBeenCalled(); + }); + + it('prevents stale OHLCV callback from overriding legacy when falling back', () => { + const mockOnPriceDirectionChange = jest.fn(); + + // Single OHLCV bar (would compute initialPriceDiff = 0, always positive) + // But priceDiff is negative + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [ + { time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 }, + ], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: false, + }); + + render( + , + ); + + // PriceAdvanced should NOT call with stale OHLCV-based value + // This test would FAIL if we remove the !shouldFallbackToLegacy guard + expect(mockOnPriceDirectionChange).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.tsx index 190b71bb436..2ac12db5993 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -24,6 +25,7 @@ import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { Hex } from '@metamask/utils'; import { normalizeTokenAddress } from '../../Bridge/utils/tokenUtils'; import AdvancedChart from '../../Charts/AdvancedChart/AdvancedChart'; +import { Skeleton } from '../../../../component-library/components-temp/Skeleton'; import { advancedChartLineChromePresets } from '../../Charts/AdvancedChart/advancedChartLineChrome.presets'; import { ChartType, @@ -46,6 +48,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTheme, LIGHT_MODE_SUCCESS_GREEN } from '../../../../util/theme'; +import { AMBIENT_NEGATIVE_COLOR } from '../../TokenDetails/components/abTestConfig'; import { AppThemeKey } from '../../../../util/theme/models'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; @@ -131,6 +134,8 @@ export interface PriceAdvancedProps { timePeriod?: TimePeriod; chartNavigationButtons?: TimePeriod[]; setTimePeriod?: (period: TimePeriod) => void; + onPriceDirectionChange?: (isPositive: boolean) => void; + useAmbientColor?: boolean; } const PriceAdvanced = ({ @@ -144,6 +149,8 @@ const PriceAdvanced = ({ timePeriod = '1d', chartNavigationButtons = [], setTimePeriod, + onPriceDirectionChange, + useAmbientColor = false, }: PriceAdvancedProps) => { const dispatch = useDispatch(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -410,18 +417,52 @@ const PriceAdvanced = ({ dynamicComparePrice, ]); - const displayDate = crosshairData - ? toDateFormat(crosshairData.time) - : dateLabel; - const { styles, theme } = useStyles(styleSheet); const { themeAppearance } = useTheme(); const isLightMode = themeAppearance === AppThemeKey.light; + const ambientSuccessGreen = isLightMode + ? LIGHT_MODE_SUCCESS_GREEN + : theme.colors.success.default; + + // Initial ambient color for chart/buttons - based on non-crosshair price diff + // This stays constant even when user hovers crosshair + const initialPriceDiff = useMemo(() => { + const rtClose = realtimeBar?.close; + const lbClose = ohlcvData[ohlcvData.length - 1]?.close; + const currentDisplayPrice = rtClose ?? lbClose ?? currentPrice; + + if (dynamicComparePrice === null) return null; + return currentDisplayPrice - dynamicComparePrice; + }, [realtimeBar, ohlcvData, currentPrice, dynamicComparePrice]); + + const initialAmbientColor = useMemo(() => { + if (!useAmbientColor) return undefined; + if (initialPriceDiff === null) return undefined; + return initialPriceDiff >= 0 ? ambientSuccessGreen : AMBIENT_NEGATIVE_COLOR; + }, [useAmbientColor, initialPriceDiff, ambientSuccessGreen]); + + // Dynamic ambient color for price diff text only - changes during crosshair hover + const ambientColor = useMemo(() => { + if (!useAmbientColor) return undefined; + if (displayDiff === null) return ambientSuccessGreen; + return displayDiff >= 0 ? ambientSuccessGreen : AMBIENT_NEGATIVE_COLOR; + }, [useAmbientColor, displayDiff, ambientSuccessGreen]); + const shouldFallbackToLegacy = !chartLoading && (ohlcvData.length < CHART_DATA_THRESHOLD || hasEmptyData || chartError); + useLayoutEffect(() => { + if (initialPriceDiff !== null && !shouldFallbackToLegacy) { + onPriceDirectionChange?.(initialPriceDiff >= 0); + } + }, [initialPriceDiff, onPriceDirectionChange, shouldFallbackToLegacy]); + + const displayDate = crosshairData + ? toDateFormat(crosshairData.time) + : dateLabel; + const shouldFallbackToLegacyRef = useRef(shouldFallbackToLegacy); shouldFallbackToLegacyRef.current = shouldFallbackToLegacy; @@ -513,6 +554,8 @@ const PriceAdvanced = ({ currentCurrency={currentCurrency} comparePrice={comparePrice} isLoading={isLoading} + onPriceDirectionChange={onPriceDirectionChange} + useAmbientColor={useAmbientColor} /> ); } @@ -570,9 +613,11 @@ const PriceAdvanced = ({ : TextColor.TextAlternative } style={ - isLightMode && displayDiff > 0 - ? { color: LIGHT_MODE_SUCCESS_GREEN } - : undefined + ambientColor + ? { color: ambientColor } + : isLightMode && displayDiff > 0 + ? { color: LIGHT_MODE_SUCCESS_GREEN } + : undefined } allowFontScaling={false} > @@ -607,26 +652,37 @@ const PriceAdvanced = ({ onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} > - + {useAmbientColor && initialAmbientColor === undefined ? ( + + ) : ( + + )} @@ -638,6 +694,7 @@ const PriceAdvanced = ({ onSelect={handleTimeRangeSelect} chartType={chartType} onChartTypeToggle={toggleChartType} + selectedColor={initialAmbientColor} /> diff --git a/app/components/UI/AssetOverview/Price/Price.legacy.tsx b/app/components/UI/AssetOverview/Price/Price.legacy.tsx index 54e77b76e81..0797416f29e 100644 --- a/app/components/UI/AssetOverview/Price/Price.legacy.tsx +++ b/app/components/UI/AssetOverview/Price/Price.legacy.tsx @@ -2,7 +2,7 @@ import { TimePeriod, TokenPrice, } from '../../../../components/hooks/useTokenHistoricalPrices'; -import React, { useMemo, useState } from 'react'; +import React, { useLayoutEffect, useMemo, useState } from 'react'; import { View } from 'react-native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { strings } from '../../../../../locales/i18n'; @@ -20,6 +20,7 @@ import { import { useTheme, LIGHT_MODE_SUCCESS_GREEN } from '../../../../util/theme'; import { AppThemeKey } from '../../../../util/theme/models'; +import { AMBIENT_NEGATIVE_COLOR } from '../../TokenDetails/components/abTestConfig'; import PriceChart from '../PriceChart/PriceChart'; import { distributeDataPoints } from '../PriceChart/utils'; import styleSheet from './Price.styles'; @@ -36,6 +37,8 @@ export interface PriceLegacyProps { timePeriod: TimePeriod; chartNavigationButtons?: TimePeriod[]; onTimePeriodChange?: (period: TimePeriod) => void; + onPriceDirectionChange?: (isPositive: boolean) => void; + useAmbientColor?: boolean; } const PriceLegacy = ({ @@ -48,6 +51,8 @@ const PriceLegacy = ({ timePeriod, chartNavigationButtons = [], onTimePeriodChange, + onPriceDirectionChange, + useAmbientColor = false, }: PriceLegacyProps) => { const [activeChartIndex, setActiveChartIndex] = useState(-1); @@ -94,10 +99,42 @@ const PriceLegacy = ({ const displayDiff = diff ?? priceDiff; const diffSign = displayDiff > 0 ? '+' : displayDiff < 0 ? '-' : ''; + useLayoutEffect(() => { + if (!isLoading) { + onPriceDirectionChange?.(priceDiff >= 0); + } + }, [priceDiff, isLoading, onPriceDirectionChange]); + const { styles, theme } = useStyles(styleSheet); const { themeAppearance } = useTheme(); const isLightMode = themeAppearance === AppThemeKey.light; + const ambientSuccessGreen = isLightMode + ? LIGHT_MODE_SUCCESS_GREEN + : theme.colors.success.default; + + // Initial ambient color for chart/buttons - based on non-hover price diff + const initialAmbientColor = useMemo(() => { + if (!useAmbientColor) return undefined; + return priceDiff >= 0 ? ambientSuccessGreen : AMBIENT_NEGATIVE_COLOR; + }, [useAmbientColor, priceDiff, ambientSuccessGreen]); + + // Dynamic ambient color for price diff text only - changes during chart hover + const ambientColor = useMemo(() => { + if (!useAmbientColor) return undefined; + return displayDiff >= 0 ? ambientSuccessGreen : AMBIENT_NEGATIVE_COLOR; + }, [useAmbientColor, displayDiff, ambientSuccessGreen]); + + const getPriceDiffStyle = () => { + if (ambientColor) { + return { color: ambientColor }; + } + if (isLightMode && displayDiff > 0) { + return { color: LIGHT_MODE_SUCCESS_GREEN }; + } + return undefined; + }; + return ( <> @@ -150,11 +187,7 @@ const PriceLegacy = ({ ? TextColor.ErrorDefault : TextColor.TextAlternative } - style={ - isLightMode && displayDiff > 0 - ? { color: LIGHT_MODE_SUCCESS_GREEN } - : undefined - } + style={getPriceDiffStyle()} allowFontScaling={false} > {diffSign} @@ -189,6 +222,7 @@ const PriceLegacy = ({ priceDiff={priceDiff} isLoading={isLoading} onChartIndexChange={handleChartInteraction} + chartColorOverride={initialAmbientColor} /> {chartNavigationButtons.length > 0 && onTimePeriodChange && ( @@ -203,6 +237,7 @@ const PriceLegacy = ({ )} onPress={() => onTimePeriodChange(label)} selected={timePeriod === label} + selectedColor={initialAmbientColor} /> ))} diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index b3d0ef82e95..1f6524ccd97 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -28,6 +28,8 @@ export type PriceProps = PriceSharedProps & { timePeriod: TimePeriod; chartNavigationButtons?: TimePeriod[]; setTimePeriod?: (period: TimePeriod) => void; + onPriceDirectionChange?: (isPositive: boolean) => void; + useAmbientColor?: boolean; }; const Price = (props: PriceProps) => { diff --git a/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx b/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx index 6ade2e4fbb1..07d4553cf5d 100644 --- a/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx +++ b/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx @@ -48,6 +48,8 @@ interface PriceChartProps { onChartIndexChange: (index: number) => void; /** Match token overview AdvancedChart height. */ chartHeight?: number; + /** Override line color (A/B test). */ + chartColorOverride?: string; } const PriceChart = ({ @@ -56,6 +58,7 @@ const PriceChart = ({ isLoading, onChartIndexChange, chartHeight = TOKEN_OVERVIEW_CHART_HEIGHT, + chartColorOverride, }: PriceChartProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const emptyDisplayTrackedRef = useRef(false); @@ -67,9 +70,10 @@ const PriceChart = ({ const { styles, theme } = useStyles(styleSheet, { chartHeight }); const { themeAppearance } = useTheme(); const chartColor = - themeAppearance === AppThemeKey.light + chartColorOverride ?? + (themeAppearance === AppThemeKey.light ? LIGHT_MODE_SUCCESS_GREEN - : theme.colors.success.default; + : theme.colors.success.default); useEffect(() => { setPositionX(-1); diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx index 2865ae3a825..e87a5c87bd1 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -94,6 +94,9 @@ const AdvancedChart = forwardRef( lineChrome, visibleFromMs, visibleToMs, + lineColorOverride, + successColorOverride, + errorColorOverride, }, ref, ) => { @@ -117,6 +120,7 @@ const AdvancedChart = forwardRef( const activeIndicatorsRef = useRef>(new Set()); const [webViewLoaded, setWebViewLoaded] = useState(false); + const webViewLoadedRef = useRef(false); const prevPositionLinesRef = useRef(positionLines); const prevChartTypeRef = useRef(chartType); const prevOhlcvDataRef = useRef([]); @@ -132,8 +136,19 @@ const AdvancedChart = forwardRef( enableDrawingTools, disabledFeatures, lineChrome, + lineColorOverride, + successColorOverride, + errorColorOverride, }), - [theme, enableDrawingTools, disabledFeatures, lineChrome], + [ + theme, + enableDrawingTools, + disabledFeatures, + lineChrome, + lineColorOverride, + successColorOverride, + errorColorOverride, + ], ); // Reset all chart state when the WebView reloads due to htmlContent changes @@ -141,6 +156,8 @@ const AdvancedChart = forwardRef( skeletonHiddenReportedRef.current = false; setChartReadyCount(0); setWebViewLoaded(false); + webViewLoadedRef.current = false; + setWebViewError(null); activeIndicatorsRef.current.clear(); prevPositionLinesRef.current = undefined; prevChartTypeRef.current = undefined; @@ -346,7 +363,7 @@ const AdvancedChart = forwardRef( } case 'ERROR': - if (!isChartReady) { + if (!isChartReady && webViewLoadedRef.current) { setWebViewError(message.payload.message); } onError?.(message.payload.message); @@ -386,6 +403,7 @@ const AdvancedChart = forwardRef( const handleLoadEnd = useCallback(() => { setWebViewLoaded(true); + webViewLoadedRef.current = true; }, []); // ---- Ref API ---- @@ -401,6 +419,7 @@ const AdvancedChart = forwardRef( setLayoutSettling(false); setChartReadyCount(0); setWebViewLoaded(false); + webViewLoadedRef.current = false; setWebViewError(null); activeIndicatorsRef.current.clear(); prevPositionLinesRef.current = undefined; diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts index c73389aab9b..30b115463b7 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts @@ -490,6 +490,13 @@ export interface AdvancedChartProps { * which can be ahead of the last candle and push the left edge off-screen. */ visibleToMs?: number; + + /** Override the chart line color baked into the HTML template (A/B test). */ + lineColorOverride?: string; + /** Override the candlestick up/success color baked into the HTML template (A/B test). */ + successColorOverride?: string; + /** Override the candlestick down/error color baked into the HTML template (A/B test). */ + errorColorOverride?: string; } /** diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts index 96596f56843..963fd7fbfb8 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts @@ -53,6 +53,9 @@ interface ChartFeatures { enableDrawingTools?: boolean; disabledFeatures?: string[]; lineChrome?: LineChromeOptions; + lineColorOverride?: string; + successColorOverride?: string; + errorColorOverride?: string; } const createConfigScript = ( @@ -61,6 +64,10 @@ const createConfigScript = ( features: ChartFeatures, ): string => { const lc = resolveLineChromeOptions(features.lineChrome); + const successColor = + features.successColorOverride ?? getChartSuccessColor(theme); + const lineColor = features.lineColorOverride ?? successColor; + const errorColor = features.errorColorOverride ?? theme.colors.error.default; return ` window.CONFIG = { libraryUrl: '${libraryUrl}', @@ -68,8 +75,9 @@ window.CONFIG = { backgroundColor: '${theme.colors.background.default}', borderColor: '${stripHexAlpha(theme.colors.border.muted)}', textColor: '${stripHexAlpha(theme.colors.text.muted)}', - successColor: '${getChartSuccessColor(theme)}', - errorColor: '${theme.colors.error.default}', + successColor: '${successColor}', + lineColor: '${lineColor}', + errorColor: '${errorColor}', primaryColor: '${theme.colors.primary.default}' }, features: { @@ -96,6 +104,8 @@ export const createAdvancedChartTemplate = ( theme: Theme, features: ChartFeatures = {}, ): string => { + const resolvedLineColor = + features.lineColorOverride ?? getChartSuccessColor(theme); const configInline = createConfigScript( CHARTING_LIBRARY_URL, theme, @@ -212,7 +222,7 @@ export const createAdvancedChartTemplate = ( */ #last-close-price-label { z-index: 50; - background: ${stripHexAlpha(getChartSuccessColor(theme))}; + background: ${stripHexAlpha(resolvedLineColor)}; color: ${stripHexAlpha(theme.colors.success.inverse)}; } /* @@ -224,8 +234,8 @@ export const createAdvancedChartTemplate = ( #custom-series-last-value-label { z-index: 55; background: transparent; - border: 1px solid ${stripHexAlpha(getChartSuccessColor(theme))}; - color: ${stripHexAlpha(getChartSuccessColor(theme))}; + border: 1px solid ${stripHexAlpha(resolvedLineColor)}; + color: ${stripHexAlpha(resolvedLineColor)}; } /* * Crosshair price pill draws above last-close when both share the same Y so text stays readable. diff --git a/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx index d72761fa18e..f737c911897 100644 --- a/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx +++ b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx @@ -9,7 +9,6 @@ import { BoxAlignItems, FontWeight, Icon, - IconColor, IconName, IconSize, } from '@metamask/design-system-react-native'; @@ -62,6 +61,8 @@ interface TimeRangeSelectorProps { chartType?: ChartType; /** Called when the user taps the chart type toggle icon. */ onChartTypeToggle?: () => void; + /** Override background color for the selected pill (A/B test). */ + selectedColor?: string; } const TimeRangeSelector: React.FC = ({ @@ -71,6 +72,7 @@ const TimeRangeSelector: React.FC = ({ ranges = TIME_RANGES, chartType, onChartTypeToggle, + selectedColor, }) => { const tw = useTailwind(); const { colors } = useTheme(); @@ -119,7 +121,10 @@ const TimeRangeSelector: React.FC = ({ style={({ pressed }) => tw.style( SEGMENT_BUTTON_BASE, - isSelected && 'bg-muted', + isSelected && + (selectedColor + ? { backgroundColor: selectedColor } + : 'bg-muted'), pressed && 'opacity-70', ) } @@ -129,7 +134,18 @@ const TimeRangeSelector: React.FC = ({ variant={TextVariant.BodySm} fontWeight={FontWeight.Medium} twClassName={ - isSelected ? 'text-text-default' : 'text-text-alternative' + isSelected + ? selectedColor + ? 'text-success-inverse' + : 'text-text-default' + : selectedColor + ? undefined + : 'text-text-alternative' + } + style={ + !isSelected && selectedColor + ? { color: selectedColor } + : undefined } > {range} @@ -154,13 +170,21 @@ const TimeRangeSelector: React.FC = ({ ) : ( )} diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx index 21ede7c67a8..5bfc400b652 100644 --- a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx @@ -495,6 +495,9 @@ describe('AdvancedChart', () => { ); const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); act(() => { webView.props.onMessage({ nativeEvent: { @@ -910,6 +913,9 @@ describe('AdvancedChart', () => { ); const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); act(() => { webView.props.onMessage({ nativeEvent: { @@ -931,6 +937,9 @@ describe('AdvancedChart', () => { ); const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); act(() => { webView.props.onMessage({ nativeEvent: { diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx index 1768165edec..0f014c4f86d 100644 --- a/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx +++ b/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx @@ -1,9 +1,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; import TimeRangeSelector, { TIME_RANGE_CONFIGS, type TimeRange, } from '../TimeRangeSelector'; +import { ChartType } from '../AdvancedChart.types'; +import { AMBIENT_NEGATIVE_COLOR } from '../../../TokenDetails/components/abTestConfig'; describe('TimeRangeSelector', () => { const defaultProps = { @@ -60,6 +63,52 @@ describe('TimeRangeSelector', () => { expect(onSelect).toHaveBeenCalledWith('1D'); }); + describe('selectedColor prop', () => { + it('applies selectedColor to chart type toggle icon', () => { + const { getByLabelText } = render( + , + ); + + const toggleButton = getByLabelText('Switch to candlestick chart'); + const icon = toggleButton.children[0] as ReactTestInstance; + expect(icon.props.twClassName).toBe(`text-[${AMBIENT_NEGATIVE_COLOR}]`); + }); + + it('uses default icon class when selectedColor is not set', () => { + const { getByLabelText } = render( + , + ); + + const toggleButton = getByLabelText('Switch to candlestick chart'); + const icon = toggleButton.children[0] as ReactTestInstance; + expect(icon.props.twClassName).toBe('text-icon-alternative'); + }); + + it('applies selectedColor to candlestick toggle icon', () => { + const { getByLabelText } = render( + , + ); + + const toggleButton = getByLabelText('Switch to line chart'); + const icon = toggleButton.children[0] as ReactTestInstance; + expect(icon.props.twClassName).toBe(`text-[${AMBIENT_NEGATIVE_COLOR}]`); + }); + }); + describe('TIME_RANGE_CONFIGS', () => { it('has a config for every time range', () => { const ranges: TimeRange[] = ['1H', '1D', '1W', '1M', '1Y']; diff --git a/app/components/UI/Charts/AdvancedChart/useOHLCVChart.ts b/app/components/UI/Charts/AdvancedChart/useOHLCVChart.ts index 74f509a3fe0..8ff36b3907e 100644 --- a/app/components/UI/Charts/AdvancedChart/useOHLCVChart.ts +++ b/app/components/UI/Charts/AdvancedChart/useOHLCVChart.ts @@ -74,7 +74,19 @@ async function fetchOHLCV( url.searchParams.set('vsCurrency', params.vsCurrency); } - const response = await fetch(url.toString(), { signal }); + // Add 3 second timeout to prevent infinite hang + const FETCH_TIMEOUT_MS = 3000; + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('OHLCV fetch timeout')), + FETCH_TIMEOUT_MS, + ); + }); + + const response = await Promise.race([ + fetch(url.toString(), { signal }), + timeoutPromise, + ]); if (!response.ok) { throw new Error(`OHLCV API error: ${response.status}`); diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js index f509ddc036e..c61019fe55d 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js @@ -715,7 +715,8 @@ function getSeriesColorOverrides(color) { */ function applySeriesColors() { if (!window.chartWidget) return; - var color = window.CONFIG.theme.successColor; + const color = + window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; try { window.chartWidget.applyOverrides(getSeriesColorOverrides(color)); var series = window.chartWidget.activeChart().getSeries(); @@ -1204,8 +1205,9 @@ function updateVisibleEdgeOutlinePriceLabel() { const theme = (w.CONFIG && w.CONFIG.theme) || {}; const upColor = theme.successColor || '#0C9F76'; + const lineColor = theme.lineColor || upColor; const downColor = theme.errorColor || '#E06470'; - let outlineColor = upColor; + let outlineColor = ct === 2 ? lineColor : upColor; if (ct === 1) { const o = Number(edgeBar.open); const c = Number(edgeBar.close); @@ -1951,7 +1953,8 @@ function handleSetChartType(payload) { var ac = window.chartWidget.activeChart(); ac.setChartType(type); - var color = window.CONFIG.theme.successColor; + const color = + window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; var series = ac.getSeries(); if (type === 2) { series.setChartStyleProperties(2, { @@ -2246,7 +2249,8 @@ function createLineLastPriceLine() { var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; var chart = window.chartWidget.activeChart(); - var color = window.CONFIG.theme.successColor; + const color = + window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; var seriesPt = resolveLineEndOverlayPoint(chart); var linePrice = seriesPt && isFinite(seriesPt.price) ? seriesPt.price : lastBar.close; @@ -3028,7 +3032,8 @@ function refreshLineEndDot() { return; } - var color = window.CONFIG.theme.successColor; + const color = + window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; function placeLineEndIcon() { if (placementGen !== window.__lineEndDotPlacementGen) { @@ -3754,7 +3759,7 @@ function initChart() { 'mainSeriesProperties.candleStyle.wickUpColor': theme.successColor, 'mainSeriesProperties.candleStyle.wickDownColor': theme.errorColor, }, - getSeriesColorOverrides(theme.successColor), + getSeriesColorOverrides(theme.lineColor || theme.successColor), ), loading_screen: { diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts index b73a59124d7..0f41427f7f7 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -724,7 +724,7 @@ function getSeriesColorOverrides(color) { */ function applySeriesColors() { if (!window.chartWidget) return; - var color = window.CONFIG.theme.successColor; + const color = window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; try { window.chartWidget.applyOverrides(getSeriesColorOverrides(color)); var series = window.chartWidget.activeChart().getSeries(); @@ -1213,8 +1213,9 @@ function updateVisibleEdgeOutlinePriceLabel() { const theme = (w.CONFIG && w.CONFIG.theme) || {}; const upColor = theme.successColor || '#0C9F76'; + const lineColor = theme.lineColor || upColor; const downColor = theme.errorColor || '#E06470'; - let outlineColor = upColor; + let outlineColor = ct === 2 ? lineColor : upColor; if (ct === 1) { const o = Number(edgeBar.open); const c = Number(edgeBar.close); @@ -1960,7 +1961,8 @@ function handleSetChartType(payload) { var ac = window.chartWidget.activeChart(); ac.setChartType(type); - var color = window.CONFIG.theme.successColor; + const color = + window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; var series = ac.getSeries(); if (type === 2) { series.setChartStyleProperties(2, { @@ -2255,7 +2257,7 @@ function createLineLastPriceLine() { var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; var chart = window.chartWidget.activeChart(); - var color = window.CONFIG.theme.successColor; + const color = window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; var seriesPt = resolveLineEndOverlayPoint(chart); var linePrice = seriesPt && isFinite(seriesPt.price) ? seriesPt.price : lastBar.close; @@ -3037,7 +3039,7 @@ function refreshLineEndDot() { return; } - var color = window.CONFIG.theme.successColor; + const color = window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; function placeLineEndIcon() { if (placementGen !== window.__lineEndDotPlacementGen) { @@ -3763,7 +3765,7 @@ function initChart() { 'mainSeriesProperties.candleStyle.wickUpColor': theme.successColor, 'mainSeriesProperties.candleStyle.wickDownColor': theme.errorColor, }, - getSeriesColorOverrides(theme.successColor), + getSeriesColorOverrides(theme.lineColor || theme.successColor), ), loading_screen: { diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx index 895372ea186..1c69e48b356 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx @@ -10,6 +10,11 @@ import { selectDepositActiveFlag, selectDepositMinimumVersionFlag, } from '../../../../selectors/featureFlagController/deposit'; +import { + AMBIENT_NEGATIVE_COLOR, + AMBIENT_PRICE_COLOR_AB_KEY, +} from '../components/abTestConfig'; +import { LIGHT_MODE_SUCCESS_GREEN } from '../../../../util/theme'; const mockUseSelector = jest.fn(); jest.mock('react-redux', () => ({ @@ -52,17 +57,19 @@ jest.mock('@react-navigation/native', () => ({ useRoute: () => ({ params: mockRouteParams() }), })); +const defaultUseTokenPriceReturn = { + currentPrice: 100, + priceDiff: 5, + comparePrice: 95, + prices: [], + isLoading: false, + setTimePeriod: jest.fn(), + chartNavigationButtons: ['1d', '1w', '1m'], + currentCurrency: 'USD', +}; +const mockUseTokenPrice = jest.fn(() => defaultUseTokenPriceReturn); jest.mock('../hooks/useTokenPrice', () => ({ - useTokenPrice: () => ({ - currentPrice: 100, - priceDiff: 5, - comparePrice: 95, - prices: [], - isLoading: false, - setTimePeriod: jest.fn(), - chartNavigationButtons: ['1d', '1w', '1m'], - currentCurrency: 'USD', - }), + useTokenPrice: (...args: unknown[]) => mockUseTokenPrice(...(args as [])), })); const mockUseTokenBalance = jest.fn(); @@ -106,10 +113,16 @@ jest.mock('../hooks/useTokenTransactions', () => ({ mockUseTokenTransactions(...args), })); +const mockTokenDetailsInlineHeader = jest.fn( + (_props: Record) => null, +); jest.mock('../components/TokenDetailsInlineHeader', () => ({ - TokenDetailsInlineHeader: () => null, + TokenDetailsInlineHeader: (props: Record) => + mockTokenDetailsInlineHeader(props), })); +let mockLastUseAmbientColorProp: boolean | undefined; +let mockLatestPriceDirectionChange: ((isPositive: boolean) => void) | undefined; let mockAutoResolveMarketInsights = true; let mockLatestMarketInsightsResolver: | ((params: { isDisplayed: boolean; severity: string | undefined }) => void) @@ -128,14 +141,20 @@ jest.mock('../components/AssetOverviewContent', () => { const ReactLib = jest.requireActual('react'); const AssetOverviewContentMock = ({ onMarketInsightsDisplayResolved, + onPriceDirectionChange, token, + useAmbientColor, }: { onMarketInsightsDisplayResolved?: (params: { isDisplayed: boolean; severity: string | undefined; }) => void; + onPriceDirectionChange?: (isPositive: boolean) => void; token?: { address?: string; chainId?: string; symbol?: string }; + useAmbientColor?: boolean; }) => { + mockLastUseAmbientColorProp = useAmbientColor; + mockLatestPriceDirectionChange = onPriceDirectionChange; const insightsTokenKey = `${token?.address ?? ''}:${token?.chainId ?? ''}:${token?.symbol ?? ''}`; ReactLib.useEffect(() => { mockLatestMarketInsightsResolver = onMarketInsightsDisplayResolved; @@ -231,12 +250,22 @@ jest.mock('../../Bridge/hooks/useRWAToken', () => ({ }), })); -jest.mock('../../../../hooks/useABTest', () => ({ - useABTest: jest.fn(() => ({ +const mockUseABTest = jest.fn((key: string) => { + if (key === AMBIENT_PRICE_COLOR_AB_KEY) { + return { + variant: { useAmbientPriceColor: false }, + variantName: 'control', + isActive: false, + }; + } + return { variant: { swapLabelKey: 'asset_overview.swap' }, variantName: 'control', isActive: false, - })), + }; +}); +jest.mock('../../../../hooks/useABTest', () => ({ + useABTest: (...args: unknown[]) => mockUseABTest(...(args as [string])), })); jest.mock('../hooks/useStickyFooterTracking', () => ({ @@ -253,6 +282,9 @@ describe('TokenDetails', () => { mockRouteParams.mockReturnValue(defaultRouteParams); mockAutoResolveMarketInsights = true; mockLatestMarketInsightsResolver = undefined; + mockLastUseAmbientColorProp = undefined; + mockLatestPriceDirectionChange = undefined; + mockUseTokenPrice.mockReturnValue(defaultUseTokenPriceReturn); mockBuild.mockReturnValue({ category: 'token-details-opened' }); mockAddProperties.mockReturnValue({ build: mockBuild }); mockCreateEventBuilder.mockReturnValue({ @@ -487,4 +519,105 @@ describe('TokenDetails', () => { ); }); }); + + describe('Ambient price color A/B test', () => { + const enableAmbientColor = () => { + mockUseABTest.mockImplementation((key: string) => { + if (key === AMBIENT_PRICE_COLOR_AB_KEY) { + return { + variant: { useAmbientPriceColor: true }, + variantName: 'treatment', + isActive: true, + }; + } + return { + variant: { swapLabelKey: 'asset_overview.swap' }, + variantName: 'control', + isActive: false, + }; + }); + }; + + it('does not pass useAmbientColor in control variant', () => { + render(); + + expect(mockLastUseAmbientColorProp).toBeFalsy(); + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ iconColor: undefined }), + ); + }); + + it('passes useAmbientColor=true in treatment variant', () => { + enableAmbientColor(); + + render(); + + expect(mockLastUseAmbientColorProp).toBe(true); + }); + + it('keeps iconColor undefined until chart reports direction', () => { + enableAmbientColor(); + mockUseTokenPrice.mockReturnValue({ + ...defaultUseTokenPriceReturn, + priceDiff: 10, + }); + + render(); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ iconColor: undefined }), + ); + }); + + it('applies success green when chart reports positive direction', () => { + enableAmbientColor(); + + render(); + act(() => { + mockLatestPriceDirectionChange?.(true); + }); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ iconColor: LIGHT_MODE_SUCCESS_GREEN }), + ); + }); + + it('applies negative color when chart reports negative direction', () => { + enableAmbientColor(); + + render(); + act(() => { + mockLatestPriceDirectionChange?.(false); + }); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ + iconColor: AMBIENT_NEGATIVE_COLOR, + }), + ); + }); + + it('returns undefined iconColor when treatment + price is loading', () => { + enableAmbientColor(); + mockUseTokenPrice.mockReturnValue({ + ...defaultUseTokenPriceReturn, + isLoading: true, + priceDiff: 0, + }); + + render(); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ iconColor: undefined }), + ); + }); + + it('hides sticky footer while chart direction is unresolved', () => { + enableAmbientColor(); + + const { queryByTestId } = render(); + + expect(queryByTestId('bottomsheetfooter')).toBeNull(); + }); + }); }); diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 127eb46d56e..34e5d9560b1 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -37,6 +37,14 @@ import MultichainTransactionsView from '../../../Views/MultichainTransactionsVie import { TransactionDetailLocation } from '../../../../core/Analytics/events/transactions'; import TokenDetailsStickyFooter from '../components/TokenDetailsStickyFooter'; import { MarketInsightsDisclaimerBottomSheet } from '../../MarketInsights'; +import { useABTest } from '../../../../hooks/useABTest'; +import { + AMBIENT_NEGATIVE_COLOR, + AMBIENT_PRICE_COLOR_AB_KEY, + AMBIENT_PRICE_COLOR_VARIANTS, +} from '../components/abTestConfig'; +import { useTheme, LIGHT_MODE_SUCCESS_GREEN } from '../../../../util/theme'; +import { AppThemeKey } from '../../../../util/theme/models'; const styleSheet = (params: { theme: Theme }) => { const { theme } = params; @@ -132,10 +140,17 @@ const TokenDetails: React.FC<{ }) => void; onStickyButtonsResolved?: (shown: 'both' | 'buy' | 'swap' | null) => void; }> = ({ token, onMarketInsightsDisplayResolved, onStickyButtonsResolved }) => { - const { styles } = useStyles(styleSheet, {}); + const { styles, theme } = useStyles(styleSheet, {}); + const { themeAppearance } = useTheme(); + const isLightMode = themeAppearance === AppThemeKey.light; const navigation = useNavigation(); const [isInsightsDisclaimerVisible, setIsInsightsDisclaimerVisible] = useState(false); + const { variant: ambientColorVariant } = useABTest( + AMBIENT_PRICE_COLOR_AB_KEY, + AMBIENT_PRICE_COLOR_VARIANTS, + ); + const useAmbientColor = ambientColorVariant.useAmbientPriceColor; const caip19AssetId = useMemo((): CaipAssetType | null => { try { @@ -182,6 +197,28 @@ const TokenDetails: React.FC<{ chartNavigationButtons, } = useTokenPrice({ token }); + const [chartPricePositive, setChartPricePositive] = useState( + null, + ); + const handlePriceDirectionChange = useCallback((isPositive: boolean) => { + setChartPricePositive(isPositive); + }, []); + + const ambientIconColor = useMemo(() => { + if (!useAmbientColor || chartPricePositive === null) return undefined; + + const successColor = isLightMode + ? LIGHT_MODE_SUCCESS_GREEN + : theme.colors.success.default; + + return chartPricePositive ? successColor : AMBIENT_NEGATIVE_COLOR; + }, [ + useAmbientColor, + chartPricePositive, + isLightMode, + theme.colors.success.default, + ]); + const { balance, fiatBalance, @@ -243,6 +280,8 @@ const TokenDetails: React.FC<{ securityData={securityData} isSecurityDataLoading={isSecurityDataLoading} hasSecurityDataError={Boolean(securityDataError)} + onPriceDirectionChange={handlePriceDirectionChange} + useAmbientColor={useAmbientColor} ///: BEGIN:ONLY_INCLUDE_IF(tron) stakedTrxAsset={stakedTrxAsset} inLockPeriodBalance={inLockPeriodBalance} @@ -267,7 +306,11 @@ const TokenDetails: React.FC<{ ); return ( - navigation.goBack()} /> + navigation.goBack()} + iconColor={ambientIconColor} + useAmbientColor={useAmbientColor} + /> {txLoading ? ( renderLoader() @@ -302,7 +345,7 @@ const TokenDetails: React.FC<{ location={TransactionDetailLocation.AssetDetails} /> )} - {!txLoading && ( + {!txLoading && !(useAmbientColor && chartPricePositive === null) && ( )} {isInsightsDisclaimerVisible && ( diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 3f8796f980b..7b06ad6450b 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -198,6 +198,10 @@ export interface AssetOverviewContentProps { isSecurityDataLoading?: boolean; /** Whether the security data fetch failed. Hides the card when true. */ hasSecurityDataError?: boolean; + + // Ambient price color A/B test + onPriceDirectionChange?: (isPositive: boolean) => void; + useAmbientColor?: boolean; } /** @@ -237,6 +241,8 @@ const AssetOverviewContent: React.FC = ({ securityData, isSecurityDataLoading = false, hasSecurityDataError = false, + onPriceDirectionChange, + useAmbientColor, }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -711,6 +717,8 @@ const AssetOverviewContent: React.FC = ({ currentPrice={currentPrice} comparePrice={comparePrice} isLoading={isLoading} + onPriceDirectionChange={onPriceDirectionChange} + useAmbientColor={useAmbientColor} /> {!isTokenTradingOpen(token as BridgeToken) && ( diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx index 46a82c86167..1e380d0f478 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { TokenDetailsInlineHeader } from './TokenDetailsInlineHeader'; +import { LIGHT_MODE_SUCCESS_GREEN } from '../../../../util/theme'; describe('TokenDetailsInlineHeader', () => { const mockOnBackPress = jest.fn(); @@ -9,21 +10,80 @@ describe('TokenDetailsInlineHeader', () => { jest.clearAllMocks(); }); - it('renders back button', () => { - const { getByTestId } = render( - , - ); + describe('control group (useAmbientColor=false)', () => { + it('renders back button even when iconColor is undefined', () => { + const { getByTestId } = render( + , + ); - expect(getByTestId('back-arrow-button')).toBeOnTheScreen(); + expect(getByTestId('back-arrow-button')).toBeOnTheScreen(); + }); + + it('renders back button when iconColor is provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('back-arrow-button')).toBeOnTheScreen(); + }); + + it('calls onBackPress when back button is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('back-arrow-button')); + + expect(mockOnBackPress).toHaveBeenCalledTimes(1); + }); }); - it('calls onBackPress when back button is pressed', () => { - const { getByTestId } = render( - , - ); + describe('treatment group (useAmbientColor=true)', () => { + it('does not render back button when iconColor is undefined', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('back-arrow-button')).not.toBeOnTheScreen(); + }); + + it('renders back button when iconColor is provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('back-arrow-button')).toBeOnTheScreen(); + }); + + it('calls onBackPress when back button is pressed', () => { + const { getByTestId } = render( + , + ); - fireEvent.press(getByTestId('back-arrow-button')); + fireEvent.press(getByTestId('back-arrow-button')); - expect(mockOnBackPress).toHaveBeenCalledTimes(1); + expect(mockOnBackPress).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx index 7f31e384c40..a3e548b3141 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx @@ -46,20 +46,35 @@ const inlineHeaderStyles = (params: { export const TokenDetailsInlineHeader = ({ onBackPress, + iconColor, + useAmbientColor = false, }: { onBackPress: () => void; + /** Hex color string for the back button icon (A/B test). */ + iconColor?: string; + useAmbientColor?: boolean; }) => { const insets = useSafeAreaInsets(); const { styles } = useStyles(inlineHeaderStyles, { insets }); + + // In control (useAmbientColor=false): always show button + // In treatment (useAmbientColor=true): only show when iconColor is defined + const shouldShowButton = !useAmbientColor || iconColor !== undefined; + return ( - + {shouldShowButton && ( + + )} diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx index 7b7e227b681..06f3b0813e9 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx @@ -3,9 +3,11 @@ import { fireEvent, render } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import TokenDetailsStickyFooter from './TokenDetailsStickyFooter'; import { + AMBIENT_NEGATIVE_COLOR, STICKY_FOOTER_SWAP_LABEL_VARIANTS, StickyFooterSwapLabelVariant, } from './abTestConfig'; +import { LIGHT_MODE_SUCCESS_GREEN } from '../../../../util/theme'; import type { TokenDetailsRouteParams } from '../constants/constants'; import type { TokenSecurityData } from '@metamask/assets-controllers'; @@ -52,8 +54,26 @@ jest.mock('./RwaUnavailableBottomSheet/RwaUnavailableBottomSheet', () => ({ })); jest.mock('../../../../util/theme', () => { - const { mockTheme } = jest.requireActual('../../../../util/theme'); - return { useTheme: jest.fn(() => mockTheme) }; + const actual = jest.requireActual('../../../../util/theme'); + return { ...actual, useTheme: jest.fn(() => actual.mockTheme) }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const { View, Text } = jest.requireActual('react-native'); + return { + ...actual, + Button: ({ + testID, + children, + twClassName, + ...rest + }: Record) => ( + + {children} + + ), + }; }); const mockOnBuy = jest.fn(); @@ -406,6 +426,89 @@ describe('TokenDetailsStickyFooter', () => { }); }); + describe('ambient price color A/B test', () => { + const ambientProps = { + ...defaultProps, + swapTestID: 'swap-btn', + buyTestID: 'buy-btn', + }; + + const defaultSuccessBg = `bg-[${LIGHT_MODE_SUCCESS_GREEN}]`; + const defaultSuccessBorder = `border-[${LIGHT_MODE_SUCCESS_GREEN}]`; + + it('uses default success styles when useAmbientColor is false', () => { + const { getByTestId } = render( + , + ); + + const buyBtn = getByTestId('buy-btn'); + expect(buyBtn.props.twClassName).toBe(defaultSuccessBg); + }); + + it('uses error accent on success button when useAmbientColor + negative price', () => { + const { getByTestId } = render( + , + ); + + const buyBtn = getByTestId('buy-btn'); + expect(buyBtn.props.twClassName).toBe(`bg-[${AMBIENT_NEGATIVE_COLOR}]`); + }); + + it('uses error accent on secondary button border when useAmbientColor + negative price', () => { + const { getByTestId } = render( + , + ); + + const swapBtn = getByTestId('swap-btn'); + expect(swapBtn.props.twClassName).toBe( + `bg-transparent border-[${AMBIENT_NEGATIVE_COLOR}]`, + ); + }); + + it('uses default success styles when useAmbientColor + positive price', () => { + const { getByTestId } = render( + , + ); + + const buyBtn = getByTestId('buy-btn'); + expect(buyBtn.props.twClassName).toBe(defaultSuccessBg); + }); + + it('uses default success styles when isPricePositive is null (not yet resolved)', () => { + const { getByTestId } = render( + , + ); + + const buyBtn = getByTestId('buy-btn'); + expect(buyBtn.props.twClassName).toBe(defaultSuccessBg); + }); + }); + describe('RWA geo-restriction', () => { it('blocks the buy action when token is a geo-restricted stock', () => { mockIsStockToken.mockReturnValue(true); diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx index bb12bb89fa6..fc6cb1bf503 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx @@ -18,6 +18,7 @@ import { useRWAToken } from '../../Bridge/hooks/useRWAToken'; import useTokenBuyability from '../../Ramp/hooks/useTokenBuyability'; import { useABTest } from '../../../../hooks/useABTest'; import { + AMBIENT_NEGATIVE_COLOR, STICKY_FOOTER_SWAP_LABEL_AB_KEY, STICKY_FOOTER_SWAP_LABEL_VARIANTS, } from './abTestConfig'; @@ -74,6 +75,10 @@ interface TokenStickyFooterProps { onBuyPress?: () => void; /** Page name sent with swap/bridge analytics. Defaults to `'MainView'`. */ sourcePage?: string; + /** When true, use success (green) accent; when false, use error (red) accent. Null means not yet resolved. */ + isPricePositive?: boolean | null; + /** Whether the ambient price color A/B test treatment is active. */ + useAmbientColor?: boolean; } const TokenDetailsStickyFooter: React.FC = ({ @@ -89,21 +94,29 @@ const TokenDetailsStickyFooter: React.FC = ({ onSwapPress, onBuyPress, sourcePage, + isPricePositive = null, + useAmbientColor = false, }) => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { colors, themeAppearance } = useTheme(); const isLightMode = themeAppearance === AppThemeKey.light; - const successBg = isLightMode - ? `bg-[${LIGHT_MODE_SUCCESS_GREEN}]` - : 'bg-success-default'; - const successBorder = isLightMode - ? `border-[${LIGHT_MODE_SUCCESS_GREEN}]` - : 'border-success-default'; - const successText = isLightMode - ? `text-[${LIGHT_MODE_SUCCESS_GREEN}]` - : 'text-success-default'; + const useErrorAccent = useAmbientColor && isPricePositive === false; + + const getSuccessClass = (prefix: string, defaultClass: string) => { + if (useErrorAccent) { + return `${prefix}-[${AMBIENT_NEGATIVE_COLOR}]`; + } + if (isLightMode) { + return `${prefix}-[${LIGHT_MODE_SUCCESS_GREEN}]`; + } + return defaultClass; + }; + + const successBg = getSuccessClass('bg', 'bg-success-default'); + const successBorder = getSuccessClass('border', 'border-success-default'); + const successText = getSuccessClass('text', 'text-success-default'); const secondaryTextProps = useMemo( () => ({ twClassName: successText }) as const, diff --git a/app/components/UI/TokenDetails/components/abTestConfig.ts b/app/components/UI/TokenDetails/components/abTestConfig.ts index 5680f0939b3..2b453508e9c 100644 --- a/app/components/UI/TokenDetails/components/abTestConfig.ts +++ b/app/components/UI/TokenDetails/components/abTestConfig.ts @@ -1,6 +1,43 @@ import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events'; import type { ABTestAnalyticsMapping } from '../../../../util/analytics/abTestAnalytics.types'; +// --- Ambient Price Color A/B Test --- + +// TODO: Update hardcoded color once we get confirmation from design leads. +// eslint-disable-next-line @metamask/design-tokens/color-no-hex +export const AMBIENT_NEGATIVE_COLOR = '#FF5C16'; + +export const AMBIENT_PRICE_COLOR_AB_KEY = + 'assetsASSETS3205AbtestAmbientPriceColor'; + +export enum AmbientPriceColorVariant { + Control = 'control', + Treatment = 'treatment', +} + +export const AMBIENT_PRICE_COLOR_VARIANTS: Record< + AmbientPriceColorVariant, + { useAmbientPriceColor: boolean } +> = { + [AmbientPriceColorVariant.Control]: { useAmbientPriceColor: false }, + [AmbientPriceColorVariant.Treatment]: { useAmbientPriceColor: true }, +}; + +export const AMBIENT_PRICE_COLOR_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapping = + { + flagKey: AMBIENT_PRICE_COLOR_AB_KEY, + validVariants: Object.values(AmbientPriceColorVariant), + eventNames: [ + EVENT_NAME.TOKEN_DETAILS_OPENED, + EVENT_NAME.TOKEN_DETAILS_CTA_CLICKED, + EVENT_NAME.SWAP_PAGE_VIEWED, + EVENT_NAME.ONRAMP_PURCHASE_SUBMITTED, + EVENT_NAME.ONRAMP_PURCHASE_COMPLETED, + ], + }; + +// --- Sticky Footer Swap Label A/B Test --- + export const STICKY_FOOTER_SWAP_LABEL_AB_KEY = 'stickyButtonsAbTest'; export enum StickyFooterSwapLabelVariant { diff --git a/app/components/hooks/useTokenHistoricalPrices.ts b/app/components/hooks/useTokenHistoricalPrices.ts index 1f97fa551af..d74e5e7a850 100644 --- a/app/components/hooks/useTokenHistoricalPrices.ts +++ b/app/components/hooks/useTokenHistoricalPrices.ts @@ -73,7 +73,21 @@ const useTokenHistoricalPrices = ({ name: TraceName.FetchHistoricalPrices, data: { uri: uri.toString() }, }); - const response = await fetch(uri.toString()); + + // Add 3 second timeout to prevent infinite hang + const FETCH_TIMEOUT_MS = 3000; + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('Historical prices fetch timeout')), + FETCH_TIMEOUT_MS, + ); + }); + + const response = await Promise.race([ + fetch(uri.toString()), + timeoutPromise, + ]); + endTrace({ name: TraceName.FetchHistoricalPrices }); if (response.status === 204) { setPrices([]); diff --git a/app/util/analytics/abTestAnalyticsRegistry.ts b/app/util/analytics/abTestAnalyticsRegistry.ts index 5dfa5d1118f..ba9636f395c 100644 --- a/app/util/analytics/abTestAnalyticsRegistry.ts +++ b/app/util/analytics/abTestAnalyticsRegistry.ts @@ -8,7 +8,10 @@ import { HUB_PAGE_DISCOVERY_TABS_AB_TEST_ANALYTICS_MAPPING, WALLET_HOME_POST_ONBOARDING_AB_TEST_ANALYTICS_MAPPING, } from '../../components/Views/Homepage/abTestConfig'; -import { STICKY_FOOTER_SWAP_LABEL_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/TokenDetails/components/abTestConfig'; +import { + AMBIENT_PRICE_COLOR_AB_TEST_ANALYTICS_MAPPING, + STICKY_FOOTER_SWAP_LABEL_AB_TEST_ANALYTICS_MAPPING, +} from '../../components/UI/TokenDetails/components/abTestConfig'; import { WHATS_HAPPENING_EXPLORE_AB_TEST_ANALYTICS_MAPPING } from '../../components/Views/TrendingView/abTestConfig'; export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [ @@ -29,5 +32,6 @@ export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [ WHATS_HAPPENING_EXPLORE_AB_TEST_ANALYTICS_MAPPING, // Token Details + AMBIENT_PRICE_COLOR_AB_TEST_ANALYTICS_MAPPING, STICKY_FOOTER_SWAP_LABEL_AB_TEST_ANALYTICS_MAPPING, ]; diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx b/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx index 0addffbf64a..ab396f51047 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx +++ b/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx @@ -116,18 +116,24 @@ const inactiveABTestResult: MockABTestResult = { describe('useSubmitBridgeTx', () => { const mockABTests = ({ - first = inactiveABTestResult, - second = inactiveABTestResult, + numpad = inactiveABTestResult, + tokenSelector = inactiveABTestResult, + stickyFooter = inactiveABTestResult, + ambientColor = inactiveABTestResult, }: { - first?: MockABTestResult; - second?: MockABTestResult; + numpad?: MockABTestResult; + tokenSelector?: MockABTestResult; + stickyFooter?: MockABTestResult; + ambientColor?: MockABTestResult; } = {}) => { jest .mocked(useABTest) .mockReset() .mockReturnValue(inactiveABTestResult) - .mockReturnValueOnce(first) - .mockReturnValueOnce(second); + .mockReturnValueOnce(numpad) + .mockReturnValueOnce(tokenSelector) + .mockReturnValueOnce(stickyFooter) + .mockReturnValueOnce(ambientColor); }; beforeEach(() => { @@ -229,7 +235,7 @@ describe('useSubmitBridgeTx', () => { // Re-render with an active assignment to verify submitTx forwards activeAbTests. mockABTests({ - second: { + tokenSelector: { variant: {}, variantName: 'treatment', isActive: true, @@ -518,7 +524,7 @@ describe('useSubmitBridgeTx', () => { // Re-render with an active assignment to verify submitIntent forwards activeAbTests. mockABTests({ - second: { + tokenSelector: { variant: {}, variantName: 'treatment', isActive: true, @@ -552,6 +558,59 @@ describe('useSubmitBridgeTx', () => { expect(txResult).toEqual(mockIntentResult); }); + it('forwards ambient color AB test assignment via submitTx when active', async () => { + mockABTests({ + ambientColor: { + variant: {}, + variantName: 'treatment', + isActive: true, + }, + }); + mockSubmitTx.mockResolvedValueOnce({ + chainId: '0x1', + id: '1', + networkClientId: '1', + status: 'submitted', + time: Date.now(), + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + } as TransactionMeta); + + const { result } = renderHook(() => useSubmitBridgeTx(), { + wrapper: createWrapper(), + }); + + const mockQuoteResponse = { + ...DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0], + ...DummyQuoteMetadata, + }; + + await result.current.submitBridgeTx({ + quoteResponse: mockQuoteResponse as BridgeQuoteResponse, + }); + + expect(mockSubmitTx).toHaveBeenLastCalledWith( + '0x1234567890123456789012345678901234567890', + { + ...mockQuoteResponse, + approval: undefined, + }, + true, + undefined, + undefined, + undefined, + [ + expect.objectContaining({ + key: expect.any(String), + value: 'treatment', + key_value_pair: expect.stringMatching(/[=]treatment$/u), + }), + ], + null, + ); + }); + it('forwards tokenSecurityTypeDestination from destination token securityData', async () => { const { result } = renderHook(() => useSubmitBridgeTx(), { wrapper: createWrapper({ diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.ts b/app/util/bridge/hooks/useSubmitBridgeTx.ts index 784d1fcbd45..f7eb1cc498e 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.ts +++ b/app/util/bridge/hooks/useSubmitBridgeTx.ts @@ -21,6 +21,8 @@ import { TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, } from '../../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig'; import { + AMBIENT_PRICE_COLOR_AB_KEY, + AMBIENT_PRICE_COLOR_VARIANTS, STICKY_FOOTER_SWAP_LABEL_AB_KEY, STICKY_FOOTER_SWAP_LABEL_VARIANTS, } from '../../../components/UI/TokenDetails/components/abTestConfig'; @@ -69,6 +71,10 @@ export default function useSubmitBridgeTx() { STICKY_FOOTER_SWAP_LABEL_AB_KEY, STICKY_FOOTER_SWAP_LABEL_VARIANTS, ); + const { + variantName: ambientColorVariantName, + isActive: isAmbientColorAbActive, + } = useABTest(AMBIENT_PRICE_COLOR_AB_KEY, AMBIENT_PRICE_COLOR_VARIANTS); const abTests = abTestContext?.assetsASSETS2493AbtestTokenDetailsLayout ? { @@ -106,6 +112,15 @@ export default function useSubmitBridgeTx() { ); } + if (isAmbientColorAbActive) { + tests.push( + createActiveABTestAssignment( + AMBIENT_PRICE_COLOR_AB_KEY, + ambientColorVariantName, + ), + ); + } + return tests.length > 0 ? tests : undefined; }, [ isNumpadAbActive, @@ -114,6 +129,8 @@ export default function useSubmitBridgeTx() { tokenSelectorVariantName, isStickyFooterAbActive, stickyFooterVariantName, + isAmbientColorAbActive, + ambientColorVariantName, ]); const submitBridgeTx = async ({ From 6159478015bb4b6789309aa05d5182ed4187620b Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 27 May 2026 00:39:33 +0800 Subject: [PATCH 14/16] fix(perps): investigate Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event' (#30612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix `TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'` crash caused by the `CloseEvent` polyfill in `shim.js` using `event-target-shim`'s `Event` class instead of React Native's own `Event` class. When `@nktkas/rews` (Hyperliquid SDK WebSocket transport) dispatches a `CloseEvent` on the native WebSocket, RN's `dispatchEvent` validates `event instanceof RNEvent` — which failed because `event-target-shim` provides a different `Event` class. Replaced `event-target-shim` globals with React Native's own `Event`, `EventTarget`, `CloseEvent`, and `MessageEvent` classes so all `instanceof` checks pass consistently. ## **Changelog** CHANGELOG entry: Fixed a crash caused by CloseEvent dispatch on WebSocket failing instanceof validation ## **Related issues** Fixes: [TAT-3223](https://consensyssoftware.atlassian.net/browse/TAT-3223) ## **Manual testing steps** ```gherkin Feature: CloseEvent dispatch on native WebSocket Scenario: CloseEvent is dispatched on native WebSocket without error Given the app is running with Hyperliquid SDK active When a WebSocket connection is closed while in CONNECTING state Then no TypeError is thrown And the CloseEvent is dispatched successfully ``` ## **Screenshots/Recordings** State-only fix: no visual evidence needed. Both ACs proven via CDP eval (CloseEvent dispatch success) and lint:tsc (no TS errors). ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Validation Recipe** recipe.json ```json { "pr": "TAT-3223", "title": "CloseEvent dispatchEvent on native WebSocket must not throw TypeError", "jira": "TAT-3223", "acceptance_criteria": [ "CloseEvent dispatched on native WebSocket must not throw TypeError", "No new TypeScript errors introduced by the fix" ], "validate": { "static": ["yarn lint:tsc"], "workflow": { "pre_conditions": ["wallet.unlocked"], "entry": "ac1-eval-closeevent-dispatch", "nodes": { "ac1-eval-closeevent-dispatch": { "action": "eval_sync", "expression": "(function() { try { var ws = new WebSocket('wss://echo.websocket.org'); var ce = new CloseEvent('close', {code: 1006, reason: '', wasClean: false}); ws.dispatchEvent(ce); ws.close(); return JSON.stringify({success: true, error: null}); } catch(e) { return JSON.stringify({success: false, error: e.message}); } })()", "assert": { "operator": "eq", "field": "success", "value": true }, "next": "ac1-eval-closeevent-props" }, "ac1-eval-closeevent-props": { "action": "eval_sync", "expression": "(function() { var ce = new CloseEvent('close', {code: 1006, reason: 'test', wasClean: true}); return JSON.stringify({type: ce.type, code: ce.code, reason: ce.reason, wasClean: ce.wasClean}); })()", "assert": { "all": [ { "operator": "eq", "field": "code", "value": 1006 }, { "operator": "eq", "field": "reason", "value": "test" }, { "operator": "eq", "field": "wasClean", "value": true } ] }, "next": "ac1-eval-messageevent-dispatch" }, "ac1-eval-messageevent-dispatch": { "action": "eval_sync", "expression": "(function() { try { var ws = new WebSocket('wss://echo.websocket.org'); var me = new MessageEvent('message', {data: 'hello'}); ws.dispatchEvent(me); ws.close(); return JSON.stringify({success: true, error: null}); } catch(e) { return JSON.stringify({success: false, error: e.message}); } })()", "assert": { "operator": "eq", "field": "success", "value": true }, "next": "setup-done" }, "setup-done": { "action": "end", "status": "pass" } } } } } ``` ## **Recipe Workflow** workflow.mmd ```mermaid graph TD ac1-eval-closeevent-dispatch["ac1-eval-closeevent-dispatcheval_sync: CloseEvent dispatch on native WS"] ac1-eval-closeevent-props["ac1-eval-closeevent-propseval_sync: Verify CloseEvent properties"] ac1-eval-messageevent-dispatch["ac1-eval-messageevent-dispatcheval_sync: MessageEvent dispatch on native WS"] setup-done["setup-doneend: pass"] ac1-eval-closeevent-dispatch --> ac1-eval-closeevent-props ac1-eval-closeevent-props --> ac1-eval-messageevent-dispatch ac1-eval-messageevent-dispatch --> setup-done ``` [TAT-3223]: https://consensyssoftware.atlassian.net/browse/TAT-3223?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Touches app bootstrap polyfills used by Hyperliquid WebSockets; low blast radius but wrong globals could break perps connectivity at runtime. > > **Overview** > Fixes a **Hyperliquid / perps WebSocket crash** where `dispatchEvent` rejected `CloseEvent` because polyfilled events did not pass React Native’s `instanceof Event` check. > > **`shim.js`** stops using **`event-target-shim`** and hand-rolled `CloseEvent` / `MessageEvent` constructors. When globals are missing, it assigns React Native’s own **`Event`**, **`EventTarget`**, **`CloseEvent`**, and **`MessageEvent`** from RN private web API modules so events dispatched by `@nktkas/rews` match what RN’s WebSocket `EventTarget` expects. > > **`event-target-shim`** is removed from **`package.json`** / lockfile. **`shim.test.js`** adds unit coverage for RN `CloseEvent` and `MessageEvent` properties and inheritance. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5d5cb890f0580cb94d2af53ac577caa515aa86a5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 1 - shim.js | 41 +++++++++++++--------------- shim.test.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 8 ------ 4 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 shim.test.js diff --git a/package.json b/package.json index d2cc9fc5559..271278f8926 100644 --- a/package.json +++ b/package.json @@ -424,7 +424,6 @@ "ethereumjs-util": "^7.0.10", "ethers": "^5.0.14", "ethjs-ens": "2.0.1", - "event-target-shim": "^6.0.2", "eventemitter2": "^6.4.9", "events": "3.0.0", "expo": "54.0.33", diff --git a/shim.js b/shim.js index f946306f4d6..1bee816c3e8 100644 --- a/shim.js +++ b/shim.js @@ -158,14 +158,23 @@ global.crypto = { process.browser = false; -// EventTarget polyfills for Hyperliquid SDK WebSocket support +// EventTarget / Event polyfills for Hyperliquid SDK WebSocket support. +// React Native's WebSocket extends RN's internal EventTarget, whose +// dispatchEvent validates `event instanceof RNEvent`. The event-target-shim +// package provides a *different* Event class that fails this check, causing +// "parameter 1 is not of type 'Event'" TypeErrors when @nktkas/rews dispatches +// CloseEvent on the native WebSocket. Use RN's own classes so all instanceof +// checks pass consistently. if ( typeof global.EventTarget === 'undefined' || typeof global.Event === 'undefined' ) { - const { Event, EventTarget } = require('event-target-shim'); - global.EventTarget = EventTarget; - global.Event = Event; + // eslint-disable-next-line @react-native/no-deep-imports -- RN does not export Event/EventTarget at the top level + global.Event = + require('react-native/src/private/webapis/dom/events/Event').default; + // eslint-disable-next-line @react-native/no-deep-imports -- RN does not export EventTarget at the top level + global.EventTarget = + require('react-native/src/private/webapis/dom/events/EventTarget').default; } if (typeof global.CustomEvent === 'undefined') { @@ -178,29 +187,17 @@ if (typeof global.CustomEvent === 'undefined') { } // CloseEvent polyfill for @nktkas/rews v2 (used by Hyperliquid SDK WebSocket transport) -// React Native/Hermes does not provide CloseEvent as a global constructor if (typeof global.CloseEvent === 'undefined') { - global.CloseEvent = function (type, params) { - params = params || {}; - const event = new global.Event(type, params); - event.code = params.code ?? 0; - event.reason = params.reason ?? ''; - event.wasClean = params.wasClean ?? false; - return event; - }; + // eslint-disable-next-line @react-native/no-deep-imports -- RN does not export CloseEvent at the top level + global.CloseEvent = + require('react-native/src/private/webapis/websockets/events/CloseEvent').default; } // MessageEvent polyfill for @nktkas/rews v2 (used by Hyperliquid SDK WebSocket transport) -// React Native/Hermes does not provide MessageEvent as a global constructor if (typeof global.MessageEvent === 'undefined') { - global.MessageEvent = function (type, params) { - params = params || {}; - const event = new global.Event(type, params); - event.data = params.data ?? null; - event.origin = params.origin ?? ''; - event.lastEventId = params.lastEventId ?? ''; - return event; - }; + // eslint-disable-next-line @react-native/no-deep-imports -- RN does not export MessageEvent at the top level + global.MessageEvent = + require('react-native/src/private/webapis/html/events/MessageEvent').default; } class AbortError extends Error { diff --git a/shim.test.js b/shim.test.js new file mode 100644 index 00000000000..75402f6e840 --- /dev/null +++ b/shim.test.js @@ -0,0 +1,75 @@ +/** + * Tests for the Event/EventTarget/CloseEvent/MessageEvent polyfills in shim.js. + * + * The core fix (TAT-3223) ensures polyfilled globals use React Native's own + * Event classes for instanceof compatibility with RN's EventTarget.dispatchEvent. + * Full dispatch compatibility is validated by the agentic recipe against the + * live runtime; these unit tests verify constructor behavior and property access. + */ + +/* eslint-disable @react-native/no-deep-imports, import-x/no-commonjs */ +const RNCloseEvent = + require('react-native/src/private/webapis/websockets/events/CloseEvent').default; +const RNMessageEvent = + require('react-native/src/private/webapis/html/events/MessageEvent').default; +const RNEvent = + require('react-native/src/private/webapis/dom/events/Event').default; +/* eslint-enable @react-native/no-deep-imports, import-x/no-commonjs */ + +describe('Event polyfill shims (TAT-3223)', () => { + describe('CloseEvent', () => { + it('preserves code, reason, and wasClean via getters', () => { + const ce = new RNCloseEvent('close', { + code: 1006, + reason: 'abnormal', + wasClean: false, + }); + + expect(ce.type).toBe('close'); + expect(ce.code).toBe(1006); + expect(ce.reason).toBe('abnormal'); + expect(ce.wasClean).toBe(false); + }); + + it('defaults code to 0, reason to empty, wasClean to false', () => { + const ce = new RNCloseEvent('close'); + + expect(ce.code).toBe(0); + expect(ce.reason).toBe(''); + expect(ce.wasClean).toBe(false); + }); + + it('extends RN Event', () => { + const ce = new RNCloseEvent('close', { code: 1000 }); + + expect(ce instanceof RNEvent).toBe(true); + expect(ce.type).toBe('close'); + }); + }); + + describe('MessageEvent', () => { + it('preserves data and origin via getters', () => { + const me = new RNMessageEvent('message', { + data: 'payload', + origin: 'wss://example.com', + }); + + expect(me.type).toBe('message'); + expect(me.data).toBe('payload'); + expect(me.origin).toBe('wss://example.com'); + }); + + it('defaults data to undefined, origin to empty string', () => { + const me = new RNMessageEvent('message'); + + expect(me.data).toBeUndefined(); + expect(me.origin).toBe(''); + }); + + it('extends RN Event', () => { + const me = new RNMessageEvent('message', { data: 'test' }); + + expect(me instanceof RNEvent).toBe(true); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4cd553c1bc7..8a708bfec48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28572,13 +28572,6 @@ __metadata: languageName: node linkType: hard -"event-target-shim@npm:^6.0.2": - version: 6.0.2 - resolution: "event-target-shim@npm:6.0.2" - checksum: 10/aa69fc4193cad3f1e4dc0c2d3f2689ea2d477f5ff2fbee8b65f866035b15658e1985932b06ba2190c3d2cc9cc6802c26facd6c60487590c1a05f44545ec24f42 - languageName: node - linkType: hard - "eventemitter2@npm:^6.4.9": version: 6.4.9 resolution: "eventemitter2@npm:6.4.9" @@ -35510,7 +35503,6 @@ __metadata: ethereumjs-util: "npm:^7.0.10" ethers: "npm:^5.0.14" ethjs-ens: "npm:2.0.1" - event-target-shim: "npm:^6.0.2" eventemitter2: "npm:^6.4.9" events: "npm:3.0.0" execa: "npm:^8.0.1" From 6d2217775534660f90d8ec0ff7516300c578195e Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Tue, 26 May 2026 19:59:12 +0300 Subject: [PATCH 15/16] feat(bridge): add fiat source amount input (#29756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds fiat amount entry to the Bridge source amount input so users can toggle between entering a token amount and entering the equivalent fiat value. The source amount remains stored as the token amount for quote requests and balance checks, while the UI can display fiat input, token secondary amounts, and the correct keypad decimal constraints. This also updates the source input cursor behavior, keypad state, and tap target handling so fiat/token toggling keeps the cursor at the end of the visible amount and tapping the full source amount area still focuses the input. ## **Changelog** CHANGELOG entry: Added the ability to enter swap and bridge source amounts in fiat ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4448 ## **Manual testing steps** ```gherkin Feature: Fiat source amount input Scenario: user enters a source amount in fiat mode Given the user is on the Swap or Bridge screen with source and destination tokens selected And source token price data is available When the user taps the amount type toggle on the source amount field And the user enters a fiat amount with the keypad Then the source input shows the fiat currency symbol And the secondary amount shows the truncated token amount And the quote request uses the converted token amount Scenario: user toggles back to token mode Given the user has entered a fiat source amount When the user taps the amount type toggle again Then the source input shows the token amount And the cursor is positioned at the end of the amount Scenario: user taps the source amount area outside the number Given the source amount keypad is closed When the user taps empty space in the source amount input area Then the source amount input is focused And the keypad opens ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes swap/bridge amount input, quote-driving token amounts, and fiat conversion paths; mistakes could mis-quote or show wrong balances, though behavior is flag-gated and heavily tested. > > **Overview** > Adds **fiat vs token entry** on the Bridge/Swap source field behind the remote flag `enableFiatToggle`. Users can switch with a vertical-swap control; Redux still stores the **token** amount for quotes, balance checks, and `srcTokenAmount`, while the UI can show fiat in the primary field with a token secondary line (and the destination field can mirror fiat-primary when source is in fiat mode). > > **`useSourceAmountInput`** centralizes mode state, fiat↔token conversion (`sourceAmountInputMode` utils), keypad decimals, cursor-at-end on focus/toggle, and fallbacks when price data is missing. **`TokenInputArea`** gains currency prefix, optional secondary line, balance-check override, and the toggle. **`calcTokenFiatRate`** / **`useTokenFiatRate`** supply per-token fiat rates (including native EVM). Max, presets, and flip reset or sync fiat display via `syncFiatAmountToTokenAmount` / `resetToTokenMode`. > > Coverage includes Bridge view component tests (toggle, quotes, cursor, missing price) and unit tests for conversion helpers and `TokenInputArea` overrides. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ace57041e93c6c7b430a2f673682e7785bffab0f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Views/BridgeView/BridgeView.testIds.ts | 1 + .../Views/BridgeView/BridgeView.view.test.tsx | 302 +++++++++++++++++- .../UI/Bridge/Views/BridgeView/index.tsx | 68 ++-- .../TokenInputArea/TokenInputArea.test.tsx | 63 ++++ .../components/TokenInputArea/index.tsx | 201 +++++++++--- .../hooks/useSourceAmountCursor.test.ts | 19 ++ .../UI/Bridge/hooks/useSourceAmountCursor.ts | 8 + .../hooks/useSourceAmountInput/index.ts | 232 ++++++++++++++ .../UI/Bridge/hooks/useTokenFiatRate/index.ts | 30 ++ .../UI/Bridge/utils/currencyUtils.ts | 24 ++ .../UI/Bridge/utils/exchange-rates.ts | 54 ++++ .../utils/sourceAmountInputMode.test.ts | 75 +++++ .../UI/Bridge/utils/sourceAmountInputMode.ts | 65 ++++ tests/component-view/presets/bridge.ts | 2 +- tests/feature-flags/feature-flag-registry.ts | 8 + 15 files changed, 1071 insertions(+), 81 deletions(-) create mode 100644 app/components/UI/Bridge/hooks/useSourceAmountInput/index.ts create mode 100644 app/components/UI/Bridge/hooks/useTokenFiatRate/index.ts create mode 100644 app/components/UI/Bridge/utils/sourceAmountInputMode.test.ts create mode 100644 app/components/UI/Bridge/utils/sourceAmountInputMode.ts diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts index fbc9cd23748..94fcc24838b 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts @@ -2,6 +2,7 @@ export const BridgeViewSelectorsIDs = { SOURCE_TOKEN_AREA: 'source-token-area', DESTINATION_TOKEN_AREA: 'dest-token-area', SOURCE_TOKEN_INPUT: 'source-token-area-input', + SOURCE_AMOUNT_TYPE_TOGGLE: 'source-token-area-amount-type-toggle', DESTINATION_TOKEN_INPUT: 'dest-token-area-input', CONFIRM_BUTTON: 'bridge-confirm-button', CONFIRM_BUTTON_KEYPAD: 'bridge-confirm-button-keypad', diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx index d1b93caf4b6..d13b53ca332 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx @@ -5,7 +5,10 @@ import { act, fireEvent, waitFor, within } from '@testing-library/react-native'; import { strings } from '../../../../../../locales/i18n'; import React from 'react'; import { Text } from 'react-native'; -import { renderScreenWithRoutes } from '../../../../../../tests/component-view/render'; +import { + renderComponentViewScreen, + renderScreenWithRoutes, +} from '../../../../../../tests/component-view/render'; import Routes from '../../../../../constants/navigation/Routes'; import { initialStateBridge } from '../../../../../../tests/component-view/presets/bridge'; import BridgeView from './index'; @@ -113,6 +116,303 @@ describeForPlatforms('BridgeView', () => { expect(await findByText('$19,000.00')).toBeOnTheScreen(); }); + it('toggles source input from token amount to fiat value and back', async () => { + const { getByTestId, findByDisplayValue, findByText } = + defaultBridgeWithTokens({ + bridge: { + sourceAmount: '1', + sourceToken: ETH_SOURCE, + destToken: undefined, + }, + } as unknown as Record); + + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + + expect(await findByDisplayValue('2,000')).toBeOnTheScreen(); + expect(await findByText('1 ETH')).toBeOnTheScreen(); + expect( + getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT).props.selection, + ).toEqual({ start: 5, end: 5 }); + + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + + expect(await findByDisplayValue('1')).toBeOnTheScreen(); + expect(await findByText('$2,000.00')).toBeOnTheScreen(); + expect( + getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT).props.selection, + ).toEqual({ start: 1, end: 1 }); + }); + + it('mirrors source fiat mode on the destination amount display', async () => { + const state = initialStateBridge({ deterministicFiat: true }) + .withBridgeRecommendedQuoteEvmSimple() + .withOverrides({ + bridge: { + ...DEFAULT_BRIDGE, + sourceAmount: '1', + selectedDestChainId: '0x1', + }, + engine: { + backgroundState: { + TokenRatesController: { + marketData: { + '0x1': { + [USDC_DEST.address]: { + tokenAddress: USDC_DEST.address, + currency: 'ETH', + price: 0.0005, + }, + }, + }, + }, + }, + }, + } as unknown as DeepPartial) + .build(); + const bridgeControllerState = ( + (state as unknown as DeepPartial).engine?.backgroundState as + | Record + | undefined + )?.BridgeController as + | { + recommendedQuote: Record; + quotes: Record[]; + } + | undefined; + const recommendedQuote = bridgeControllerState?.recommendedQuote; + const quote = recommendedQuote?.quote as Record; + const quoteWithTrade = { + ...recommendedQuote, + quote: { + ...quote, + bridgeId: 'test-bridge', + bridges: ['test-bridge'], + steps: [], + }, + trade: { + value: '0xde0b6b3a7640000', + gasLimit: 0, + effectiveGas: 0, + }, + }; + + if (bridgeControllerState) { + bridgeControllerState.recommendedQuote = quoteWithTrade; + bridgeControllerState.quotes = [quoteWithTrade]; + } + + const { getByTestId, getByText } = renderComponentViewScreen( + BridgeView as unknown as React.ComponentType, + { name: Routes.BRIDGE.BRIDGE_VIEW }, + { state }, + ); + + await waitFor(() => { + expect( + getByTestId(BridgeViewSelectorsIDs.DESTINATION_TOKEN_INPUT).props.value, + ).toBe('1'); + }); + + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + + await waitFor(() => { + expect( + getByTestId(BridgeViewSelectorsIDs.DESTINATION_TOKEN_INPUT).props.value, + ).toBe('$1.00'); + }); + expect(getByText('1 USDC')).toBeOnTheScreen(); + + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + + await waitFor(() => { + expect( + getByTestId(BridgeViewSelectorsIDs.DESTINATION_TOKEN_INPUT).props.value, + ).toBe('1'); + }); + }); + + it('resets source cursor to the end when input is focused again', async () => { + const { getByTestId, getByText, findByDisplayValue } = + defaultBridgeWithTokens({ + bridge: { + sourceAmount: '1234', + sourceToken: ETH_SOURCE, + destToken: undefined, + }, + } as unknown as Record); + const sourceInput = getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT); + + fireEvent(sourceInput, 'selectionChange', { + nativeEvent: { selection: { start: 1, end: 1 } }, + }); + fireEvent(sourceInput, 'blur'); + fireEvent(sourceInput, 'focus'); + + expect(sourceInput.props.selection).toEqual({ start: 5, end: 5 }); + + await waitFor(() => { + expect( + getByTestId(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON), + ).toBeOnTheScreen(); + }); + fireEvent.press(getByText('9')); + + expect(await findByDisplayValue('12,349')).toBeOnTheScreen(); + }); + + it('shows zero secondary value when source amount is empty', async () => { + const { getByTestId, findByText } = defaultBridgeWithTokens({ + bridge: { + sourceAmount: undefined, + sourceToken: ETH_SOURCE, + destToken: undefined, + }, + } as unknown as Record); + + expect(await findByText('$0')).toBeOnTheScreen(); + + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + + expect(await findByText('0 ETH')).toBeOnTheScreen(); + }); + + it('floors the fiat-mode secondary token amount to the shared Bridge precision', async () => { + const { getByTestId, findByText, queryByText } = defaultBridgeWithTokens({ + bridge: { + sourceAmount: '0.054266763023182519', + sourceToken: ETH_SOURCE, + destToken: undefined, + }, + } as unknown as Record); + + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + + expect(await findByText('0.05426 ETH')).toBeOnTheScreen(); + expect(queryByText('0.05427 ETH')).toBeNull(); + expect(queryByText('0.054266763023182519 ETH')).toBeNull(); + }); + + it('keeps quote requests based on token amount after fiat input', async () => { + const updateQuoteSpy = jest.spyOn( + Engine.context.BridgeController, + 'updateBridgeQuoteRequestParams', + ); + const { getByTestId, getByText, findByDisplayValue, findByText, store } = + defaultBridgeWithTokens({ + bridge: { + sourceAmount: '0', + sourceToken: ETH_SOURCE, + destToken: USDC_DEST, + selectedDestChainId: '0x1', + }, + engine: { + backgroundState: { + BridgeController: { + quotes: [], + recommendedQuote: null, + quotesLastFetched: 0, + quotesLoadingStatus: null, + quoteFetchError: null, + }, + }, + }, + } as unknown as Record); + + updateQuoteSpy.mockClear(); + fireEvent.press( + getByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ); + fireEvent( + getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT), + 'pressIn', + ); + + await waitFor(() => { + expect( + getByTestId(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON), + ).toBeOnTheScreen(); + }); + + fireEvent.press(getByText('5')); + fireEvent.press(getByText('0')); + + expect(await findByDisplayValue('50')).toBeOnTheScreen(); + expect(await findByText('0.025 ETH')).toBeOnTheScreen(); + + await waitFor(() => { + expect(store.getState().bridge.sourceAmount).toBe('0.025'); + }); + await waitFor( + () => { + expect(updateQuoteSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '25000000000000000', + }), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }, + { timeout: 1000 }, + ); + + updateQuoteSpy.mockRestore(); + }); + + it('keeps source input in token mode when price data is unavailable', async () => { + const sourceTokenWithoutPrice = { + ...ETH_SOURCE, + address: '0x1234567890123456789012345678901234567890', + symbol: 'NOPE', + }; + const { getByTestId, queryByTestId, queryByText, findByDisplayValue } = + renderBridgeView({ + overrides: { + bridge: { + ...DEFAULT_BRIDGE, + sourceAmount: '1', + sourceToken: sourceTokenWithoutPrice, + destToken: undefined, + }, + engine: { + backgroundState: { + CurrencyRateController: { + currentCurrency: 'USD', + currencyRates: {}, + conversionRate: 0, + }, + TokenRatesController: { + marketData: {}, + }, + }, + }, + } as unknown as DeepPartial, + }); + + fireEvent( + getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT), + 'pressIn', + ); + + expect( + queryByTestId(BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE), + ).toBeNull(); + expect(queryByText('$0.00')).toBeNull(); + expect(await findByDisplayValue('1')).toBeOnTheScreen(); + }); + it('renders enabled confirm button with tokens, amount and recommended quote', () => { const now = Date.now(); const { getAllByTestId } = defaultBridgeWithTokens({ diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 33624110fca..f2db7c2fa36 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -109,10 +109,10 @@ import BridgeTrendingTokensSection from '../../components/BridgeTrendingTokensSe import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController'; import type { RootState } from '../../../../../reducers'; import { useTrackSwapPageViewed } from '../../hooks/useTrackSwapPageViewed/index.ts'; -import { useSourceAmountCursor } from '../../hooks/useSourceAmountCursor.ts'; import { BridgeViewFooter } from './BridgeViewFooter.tsx'; import { getQuoteStreamReasonString } from './BridgeView.utils'; import { hasMissingPriceData } from '../../utils/hasMissingPriceData'; +import { useSourceAmountInput } from '../../hooks/useSourceAmountInput'; import { useInsufficientNativeReserveError } from '../../hooks/useInsufficientNativeReserveError/index.ts'; import { ButtonSize, @@ -136,6 +136,10 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { (state: RootState) => selectRemoteFeatureFlags(state).swapsTrendingTokens === true, ); + const isFiatToggleEnabled = useSelector( + (state: RootState) => + selectRemoteFeatureFlags(state).enableFiatToggle === true, + ); const { styles } = useStyles(createStyles); const dispatch = useDispatch(); @@ -179,17 +183,13 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { }, [dispatch], ); - const { - sourceSelection, - handleSourceSelectionChange, - handleKeypadChange, - resetSourceAmountCursorPosition, - } = useSourceAmountCursor({ + const sourceAmountInput = useSourceAmountInput({ + isFiatToggleEnabled, sourceAmount, - sourceTokenDecimals: sourceToken?.decimals, - maxInputLength: MAX_INPUT_LENGTH, + sourceToken, onSourceAmountChange: handleSourceAmountChange, }); + const { resetToTokenMode, syncFiatAmountToTokenAmount } = sourceAmountInput; /** The entry point location for analytics (e.g. Main View, Token View, Trending Explore) */ const location = route.params?.location; @@ -379,7 +379,7 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { balance, MAX_INPUT_LENGTH, ); - resetSourceAmountCursorPosition(); + syncFiatAmountToTokenAmount(cleaned); dispatch(setSourceAmountAsMax(cleaned)); } }; @@ -388,15 +388,12 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { (value: string) => { // Quick-pick presets replace the full amount rather than editing at the // current cursor position, so clear the cursor state before updating. - resetSourceAmountCursorPosition(); - dispatch( - setSourceAmount( - normalizeSourceAmountToMaxLength(value, MAX_INPUT_LENGTH) || - undefined, - ), - ); + const normalizedValue = + normalizeSourceAmountToMaxLength(value, MAX_INPUT_LENGTH) || undefined; + syncFiatAmountToTokenAmount(normalizedValue); + dispatch(setSourceAmount(normalizedValue)); }, - [dispatch, resetSourceAmountCursorPosition], + [dispatch, syncFiatAmountToTokenAmount], ); const handleSourceTokenPress = () => @@ -405,9 +402,11 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { }); const handleFlipTokensPress = useCallback(() => { - resetSourceAmountCursorPosition(); - void handleSwitchTokens(destTokenAmount)(); - }, [destTokenAmount, handleSwitchTokens, resetSourceAmountCursorPosition]); + resetToTokenMode(); + handleSwitchTokens(destTokenAmount)().catch((error) => { + console.error('Error switching bridge tokens:', error); + }); + }, [destTokenAmount, handleSwitchTokens, resetToTokenMode]); const handleDestTokenPress = () => navigation.navigate(Routes.BRIDGE.TOKEN_SELECTOR, { @@ -478,8 +477,8 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { { testID={BridgeViewSelectorsIDs.SOURCE_TOKEN_AREA} tokenType={TokenInputAreaType.Source} onInputPress={() => keypadRef.current?.open()} - onSelectionChange={handleSourceSelectionChange} + onFocus={sourceAmountInput.handleFocus} + onSelectionChange={sourceAmountInput.handleSelectionChange} onTokenPress={handleSourceTokenPress} onMaxPress={handleSourceMaxPress} latestAtomicBalance={latestSourceBalance?.atomicBalance} isSourceToken isQuoteSponsored={isQuoteSponsored} + inputPrefix={sourceAmountInput.inputPrefix} + secondaryValue={sourceAmountInput.secondaryValue} + balanceCheckAmount={sourceAmountInput.balanceCheckAmount} + onAmountTypeTogglePress={ + sourceAmountInput.canToggle + ? sourceAmountInput.handleToggle + : undefined + } + amountTypeToggleTestID={ + BridgeViewSelectorsIDs.SOURCE_AMOUNT_TYPE_TOGGLE + } /> { isLoading={!destTokenAmount && isLoading} style={styles.destTokenArea} isQuoteSponsored={isQuoteSponsored} + showFiatAmountAsPrimary={sourceAmountInput.isFiatMode} /> @@ -708,10 +720,10 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { {sourceAmount && sourceAmount !== '0' ? ( ({ useDisplayCurrencyValue: jest.fn(() => '$100.00'), })); +jest.mock('../../hooks/useInsufficientBalance', () => jest.fn(() => false)); + import { useShouldRenderMaxOption } from '../../hooks/useShouldRenderMaxOption'; const mockUseShouldRenderMaxOption = useShouldRenderMaxOption as jest.MockedFunction< @@ -63,6 +65,12 @@ const mockUseFormattedBalanceWithThreshold = typeof useFormattedBalanceWithThreshold >; +import useIsInsufficientBalance from '../../hooks/useInsufficientBalance'; +const mockUseIsInsufficientBalance = + useIsInsufficientBalance as jest.MockedFunction< + typeof useIsInsufficientBalance + >; + import { useDisplayCurrencyValue } from '../../hooks/useDisplayCurrencyValue'; const mockUseDisplayCurrencyValue = useDisplayCurrencyValue as jest.MockedFunction< @@ -81,6 +89,7 @@ describe('TokenInputArea', () => { mockUseShouldRenderMaxOption.mockReturnValue(true); mockUseFormattedBalanceWithThreshold.mockReturnValue('100'); mockUseDisplayCurrencyValue.mockReturnValue('$100.00'); + mockUseIsInsufficientBalance.mockReturnValue(false); }); it('renders with initial state', () => { @@ -855,6 +864,60 @@ describe('TokenInputArea', () => { }); }); + describe('amount overrides', () => { + const mockToken: BridgeToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + decimals: 18, + chainId: '0x1' as `0x${string}`, + }; + const renderAmountOverrideInput = ( + props: Partial> = {}, + ) => + renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + it('uses token amount for balance checks when display amount is fiat', () => { + renderAmountOverrideInput({ + amount: '113.28', + balanceCheckAmount: '0.05', + inputPrefix: '$', + }); + + expect(mockUseIsInsufficientBalance).toHaveBeenCalledWith( + expect.objectContaining({ amount: '0.05' }), + ); + }); + + it('shows fiat as primary and token amount as secondary for destination display mode', () => { + mockUseDisplayCurrencyValue.mockReturnValue('1,23 €'); + + const { getByTestId, getByText } = renderAmountOverrideInput({ + amount: '1.234567', + tokenType: TokenInputAreaType.Destination, + showFiatAmountAsPrimary: true, + }); + + expect(getByTestId('token-input-input').props.value).toBe('1,23 €'); + expect(getByText('1.23456 TEST')).toBeOnTheScreen(); + expect(mockUseDisplayCurrencyValue).toHaveBeenCalledWith( + '1.234567', + mockToken, + ); + }); + }); + describe('token button vs select button', () => { const mockToken: BridgeToken = { address: '0x1234567890123456789012345678901234567890', diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index 0de1dd526b7..dbe5e897e38 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -8,6 +8,7 @@ import { Platform, TextInputSelectionChangeEventData, NativeSyntheticEvent, + TouchableOpacity, } from 'react-native'; import { useSelector } from 'react-redux'; import { useStyles } from '../../../../../component-library/hooks'; @@ -15,6 +16,11 @@ import { Box } from '../../../Box/Box'; import Text, { TextColor, } from '../../../../../component-library/components/Texts/Text'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; import Input from '../../../../../component-library/components/Form/TextField/foundation/Input'; import { TokenButton } from '../TokenButton'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; @@ -45,6 +51,7 @@ import { useAutoSizingFont } from '../../hooks/useAutoSizingFont'; import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; import { useFormattedBalanceWithThreshold } from '../../hooks/useFormattedBalanceWithThreshold'; import { useDisplayCurrencyValue } from '../../hooks/useDisplayCurrencyValue'; +import { formatSecondaryTokenAmount } from '../../utils/sourceAmountInputMode'; export const MAX_INPUT_LENGTH = 36; @@ -67,12 +74,42 @@ const createStyles = ({ amountContainer: { flex: 1, }, + amountInputWrapper: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, input: { borderWidth: 0, lineHeight: vars.fontSize * 1.25, height: vars.fontSize * 1.25, fontSize: vars.fontSize, paddingVertical: Platform.OS === 'ios' ? 2 : 1, + flex: 1, + flexShrink: 1, + }, + inputPrefix: { + lineHeight: vars.fontSize * 1.25, + height: vars.fontSize * 1.25, + fontSize: vars.fontSize, + paddingVertical: Platform.OS === 'ios' ? 2 : 1, + transform: [{ translateY: -vars.fontSize * 0.08 }], + ...(Platform.OS === 'android' && { + includeFontPadding: false, + textAlignVertical: 'center', + paddingVertical: 0, + paddingTop: 1, + }), + }, + secondaryValueContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + amountTypeToggle: { + alignItems: 'center', + justifyContent: 'center', }, currencyContainer: { flex: 1, @@ -128,6 +165,12 @@ interface TokenInputAreaProps { isSourceToken?: boolean; style?: StyleProp; isQuoteSponsored?: boolean; + inputPrefix?: string; + secondaryValue?: string | null; + balanceCheckAmount?: string; + onAmountTypeTogglePress?: () => void; + amountTypeToggleTestID?: string; + showFiatAmountAsPrimary?: boolean; } export const TokenInputArea = forwardRef< @@ -155,6 +198,12 @@ export const TokenInputArea = forwardRef< isSourceToken, style, isQuoteSponsored = false, + inputPrefix, + secondaryValue, + balanceCheckAmount, + onAmountTypeTogglePress, + amountTypeToggleTestID, + showFiatAmountAsPrimary = false, }, ref, ) => { @@ -202,13 +251,39 @@ export const TokenInputArea = forwardRef< }); }; + const tokenAmount = balanceCheckAmount ?? amount; const isInsufficientBalance = useIsInsufficientBalance({ - amount, + amount: tokenAmount, token, latestAtomicBalance, }); - const currencyValue = useDisplayCurrencyValue(amount, token); + const defaultCurrencyValue = useDisplayCurrencyValue(tokenAmount, token); + const shouldShowFiatAmountAsPrimary = Boolean( + tokenType === TokenInputAreaType.Destination && + showFiatAmountAsPrimary && + token && + amount && + Number(amount) > 0, + ); + // Ensures the secondary amount is displayed with the same precision as the source amount + const secondaryTokenAmountDisplayValue = shouldShowFiatAmountAsPrimary + ? `${formatAmountWithLocaleSeparators( + formatSecondaryTokenAmount(amount) ?? amount ?? '0', + )} ${token?.symbol}` + : undefined; + const defaultSecondaryAmountDisplayValue = + secondaryTokenAmountDisplayValue ?? defaultCurrencyValue; + const secondaryAmountDisplayValue = + secondaryValue === undefined + ? defaultSecondaryAmountDisplayValue + : secondaryValue; + const shouldShowSecondaryAmount = + token && + secondaryAmountDisplayValue && + (secondaryValue !== undefined || + shouldShowFiatAmountAsPrimary || + (amount && Number(amount) > 0)); const formattedBalance = useFormattedBalanceWithThreshold( tokenBalance, @@ -233,16 +308,18 @@ export const TokenInputArea = forwardRef< ? formattedBalance : formattedAddress; - const displayedAmount = useMemo( - () => - amount && amount !== '0' - ? formatAmountWithLocaleSeparators(amount) - : amount, - [amount], - ); + const primaryAmountDisplayValue = useMemo(() => { + if (shouldShowFiatAmountAsPrimary) { + return defaultCurrencyValue; + } + + return amount && amount !== '0' + ? formatAmountWithLocaleSeparators(amount) + : amount; + }, [amount, defaultCurrencyValue, shouldShowFiatAmountAsPrimary]); const { fontSize, onContainerLayout } = useAutoSizingFont({ - text: displayedAmount || '0', + text: `${inputPrefix ?? ''}${primaryAmountDisplayValue || '0'}`, }); const { styles } = useStyles(createStyles, { fontSize, hidden: !subtitle }); @@ -259,44 +336,49 @@ export const TokenInputArea = forwardRef< {isLoading ? ( ) : ( - { - onInputPress?.(); - }} - onFocus={() => { - onFocus?.(); - onInputPress?.(); - }} - onBlur={() => { - onBlur?.(); - }} - // Source selection is controlled so Bridge can keep the - // visible caret aligned with the raw cursor used by keypad - // edits. On iOS you have to use the press-and-drag magnifier - // handle; Android supports direct tap placement. - selection={ - // Android only issue, for long numbers, the input field will focus on the right hand side - // Force it to focus on the left hand side - tokenType === TokenInputAreaType.Destination - ? { start: 0, end: 0 } - : selection - } - onSelectionChange={ - tokenType === TokenInputAreaType.Source - ? onSelectionChange - : undefined - } - /> + + {inputPrefix ? ( + {inputPrefix} + ) : null} + { + onInputPress?.(); + }} + onFocus={() => { + onFocus?.(); + onInputPress?.(); + }} + onBlur={() => { + onBlur?.(); + }} + // Source selection is controlled so Bridge can keep the + // visible caret aligned with the raw cursor used by keypad + // edits. On iOS you have to use the press-and-drag magnifier + // handle; Android supports direct tap placement. + selection={ + // Android only issue, for long numbers, the input field will focus on the right hand side + // Force it to focus on the left hand side + tokenType === TokenInputAreaType.Destination + ? { start: 0, end: 0 } + : selection + } + onSelectionChange={ + tokenType === TokenInputAreaType.Source + ? onSelectionChange + : undefined + } + /> + )} {token ? ( @@ -328,9 +410,26 @@ export const TokenInputArea = forwardRef< ) : ( <> - {token && amount && Number(amount) > 0 && currencyValue ? ( - {currencyValue} - ) : null} + + {shouldShowSecondaryAmount ? ( + + {secondaryAmountDisplayValue} + + ) : null} + {onAmountTypeTogglePress ? ( + + + + ) : null} + { expect(result.current.sourceSelection).toBeUndefined(); }); + it('sets controlled selection to the end of the provided amount', () => { + const onSourceAmountChange = jest.fn(); + const { result } = renderHook(() => + useSourceAmountCursor({ + sourceAmount: '1234', + sourceTokenDecimals: 18, + maxInputLength: 10, + onSourceAmountChange, + }), + ); + + act(() => { + result.current.setSourceAmountCursorPositionToEnd('1234'); + }); + + // Raw cursor index 4 maps to formatted cursor index 5 for "1,234". + expect(result.current.sourceSelection).toEqual({ start: 5, end: 5 }); + }); + it('allows keypad edits up to max input length', () => { const onSourceAmountChange = jest.fn(); diff --git a/app/components/UI/Bridge/hooks/useSourceAmountCursor.ts b/app/components/UI/Bridge/hooks/useSourceAmountCursor.ts index 55747946afa..a74574a6e10 100644 --- a/app/components/UI/Bridge/hooks/useSourceAmountCursor.ts +++ b/app/components/UI/Bridge/hooks/useSourceAmountCursor.ts @@ -30,6 +30,7 @@ interface UseSourceAmountCursorResult { ) => void; handleKeypadChange: ({ pressedKey, value }: KeypadChangeData) => void; resetSourceAmountCursorPosition: () => void; + setSourceAmountCursorPositionToEnd: (sourceAmount?: string) => void; } const isDestructiveKey = (pressedKey: Keys) => @@ -138,10 +139,17 @@ export const useSourceAmountCursor = ({ [], ); + const setSourceAmountCursorPositionToEnd = useCallback( + (nextSourceAmount?: string) => + setRawSourceAmountCursorPosition((nextSourceAmount || '0').length), + [], + ); + return { sourceSelection, handleSourceSelectionChange, handleKeypadChange, resetSourceAmountCursorPosition, + setSourceAmountCursorPositionToEnd, }; }; diff --git a/app/components/UI/Bridge/hooks/useSourceAmountInput/index.ts b/app/components/UI/Bridge/hooks/useSourceAmountInput/index.ts new file mode 100644 index 00000000000..95cfda6ecc1 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSourceAmountInput/index.ts @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import { MAX_INPUT_LENGTH } from '../../components/TokenInputArea'; +import { BridgeToken } from '../../types'; +import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; +import { + FIAT_INPUT_DECIMALS, + formatFiatInputAmount, + formatSecondaryTokenAmount, + formatTokenInputAmountFromFiat, +} from '../../utils/sourceAmountInputMode'; +import { formatCurrency, getCurrencySymbol } from '../../utils/currencyUtils'; +import { useSourceAmountCursor } from '../useSourceAmountCursor'; +import { useTokenFiatRate } from '../useTokenFiatRate'; + +const FIAT_KEYPAD_CURRENCY = 'SWAPS_FIAT_INPUT'; + +export const useSourceAmountInput = ({ + isFiatToggleEnabled, + sourceAmount, + sourceToken, + onSourceAmountChange, +}: { + isFiatToggleEnabled: boolean; + sourceAmount: string | undefined; + sourceToken: BridgeToken | undefined; + onSourceAmountChange: (value: string | undefined) => void; +}) => { + const [isFiatMode, setIsFiatMode] = useState(false); + const [fiatAmount, setFiatAmount] = useState(); + const currentCurrency = useSelector(selectCurrentCurrency); + const fiatRate = useTokenFiatRate(sourceToken); + const canToggle = Boolean(isFiatToggleEnabled && fiatRate && fiatRate > 0); + const amount = isFiatMode ? fiatAmount : sourceAmount; + const isFiatInputChangeRef = useRef(false); + + const handleAmountChange = useCallback( + (value: string | undefined) => { + if (!isFiatMode) { + isFiatInputChangeRef.current = false; + onSourceAmountChange(value); + return; + } + + setFiatAmount(value); + isFiatInputChangeRef.current = true; + onSourceAmountChange( + formatTokenInputAmountFromFiat({ + fiatAmount: value, + tokenFiatRate: fiatRate, + tokenDecimals: sourceToken?.decimals, + }), + ); + }, + [fiatRate, isFiatMode, onSourceAmountChange, sourceToken?.decimals], + ); + + const { + sourceSelection: selection, + handleSourceSelectionChange: handleSelectionChange, + handleKeypadChange, + resetSourceAmountCursorPosition, + setSourceAmountCursorPositionToEnd, + } = useSourceAmountCursor({ + sourceAmount: amount, + sourceTokenDecimals: isFiatMode + ? FIAT_INPUT_DECIMALS + : sourceToken?.decimals, + maxInputLength: MAX_INPUT_LENGTH, + onSourceAmountChange: handleAmountChange, + }); + + // If price data disappears while fiat mode is active, fall back to token mode + // so the input never accepts fiat values that cannot be converted reliably. + useEffect(() => { + if (canToggle || !isFiatMode) { + return; + } + + resetSourceAmountCursorPosition(); + setIsFiatMode(false); + setFiatAmount(undefined); + isFiatInputChangeRef.current = false; + }, [canToggle, isFiatMode, resetSourceAmountCursorPosition]); + + // Keep the visible fiat amount aligned when the canonical token amount + // changes outside fiat typing, such as Max, presets, token, or rate updates. + useEffect(() => { + if (!isFiatMode || !canToggle) { + return; + } + + const nextFiatAmount = formatFiatInputAmount(sourceAmount, fiatRate); + if (isFiatInputChangeRef.current) { + const tokenAmountFromFiatInput = formatTokenInputAmountFromFiat({ + fiatAmount, + tokenFiatRate: fiatRate, + tokenDecimals: sourceToken?.decimals, + }); + if (tokenAmountFromFiatInput === sourceAmount) { + isFiatInputChangeRef.current = false; + } + return; + } + + if (nextFiatAmount === fiatAmount) { + return; + } + + setFiatAmount(nextFiatAmount); + resetSourceAmountCursorPosition(); + }, [ + canToggle, + fiatAmount, + fiatRate, + isFiatMode, + resetSourceAmountCursorPosition, + sourceAmount, + sourceToken?.decimals, + ]); + + const syncFiatAmountToTokenAmount = useCallback( + (tokenAmount: string | undefined) => { + resetSourceAmountCursorPosition(); + isFiatInputChangeRef.current = false; + if (isFiatMode) { + setFiatAmount(formatFiatInputAmount(tokenAmount, fiatRate)); + } + }, + [fiatRate, isFiatMode, resetSourceAmountCursorPosition], + ); + + const resetToTokenMode = useCallback(() => { + resetSourceAmountCursorPosition(); + setIsFiatMode(false); + setFiatAmount(undefined); + isFiatInputChangeRef.current = false; + }, [resetSourceAmountCursorPosition]); + + const handleToggle = useCallback(() => { + if (!canToggle) { + return; + } + + if (isFiatMode) { + setSourceAmountCursorPositionToEnd(sourceAmount); + setIsFiatMode(false); + setFiatAmount(undefined); + isFiatInputChangeRef.current = false; + return; + } + + const nextFiatAmount = formatFiatInputAmount(sourceAmount, fiatRate); + setSourceAmountCursorPositionToEnd(nextFiatAmount); + setFiatAmount(nextFiatAmount); + setIsFiatMode(true); + }, [ + canToggle, + fiatRate, + isFiatMode, + setSourceAmountCursorPositionToEnd, + sourceAmount, + ]); + + const secondaryValue = useMemo(() => { + if (isFiatMode) { + if (!sourceToken) { + return null; + } + + if (sourceAmount && Number(sourceAmount) > 0) { + const formattedSourceAmount = formatSecondaryTokenAmount(sourceAmount); + + return `${formatAmountWithLocaleSeparators( + formattedSourceAmount ?? sourceAmount, + )} ${sourceToken.symbol}`; + } + + return `0 ${sourceToken.symbol}`; + } + + if (!isFiatToggleEnabled) { + return undefined; + } + + if (!canToggle) { + return null; + } + + return sourceAmount && Number(sourceAmount) > 0 + ? undefined + : formatCurrency(0, currentCurrency, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + }, [ + canToggle, + currentCurrency, + isFiatMode, + isFiatToggleEnabled, + sourceAmount, + sourceToken, + ]); + + const inputPrefix = isFiatMode + ? getCurrencySymbol(currentCurrency || 'usd') + : undefined; + + return { + amount, + balanceCheckAmount: sourceAmount, + canToggle, + handleFocus: () => setSourceAmountCursorPositionToEnd(amount), + handleKeypadChange, + handleSelectionChange, + handleToggle, + inputPrefix, + isFiatMode, + keypadCurrency: isFiatMode + ? FIAT_KEYPAD_CURRENCY + : sourceToken?.symbol || 'ETH', + keypadDecimals: isFiatMode + ? FIAT_INPUT_DECIMALS + : (sourceToken?.decimals ?? Infinity), + keypadValue: amount || '0', + resetToTokenMode, + secondaryValue, + selection, + syncFiatAmountToTokenAmount, + }; +}; diff --git a/app/components/UI/Bridge/hooks/useTokenFiatRate/index.ts b/app/components/UI/Bridge/hooks/useTokenFiatRate/index.ts new file mode 100644 index 00000000000..e0a4cff37a3 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useTokenFiatRate/index.ts @@ -0,0 +1,30 @@ +import { useSelector } from 'react-redux'; +import { BridgeToken } from '../../types'; +import { calcTokenFiatRate } from '../../utils/exchange-rates'; +import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; +import { selectCurrencyRates } from '../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +import { selectMultichainAssetsRates } from '../../../../../selectors/multichain'; +///: END:ONLY_INCLUDE_IF(keyring-snaps) + +export const useTokenFiatRate = (token?: BridgeToken) => { + const evmMultiChainMarketData = useSelector(selectTokenMarketData); + const evmMultiChainCurrencyRates = useSelector(selectCurrencyRates); + const networkConfigurationsByChainId = useSelector( + selectNetworkConfigurations, + ); + + let nonEvmMultichainAssetRates = {}; + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + nonEvmMultichainAssetRates = useSelector(selectMultichainAssetsRates); + ///: END:ONLY_INCLUDE_IF(keyring-snaps) + + return calcTokenFiatRate({ + token, + evmMultiChainMarketData, + networkConfigurationsByChainId, + evmMultiChainCurrencyRates, + nonEvmMultichainAssetRates, + }); +}; diff --git a/app/components/UI/Bridge/utils/currencyUtils.ts b/app/components/UI/Bridge/utils/currencyUtils.ts index 1612d7c075b..5f393245eb0 100644 --- a/app/components/UI/Bridge/utils/currencyUtils.ts +++ b/app/components/UI/Bridge/utils/currencyUtils.ts @@ -46,3 +46,27 @@ export function formatCurrency( return String(amount); } } + +export function getCurrencySymbol(currency: string): string { + const normalizedCurrency = (currency || 'USD').toUpperCase(); + + try { + const formattedZero = getIntlNumberFormatter(I18n.locale, { + style: 'currency', + currency: normalizedCurrency, + currencyDisplay: 'symbol', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(0); + + const currencySymbol = formattedZero.replace(/[\d\s.,'’_-]/gu, '').trim(); + + if (currencySymbol && currencySymbol.toUpperCase() !== normalizedCurrency) { + return currencySymbol; + } + } catch (error) { + console.error('Error getting currency symbol:', error); + } + + return normalizedCurrency; +} diff --git a/app/components/UI/Bridge/utils/exchange-rates.ts b/app/components/UI/Bridge/utils/exchange-rates.ts index 7495fc092f7..35ce83e6618 100644 --- a/app/components/UI/Bridge/utils/exchange-rates.ts +++ b/app/components/UI/Bridge/utils/exchange-rates.ts @@ -2,6 +2,7 @@ import { formatChainIdToCaip, formatChainIdToHex, isNonEvmChainId, + isNativeAddress, } from '@metamask/bridge-controller'; import { Hex, @@ -113,8 +114,57 @@ export interface CalcTokenFiatValueParams { nonEvmMultichainAssetRates: ReturnType; } +export type CalcTokenFiatRateParams = Omit; + +/** + * Gets the rate of one token in the user's current fiat currency. + * @returns The numeric fiat rate, or undefined when price data is unavailable. + */ +export const calcTokenFiatRate = ({ + token, + evmMultiChainMarketData, + networkConfigurationsByChainId, + evmMultiChainCurrencyRates, + nonEvmMultichainAssetRates, +}: CalcTokenFiatRateParams): number | undefined => { + if (!token) { + return undefined; + } + + if (isNonEvmChainId(token.chainId)) { + const assetId = token.address as CaipAssetType; + const rate = nonEvmMultichainAssetRates?.[assetId]?.rate; + if (rate) { + return Number(rate); + } + + return token.currencyExchangeRate; + } + + const evmChainId = token.chainId as Hex; + const evmMultiChainExchangeRates = evmMultiChainMarketData?.[evmChainId]; + const evmTokenMarketData = evmMultiChainExchangeRates?.[token.address as Hex]; + + const nativeCurrency = + networkConfigurationsByChainId[evmChainId]?.nativeCurrency; + const multiChainConversionRate = + evmMultiChainCurrencyRates?.[nativeCurrency]?.conversionRate; + + if (multiChainConversionRate && isNativeAddress(token.address)) { + return multiChainConversionRate; + } + + if (multiChainConversionRate && evmTokenMarketData?.price) { + return multiChainConversionRate * evmTokenMarketData.price; + } + + return token.currencyExchangeRate; +}; + /** * Calculates the fiat value of a token amount in the user's current currency + * Keep this amount-based legacy path separate from calcTokenFiatRate so existing + * display, balance, and analytics consumers preserve their rounding/fallback behavior. * @returns The numeric fiat value (not formatted) */ export const calcTokenFiatValue = ({ @@ -152,6 +202,10 @@ export const calcTokenFiatValue = ({ const multiChainConversionRate = evmMultiChainCurrencyRates?.[nativeCurrency]?.conversionRate; + if (multiChainConversionRate && isNativeAddress(token.address)) { + return Number(balanceToFiatNumber(amount, multiChainConversionRate, 1)); + } + if (multiChainConversionRate && evmTokenMarketData?.price) { return Number( balanceToFiatNumber( diff --git a/app/components/UI/Bridge/utils/sourceAmountInputMode.test.ts b/app/components/UI/Bridge/utils/sourceAmountInputMode.test.ts new file mode 100644 index 00000000000..f0372b2a831 --- /dev/null +++ b/app/components/UI/Bridge/utils/sourceAmountInputMode.test.ts @@ -0,0 +1,75 @@ +import { + FIAT_INPUT_DECIMALS, + SECONDARY_TOKEN_AMOUNT_DECIMALS, + formatFiatInputAmount, + formatSecondaryTokenAmount, + formatTokenInputAmountFromFiat, +} from './sourceAmountInputMode'; + +describe('sourceAmountInputMode', () => { + describe('formatFiatInputAmount', () => { + it('converts token amount to fiat input amount', () => { + expect(formatFiatInputAmount('0.025', 2000)).toBe('50'); + }); + + it('returns undefined when rate is unavailable', () => { + expect(formatFiatInputAmount('1', undefined)).toBeUndefined(); + }); + }); + + describe('formatTokenInputAmountFromFiat', () => { + it('converts fiat amount to token input amount', () => { + expect( + formatTokenInputAmountFromFiat({ + fiatAmount: '50', + tokenFiatRate: 2000, + tokenDecimals: 18, + }), + ).toBe('0.025'); + }); + + it('rounds down to token decimals', () => { + expect( + formatTokenInputAmountFromFiat({ + fiatAmount: '1', + tokenFiatRate: 3, + tokenDecimals: 2, + }), + ).toBe('0.33'); + }); + + it('returns undefined when token decimals are unavailable', () => { + expect( + formatTokenInputAmountFromFiat({ + fiatAmount: '50', + tokenFiatRate: 2000, + tokenDecimals: undefined, + }), + ).toBeUndefined(); + }); + }); + + describe('formatSecondaryTokenAmount', () => { + it('floors token amount to secondary display decimals', () => { + expect(formatSecondaryTokenAmount('0.054266763023182519')).toBe( + '0.05426', + ); + }); + + it('trims trailing zeros after flooring', () => { + expect(formatSecondaryTokenAmount('1.230009')).toBe('1.23'); + }); + + it('returns undefined for missing token amount', () => { + expect(formatSecondaryTokenAmount(undefined)).toBeUndefined(); + }); + }); + + it('uses two decimals for fiat input', () => { + expect(FIAT_INPUT_DECIMALS).toBe(2); + }); + + it('uses five decimals for secondary token amounts', () => { + expect(SECONDARY_TOKEN_AMOUNT_DECIMALS).toBe(5); + }); +}); diff --git a/app/components/UI/Bridge/utils/sourceAmountInputMode.ts b/app/components/UI/Bridge/utils/sourceAmountInputMode.ts new file mode 100644 index 00000000000..f743a2425f0 --- /dev/null +++ b/app/components/UI/Bridge/utils/sourceAmountInputMode.ts @@ -0,0 +1,65 @@ +import { BigNumber } from 'bignumber.js'; +import { trimTrailingZeros } from './trimTrailingZeros'; + +export const FIAT_INPUT_DECIMALS = 2; +export const SECONDARY_TOKEN_AMOUNT_DECIMALS = 5; + +export const formatFiatInputAmount = ( + tokenAmount: string | undefined, + tokenFiatRate: number | undefined, +): string | undefined => { + if (!tokenAmount || !tokenFiatRate) { + return undefined; + } + + const fiatAmount = new BigNumber(tokenAmount).multipliedBy(tokenFiatRate); + if (!fiatAmount.isFinite()) { + return undefined; + } + + return trimTrailingZeros( + fiatAmount.decimalPlaces(FIAT_INPUT_DECIMALS).toFixed(), + ); +}; + +export const formatTokenInputAmountFromFiat = ({ + fiatAmount, + tokenFiatRate, + tokenDecimals, +}: { + fiatAmount: string | undefined; + tokenFiatRate: number | undefined; + tokenDecimals: number | undefined; +}): string | undefined => { + if (!fiatAmount || !tokenFiatRate || tokenDecimals === undefined) { + return undefined; + } + + const tokenAmount = new BigNumber(fiatAmount).dividedBy(tokenFiatRate); + if (!tokenAmount.isFinite()) { + return undefined; + } + + return trimTrailingZeros( + tokenAmount.decimalPlaces(tokenDecimals, BigNumber.ROUND_DOWN).toFixed(), + ); +}; + +export const formatSecondaryTokenAmount = ( + tokenAmount: string | undefined, +): string | undefined => { + if (!tokenAmount) { + return undefined; + } + + const parsedTokenAmount = new BigNumber(tokenAmount); + if (!parsedTokenAmount.isFinite()) { + return undefined; + } + + return trimTrailingZeros( + parsedTokenAmount + .decimalPlaces(SECONDARY_TOKEN_AMOUNT_DECIMALS, BigNumber.ROUND_DOWN) + .toFixed(), + ); +}; diff --git a/tests/component-view/presets/bridge.ts b/tests/component-view/presets/bridge.ts index 5c87b042915..603c7f88e64 100644 --- a/tests/component-view/presets/bridge.ts +++ b/tests/component-view/presets/bridge.ts @@ -44,7 +44,7 @@ export const initialStateBridge = (options?: InitialStateBridgeOptions) => { } as unknown as DeepPartial) .withMinimalAnalyticsController() .withAccountTreeForSelectedAccount() - .withRemoteFeatureFlags({}); + .withRemoteFeatureFlags({ enableFiatToggle: true }); if (options?.deterministicFiat) { builder.withOverrides({ diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index e8585638e2d..771e8ea94f2 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -2979,6 +2979,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + enableFiatToggle: { + name: 'enableFiatToggle', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + enableMultichainAccounts: { name: 'enableMultichainAccounts', type: FeatureFlagType.Remote, From 2ff084431d6f0619167fe8e9a0dcbe2e9c3b5a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Tue, 26 May 2026 18:29:27 +0100 Subject: [PATCH 16/16] ci: fix auto-rc-build-core permission cp-7.79.0 (#30607) ## **Description** `auto-rc-ota-build-core.yml` declares `contents: read` at the top-level permissions block. When it calls the reusable workflow `runway-create-ota-production-tag.yml`, GitHub enforces that the callee can't have more permissions than the caller grants. Since `runway-create-ota-production-tag.yml` needs `contents: write` (to push a git tag), the call fails. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/workflows/auto-rc-ota-build-core.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-rc-ota-build-core.yml b/.github/workflows/auto-rc-ota-build-core.yml index 5a67b3454e4..2f7e63d830c 100644 --- a/.github/workflows/auto-rc-ota-build-core.yml +++ b/.github/workflows/auto-rc-ota-build-core.yml @@ -58,7 +58,7 @@ on: value: ${{ jobs.trigger-build.outputs.android_version_code }} permissions: - contents: read + contents: write pull-requests: read actions: write id-token: write # required by build.yml