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 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/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' }, }} /> { 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/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(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/Bridge/hooks/useSourceAmountCursor.test.ts b/app/components/UI/Bridge/hooks/useSourceAmountCursor.test.ts index 7d6707553a1..96d9ac0663b 100644 --- a/app/components/UI/Bridge/hooks/useSourceAmountCursor.test.ts +++ b/app/components/UI/Bridge/hooks/useSourceAmountCursor.test.ts @@ -107,6 +107,25 @@ describe('useSourceAmountCursor', () => { 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/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 29f0dbdb01a..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} @@ -152,15 +168,23 @@ const TimeRangeSelector: React.FC = ({ > {chartType === ChartType.Candles ? ( ) : ( )} 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/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, }; } 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/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/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/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/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], 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/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 = () => { - - - ); }); 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/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/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/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/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: ( + + ), + 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/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/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/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/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/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 ({ 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; +}; 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; 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 a80bf2ed637..f729bdc1f5b 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", @@ -1098,6 +1097,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 +1108,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": { @@ -7206,15 +7210,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": { @@ -8547,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", @@ -8560,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 56b32046d3d..ec622290c77 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", @@ -8463,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}}%", 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/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" 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/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, 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}`, 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"