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 = () => {
- {
+ const {
+ amountDisplayMode,
+ features,
+ usdAmount,
+ target,
+ estimatedReceiveAmount,
+ sourceBalanceFiat,
+ isQuoteLoading,
+ hiddenInputRef,
+ formattedExchangeRate,
+ handleAmountAreaPress,
+ handleAmountChange,
+ handleToggleAmountDisplay,
+ } = useQuickBuyContext();
+
+ return (
+ }
+ />
+ );
+};
+
+export default QuickBuyAmount;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx
new file mode 100644
index 00000000000..b0c13bbc1d7
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler';
+import Animated from 'react-native-reanimated';
+import {
+ Box,
+ BoxAlignItems,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { strings } from '../../../../../../../locales/i18n';
+import QuickBuyAmount from './QuickBuyAmount';
+import QuickBuyActionFooter from './components/QuickBuyActionFooter';
+import QuickBuyToolbar from './components/QuickBuyToolbar';
+import { useQuickBuyContext } from './useQuickBuyContext';
+
+const AnimatedScrollView = Animated.createAnimatedComponent(
+ GestureHandlerScrollView,
+);
+
+/**
+ * Default amount-first buy layout (Figma Swap For You).
+ */
+const QuickBuyAmountScreen: React.FC = () => {
+ const tw = useTailwind();
+ const { isUnsupportedChain } = useQuickBuyContext();
+
+ if (isUnsupportedChain) {
+ return (
+
+
+ {strings('social_leaderboard.quick_buy.unsupported_chain')}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export default QuickBuyAmountScreen;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx
similarity index 100%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.test.tsx
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.test.tsx
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx
similarity index 100%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBanners.tsx
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBanners.tsx
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx
new file mode 100644
index 00000000000..4a1988d087b
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react-native';
+import QuickBuyBottomSheetSkeleton from './QuickBuyBottomSheetSkeleton';
+
+describe('QuickBuyBottomSheetSkeleton', () => {
+ it('renders the loading container', () => {
+ render();
+ expect(screen.getByTestId('quick-buy-content-loading')).toBeOnTheScreen();
+ });
+
+ it('renders the slider skeleton', () => {
+ render();
+ expect(screen.getByTestId('quick-buy-skeleton-slider')).toBeOnTheScreen();
+ });
+
+ it('renders the pay-with pill skeleton', () => {
+ render();
+ expect(screen.getByTestId('quick-buy-skeleton-pay-with')).toBeOnTheScreen();
+ });
+
+ it('renders the confirm-button skeleton', () => {
+ render();
+ expect(
+ screen.getByTestId('quick-buy-skeleton-confirm-button'),
+ ).toBeOnTheScreen();
+ });
+
+ it('does not render the old USD preset buttons', () => {
+ render();
+ expect(
+ screen.queryByTestId('quick-buy-skeleton-preset-20'),
+ ).not.toBeOnTheScreen();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx
new file mode 100644
index 00000000000..af7de1c0cf4
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton';
+import { strings } from '../../../../../../../locales/i18n';
+
+const QuickBuyBottomSheetSkeleton: React.FC = () => {
+ const tw = useTailwind();
+
+ return (
+
+ {/* Amount area — mirrors QuickBuyAmountSection pt-6 pb-4 */}
+
+ {/* Primary amount */}
+
+ {/* Secondary amount / rate tag */}
+
+ {/* Available balance */}
+
+
+
+ {/* Footer area — mirrors QuickBuyActionFooter px-4 pb-4 */}
+
+ {/* Slider — mirrors pt-2 pb-3 */}
+
+
+
+
+ {/* Pay with row */}
+
+
+ {strings('social_leaderboard.quick_buy.pay_with')}
+
+
+
+
+ {/* Confirm button */}
+
+
+
+ );
+};
+
+export default QuickBuyBottomSheetSkeleton;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx
new file mode 100644
index 00000000000..b9f0342b5b7
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import QuickBuyConfirmButton from './QuickBuyConfirmButton';
+
+const defaultProps = {
+ state: 'idle' as const,
+ label: 'Confirm',
+ hasValidAmount: false,
+ isDisabled: false,
+ onPress: jest.fn(),
+ testID: 'confirm-button',
+};
+
+describe('QuickBuyConfirmButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the label in idle state', () => {
+ render();
+ expect(screen.getByText('Confirm')).toBeOnTheScreen();
+ });
+
+ it('does not render the label in loading state', () => {
+ render();
+ expect(screen.queryByText('Confirm')).not.toBeOnTheScreen();
+ });
+
+ it('does not render the label in success state', () => {
+ render();
+ expect(screen.queryByText('Confirm')).not.toBeOnTheScreen();
+ });
+
+ it('calls onPress when tapped in idle state with a valid amount', () => {
+ const onPress = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('confirm-button'));
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onPress when state is loading', () => {
+ const onPress = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('confirm-button'));
+ expect(onPress).not.toHaveBeenCalled();
+ });
+
+ it('does not call onPress when isDisabled is true', () => {
+ const onPress = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('confirm-button'));
+ expect(onPress).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyConfirmButton.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.tsx
similarity index 68%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyConfirmButton.tsx
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.tsx
index 6d7f026d576..b897af0fc8f 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyConfirmButton.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.tsx
@@ -10,6 +10,7 @@ import Animated, {
withTiming,
useAnimatedStyle,
} from 'react-native-reanimated';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { useTheme } from '../../../../../../util/theme';
import Icon, {
IconName,
@@ -25,8 +26,9 @@ const styles = StyleSheet.create({
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
+ overflow: 'hidden',
},
- disabled: {
+ inactive: {
opacity: 0.5,
},
label: {
@@ -38,6 +40,7 @@ const styles = StyleSheet.create({
interface QuickBuyConfirmButtonProps {
state: ConfirmButtonState;
label: string;
+ hasValidAmount: boolean;
isDisabled: boolean;
onPress: () => void;
testID?: string;
@@ -46,10 +49,12 @@ interface QuickBuyConfirmButtonProps {
const QuickBuyConfirmButton: React.FC = ({
state,
label,
+ hasValidAmount,
isDisabled,
onPress,
testID,
}) => {
+ const tw = useTailwind();
const { colors } = useTheme();
const checkScale = useSharedValue(0);
@@ -62,12 +67,24 @@ const QuickBuyConfirmButton: React.FC = ({
transform: [{ scale: checkScale.value }],
}));
+ // Use design-system ButtonPrimary token equivalents:
+ const activeContainerStyle = tw.style('bg-icon-default');
+ const activeLabelStyle = tw.style('text-primary-inverse');
+
+ const labelColor = hasValidAmount
+ ? (activeLabelStyle.color as string)
+ : colors.text.alternative;
+
+ const showInactiveStyle = isDisabled && state === 'idle';
+
return (
= ({
activeOpacity={0.8}
>
{state === 'loading' && (
-
+
)}
{state === 'success' && (
)}
{state === 'idle' && (
-
- {label}
-
+ {label}
)}
);
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx
new file mode 100644
index 00000000000..bdbeb1a4da6
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx
@@ -0,0 +1,60 @@
+import React, { createContext } from 'react';
+import {
+ useQuickBuyController,
+ type UseQuickBuyControllerResult,
+} from './hooks/useQuickBuyController';
+import type {
+ QuickBuyAnalyticsContext,
+ QuickBuyFeatures,
+ QuickBuyScreen,
+ QuickBuyTarget,
+} from './types';
+
+export interface QuickBuyContextValue extends UseQuickBuyControllerResult {
+ target: QuickBuyTarget;
+ features: QuickBuyFeatures;
+ analyticsContext?: QuickBuyAnalyticsContext;
+ onClose: () => void;
+ activeScreen: QuickBuyScreen;
+ setActiveScreen: React.Dispatch>;
+}
+
+export const QuickBuyContext = createContext(null);
+
+interface QuickBuyProviderProps {
+ target: QuickBuyTarget;
+ onClose: () => void;
+ features: QuickBuyFeatures;
+ analyticsContext?: QuickBuyAnalyticsContext;
+ activeScreen: QuickBuyScreen;
+ setActiveScreen: React.Dispatch>;
+ children: React.ReactNode;
+}
+
+export const QuickBuyProvider: React.FC = ({
+ target,
+ onClose,
+ features,
+ analyticsContext,
+ activeScreen,
+ setActiveScreen,
+ children,
+}) => {
+ const controller = useQuickBuyController(target, onClose, analyticsContext);
+
+ const value: QuickBuyContextValue = {
+ ...controller,
+ target,
+ features,
+ analyticsContext,
+ onClose,
+ activeScreen,
+ setActiveScreen,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx
new file mode 100644
index 00000000000..c3cbd2be43c
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx
@@ -0,0 +1,266 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react-native';
+import { TextColor } from '@metamask/design-system-react-native';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import QuickBuyRoot from './QuickBuyRoot';
+import { useQuickBuyContext } from './useQuickBuyContext';
+import {
+ useQuickBuyController,
+ type UseQuickBuyControllerResult,
+} from './hooks/useQuickBuyController';
+import { useQuickBuySetup } from './hooks/useQuickBuySetup';
+import { positionToQuickBuyTarget } from './types';
+import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features';
+import type { Position } from '@metamask/social-controllers';
+
+jest.mock('./hooks/useQuickBuyController', () => ({
+ useQuickBuyController: jest.fn(),
+}));
+
+jest.mock('./hooks/useQuickBuySetup', () => ({
+ useQuickBuySetup: jest.fn(),
+}));
+
+let storedOnOpenCallback: (() => void) | undefined;
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const actual = jest.requireActual('@metamask/design-system-react-native');
+ const ReactMock = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+
+ return {
+ ...actual,
+ BottomSheet: ReactMock.forwardRef(
+ (
+ {
+ children,
+ onClose,
+ }: {
+ children: unknown;
+ onClose?: () => void;
+ },
+ ref: unknown,
+ ) => {
+ ReactMock.useImperativeHandle(ref, () => ({
+ onOpenBottomSheet: (cb: () => void) => {
+ storedOnOpenCallback = cb;
+ },
+ }));
+ return ReactMock.createElement(
+ View,
+ { testID: 'mock-bottom-sheet', onTouchEnd: onClose },
+ children,
+ );
+ },
+ ),
+ };
+});
+
+jest.mock('./components/QuickBuyToolbar', () => {
+ const ReactMock = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () =>
+ ReactMock.createElement(Text, { testID: 'mock-toolbar' }, 'toolbar'),
+ };
+});
+
+jest.mock('./QuickBuyAmount', () => {
+ const ReactMock = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () =>
+ ReactMock.createElement(
+ Text,
+ { testID: 'mock-amount-section' },
+ 'amount-section',
+ ),
+ };
+});
+
+jest.mock('./components/QuickBuyActionFooter', () => {
+ const ReactMock = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () =>
+ ReactMock.createElement(Text, { testID: 'mock-action-footer' }, 'footer'),
+ };
+});
+
+jest.mock('./QuickBuyBottomSheetSkeleton', () => {
+ const ReactMock = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () =>
+ ReactMock.createElement(
+ Text,
+ { testID: 'mock-skeleton' },
+ 'quick-buy-content-loading',
+ ),
+ };
+});
+
+jest.mock('../../../../../../util/theme', () => {
+ const { mockTheme } = jest.requireActual('../../../../../../util/theme');
+ return {
+ useTheme: () => mockTheme,
+ };
+});
+
+jest.mock('../../../../../../../locales/i18n', () => ({
+ strings: (key: string) => key,
+}));
+
+const mockCreateRef = () => ({ current: null });
+
+const buildHookResult = (
+ overrides: Partial = {},
+): UseQuickBuyControllerResult => ({
+ hiddenInputRef: mockCreateRef() as never,
+ destToken: undefined,
+ isSetupLoading: false,
+ isUnsupportedChain: false,
+ sourceToken: undefined,
+ sourceChainId: '0x1',
+ sourceTokenOptions: [],
+ selectedSourceToken: undefined,
+ isSourcePickerOpen: false,
+ setIsSourcePickerOpen: jest.fn(),
+ setSelectedSourceToken: jest.fn(),
+ amountDisplayMode: 'fiat',
+ usdAmount: '',
+ sliderPercent: 0,
+ maxSpendUsd: 0,
+ formattedExchangeRate: undefined,
+ metamaskFeePercent: 0,
+ estimatedReceiveAmount: undefined,
+ sourceBalanceFiat: '$0.00',
+ sourceBalanceDisplay: undefined,
+ formattedNetworkFee: '-',
+ formattedSlippage: '-',
+ formattedMinimumReceived: '-',
+ formattedPriceImpact: '-',
+ totalAmountUsd: '$0',
+ isQuoteLoading: false,
+ isSubmittingTx: false,
+ isTotalLoading: false,
+ isHardwareSolanaBlocked: false,
+ priceImpactViewData: {
+ textColor: TextColor.TextAlternative,
+ icon: undefined,
+ title: 'bridge.price_impact_info_title',
+ description: 'bridge.price_impact_info_description',
+ },
+ isPriceImpactError: false,
+ buttonError: null,
+ hasValidAmount: false,
+ isConfirmDisabled: true,
+ confirmButtonState: 'idle',
+ getButtonLabel: () => 'social_leaderboard.trader_position.buy',
+ handleClose: jest.fn(),
+ handleSliderChange: jest.fn(),
+ handleAmountAreaPress: jest.fn(),
+ handleAmountChange: jest.fn(),
+ handleToggleAmountDisplay: jest.fn(),
+ handleConfirm: jest.fn(),
+ ...overrides,
+});
+
+const createPosition = (overrides: Partial = {}): Position =>
+ ({
+ chain: 'base',
+ tokenAddress: '0x1234567890123456789012345678901234567890',
+ tokenSymbol: 'PEPE',
+ tokenName: 'Pepe',
+ positionAmount: 1000,
+ boughtUsd: 500,
+ soldUsd: 0,
+ realizedPnl: 0,
+ costBasis: 500,
+ trades: [],
+ lastTradeAt: 0,
+ currentValueUSD: 900,
+ pnlValueUsd: 400,
+ pnlPercent: 80,
+ ...overrides,
+ }) as Position;
+
+describe('QuickBuyRoot', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ storedOnOpenCallback = undefined;
+ (useQuickBuyController as jest.Mock).mockReturnValue(buildHookResult());
+ (useQuickBuySetup as jest.Mock).mockReturnValue({
+ chainId: '0x1',
+ destToken: undefined,
+ isLoading: false,
+ isUnsupportedChain: false,
+ });
+ });
+
+ it('renders default AmountScreen content when no children are passed', () => {
+ renderWithProvider(
+ ,
+ );
+
+ act(() => {
+ storedOnOpenCallback?.();
+ });
+
+ expect(screen.getByTestId('mock-toolbar')).toBeOnTheScreen();
+ expect(screen.getByTestId('mock-amount-section')).toBeOnTheScreen();
+ expect(screen.getByTestId('mock-action-footer')).toBeOnTheScreen();
+ });
+
+ it('shows unsupported chain message without amount flow', () => {
+ (useQuickBuyController as jest.Mock).mockReturnValue(
+ buildHookResult({ isUnsupportedChain: true }),
+ );
+
+ renderWithProvider(
+ ,
+ );
+
+ act(() => {
+ storedOnOpenCallback?.();
+ });
+
+ expect(
+ screen.getByText('social_leaderboard.quick_buy.unsupported_chain'),
+ ).toBeOnTheScreen();
+ expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen();
+ });
+});
+
+describe('useQuickBuyContext guard', () => {
+ it('throws when useQuickBuyContext is called outside QuickBuy.Root', () => {
+ const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+
+ const ContextProbe = () => {
+ useQuickBuyContext();
+ return null;
+ };
+
+ expect(() => renderWithProvider()).toThrow(
+ 'QuickBuy compound components must be rendered within QuickBuy.Root',
+ );
+
+ consoleError.mockRestore();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx
new file mode 100644
index 00000000000..3827a9fb44b
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx
@@ -0,0 +1,128 @@
+import {
+ BottomSheet,
+ type BottomSheetRef,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import React, { useEffect, useRef, useState } from 'react';
+import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler';
+import Animated from 'react-native-reanimated';
+import { useSelector } from 'react-redux';
+import { selectIsSubmittingTx } from '../../../../../../core/redux/slices/bridge';
+import QuickBuyAmountScreen from './QuickBuyAmountScreen';
+import { QuickBuyProvider } from './QuickBuyContext';
+import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features';
+import QuickBuyBottomSheetSkeleton from './QuickBuyBottomSheetSkeleton';
+import type {
+ QuickBuyAnalyticsContext,
+ QuickBuyFeatures,
+ QuickBuyRootProps,
+ QuickBuyScreen,
+ QuickBuyTarget,
+} from './types';
+
+export type { QuickBuyRootProps } from './types';
+
+const AnimatedScrollView = Animated.createAnimatedComponent(
+ GestureHandlerScrollView,
+);
+
+function renderActiveScreen(
+ activeScreen: QuickBuyScreen,
+ children: React.ReactNode | undefined,
+): React.ReactNode {
+ if (children !== undefined && children !== null) {
+ return children;
+ }
+
+ switch (activeScreen) {
+ case 'amount':
+ default:
+ return ;
+ }
+}
+
+interface QuickBuyRootInnerProps {
+ target: QuickBuyTarget;
+ onClose: () => void;
+ features: QuickBuyFeatures;
+ analyticsContext?: QuickBuyAnalyticsContext;
+ children?: React.ReactNode;
+}
+
+const QuickBuyRootInner: React.FC = ({
+ target,
+ onClose,
+ features,
+ analyticsContext,
+ children,
+}) => {
+ const tw = useTailwind();
+ const bottomSheetRef = useRef(null);
+ const [isContentReady, setIsContentReady] = useState(false);
+ const [activeScreen, setActiveScreen] = useState('amount');
+ const isSubmittingTx = useSelector(selectIsSubmittingTx);
+
+ useEffect(() => {
+ bottomSheetRef.current?.onOpenBottomSheet(() => {
+ setIsContentReady(true);
+ });
+ }, []);
+
+ return (
+
+ {isContentReady ? (
+
+ {renderActiveScreen(activeScreen, children)}
+
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+/**
+ * Compound Quick Buy root — bottom sheet, provider, and screen routing.
+ */
+const QuickBuyRoot: React.FC = ({
+ isVisible,
+ target,
+ onClose,
+ features = TOP_TRADERS_QUICK_BUY_FEATURES,
+ analyticsContext,
+ children,
+}) => {
+ if (!isVisible || !target) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default QuickBuyRoot;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx
similarity index 66%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx
index f7c8a71dcac..7788729b931 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx
@@ -3,19 +3,28 @@ import { act, fireEvent, screen } from '@testing-library/react-native';
import { TextColor } from '@metamask/design-system-react-native';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
import type { Position } from '@metamask/social-controllers';
-import QuickBuyBottomSheet from './QuickBuyBottomSheet';
+import { QuickBuy } from './quickBuy';
import {
- useQuickBuyBottomSheet,
- type UseQuickBuyBottomSheetResult,
-} from './useQuickBuyBottomSheet';
-import { useQuickBuySetup } from './useQuickBuySetup';
-
-// Mock the heavy hook so we can control all rendered state
-jest.mock('./useQuickBuyBottomSheet', () => ({
- useQuickBuyBottomSheet: jest.fn(),
+ useQuickBuyController,
+ type UseQuickBuyControllerResult,
+} from './hooks/useQuickBuyController';
+import { useQuickBuySetup } from './hooks/useQuickBuySetup';
+import { positionToQuickBuyTarget } from './types';
+import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features';
+
+const mockControllerState: {
+ getResult: () => UseQuickBuyControllerResult;
+} = {
+ getResult: () => {
+ throw new Error('QuickBuy controller mock not initialized');
+ },
+};
+
+jest.mock('./hooks/useQuickBuyController', () => ({
+ useQuickBuyController: jest.fn(),
}));
-jest.mock('./useQuickBuySetup', () => ({
+jest.mock('./hooks/useQuickBuySetup', () => ({
useQuickBuySetup: jest.fn(),
}));
@@ -59,29 +68,17 @@ jest.mock('@metamask/design-system-react-native', () => {
});
// Mock sub-components so their own dep trees don't pollute these tests
-jest.mock('./QuickBuyHeader', () => {
+jest.mock('./components/QuickBuyToolbar', () => {
const ReactMock = jest.requireActual('react');
const { Text } = jest.requireActual('react-native');
return {
__esModule: true,
- default: ({
- position,
- marketCap,
- }: {
- position: Position;
- marketCap?: number;
- }) =>
- ReactMock.createElement(
- Text,
- { testID: 'mock-header' },
- marketCap != null
- ? `${position.tokenSymbol}:${marketCap}`
- : position.tokenSymbol,
- ),
+ default: () =>
+ ReactMock.createElement(Text, { testID: 'mock-toolbar' }, 'toolbar'),
};
});
-jest.mock('./QuickBuyAmountInput', () => {
+jest.mock('./components/QuickBuyAmountSection', () => {
const ReactMock = jest.requireActual('react');
const { Text } = jest.requireActual('react-native');
return {
@@ -89,19 +86,28 @@ jest.mock('./QuickBuyAmountInput', () => {
default: () =>
ReactMock.createElement(
Text,
- { testID: 'mock-amount-input' },
- 'amount-input',
+ { testID: 'mock-amount-section' },
+ 'amount-section',
),
};
});
-jest.mock('./QuickBuyFooter', () => {
+jest.mock('./components/QuickBuyActionFooter', () => {
const ReactMock = jest.requireActual('react');
- const { Text } = jest.requireActual('react-native');
+ const { Text, TouchableOpacity } = jest.requireActual('react-native');
return {
__esModule: true,
default: () =>
- ReactMock.createElement(Text, { testID: 'mock-footer' }, 'footer'),
+ ReactMock.createElement(
+ TouchableOpacity,
+ {
+ testID: 'quick-buy-confirm-button',
+ onPress: () => {
+ mockControllerState.getResult().handleConfirm();
+ },
+ },
+ ReactMock.createElement(Text, null, 'confirm'),
+ ),
};
});
@@ -165,8 +171,8 @@ jest.mock('../../../../../../../locales/i18n', () => ({
const mockCreateRef = () => ({ current: null });
const buildHookResult = (
- overrides: Partial = {},
-): UseQuickBuyBottomSheetResult => ({
+ overrides: Partial = {},
+): UseQuickBuyControllerResult => ({
hiddenInputRef: mockCreateRef() as never,
destToken: undefined,
isSetupLoading: false,
@@ -179,8 +185,13 @@ const buildHookResult = (
setIsSourcePickerOpen: jest.fn(),
setSelectedSourceToken: jest.fn(),
usdAmount: '',
+ sliderPercent: 0,
+ maxSpendUsd: 0,
+ formattedExchangeRate: undefined,
+ metamaskFeePercent: 0,
estimatedReceiveAmount: undefined,
- sourceBalanceFiat: undefined,
+ sourceBalanceFiat: '$0.00',
+ sourceBalanceDisplay: undefined,
formattedNetworkFee: '-',
formattedSlippage: '-',
formattedMinimumReceived: '-',
@@ -203,9 +214,11 @@ const buildHookResult = (
confirmButtonState: 'idle',
getButtonLabel: () => 'social_leaderboard.trader_position.buy',
handleClose: jest.fn(),
- handlePresetPress: jest.fn(),
+ handleSliderChange: jest.fn(),
handleAmountAreaPress: jest.fn(),
handleAmountChange: jest.fn(),
+ handleToggleAmountDisplay: jest.fn(),
+ amountDisplayMode: 'fiat',
handleConfirm: jest.fn(),
...overrides,
});
@@ -229,11 +242,20 @@ const createPosition = (overrides: Partial = {}): Position =>
...overrides,
}) as Position;
-describe('QuickBuyBottomSheet', () => {
+const setMockQuickBuyController = (
+ overrides: Partial = {},
+) => {
+ mockControllerState.getResult = () => buildHookResult(overrides);
+ (useQuickBuyController as jest.Mock).mockImplementation(() =>
+ mockControllerState.getResult(),
+ );
+};
+
+describe('QuickBuy.Root', () => {
beforeEach(() => {
jest.clearAllMocks();
storedOnOpenCallback = undefined;
- (useQuickBuyBottomSheet as jest.Mock).mockReturnValue(buildHookResult());
+ setMockQuickBuyController();
(useQuickBuySetup as jest.Mock).mockReturnValue({
chainId: '0x1',
destToken: undefined,
@@ -249,9 +271,10 @@ describe('QuickBuyBottomSheet', () => {
describe('outer gate', () => {
it('renders nothing when isVisible is false', () => {
renderWithProvider(
- ,
);
@@ -261,9 +284,10 @@ describe('QuickBuyBottomSheet', () => {
it('mounts the inner sheet when visible with a valid position', () => {
renderWithProvider(
- ,
);
@@ -273,57 +297,48 @@ describe('QuickBuyBottomSheet', () => {
});
describe('inner sheet', () => {
- it('renders the header with the position token symbol', () => {
+ it('renders the toolbar after deferred content becomes ready', () => {
renderWithProvider(
- ,
);
- expect(screen.getByTestId('mock-header')).toBeOnTheScreen();
- expect(screen.getByText('PEPE')).toBeOnTheScreen();
- });
-
- it('forwards the marketCap prop to the header', () => {
- renderWithProvider(
- ,
- );
+ act(() => {
+ storedOnOpenCallback?.();
+ });
- expect(screen.getByText('PEPE:2300000')).toBeOnTheScreen();
+ expect(screen.getByTestId('mock-toolbar')).toBeOnTheScreen();
});
it('renders the skeleton body before deferred content becomes ready', () => {
renderWithProvider(
- ,
);
- // storedOnOpenCallback is not called — isContentReady stays false
- expect(screen.getByTestId('mock-header')).toBeOnTheScreen();
expect(screen.getByTestId('mock-skeleton')).toBeOnTheScreen();
- expect(screen.queryByTestId('mock-amount-input')).not.toBeOnTheScreen();
- expect(screen.queryByTestId('mock-footer')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('mock-toolbar')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen();
});
it('shows an unsupported chain message instead of the buy flow', () => {
- (useQuickBuyBottomSheet as jest.Mock).mockReturnValue(
- buildHookResult({ isUnsupportedChain: true }),
- );
+ setMockQuickBuyController({ isUnsupportedChain: true });
renderWithProvider(
- ,
);
@@ -334,19 +349,21 @@ describe('QuickBuyBottomSheet', () => {
expect(
screen.getByText('social_leaderboard.quick_buy.unsupported_chain'),
).toBeOnTheScreen();
- expect(screen.queryByTestId('mock-amount-input')).not.toBeOnTheScreen();
- expect(screen.queryByTestId('mock-footer')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('mock-toolbar')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen();
+ expect(
+ screen.queryByTestId('quick-buy-confirm-button'),
+ ).not.toBeOnTheScreen();
});
it('renders the amount input, footer details, and sticky confirm button for a supported chain', () => {
- (useQuickBuyBottomSheet as jest.Mock).mockReturnValue(
- buildHookResult({ isUnsupportedChain: false }),
- );
+ setMockQuickBuyController({ isUnsupportedChain: false });
renderWithProvider(
- ,
);
@@ -354,21 +371,19 @@ describe('QuickBuyBottomSheet', () => {
storedOnOpenCallback?.();
});
- expect(screen.getByTestId('mock-amount-input')).toBeOnTheScreen();
- expect(screen.getByTestId('mock-footer')).toBeOnTheScreen();
+ expect(screen.getByTestId('mock-amount-section')).toBeOnTheScreen();
expect(screen.getByTestId('quick-buy-confirm-button')).toBeOnTheScreen();
});
it('calls handleConfirm from the sticky confirm button', () => {
const handleConfirm = jest.fn();
- (useQuickBuyBottomSheet as jest.Mock).mockReturnValue(
- buildHookResult({ isUnsupportedChain: false, handleConfirm }),
- );
+ setMockQuickBuyController({ isUnsupportedChain: false, handleConfirm });
renderWithProvider(
- ,
);
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx
new file mode 100644
index 00000000000..2c34eb255c0
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import TraderPositionQuickBuy from './TraderPositionQuickBuy';
+import type { Position } from '@metamask/social-controllers';
+
+const mockQuickBuyRoot = jest.fn((_props: unknown) => null);
+
+jest.mock('./quickBuy', () => ({
+ QuickBuy: {
+ Root: (props: unknown) => mockQuickBuyRoot(props),
+ },
+}));
+
+jest.mock('./features', () => ({
+ TOP_TRADERS_QUICK_BUY_FEATURES: { tradeModes: ['buy'] },
+}));
+
+jest.mock('./types', () => ({
+ positionToQuickBuyTarget: (p: Position) => ({
+ tokenAddress: p.tokenAddress,
+ tokenSymbol: p.tokenSymbol,
+ tokenName: p.tokenName,
+ chain: p.chain,
+ }),
+}));
+
+const mockPosition: Position = {
+ tokenAddress: '0xtoken',
+ tokenSymbol: 'TKN',
+ tokenName: 'Token',
+ chain: '0x1',
+} as unknown as Position;
+
+describe('TraderPositionQuickBuy', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalled();
+ });
+
+ it('passes null target when position is null', () => {
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalledWith(
+ expect.objectContaining({ target: null }),
+ );
+ });
+
+ it('maps position to QuickBuyTarget', () => {
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: {
+ tokenAddress: '0xtoken',
+ tokenSymbol: 'TKN',
+ tokenName: 'Token',
+ chain: '0x1',
+ },
+ }),
+ );
+ });
+
+ it('passes analyticsContext when at least one analytics prop is defined', () => {
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ analyticsContext: {
+ traderAddress: '0xtrader',
+ marketCap: 1000000,
+ source: 'leaderboard',
+ },
+ }),
+ );
+ });
+
+ it('passes undefined analyticsContext when no analytics props are provided', () => {
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalledWith(
+ expect.objectContaining({ analyticsContext: undefined }),
+ );
+ });
+
+ it('passes only defined analytics props in context', () => {
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ analyticsContext: {
+ traderAddress: '0xtrader',
+ marketCap: undefined,
+ source: undefined,
+ },
+ }),
+ );
+ });
+
+ it('forwards isVisible and onClose to QuickBuy.Root', () => {
+ const onClose = jest.fn();
+ render(
+ ,
+ );
+ expect(mockQuickBuyRoot).toHaveBeenCalledWith(
+ expect.objectContaining({ isVisible: false, onClose }),
+ );
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx
new file mode 100644
index 00000000000..490c14aa946
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx
@@ -0,0 +1,67 @@
+import type { Position } from '@metamask/social-controllers';
+import React, { useMemo } from 'react';
+import type { QuickBuySheetSource } from '../../../analytics';
+import { QuickBuy } from './quickBuy';
+import { TOP_TRADERS_QUICK_BUY_FEATURES } from './features';
+import { positionToQuickBuyTarget } from './types';
+
+export interface TraderPositionQuickBuyProps {
+ isVisible: boolean;
+ position: Position | null;
+ onClose: () => void;
+ traderAddress?: string;
+ marketCap?: number;
+ source?: QuickBuySheetSource;
+}
+
+/**
+ * Top Traders adapter — maps social `Position` to `QuickBuyTarget` and
+ * bundles leaderboard analytics into `analyticsContext`.
+ */
+const TraderPositionQuickBuy: React.FC = ({
+ position,
+ isVisible,
+ onClose,
+ traderAddress,
+ marketCap,
+ source,
+}) => {
+ // Memoise on primitive fields so the target reference stays stable while
+ // the underlying position doesn't change. Without this, every parent
+ // re-render produces a new target object, which destabilises `destToken`
+ // inside `useQuickBuySetup`, which in turn re-triggers `useQuickBuyQuotes`'
+ // fetch effect — aborting in-flight quotes before they resolve and leaving
+ // the spinner stuck on.
+ // Stabilise the derived `target` reference so it doesn't destabilise the
+ // `destToken` memo inside `useQuickBuySetup` (which would in turn re-trigger
+ // `useQuickBuyQuotes`' fetch effect and abort in-flight quotes).
+ //
+ // `position` is reference-stable upstream — it's either a nav-param value or
+ // the cached result of `useTraderPosition` — so memoising on it is enough.
+ // If a future caller starts allocating a fresh `Position` on every render,
+ // switch to primitive-field deps (tokenAddress, tokenSymbol, tokenName, chain).
+ const target = useMemo(
+ () => (position ? positionToQuickBuyTarget(position) : null),
+ [position],
+ );
+
+ const analyticsContext = useMemo(() => {
+ const hasAny =
+ traderAddress !== undefined ||
+ marketCap !== undefined ||
+ source !== undefined;
+ return hasAny ? { traderAddress, marketCap, source } : undefined;
+ }, [traderAddress, marketCap, source]);
+
+ return (
+
+ );
+};
+
+export default TraderPositionQuickBuy;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
new file mode 100644
index 00000000000..aead18acdbd
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ Text,
+ TextVariant,
+ TextColor,
+ AvatarToken,
+ AvatarTokenSize,
+ Icon,
+ IconColor,
+ IconName,
+ IconSize,
+ BadgeWrapper,
+ BadgeWrapperPosition,
+ BadgeNetwork,
+} from '@metamask/design-system-react-native';
+import { TouchableOpacity } from 'react-native';
+import { strings } from '../../../../../../../../locales/i18n';
+import QuickBuyConfirmButton from '../QuickBuyConfirmButton';
+import QuickBuyBanners from '../QuickBuyBanners';
+import { useQuickBuyContext } from '../useQuickBuyContext';
+import { QuickBuyPercentageSlider } from './QuickBuyPercentageSlider';
+import { getNetworkImageSource } from '../../../../../../../util/networks';
+import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource';
+
+const QuickBuyActionFooter: React.FC = () => {
+ const {
+ sliderPercent,
+ maxSpendUsd,
+ handleSliderChange,
+ confirmButtonState,
+ getButtonLabel,
+ hasValidAmount,
+ isConfirmDisabled,
+ handleConfirm,
+ metamaskFeePercent,
+ isHardwareSolanaBlocked,
+ isPriceImpactError,
+ priceImpactViewData,
+ formattedPriceImpact,
+ sourceToken,
+ sourceChainId,
+ sourceBalanceFiat,
+ features,
+ } = useQuickBuyContext();
+
+ const isPriceImpactWarning =
+ !isPriceImpactError && !!priceImpactViewData.icon;
+
+ const networkImage = sourceChainId
+ ? getNetworkImageSource({ chainId: sourceChainId })
+ : undefined;
+
+ return (
+
+ {/* Slider — reduced top padding to tighten gap with the amount section */}
+
+
+
+
+ {/* Pay with row */}
+
+
+ {strings('social_leaderboard.quick_buy.pay_with')}
+
+
+
+
+ {sourceToken ? (
+ networkImage ? (
+ }
+ >
+
+
+ ) : (
+
+ )
+ ) : null}
+
+ {sourceToken
+ ? sourceBalanceFiat
+ ? `${sourceToken.symbol} (${sourceBalanceFiat})`
+ : sourceToken.symbol
+ : '—'}
+
+ {features.payWithSheet ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {metamaskFeePercent > 0 ? (
+
+
+ {strings('social_leaderboard.quick_buy.includes_mm_fee', {
+ fee: metamaskFeePercent,
+ })}
+
+
+ ) : null}
+
+ );
+};
+
+export default QuickBuyActionFooter;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx
new file mode 100644
index 00000000000..8ab1691edbe
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx
@@ -0,0 +1,145 @@
+import React, { createRef } from 'react';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import { TextInput } from 'react-native';
+import QuickBuyAmountSection from './QuickBuyAmountSection';
+
+const defaultProps = {
+ amountDisplayMode: 'fiat' as const,
+ fiatCryptoToggleEnabled: false,
+ usdAmount: '',
+ destSymbol: 'ETH',
+ estimatedReceiveAmount: undefined,
+ availableBalanceFiat: '$0.00',
+ isQuoteLoading: false,
+ hiddenInputRef: createRef(),
+ onAmountAreaPress: jest.fn(),
+ onAmountChange: jest.fn(),
+ onToggleAmountDisplay: jest.fn(),
+};
+
+describe('QuickBuyAmountSection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the fiat amount as primary in fiat mode', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('$50')).toBeOnTheScreen();
+ });
+
+ it('shows $0 placeholder when usdAmount is empty', () => {
+ render();
+ expect(screen.getByText('$0')).toBeOnTheScreen();
+ });
+
+ it('renders the crypto amount as primary in crypto mode', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('0.025 ETH')).toBeOnTheScreen();
+ });
+
+ it('shows 0 crypto placeholder when estimatedReceiveAmount is undefined', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('0 ETH')).toBeOnTheScreen();
+ });
+
+ it('replaces the secondary label with an ActivityIndicator when isQuoteLoading', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen();
+ // Secondary label is replaced by spinner — crypto label should NOT be present
+ expect(screen.queryByText('0 ETH')).not.toBeOnTheScreen();
+ });
+
+ it('shows the secondary label when NOT loading', () => {
+ render();
+ expect(screen.getByText('0 ETH')).toBeOnTheScreen();
+ });
+
+ it('shows the toggle button when fiatCryptoToggleEnabled', () => {
+ render();
+ expect(
+ screen.getByTestId('quick-buy-toggle-amount-display'),
+ ).toBeOnTheScreen();
+ });
+
+ it('hides the toggle button when fiatCryptoToggleEnabled is false', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByTestId('quick-buy-toggle-amount-display'),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('calls onToggleAmountDisplay when toggle is pressed', () => {
+ const onToggleAmountDisplay = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('quick-buy-toggle-amount-display'));
+ expect(onToggleAmountDisplay).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onAmountAreaPress when the area is pressed', () => {
+ const onAmountAreaPress = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('quick-buy-amount-area'));
+ expect(onAmountAreaPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows available balance when provided', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/\$1,234.56/)).toBeOnTheScreen();
+ });
+
+ it('shows locale-formatted zero available when balance is zero', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/\$0\.00/)).toBeOnTheScreen();
+ expect(screen.getByText(/available/)).toBeOnTheScreen();
+ });
+
+ it('renders a rateTag node when provided without error', () => {
+ render(>} />);
+ expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx
new file mode 100644
index 00000000000..bb9fcebb9c6
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import {
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ ActivityIndicator,
+} from 'react-native';
+import {
+ Box,
+ Text,
+ TextVariant,
+ TextColor,
+ FontWeight,
+ BoxAlignItems,
+ BoxJustifyContent,
+ BoxFlexDirection,
+ Icon,
+ IconColor,
+ IconName,
+ IconSize,
+} from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../../locales/i18n';
+import type { QuickBuyAmountDisplayMode } from '../types';
+
+const styles = StyleSheet.create({
+ amountText: { fontSize: 48, lineHeight: 52 },
+ hiddenInput: { position: 'absolute', opacity: 0, height: 0 },
+});
+
+interface QuickBuyAmountSectionProps {
+ amountDisplayMode: QuickBuyAmountDisplayMode;
+ fiatCryptoToggleEnabled: boolean;
+ usdAmount: string;
+ destSymbol: string;
+ estimatedReceiveAmount: string | undefined;
+ availableBalanceFiat: string;
+ isQuoteLoading: boolean;
+ hiddenInputRef: React.RefObject;
+ onAmountAreaPress: () => void;
+ onAmountChange: (text: string) => void;
+ onToggleAmountDisplay: () => void;
+ rateTag?: React.ReactNode;
+}
+
+const QuickBuyAmountSection: React.FC = ({
+ amountDisplayMode,
+ fiatCryptoToggleEnabled,
+ usdAmount,
+ destSymbol,
+ estimatedReceiveAmount,
+ availableBalanceFiat,
+ isQuoteLoading,
+ hiddenInputRef,
+ onAmountAreaPress,
+ onAmountChange,
+ onToggleAmountDisplay,
+ rateTag,
+}) => {
+ const fiatAmountLabel = usdAmount ? `$${usdAmount}` : '$0';
+ const cryptoAmountLabel = estimatedReceiveAmount
+ ? `${estimatedReceiveAmount} ${destSymbol}`
+ : `0 ${destSymbol}`;
+
+ const isCryptoPrimary = amountDisplayMode === 'crypto';
+ const primaryLabel = isCryptoPrimary ? cryptoAmountLabel : fiatAmountLabel;
+ const secondaryLabel = isCryptoPrimary ? fiatAmountLabel : cryptoAmountLabel;
+
+ return (
+
+
+
+ {primaryLabel}
+
+
+ {rateTag}
+
+ {isQuoteLoading ? (
+
+ ) : (
+
+
+ {secondaryLabel}
+
+ {fiatCryptoToggleEnabled ? (
+
+
+
+ ) : null}
+
+ )}
+
+
+ {strings('social_leaderboard.quick_buy.available_balance', {
+ amount: availableBalanceFiat,
+ })}
+
+
+
+
+
+ );
+};
+
+export default QuickBuyAmountSection;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx
new file mode 100644
index 00000000000..4437855e0df
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx
@@ -0,0 +1,164 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { AccessibilityActionEvent, LayoutChangeEvent } from 'react-native';
+import {
+ Gesture,
+ GestureDetector,
+ GestureHandlerRootView,
+} from 'react-native-gesture-handler';
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+} from 'react-native-reanimated';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+
+const HANDLE_SIZE = 24;
+const MARKER_SIZE = 4;
+const PERCENTAGE_STEP = 25;
+export const SNAP_POINTS = [0, 25, 50, 75, 100];
+
+export function snapToPercentageStep(value: number): number {
+ const snappedValue = Math.round(value / PERCENTAGE_STEP) * PERCENTAGE_STEP;
+ return Math.max(0, Math.min(100, snappedValue));
+}
+
+interface QuickBuyPercentageSliderProps {
+ value: number;
+ onValueChange: (value: number) => void;
+ disabled?: boolean;
+ testID?: string;
+}
+
+export function QuickBuyPercentageSlider({
+ value,
+ onValueChange,
+ disabled = false,
+ testID = 'quick-buy-percentage-slider',
+}: QuickBuyPercentageSliderProps) {
+ const tw = useTailwind();
+ const sliderWidth = useSharedValue(0);
+ const translateX = useSharedValue(0);
+ const widthRef = useRef(0);
+
+ const updatePosition = useCallback(
+ (nextValue: number, width = widthRef.current) => {
+ const snappedValue = snapToPercentageStep(nextValue);
+ translateX.value = (snappedValue / 100) * width;
+ },
+ [translateX],
+ );
+
+ const updateValueFromPosition = useCallback(
+ (position: number, width: number) => {
+ if (width === 0 || disabled) return;
+ const clampedPosition = Math.max(0, Math.min(position, width));
+ const nextValue = snapToPercentageStep((clampedPosition / width) * 100);
+ updatePosition(nextValue, width);
+ if (nextValue !== value) {
+ onValueChange(nextValue);
+ }
+ },
+ [disabled, onValueChange, updatePosition, value],
+ );
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const { width } = event.nativeEvent.layout;
+ widthRef.current = width;
+ sliderWidth.value = width;
+ updatePosition(value, width);
+ },
+ [sliderWidth, updatePosition, value],
+ );
+
+ useEffect(() => {
+ updatePosition(value);
+ }, [updatePosition, value]);
+
+ const progressStyle = useAnimatedStyle(() => ({
+ width: translateX.value,
+ }));
+
+ const handleStyle = useAnimatedStyle(() => {
+ const handleOffset = Math.max(
+ 0,
+ Math.min(
+ translateX.value - HANDLE_SIZE / 2,
+ sliderWidth.value - HANDLE_SIZE,
+ ),
+ );
+ return { transform: [{ translateX: handleOffset }] };
+ });
+
+ const gesture = Gesture.Simultaneous(
+ Gesture.Tap().onEnd((event) => {
+ runOnJS(updateValueFromPosition)(event.x, sliderWidth.value);
+ }),
+ Gesture.Pan().onUpdate((event) => {
+ runOnJS(updateValueFromPosition)(event.x, sliderWidth.value);
+ }),
+ );
+
+ const handleAccessibilityAction = useCallback(
+ (event: AccessibilityActionEvent) => {
+ const nextValue =
+ event.nativeEvent.actionName === 'increment'
+ ? snapToPercentageStep(value + PERCENTAGE_STEP)
+ : snapToPercentageStep(value - PERCENTAGE_STEP);
+ if (nextValue !== value) {
+ onValueChange(nextValue);
+ }
+ },
+ [onValueChange, value],
+ );
+
+ return (
+
+
+
+
+
+ {SNAP_POINTS.map((snapPoint) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx
new file mode 100644
index 00000000000..584faac1543
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import QuickBuyRateTag from './QuickBuyRateTag';
+
+describe('QuickBuyRateTag', () => {
+ it('renders nothing when label is undefined', () => {
+ render();
+ expect(screen.queryByTestId('quick-buy-rate-tag')).not.toBeOnTheScreen();
+ });
+
+ it('renders nothing when label is empty', () => {
+ render();
+ expect(screen.queryByTestId('quick-buy-rate-tag')).not.toBeOnTheScreen();
+ });
+
+ it('renders the label inside a non-pressable container when onPress is not provided', () => {
+ render();
+ expect(screen.getByTestId('quick-buy-rate-tag')).toBeOnTheScreen();
+ expect(screen.getByText('1 ETH = 1000 USDC')).toBeOnTheScreen();
+ expect(
+ screen.queryByTestId('quick-buy-rate-tag-pressable'),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('renders a pressable wrapper when onPress is provided', () => {
+ render();
+ expect(
+ screen.getByTestId('quick-buy-rate-tag-pressable'),
+ ).toBeOnTheScreen();
+ });
+
+ it('invokes onPress when pressed', () => {
+ const onPress = jest.fn();
+ render();
+ fireEvent.press(screen.getByTestId('quick-buy-rate-tag-pressable'));
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx
new file mode 100644
index 00000000000..a5dcfaa2008
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { TouchableOpacity } from 'react-native';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ Text,
+ TextVariant,
+ TextColor,
+ Icon,
+ IconColor,
+ IconName,
+ IconSize,
+} from '@metamask/design-system-react-native';
+
+interface QuickBuyRateTagProps {
+ label: string | undefined;
+ onPress?: () => void;
+}
+
+const QuickBuyRateTag: React.FC = ({
+ label,
+ onPress,
+}) => {
+ if (!label) return null;
+
+ const content = (
+
+
+ {label}
+
+
+
+ );
+
+ return (
+
+ {onPress ? (
+
+ {content}
+
+ ) : (
+ content
+ )}
+
+ );
+};
+
+export default QuickBuyRateTag;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx
new file mode 100644
index 00000000000..f65e69543e1
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../../locales/i18n';
+
+const QuickBuyToolbar: React.FC = () => (
+
+
+
+ {strings('social_leaderboard.quick_buy.buy_mode')}
+
+
+
+);
+
+export default QuickBuyToolbar;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts
new file mode 100644
index 00000000000..af4bc499363
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts
@@ -0,0 +1,11 @@
+import type { QuickBuyFeatures } from './types';
+
+/** Top Traders — buy-only amount sheet. */
+export const TOP_TRADERS_QUICK_BUY_FEATURES: QuickBuyFeatures = {
+ tradeModes: ['buy'],
+ quoteDetails: false,
+ selectQuote: false,
+ payWithSheet: false,
+ highPriceImpactModal: false,
+ fiatCryptoToggle: true,
+};
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts
new file mode 100644
index 00000000000..11567abbdd9
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts
@@ -0,0 +1,53 @@
+import { getBridgeTokenImageSource } from './getBridgeTokenImageSource';
+import { getAssetImageUrl } from '../../../../../UI/Bridge/hooks/useAssetMetadata/utils';
+import type { BridgeToken } from '../../../../../UI/Bridge/types';
+
+jest.mock('../../../../../UI/Bridge/hooks/useAssetMetadata/utils', () => ({
+ getAssetImageUrl: jest.fn(),
+}));
+
+const mockGetAssetImageUrl = getAssetImageUrl as jest.MockedFunction<
+ typeof getAssetImageUrl
+>;
+
+const baseToken: BridgeToken = {
+ address: '0xabc',
+ chainId: '0x1',
+ symbol: 'ETH',
+ decimals: 18,
+ name: 'Ethereum',
+};
+
+describe('getBridgeTokenImageSource', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns the token image URI when token.image is set', () => {
+ const token = { ...baseToken, image: 'https://cdn.example.com/eth.png' };
+ expect(getBridgeTokenImageSource(token)).toEqual({
+ uri: 'https://cdn.example.com/eth.png',
+ });
+ expect(mockGetAssetImageUrl).not.toHaveBeenCalled();
+ });
+
+ it('returns the CDN fallback URI when token.image is absent but getAssetImageUrl resolves', () => {
+ const token = { ...baseToken };
+ mockGetAssetImageUrl.mockReturnValue('https://cdn.example.com/0xabc.png');
+
+ expect(getBridgeTokenImageSource(token)).toEqual({
+ uri: 'https://cdn.example.com/0xabc.png',
+ });
+ expect(mockGetAssetImageUrl).toHaveBeenCalledWith(
+ token.address,
+ token.chainId,
+ );
+ });
+
+ it('returns undefined when token.image is absent and getAssetImageUrl returns null', () => {
+ const token = { ...baseToken };
+ mockGetAssetImageUrl.mockReturnValue(undefined);
+
+ expect(getBridgeTokenImageSource(token)).toBeUndefined();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/getBridgeTokenImageSource.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.ts
similarity index 100%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/getBridgeTokenImageSource.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.ts
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts
new file mode 100644
index 00000000000..4a486c8256d
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts
@@ -0,0 +1,303 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useDispatch } from 'react-redux';
+import { MetaMetricsEvents } from '../../../../../../../core/Analytics';
+import {
+ SocialLeaderboardEventProperties,
+ SocialLeaderboardEventValues,
+} from '../../../../analytics';
+import { useQuickBuyAnalytics } from './useQuickBuyAnalytics';
+
+const mockDispatch = jest.fn();
+const mockTrack = jest.fn();
+const mockResetBridgeState = jest.fn(() => ({ type: 'bridge/reset' }));
+const mockBridgeControllerResetState = jest.fn();
+
+jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(),
+}));
+
+jest.mock('../../../../analytics', () => ({
+ SocialLeaderboardEventProperties: {
+ TRADER_ADDRESS: 'trader_address',
+ CAIP19: 'caip19',
+ DISMISS_STAGE: 'dismiss_stage',
+ AMOUNT_USD: 'amount_usd',
+ AMOUNT_SELECTION_METHOD: 'amount_selection_method',
+ PAY_WITH_TOKEN: 'pay_with_token',
+ },
+ SocialLeaderboardEventValues: {
+ DISMISS_STAGE: {
+ TOKEN_DETAIL: 'token_detail',
+ AMOUNT_SELECTION: 'amount_selection',
+ CONFIRMATION: 'confirmation',
+ },
+ AMOUNT_SELECTION_METHOD: {
+ CUSTOM_INPUT: 'custom_input',
+ SLIDER: 'slider',
+ },
+ },
+ useSocialLeaderboardAnalytics: () => ({ track: mockTrack }),
+}));
+
+jest.mock('../../../../../../../core/Analytics', () => ({
+ MetaMetricsEvents: {
+ SOCIAL_QUICK_BUY_DISMISSED: 'SOCIAL_QUICK_BUY_DISMISSED',
+ SOCIAL_QUICK_BUY_AMOUNT_SELECTED: 'SOCIAL_QUICK_BUY_AMOUNT_SELECTED',
+ SOCIAL_QUICK_BUY_TRADE_SUBMITTED: 'SOCIAL_QUICK_BUY_TRADE_SUBMITTED',
+ SOCIAL_QUICK_BUY_TRADE_COMPLETED: 'SOCIAL_QUICK_BUY_TRADE_COMPLETED',
+ },
+}));
+
+jest.mock('../../../../../../../core/redux/slices/bridge', () => ({
+ resetBridgeState: () => mockResetBridgeState(),
+}));
+
+jest.mock('../../../../../../../core/Engine', () => ({
+ __esModule: true,
+ default: {
+ context: {
+ BridgeController: {
+ resetState: () => mockBridgeControllerResetState(),
+ },
+ },
+ },
+}));
+
+const TRADER = '0xTrader';
+const CAIP19 = 'eip155:1/erc20:0xtoken';
+
+describe('useQuickBuyAnalytics', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(mockDispatch);
+ });
+
+ describe('trackAmountSelected', () => {
+ it('fires AMOUNT_SELECTED with correct properties', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19));
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 25,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ 'ETH',
+ );
+ });
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED,
+ expect.objectContaining({
+ [SocialLeaderboardEventProperties.TRADER_ADDRESS]: TRADER,
+ [SocialLeaderboardEventProperties.CAIP19]: CAIP19,
+ [SocialLeaderboardEventProperties.AMOUNT_USD]: 25,
+ [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]:
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]: 'ETH',
+ }),
+ );
+ });
+
+ it('includes slider_percent when provided', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19));
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 50,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER,
+ undefined,
+ 25,
+ );
+ });
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED,
+ expect.objectContaining({ slider_percent: 25 }),
+ );
+ });
+
+ it('does not include slider_percent when not provided', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19));
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 50,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ );
+ });
+
+ const call = mockTrack.mock.calls[0][1];
+ expect(call).not.toHaveProperty('slider_percent');
+ });
+
+ it('is a no-op when traderAddress is empty', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics('', CAIP19));
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 25,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ );
+ });
+
+ expect(mockTrack).not.toHaveBeenCalled();
+ });
+
+ it('is a no-op when caip19 is empty', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, ''));
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 25,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ );
+ });
+
+ expect(mockTrack).not.toHaveBeenCalled();
+ });
+
+ it('prefers analyticsContext.traderAddress over the hook arg', () => {
+ const { result } = renderHook(() =>
+ useQuickBuyAnalytics(TRADER, CAIP19, { traderAddress: '0xOverride' }),
+ );
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 10,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ );
+ });
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED,
+ expect.objectContaining({
+ [SocialLeaderboardEventProperties.TRADER_ADDRESS]: '0xOverride',
+ }),
+ );
+ });
+ });
+
+ describe('trackTradeSubmitted / trackTradeCompleted', () => {
+ it('fires TRADE_SUBMITTED with provided props', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19));
+
+ act(() => {
+ result.current.trackTradeSubmitted({ foo: 'bar' });
+ });
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_SUBMITTED,
+ { foo: 'bar' },
+ );
+ });
+
+ it('fires TRADE_COMPLETED with provided props', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19));
+
+ act(() => {
+ result.current.trackTradeCompleted({ tx: '0xhash' });
+ });
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED,
+ { tx: '0xhash' },
+ );
+ });
+ });
+
+ describe('markTradeSubmitted', () => {
+ it('updates dismissStageRef to CONFIRMATION and sets tradeSubmittedRef', () => {
+ const { result } = renderHook(() => useQuickBuyAnalytics(TRADER, CAIP19));
+
+ act(() => {
+ result.current.markTradeSubmitted();
+ });
+
+ expect(result.current.refs.tradeSubmittedRef.current).toBe(true);
+ expect(result.current.refs.dismissStageRef.current).toBe(
+ SocialLeaderboardEventValues.DISMISS_STAGE.CONFIRMATION,
+ );
+ });
+ });
+
+ describe('unmount — DISMISSED event', () => {
+ it('fires DISMISSED on unmount when trade was not submitted', () => {
+ const { unmount } = renderHook(() =>
+ useQuickBuyAnalytics(TRADER, CAIP19),
+ );
+
+ unmount();
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED,
+ expect.objectContaining({
+ [SocialLeaderboardEventProperties.TRADER_ADDRESS]: TRADER,
+ [SocialLeaderboardEventProperties.CAIP19]: CAIP19,
+ [SocialLeaderboardEventProperties.DISMISS_STAGE]:
+ SocialLeaderboardEventValues.DISMISS_STAGE.TOKEN_DETAIL,
+ }),
+ );
+ });
+
+ it('includes amount_usd in DISMISSED event when an amount was selected', () => {
+ const { result, unmount } = renderHook(() =>
+ useQuickBuyAnalytics(TRADER, CAIP19),
+ );
+
+ act(() => {
+ result.current.trackAmountSelected(
+ 42,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ );
+ });
+
+ mockTrack.mockClear();
+ unmount();
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED,
+ expect.objectContaining({
+ [SocialLeaderboardEventProperties.AMOUNT_USD]: 42,
+ }),
+ );
+ });
+
+ it('does NOT fire DISMISSED when trade was submitted', () => {
+ const { result, unmount } = renderHook(() =>
+ useQuickBuyAnalytics(TRADER, CAIP19),
+ );
+
+ act(() => {
+ result.current.markTradeSubmitted();
+ });
+
+ mockTrack.mockClear();
+ unmount();
+
+ expect(mockTrack).not.toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED,
+ expect.anything(),
+ );
+ });
+
+ it('dispatches resetBridgeState on unmount', () => {
+ const { unmount } = renderHook(() =>
+ useQuickBuyAnalytics(TRADER, CAIP19),
+ );
+
+ unmount();
+
+ expect(mockDispatch).toHaveBeenCalled();
+ expect(mockResetBridgeState).toHaveBeenCalled();
+ });
+
+ it('calls BridgeController.resetState on unmount', () => {
+ const { unmount } = renderHook(() =>
+ useQuickBuyAnalytics(TRADER, CAIP19),
+ );
+
+ unmount();
+
+ expect(mockBridgeControllerResetState).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts
new file mode 100644
index 00000000000..dbe4081ceaa
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts
@@ -0,0 +1,139 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useDispatch } from 'react-redux';
+import { MetaMetricsEvents } from '../../../../../../../core/Analytics';
+import { resetBridgeState } from '../../../../../../../core/redux/slices/bridge';
+import Engine from '../../../../../../../core/Engine';
+import {
+ SocialLeaderboardEventProperties,
+ SocialLeaderboardEventValues,
+ useSocialLeaderboardAnalytics,
+} from '../../../../analytics';
+import type { QuickBuyAnalyticsContext } from '../types';
+
+type QuickBuyDismissStage =
+ (typeof SocialLeaderboardEventValues.DISMISS_STAGE)[keyof typeof SocialLeaderboardEventValues.DISMISS_STAGE];
+
+type AmountSelectionMethod =
+ (typeof SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD)[keyof typeof SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD];
+
+export interface QuickBuyAnalyticsRefs {
+ dismissStageRef: React.MutableRefObject;
+ tradeSubmittedRef: React.MutableRefObject;
+ lastTrackedAmountRef: React.MutableRefObject;
+ lastInputMethodRef: React.MutableRefObject;
+ submitStartedAtRef: React.MutableRefObject;
+}
+
+export function useQuickBuyAnalytics(
+ traderAddress: string,
+ caip19: string,
+ analyticsContext?: QuickBuyAnalyticsContext,
+): {
+ refs: QuickBuyAnalyticsRefs;
+ trackAmountSelected: (
+ amountUsd: number,
+ method: AmountSelectionMethod,
+ payWithToken?: string,
+ sliderPercent?: number,
+ ) => void;
+ trackTradeSubmitted: (props: Record) => void;
+ trackTradeCompleted: (props: Record) => void;
+ markTradeSubmitted: () => void;
+} {
+ const dispatch = useDispatch();
+ const { track } = useSocialLeaderboardAnalytics();
+
+ const dismissStageRef = useRef(
+ SocialLeaderboardEventValues.DISMISS_STAGE.TOKEN_DETAIL,
+ );
+ const tradeSubmittedRef = useRef(false);
+ const lastTrackedAmountRef = useRef('');
+ const lastInputMethodRef = useRef(
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ );
+ const submitStartedAtRef = useRef(null);
+
+ const resolvedTraderAddress =
+ analyticsContext?.traderAddress ?? traderAddress;
+
+ useEffect(
+ () => () => {
+ dispatch(resetBridgeState());
+ if (Engine.context.BridgeController?.resetState) {
+ Engine.context.BridgeController.resetState();
+ }
+ if (!tradeSubmittedRef.current && resolvedTraderAddress && caip19) {
+ const numeric = Number(lastTrackedAmountRef.current);
+ track(MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, {
+ [SocialLeaderboardEventProperties.TRADER_ADDRESS]:
+ resolvedTraderAddress,
+ [SocialLeaderboardEventProperties.CAIP19]: caip19,
+ [SocialLeaderboardEventProperties.DISMISS_STAGE]:
+ dismissStageRef.current,
+ [SocialLeaderboardEventProperties.AMOUNT_USD]:
+ Number.isFinite(numeric) && numeric > 0 ? numeric : undefined,
+ });
+ }
+ },
+ [dispatch, resolvedTraderAddress, caip19, track],
+ );
+
+ const trackAmountSelected = useCallback(
+ (
+ amountUsd: number,
+ method: AmountSelectionMethod,
+ payWithToken?: string,
+ sliderPercent?: number,
+ ) => {
+ if (!resolvedTraderAddress || !caip19) return;
+ lastTrackedAmountRef.current = String(amountUsd);
+ lastInputMethodRef.current = method;
+ track(MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, {
+ [SocialLeaderboardEventProperties.TRADER_ADDRESS]:
+ resolvedTraderAddress,
+ [SocialLeaderboardEventProperties.CAIP19]: caip19,
+ [SocialLeaderboardEventProperties.AMOUNT_USD]: amountUsd,
+ [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]: method,
+ [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]: payWithToken,
+ ...(sliderPercent != null ? { slider_percent: sliderPercent } : {}),
+ });
+ dismissStageRef.current =
+ SocialLeaderboardEventValues.DISMISS_STAGE.AMOUNT_SELECTION;
+ },
+ [resolvedTraderAddress, caip19, track],
+ );
+
+ const trackTradeSubmitted = useCallback(
+ (props: Record) => {
+ track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, props);
+ },
+ [track],
+ );
+
+ const trackTradeCompleted = useCallback(
+ (props: Record) => {
+ track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, props);
+ },
+ [track],
+ );
+
+ const markTradeSubmitted = useCallback(() => {
+ tradeSubmittedRef.current = true;
+ dismissStageRef.current =
+ SocialLeaderboardEventValues.DISMISS_STAGE.CONFIRMATION;
+ }, []);
+
+ return {
+ refs: {
+ dismissStageRef,
+ tradeSubmittedRef,
+ lastTrackedAmountRef,
+ lastInputMethodRef,
+ submitStartedAtRef,
+ },
+ trackAmountSelected,
+ trackTradeSubmitted,
+ trackTradeCompleted,
+ markTradeSubmitted,
+ };
+}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
similarity index 68%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
index 18ae61263b0..8367caf6b86 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
@@ -2,30 +2,39 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
playSuccessNotification,
playErrorNotification,
-} from '../../../../../../util/haptics';
+} from '../../../../../../../util/haptics';
import { TextInput } from 'react-native';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
-import type { Position } from '@metamask/social-controllers';
+import type {
+ QuickBuyAmountDisplayMode,
+ QuickBuyAnalyticsContext,
+ QuickBuyTarget,
+} from '../types';
+import { useQuickBuyAnalytics } from './useQuickBuyAnalytics';
+import { formatExchangeRate } from '../utils/formatExchangeRate';
+import { getMetamaskFeePercent } from '../utils/getMetamaskFeePercent';
+import { snapToPercentageStep } from '../components/QuickBuyPercentageSlider';
import type { Hex } from '@metamask/utils';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import { selectDefaultSourceToken } from '../../../utils/tokenSelection';
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { selectDefaultSourceToken } from '../../../../utils/tokenSelection';
import { useQuickBuySetup } from './useQuickBuySetup';
import { useSourceTokenOptions } from './useSourceTokenOptions';
import { useQuickBuyQuotes } from './useQuickBuyQuotes';
-import { isGaslessQuote } from '../../../../../UI/Bridge/utils/isGaslessQuote';
+import { isGaslessQuote } from '../../../../../../UI/Bridge/utils/isGaslessQuote';
+import { formatCurrency } from '../../../../../../UI/Bridge/utils/currencyUtils';
+import { selectCurrentCurrency } from '../../../../../../../selectors/currencyRateController';
import {
isNumberValue,
dotAndCommaDecimalFormatter,
-} from '../../../../../../util/number';
+} from '../../../../../../../util/number/bigint';
import { isNonEvmChainId } from '@metamask/bridge-controller';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
-import { useGasFeeEstimates } from '../../../../confirmations/hooks/gas/useGasFeeEstimates';
+import { useGasFeeEstimates } from '../../../../../confirmations/hooks/gas/useGasFeeEstimates';
import {
setSourceAmount,
setSourceToken,
setDestToken,
- resetBridgeState,
selectIsSubmittingTx,
selectDestAddress,
selectSlippage,
@@ -34,51 +43,36 @@ import {
selectIsSolanaSourced,
selectBridgeFeatureFlags,
setIsSubmittingTx,
-} from '../../../../../../core/redux/slices/bridge';
-import { useLatestBalance } from '../../../../../UI/Bridge/hooks/useLatestBalance';
-import useIsInsufficientBalance from '../../../../../UI/Bridge/hooks/useInsufficientBalance';
-import { useHasSufficientGas } from '../../../../../UI/Bridge/hooks/useHasSufficientGas';
-import { useIsNetworkFeeUnavailable } from '../../../../../UI/Bridge/hooks/useIsNetworkFeeUnavailable';
-import { useInitialSlippage } from '../../../../../UI/Bridge/hooks/useInitialSlippage';
-import { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData';
+} from '../../../../../../../core/redux/slices/bridge';
+import { useLatestBalance } from '../../../../../../UI/Bridge/hooks/useLatestBalance';
+import useIsInsufficientBalance from '../../../../../../UI/Bridge/hooks/useInsufficientBalance';
+import { useHasSufficientGas } from '../../../../../../UI/Bridge/hooks/useHasSufficientGas';
+import { useIsNetworkFeeUnavailable } from '../../../../../../UI/Bridge/hooks/useIsNetworkFeeUnavailable';
+import { useInitialSlippage } from '../../../../../../UI/Bridge/hooks/useInitialSlippage';
+import { usePriceImpactViewData } from '../../../../../../UI/Bridge/hooks/usePriceImpactViewData';
import {
parsePriceImpact,
exceedsPriceImpactErrorThreshold,
-} from '../../../../../UI/Bridge/utils/getPriceImpactViewData';
-import { selectShouldUseSmartTransaction } from '../../../../../../selectors/smartTransactionsController';
-import { useRefreshSmartTransactionsLiveness } from '../../../../../hooks/useRefreshSmartTransactionsLiveness';
-import { useIsGasIncludedSTXSendBundleSupported } from '../../../../../UI/Bridge/hooks/useIsGasIncludedSTXSendBundleSupported';
-import { useRecipientInitialization } from '../../../../../UI/Bridge/hooks/useRecipientInitialization';
-import { selectSourceWalletAddress } from '../../../../../../selectors/bridge';
-import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController';
-import { isHardwareAccount } from '../../../../../../util/address';
-import Engine from '../../../../../../core/Engine';
-import Routes from '../../../../../../constants/navigation/Routes';
-import { strings } from '../../../../../../../locales/i18n';
-import { calcTokenValue } from '../../../../../../util/transactions';
-import Logger from '../../../../../../util/Logger';
-import { buildSocialLoggerErrorOptions } from '../../../../../../util/social/socialServiceTelemetry';
+} from '../../../../../../UI/Bridge/utils/getPriceImpactViewData';
+import { selectShouldUseSmartTransaction } from '../../../../../../../selectors/smartTransactionsController';
+import { useRefreshSmartTransactionsLiveness } from '../../../../../../hooks/useRefreshSmartTransactionsLiveness';
+import { useIsGasIncludedSTXSendBundleSupported } from '../../../../../../UI/Bridge/hooks/useIsGasIncludedSTXSendBundleSupported';
+import { useRecipientInitialization } from '../../../../../../UI/Bridge/hooks/useRecipientInitialization';
+import { selectSourceWalletAddress } from '../../../../../../../selectors/bridge';
+import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../../selectors/accountsController';
+import { isHardwareAccount } from '../../../../../../../util/address';
+import Engine from '../../../../../../../core/Engine';
+import Routes from '../../../../../../../constants/navigation/Routes';
+import { strings } from '../../../../../../../../locales/i18n';
+import { calcTokenValue } from '../../../../../../../util/transactions';
+import Logger from '../../../../../../../util/Logger';
+import { buildSocialLoggerErrorOptions } from '../../../../../../../util/social/socialServiceTelemetry';
import {
SocialLeaderboardEventProperties,
SocialLeaderboardEventValues,
- useSocialLeaderboardAnalytics,
- type QuickBuySheetSource,
-} from '../../../analytics';
-import { MetaMetricsEvents } from '../../../../../../core/Analytics';
-import { chainNameToId } from '../../../utils/chainMapping';
-import { toAssetId } from '../../../../../UI/Bridge/hooks/useAssetMetadata/utils';
-
-type QuickBuyDismissStage =
- (typeof SocialLeaderboardEventValues.DISMISS_STAGE)[keyof typeof SocialLeaderboardEventValues.DISMISS_STAGE];
-
-export interface QuickBuyAnalyticsContext {
- /** Wallet address of the trader being copied. Required for analytics. */
- traderAddress?: string;
- /** Destination-token market cap; passes through on sheet-viewed. */
- marketCap?: number;
- /** Surface that opened the sheet. */
- source?: QuickBuySheetSource;
-}
+} from '../../../../analytics';
+import { chainNameToId } from '../../../../utils/chainMapping';
+import { toAssetId } from '../../../../../../UI/Bridge/hooks/useAssetMetadata/utils';
export type QuickBuyButtonError =
| 'insufficient_balance'
@@ -91,7 +85,7 @@ const BUTTON_ERROR_LABELS: Record = {
no_quotes: 'social_leaderboard.quick_buy.no_quotes',
};
-export interface UseQuickBuyBottomSheetResult {
+export interface UseQuickBuyControllerResult {
// refs
hiddenInputRef: React.RefObject;
// setup
@@ -109,9 +103,15 @@ export interface UseQuickBuyBottomSheetResult {
React.SetStateAction
>;
// amount
+ amountDisplayMode: QuickBuyAmountDisplayMode;
usdAmount: string;
+ sliderPercent: number;
+ maxSpendUsd: number;
+ formattedExchangeRate: string | undefined;
+ metamaskFeePercent: number;
estimatedReceiveAmount: string | undefined;
- sourceBalanceFiat: string | undefined;
+ sourceBalanceFiat: string;
+ sourceBalanceDisplay: string | undefined;
formattedNetworkFee: string;
formattedSlippage: string;
formattedMinimumReceived: string;
@@ -133,51 +133,46 @@ export interface UseQuickBuyBottomSheetResult {
getButtonLabel: () => string;
// handlers
handleClose: () => void;
- handlePresetPress: (preset: string) => void;
+ handleSliderChange: (percent: number) => void;
handleAmountAreaPress: () => void;
handleAmountChange: (text: string) => void;
+ handleToggleAmountDisplay: () => void;
handleConfirm: () => Promise;
}
-export function useQuickBuyBottomSheet(
- position: Position,
+export function useQuickBuyController(
+ target: QuickBuyTarget,
onClose: () => void,
analyticsContext?: QuickBuyAnalyticsContext,
-): UseQuickBuyBottomSheetResult {
+): UseQuickBuyControllerResult {
const hiddenInputRef = useRef(null);
const dispatch = useDispatch();
const navigation = useNavigation();
- const { track } = useSocialLeaderboardAnalytics();
- // Stable refs so analytics callbacks don't capture stale context across
- // unmount cleanups. The cleanup effect below reads these to fire the
- // dismissed event without re-binding on each amount change.
const traderAddress = analyticsContext?.traderAddress ?? '';
const caip19 = useMemo(() => {
- const caipChainId = chainNameToId(position.chain);
+ const caipChainId = chainNameToId(target.chain);
if (!caipChainId) return '';
- return toAssetId(position.tokenAddress, caipChainId) ?? '';
- }, [position.chain, position.tokenAddress]);
+ return toAssetId(target.tokenAddress, caipChainId) ?? '';
+ }, [target.chain, target.tokenAddress]);
+
+ const {
+ refs: { lastInputMethodRef, lastTrackedAmountRef, submitStartedAtRef },
+ trackAmountSelected,
+ trackTradeSubmitted,
+ trackTradeCompleted,
+ markTradeSubmitted,
+ } = useQuickBuyAnalytics(traderAddress, caip19, analyticsContext);
const [usdAmount, setUsdAmount] = useState('');
+ // Fiat-first: every input path (slider, hidden TextInput, amount-area press)
+ // edits the USD amount, so the primary label must default to fiat as well.
+ // The user can swap to crypto display via the toggle once a quote is available.
+ const [amountDisplayMode, setAmountDisplayMode] =
+ useState('fiat');
+ const [sliderPercent, setSliderPercent] = useState(0);
+ const lastSnappedSliderPercentRef = useRef(0);
const [txPhase, setTxPhase] = useState<'idle' | 'success'>('idle');
- // Marks where the current usdAmount value came from. Reset to 'preset' when
- // a chip is pressed, otherwise 'custom_input' on each keystroke. Used to
- // disambiguate the analytics method without re-firing on every keystroke.
- const lastInputMethodRef = useRef<'preset' | 'custom_input'>('custom_input');
- // Last usdAmount we already emitted analytics for; prevents duplicate
- // events when redux state churns and effects re-run.
- const lastTrackedAmountRef = useRef('');
- // Tracks the highest dismiss-stage the user reached so the cleanup effect
- // can attach the right `dismiss_stage` to the dismissed event.
- const dismissStageRef = useRef(
- SocialLeaderboardEventValues.DISMISS_STAGE.TOKEN_DETAIL,
- );
- // Cleared once the trade is submitted (success path) so we don't double-
- // count dismissed events on top of completed events.
- const tradeSubmittedRef = useRef(false);
- // Captures the trade timer so trade-completed can compute execution_time_ms.
- const submitStartedAtRef = useRef(null);
const isSubmittingTx = useSelector(selectIsSubmittingTx);
const walletAddress = useSelector(selectSourceWalletAddress);
@@ -187,6 +182,7 @@ export function useQuickBuyBottomSheet(
const isNonEvmNonEvmBridge = useSelector(selectIsNonEvmNonEvmBridge);
const isSolanaSourced = useSelector(selectIsSolanaSourced);
const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags);
+ const currentCurrency = useSelector(selectCurrentCurrency);
const selectedAddress = useSelector(
selectSelectedInternalAccountFormattedAddress,
);
@@ -200,7 +196,7 @@ export function useQuickBuyBottomSheet(
destToken,
isLoading: isSetupLoading,
isUnsupportedChain,
- } = useQuickBuySetup(position);
+ } = useQuickBuySetup(target);
const { options: sourceTokenOptions } = useSourceTokenOptions(destChainId);
const [selectedSourceToken, setSelectedSourceToken] = useState<
@@ -328,7 +324,10 @@ export function useQuickBuyBottomSheet(
const num = parseFloat(amount);
if (isNaN(num)) return '-';
const floored = Math.floor(num * 1e8) / 1e8;
- const formatted = floored.toFixed(8).replace(/\.?0+$/, '') || '0';
+ const formatted = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 8,
+ useGrouping: false,
+ }).format(floored);
return `${formatted} ${symbol}`;
}, [activeQuote, destToken]);
@@ -376,78 +375,111 @@ export function useQuickBuyBottomSheet(
const hasDestinationPicker = isEvmNonEvmBridge || isNonEvmNonEvmBridge;
const isDestinationAddressMissing = hasDestinationPicker && !destAddress;
- // Cleanup bridge state on unmount, and emit `Quick Buy Dismissed` whenever
- // the sheet closes without a successful submission.
- useEffect(
- () => () => {
- dispatch(resetBridgeState());
- if (Engine.context.BridgeController?.resetState) {
- Engine.context.BridgeController.resetState();
- }
- if (!tradeSubmittedRef.current && traderAddress && caip19) {
- const numeric = Number(lastTrackedAmountRef.current);
- track(MetaMetricsEvents.SOCIAL_QUICK_BUY_DISMISSED, {
- [SocialLeaderboardEventProperties.TRADER_ADDRESS]: traderAddress,
- [SocialLeaderboardEventProperties.CAIP19]: caip19,
- [SocialLeaderboardEventProperties.DISMISS_STAGE]:
- dismissStageRef.current,
- [SocialLeaderboardEventProperties.AMOUNT_USD]:
- Number.isFinite(numeric) && numeric > 0 ? numeric : undefined,
- });
- }
- },
- [dispatch, traderAddress, caip19, track],
+ const sourceBalanceFiatUsd = useMemo(() => {
+ if (
+ !latestSourceBalance?.displayBalance ||
+ !sourceToken?.currencyExchangeRate
+ ) {
+ return 0;
+ }
+ const balance = parseFloat(latestSourceBalance.displayBalance);
+ if (!Number.isFinite(balance)) return 0;
+ const fiat = balance * sourceToken.currencyExchangeRate;
+ return Number.isFinite(fiat) && fiat > 0 ? fiat : 0;
+ }, [latestSourceBalance?.displayBalance, sourceToken?.currencyExchangeRate]);
+
+ const sourceBalanceFiat = useMemo(
+ () => formatCurrency(sourceBalanceFiatUsd, currentCurrency),
+ [sourceBalanceFiatUsd, currentCurrency],
+ );
+
+ const sourceBalanceDisplay = useMemo(() => {
+ if (!latestSourceBalance?.displayBalance || !sourceToken?.symbol) {
+ return undefined;
+ }
+ const balance = parseFloat(latestSourceBalance.displayBalance);
+ if (isNaN(balance)) return undefined;
+ const formatted = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 6,
+ useGrouping: false,
+ }).format(balance);
+ return `${formatted} ${sourceToken.symbol}`;
+ }, [latestSourceBalance?.displayBalance, sourceToken?.symbol]);
+
+ const maxSpendUsd = sourceBalanceFiatUsd;
+
+ const formattedExchangeRate = useMemo(
+ () => formatExchangeRate(destToken, sourceToken),
+ [destToken, sourceToken],
+ );
+
+ const metamaskFeePercent = useMemo(
+ () => getMetamaskFeePercent(activeQuote),
+ [activeQuote],
);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
- const handlePresetPress = useCallback(
- (preset: string) => {
- lastInputMethodRef.current = 'preset';
- setUsdAmount(preset);
- const numericPreset = Number(preset);
- const presetValue =
- numericPreset === 20 ||
- numericPreset === 50 ||
- numericPreset === 100 ||
- numericPreset === 250
- ? numericPreset
- : undefined;
- lastTrackedAmountRef.current = preset;
- if (traderAddress && caip19) {
- track(MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, {
- [SocialLeaderboardEventProperties.TRADER_ADDRESS]: traderAddress,
- [SocialLeaderboardEventProperties.CAIP19]: caip19,
- [SocialLeaderboardEventProperties.AMOUNT_USD]: numericPreset,
- [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]:
- SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.PRESET,
- [SocialLeaderboardEventProperties.PRESET_VALUE]: presetValue,
- [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]:
- sourceToken?.symbol,
- });
+ const handleSliderChange = useCallback(
+ (percent: number) => {
+ const snapped = snapToPercentageStep(percent);
+ if (snapped === lastSnappedSliderPercentRef.current) {
+ return;
+ }
+
+ lastSnappedSliderPercentRef.current = snapped;
+ setSliderPercent(snapped);
+ if (maxSpendUsd <= 0) {
+ setUsdAmount('');
+ return;
+ }
+ const nextUsd =
+ snapped === 0 ? '' : ((maxSpendUsd * snapped) / 100).toFixed(2);
+ setUsdAmount(nextUsd);
+ lastInputMethodRef.current =
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER;
+ const numericUsd = Number(nextUsd);
+ if (snapped > 0 && Number.isFinite(numericUsd) && numericUsd > 0) {
+ trackAmountSelected(
+ numericUsd,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER,
+ sourceToken?.symbol,
+ snapped,
+ );
}
- dismissStageRef.current =
- SocialLeaderboardEventValues.DISMISS_STAGE.AMOUNT_SELECTION;
},
- [traderAddress, caip19, sourceToken?.symbol, track],
+ [maxSpendUsd, sourceToken?.symbol, trackAmountSelected, lastInputMethodRef],
);
const handleAmountAreaPress = useCallback(() => {
+ // Ensure the user always types in fiat so the keyboard digits match what
+ // they see. Crypto display mode is view-only; switch back on input focus.
+ setAmountDisplayMode('fiat');
hiddenInputRef.current?.focus();
}, []);
- const handleAmountChange = useCallback((text: string) => {
- lastInputMethodRef.current = 'custom_input';
- const cleaned = dotAndCommaDecimalFormatter(text).replace(/[^0-9.]/g, '');
- const normalized = cleaned.startsWith('.') ? `0${cleaned}` : cleaned;
- const parts = normalized.split('.');
- if (parts.length > 2) return;
- if (parts.length === 2 && parts[1].length > 2) return;
- setUsdAmount(normalized);
+ const handleToggleAmountDisplay = useCallback(() => {
+ setAmountDisplayMode((mode) => (mode === 'fiat' ? 'crypto' : 'fiat'));
}, []);
+ const handleAmountChange = useCallback(
+ (text: string) => {
+ lastInputMethodRef.current =
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT;
+ const cleaned = dotAndCommaDecimalFormatter(text).replace(/[^0-9.]/g, '');
+ const normalized = cleaned.startsWith('.') ? `0${cleaned}` : cleaned;
+ const parts = normalized.split('.');
+ if (parts.length > 2) return;
+ if (parts.length === 2 && parts[1].length > 2) return;
+ setUsdAmount(normalized);
+ lastSnappedSliderPercentRef.current = 0;
+ setSliderPercent(0);
+ },
+ [lastInputMethodRef],
+ );
+
// Debounced track for custom amount entries — fires once after the user
// stops typing for 500ms, so we don't emit on every keystroke.
useEffect(() => {
@@ -455,25 +487,22 @@ export function useQuickBuyBottomSheet(
if (!usdAmount) return;
const numeric = Number(usdAmount);
if (!Number.isFinite(numeric) || numeric <= 0) return;
- if (lastTrackedAmountRef.current === usdAmount) return;
+ if (lastTrackedAmountRef.current === String(numeric)) return;
const handle = setTimeout(() => {
- lastTrackedAmountRef.current = usdAmount;
- if (traderAddress && caip19) {
- track(MetaMetricsEvents.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, {
- [SocialLeaderboardEventProperties.TRADER_ADDRESS]: traderAddress,
- [SocialLeaderboardEventProperties.CAIP19]: caip19,
- [SocialLeaderboardEventProperties.AMOUNT_USD]: numeric,
- [SocialLeaderboardEventProperties.AMOUNT_SELECTION_METHOD]:
- SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
- [SocialLeaderboardEventProperties.PAY_WITH_TOKEN]:
- sourceToken?.symbol,
- });
- }
- dismissStageRef.current =
- SocialLeaderboardEventValues.DISMISS_STAGE.AMOUNT_SELECTION;
+ trackAmountSelected(
+ numeric,
+ SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT,
+ sourceToken?.symbol,
+ );
}, 500);
return () => clearTimeout(handle);
- }, [usdAmount, traderAddress, caip19, sourceToken?.symbol, track]);
+ }, [
+ usdAmount,
+ sourceToken?.symbol,
+ trackAmountSelected,
+ lastInputMethodRef,
+ lastTrackedAmountRef,
+ ]);
const handleConfirm = useCallback(async () => {
if (!activeQuote || !walletAddress) return;
@@ -486,7 +515,7 @@ export function useQuickBuyBottomSheet(
: undefined;
const submittedTraderAddress = traderAddress;
const submittedCaip19 = caip19;
- const submittedAssetName = destToken?.symbol ?? position.tokenSymbol;
+ const submittedAssetName = destToken?.symbol ?? target.tokenSymbol;
const submittedPayWith = sourceToken?.symbol;
// Shared by the SUBMITTED + COMPLETED (success / failure) events. Built
@@ -504,11 +533,9 @@ export function useQuickBuyBottomSheet(
: null;
if (tradeBaseProps) {
- track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, tradeBaseProps);
+ trackTradeSubmitted(tradeBaseProps);
}
- tradeSubmittedRef.current = true;
- dismissStageRef.current =
- SocialLeaderboardEventValues.DISMISS_STAGE.CONFIRMATION;
+ markTradeSubmitted();
submitStartedAtRef.current = Date.now();
const elapsedMs = () =>
@@ -529,7 +556,7 @@ export function useQuickBuyBottomSheet(
? ((submitResult as { hash?: string }).hash as string)
: undefined;
if (tradeBaseProps) {
- track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, {
+ trackTradeCompleted({
...tradeBaseProps,
[SocialLeaderboardEventProperties.AMOUNT_TOKEN]: amountToken,
[SocialLeaderboardEventProperties.TX_HASH]: txHash,
@@ -549,7 +576,7 @@ export function useQuickBuyBottomSheet(
surface: 'quick_buy',
operation: 'submit_tx',
extraMessage: 'Error submitting QuickBuy tx',
- source: 'useQuickBuyBottomSheet',
+ source: 'useQuickBuyController',
error,
extraTags: {
sourceChainId: sourceToken?.chainId ?? 'unknown',
@@ -559,7 +586,7 @@ export function useQuickBuyBottomSheet(
);
await playErrorNotification();
if (tradeBaseProps) {
- track(MetaMetricsEvents.SOCIAL_QUICK_BUY_TRADE_COMPLETED, {
+ trackTradeCompleted({
...tradeBaseProps,
[SocialLeaderboardEventProperties.AMOUNT_TOKEN]: amountToken,
[SocialLeaderboardEventProperties.EXECUTION_TIME_MS]: elapsedMs(),
@@ -583,22 +610,14 @@ export function useQuickBuyBottomSheet(
traderAddress,
caip19,
destToken?.symbol,
- position.tokenSymbol,
+ target.tokenSymbol,
sourceToken?.symbol,
- track,
+ trackTradeSubmitted,
+ trackTradeCompleted,
+ markTradeSubmitted,
+ submitStartedAtRef,
]);
- const sourceBalanceFiat = useMemo(() => {
- if (
- !latestSourceBalance?.displayBalance ||
- !sourceToken?.currencyExchangeRate
- )
- return undefined;
- const balance = parseFloat(latestSourceBalance.displayBalance);
- if (isNaN(balance)) return undefined;
- return `$${(balance * sourceToken.currencyExchangeRate).toFixed(2)}`;
- }, [latestSourceBalance?.displayBalance, sourceToken?.currencyExchangeRate]);
-
const hasError = Boolean(quoteFetchError || isNoQuotesAvailable);
const hasValidAmount = Boolean(usdAmount && Number(usdAmount) > 0);
const hasQuoteRequestableAmount = useMemo(() => {
@@ -684,10 +703,13 @@ export function useQuickBuyBottomSheet(
}
const getButtonLabel = useCallback(() => {
+ if (!hasValidAmount) {
+ return strings('social_leaderboard.trader_position.buy');
+ }
if (buttonError) return strings(BUTTON_ERROR_LABELS[buttonError]);
if (isSubmittingTx) return strings('bridge.submitting_transaction');
return strings('social_leaderboard.trader_position.buy');
- }, [buttonError, isSubmittingTx]);
+ }, [buttonError, hasValidAmount, isSubmittingTx]);
return {
hiddenInputRef,
@@ -701,9 +723,15 @@ export function useQuickBuyBottomSheet(
isSourcePickerOpen,
setIsSourcePickerOpen,
setSelectedSourceToken,
+ amountDisplayMode,
usdAmount,
+ sliderPercent,
+ maxSpendUsd,
+ formattedExchangeRate,
+ metamaskFeePercent,
estimatedReceiveAmount,
sourceBalanceFiat,
+ sourceBalanceDisplay,
formattedNetworkFee,
formattedSlippage,
formattedMinimumReceived,
@@ -721,9 +749,10 @@ export function useQuickBuyBottomSheet(
confirmButtonState,
getButtonLabel,
handleClose,
- handlePresetPress,
+ handleSliderChange,
handleAmountAreaPress,
handleAmountChange,
+ handleToggleAmountDisplay,
handleConfirm,
};
}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts
similarity index 89%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts
index 64feb896662..e90292cd3a0 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts
@@ -6,35 +6,36 @@ import {
isNonEvmChainId,
selectBridgeQuotes as selectBridgeQuotesBase,
SortOrder,
+ type BridgeAppState,
type GenericQuoteRequest,
type L1GasFees,
type NonEvmFees,
type QuoteResponse,
} from '@metamask/bridge-controller';
-import type { RootState } from '../../../../../../reducers';
-import Engine from '../../../../../../core/Engine';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import { fromTokenMinimalUnit } from '../../../../../../util/number';
-import { areAddressesEqual } from '../../../../../../util/address';
-import { calcTokenValue } from '../../../../../../util/transactions';
-import { analytics } from '../../../../../../util/analytics/analytics';
-import { selectRemoteFeatureFlags } from '../../../../../../selectors/featureFlagController';
+import type { RootState } from '../../../../../../../reducers';
+import Engine from '../../../../../../../core/Engine';
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { fromTokenMinimalUnit } from '../../../../../../../util/number/bigint';
+import { areAddressesEqual } from '../../../../../../../util/address';
+import { calcTokenValue } from '../../../../../../../util/transactions';
+import { analytics } from '../../../../../../../util/analytics/analytics';
+import { selectRemoteFeatureFlags } from '../../../../../../../selectors/featureFlagController';
import {
selectDestAddress,
selectSlippage,
-} from '../../../../../../core/redux/slices/bridge';
+} from '../../../../../../../core/redux/slices/bridge';
import {
selectGasIncludedQuoteParams,
selectSourceWalletAddress,
-} from '../../../../../../selectors/bridge';
-import { getDecimalChainId } from '../../../../../../util/networks';
-import Logger from '../../../../../../util/Logger';
-import { buildSocialLoggerErrorOptions } from '../../../../../../util/social/socialServiceTelemetry';
+} from '../../../../../../../selectors/bridge';
+import { getDecimalChainId } from '../../../../../../../util/networks';
+import Logger from '../../../../../../../util/Logger';
+import { buildSocialLoggerErrorOptions } from '../../../../../../../util/social/socialServiceTelemetry';
import {
SocialLeaderboardEventProperties,
useSocialLeaderboardAnalytics,
-} from '../../../analytics';
-import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+} from '../../../../analytics';
+import { MetaMetricsEvents } from '../../../../../../../core/Analytics';
export type QuickBuyQuote = QuoteResponse & L1GasFees & NonEvmFees;
@@ -330,15 +331,16 @@ export function useQuickBuyQuotes({
}
: {};
- const controllerFields = {
+ const existingQuoteRequest = metadataDeps.bridgeController.quoteRequest;
+ const quoteRequestBase = Array.isArray(existingQuoteRequest)
+ ? (existingQuoteRequest[0] ?? {})
+ : (existingQuoteRequest ?? {});
+
+ const controllerFields: BridgeAppState = {
...metadataDeps.bridgeController,
quotes: rawQuotes,
- quoteRequest: [
- {
- ...(metadataDeps.bridgeController.quoteRequest?.[0] ?? {}),
- ...quoteRequestPatch,
- },
- ],
+ // selectBridgeQuotes destructures quoteRequest as an array at runtime.
+ quoteRequest: [{ ...quoteRequestBase, ...quoteRequestPatch }],
gasFeeEstimatesByChainId: metadataDeps.gasFeeEstimatesByChainId,
...metadataDeps.multichainAssetsRates,
...metadataDeps.tokenRates,
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuySetup.ts
similarity index 89%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuySetup.ts
index 20e5e92521e..618ef250289 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuySetup.ts
@@ -6,11 +6,11 @@ import {
formatChainIdToHex,
isNonEvmChainId,
} from '@metamask/bridge-controller';
-import type { Position } from '@metamask/social-controllers';
-import { useAssetMetadata } from '../../../../../UI/Bridge/hooks/useAssetMetadata';
-import { chainNameToId } from '../../../utils/chainMapping';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import { selectIsBridgeEnabledSourceFactory } from '../../../../../../core/redux/slices/bridge';
+import type { QuickBuyTarget } from '../types';
+import { useAssetMetadata } from '../../../../../../UI/Bridge/hooks/useAssetMetadata';
+import { chainNameToId } from '../../../../utils/chainMapping';
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { selectIsBridgeEnabledSourceFactory } from '../../../../../../../core/redux/slices/bridge';
export interface QuickBuySetupResult {
/** The destination chain ID (hex or CAIP) for this position's chain */
@@ -30,8 +30,9 @@ export interface QuickBuySetupResult {
* Source token selection is handled separately by useSourceTokenOptions.
*/
export const useQuickBuySetup = (
- position: Position | null,
+ target: QuickBuyTarget | null,
): QuickBuySetupResult => {
+ const position = target;
const isBridgeEnabledSource = useSelector(selectIsBridgeEnabledSourceFactory);
// Destination chain from the position — hex for EVM, CAIP for non-EVM
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useSourceTokenOptions.ts
similarity index 90%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useSourceTokenOptions.ts
index fc4e8f5d9bb..d9e76e1c844 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useSourceTokenOptions.ts
@@ -4,20 +4,20 @@ import { CaipChainId, Hex } from '@metamask/utils';
import { formatUnits } from 'ethers/lib/utils';
import { isSolanaChainId } from '@metamask/bridge-controller';
import { SolScope } from '@metamask/keyring-api';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import type { RootState } from '../../../../../../reducers';
-import { selectAccountsByChainId } from '../../../../../../selectors/accountTrackerController';
-import { selectSelectedInternalAccountByScope } from '../../../../../../selectors/multichainAccounts/accounts';
-import { selectTokensBalances } from '../../../../../../selectors/tokenBalancesController';
-import { selectTokenMarketData } from '../../../../../../selectors/tokenRatesController';
-import { selectCurrencyRates } from '../../../../../../selectors/currencyRateController';
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import type { RootState } from '../../../../../../../reducers';
+import { selectAccountsByChainId } from '../../../../../../../selectors/accountTrackerController';
+import { selectSelectedInternalAccountByScope } from '../../../../../../../selectors/multichainAccounts/accounts';
+import { selectTokensBalances } from '../../../../../../../selectors/tokenBalancesController';
+import { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController';
+import { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController';
import {
selectMultichainBalances,
selectMultichainAssetsRates,
-} from '../../../../../../selectors/multichain/multichain';
-import { getSourceTokenCandidates } from './sourceTokenCandidates';
-import { toChecksumAddress } from '../../../../../../util/address';
-import { EVM_SCOPE } from '../../../../../UI/Earn/constants/networks';
+} from '../../../../../../../selectors/multichain/multichain';
+import { getSourceTokenCandidates } from '../sourceTokenCandidates';
+import { toChecksumAddress } from '../../../../../../../util/address';
+import { EVM_SCOPE } from '../../../../../../UI/Earn/constants/networks';
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts
new file mode 100644
index 00000000000..43e2c94340b
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts
@@ -0,0 +1,28 @@
+/** Compound API — prefer `QuickBuy.Root`, `QuickBuy.AmountScreen`, etc. */
+export { QuickBuy } from './quickBuy';
+
+export type { QuickBuyRootProps } from './types';
+export type { QuickBuyContextValue } from './QuickBuyContext';
+export type { QuickBuySheetProps } from './types';
+export type { TraderPositionQuickBuyProps } from './TraderPositionQuickBuy';
+export type {
+ QuickBuyTarget,
+ QuickBuyFeatures,
+ QuickBuyTradeMode,
+ QuickBuyScreen,
+ QuickBuyAnalyticsContext,
+} from './types';
+
+export { TOP_TRADERS_QUICK_BUY_FEATURES } from './features';
+export { positionToQuickBuyTarget } from './types';
+
+/** Top Traders host adapter */
+export { default as TraderPositionQuickBuy } from './TraderPositionQuickBuy';
+export { default } from './TraderPositionQuickBuy';
+
+/** Named convenience exports — same components as `QuickBuy.*` */
+export { default as QuickBuyRoot } from './QuickBuyRoot';
+export { default as QuickBuyAmountScreen } from './QuickBuyAmountScreen';
+export { default as QuickBuyAmount } from './QuickBuyAmount';
+export { default as QuickBuyToolbar } from './components/QuickBuyToolbar';
+export { default as QuickBuyFooter } from './components/QuickBuyActionFooter';
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts
new file mode 100644
index 00000000000..d656f1fe914
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts
@@ -0,0 +1,13 @@
+import QuickBuyAmount from './QuickBuyAmount';
+import QuickBuyAmountScreen from './QuickBuyAmountScreen';
+import QuickBuyRoot from './QuickBuyRoot';
+import QuickBuyActionFooter from './components/QuickBuyActionFooter';
+import QuickBuyToolbar from './components/QuickBuyToolbar';
+
+export const QuickBuy = {
+ Root: QuickBuyRoot,
+ AmountScreen: QuickBuyAmountScreen,
+ Toolbar: QuickBuyToolbar,
+ Amount: QuickBuyAmount,
+ Footer: QuickBuyActionFooter,
+} as const;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts
new file mode 100644
index 00000000000..1521650b5f9
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts
@@ -0,0 +1,137 @@
+import { SolScope } from '@metamask/keyring-api';
+import { NETWORK_CHAIN_ID } from '../../../../../../util/networks/customNetworks';
+import { getSourceTokenCandidates, getTokenKey } from './sourceTokenCandidates';
+import type { BridgeToken } from '../../../../../UI/Bridge/types';
+
+const mockNativeToken = (chainId: string): BridgeToken =>
+ ({
+ address: '0x0000000000000000000000000000000000000000',
+ chainId,
+ symbol: 'ETH',
+ name: 'Ether',
+ decimals: 18,
+ }) as unknown as BridgeToken;
+
+jest.mock('../../../../../UI/Bridge/utils/tokenUtils', () => ({
+ getNativeSourceToken: jest.fn((chainId: string) => mockNativeToken(chainId)),
+}));
+
+jest.mock(
+ '../../../../../UI/Bridge/constants/default-swap-dest-tokens',
+ () => ({
+ DefaultSwapDestTokens: {
+ 'eip155:1/erc20:usdc': {
+ symbol: 'USDC',
+ address: '0xusdc',
+ chainId: '0x1',
+ decimals: 6,
+ name: 'USD Coin',
+ },
+ 'eip155:137/erc20:usdc_matic': {
+ symbol: 'USDC',
+ address: '0xusdc_matic',
+ chainId: '0x89',
+ decimals: 6,
+ name: 'USD Coin (Polygon)',
+ },
+ 'eip155:1/erc20:eth': {
+ symbol: 'WETH',
+ address: '0xweth',
+ chainId: '0x1',
+ decimals: 18,
+ name: 'Wrapped Ether',
+ },
+ },
+ Bip44TokensForDefaultPairs: {
+ 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': {
+ symbol: 'USDC',
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ chainId: '0x1',
+ decimals: 6,
+ name: 'USD Coin',
+ },
+ },
+ }),
+);
+
+jest.mock('../../../../../../constants/bridge', () => ({
+ ETH_USDT_ADDRESS: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+}));
+
+describe('getTokenKey', () => {
+ it('returns address:chainId with address lowercased', () => {
+ const token = {
+ address: '0xABCD',
+ chainId: '0x1',
+ } as unknown as BridgeToken;
+
+ expect(getTokenKey(token)).toBe('0xabcd:0x1');
+ });
+
+ it('handles already lowercase addresses', () => {
+ const token = {
+ address: '0xabcd',
+ chainId: '0x89',
+ } as unknown as BridgeToken;
+
+ expect(getTokenKey(token)).toBe('0xabcd:0x89');
+ });
+});
+
+describe('getSourceTokenCandidates', () => {
+ it('returns an array of BridgeToken candidates', () => {
+ const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET);
+ expect(Array.isArray(candidates)).toBe(true);
+ expect(candidates.length).toBeGreaterThan(0);
+ });
+
+ it('includes stablecoin candidates (USDC, USDT)', () => {
+ const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET);
+ const symbols = candidates.map((t) => t.symbol);
+ expect(symbols).toContain('USDC');
+ expect(symbols).toContain('USDT');
+ });
+
+ it('includes native SOL for Solana mainnet', () => {
+ const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET);
+ const solTokens = candidates.filter((t) => t.chainId === SolScope.Mainnet);
+ expect(solTokens.length).toBeGreaterThan(0);
+ });
+
+ it('includes native token for all major EVM chains', () => {
+ const candidates = getSourceTokenCandidates(undefined);
+ const chainIds = candidates.map((t) => t.chainId);
+ expect(chainIds).toContain(NETWORK_CHAIN_ID.MAINNET);
+ expect(chainIds).toContain(NETWORK_CHAIN_ID.BASE);
+ });
+
+ it('does not duplicate native token when destChainId is already in NATIVE_TOKEN_CHAIN_IDS', () => {
+ const candidates = getSourceTokenCandidates(NETWORK_CHAIN_ID.MAINNET);
+ const mainnetNatives = candidates.filter(
+ (t) =>
+ t.chainId === NETWORK_CHAIN_ID.MAINNET &&
+ t.address === '0x0000000000000000000000000000000000000000',
+ );
+ // Should appear exactly once from the NATIVE_TOKEN_CHAIN_IDS loop
+ expect(mainnetNatives.length).toBe(1);
+ });
+
+ it('adds native token for a novel EVM destChainId not in NATIVE_TOKEN_CHAIN_IDS', () => {
+ const novelChain = '0x12345';
+ const candidates = getSourceTokenCandidates(novelChain);
+ const novelNatives = candidates.filter((t) => t.chainId === novelChain);
+ expect(novelNatives.length).toBeGreaterThan(0);
+ });
+
+ it('does NOT add native token for a non-EVM (non-0x) destChainId', () => {
+ const candidates = getSourceTokenCandidates(SolScope.Mainnet);
+ // Solana mainnet natives are added explicitly, not through the EVM branch
+ const solNatives = candidates.filter((t) => t.chainId === SolScope.Mainnet);
+ // Exactly one SOL native (added in the explicit push, not duplicated)
+ expect(solNatives.length).toBe(1);
+ });
+
+ it('handles undefined destChainId without error', () => {
+ expect(() => getSourceTokenCandidates(undefined)).not.toThrow();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/sourceTokenCandidates.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.ts
similarity index 100%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/sourceTokenCandidates.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.ts
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts
new file mode 100644
index 00000000000..35fcbc24742
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts
@@ -0,0 +1,60 @@
+import type { ReactNode } from 'react';
+import type { Position } from '@metamask/social-controllers';
+import type { QuickBuySheetSource } from '../../../analytics';
+
+/** Host-agnostic trade target — maps from social `Position` via adapter. */
+export interface QuickBuyTarget {
+ tokenAddress: string;
+ tokenSymbol: string;
+ tokenName: string;
+ chain: string;
+}
+
+export type QuickBuyTradeMode = 'buy' | 'sell';
+
+/** Which amount is shown as the large primary value in the amount section. */
+export type QuickBuyAmountDisplayMode = 'fiat' | 'crypto';
+
+export type QuickBuyScreen =
+ | 'amount'
+ | 'quoteDetails'
+ | 'selectQuote'
+ | 'payWith';
+
+/** Feature flags for optional flow pieces (enabled per consumer). */
+export interface QuickBuyFeatures {
+ tradeModes: QuickBuyTradeMode[];
+ quoteDetails: boolean;
+ selectQuote: boolean;
+ payWithSheet: boolean;
+ highPriceImpactModal: boolean;
+ fiatCryptoToggle: boolean;
+}
+
+export interface QuickBuyAnalyticsContext {
+ traderAddress?: string;
+ marketCap?: number;
+ source?: QuickBuySheetSource;
+}
+
+export interface QuickBuySheetProps {
+ isVisible: boolean;
+ target: QuickBuyTarget | null;
+ onClose: () => void;
+ features?: QuickBuyFeatures;
+ analyticsContext?: QuickBuyAnalyticsContext;
+ children?: ReactNode;
+}
+
+/** Same contract as `QuickBuySheetProps` — props for `QuickBuy.Root`. */
+export type QuickBuyRootProps = QuickBuySheetProps;
+
+/** Maps a social leaderboard position into a portable QuickBuy target. */
+export function positionToQuickBuyTarget(position: Position): QuickBuyTarget {
+ return {
+ tokenAddress: position.tokenAddress,
+ tokenSymbol: position.tokenSymbol,
+ tokenName: position.tokenName,
+ chain: position.chain,
+ };
+}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts
new file mode 100644
index 00000000000..cf443a36051
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts
@@ -0,0 +1,14 @@
+import { useContext } from 'react';
+import { QuickBuyContext, type QuickBuyContextValue } from './QuickBuyContext';
+
+export function useQuickBuyContext(): QuickBuyContextValue {
+ const context = useContext(QuickBuyContext);
+
+ if (!context) {
+ throw new Error(
+ 'QuickBuy compound components must be rendered within QuickBuy.Root',
+ );
+ }
+
+ return context;
+}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
similarity index 79%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
index 1a877a6669e..9108d6b2563 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
@@ -1,11 +1,12 @@
import { renderHook, act } from '@testing-library/react-native';
import { useSelector, useDispatch } from 'react-redux';
import type { Position } from '@metamask/social-controllers';
-import { useQuickBuyBottomSheet } from './useQuickBuyBottomSheet';
+import { useQuickBuyController } from './hooks/useQuickBuyController';
+import { positionToQuickBuyTarget } from './types';
import { selectDefaultSourceToken } from '../../../utils/tokenSelection';
-import { useQuickBuySetup } from './useQuickBuySetup';
-import { useSourceTokenOptions } from './useSourceTokenOptions';
-import { useQuickBuyQuotes } from './useQuickBuyQuotes';
+import { useQuickBuySetup } from './hooks/useQuickBuySetup';
+import { useSourceTokenOptions } from './hooks/useSourceTokenOptions';
+import { useQuickBuyQuotes } from './hooks/useQuickBuyQuotes';
import { useLatestBalance } from '../../../../../UI/Bridge/hooks/useLatestBalance';
import useIsInsufficientBalance from '../../../../../UI/Bridge/hooks/useInsufficientBalance';
import { useHasSufficientGas } from '../../../../../UI/Bridge/hooks/useHasSufficientGas';
@@ -22,6 +23,7 @@ import {
} from '../../../../../../core/redux/slices/bridge';
import { selectSourceWalletAddress } from '../../../../../../selectors/bridge';
import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController';
+import { selectCurrentCurrency } from '../../../../../../selectors/currencyRateController';
import { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData';
import { TextColor } from '@metamask/design-system-react-native';
import type { BridgeToken } from '../../../../../UI/Bridge/types';
@@ -44,15 +46,33 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: jest.fn() }),
}));
-jest.mock('./useQuickBuySetup', () => ({
+const mockTrackAmountSelected = jest.fn();
+
+jest.mock('./hooks/useQuickBuyAnalytics', () => ({
+ useQuickBuyAnalytics: () => ({
+ refs: {
+ dismissStageRef: { current: 'amount_selection' },
+ tradeSubmittedRef: { current: false },
+ lastTrackedAmountRef: { current: '' },
+ lastInputMethodRef: { current: 'custom_input' },
+ submitStartedAtRef: { current: null },
+ },
+ trackAmountSelected: mockTrackAmountSelected,
+ trackTradeSubmitted: jest.fn(),
+ trackTradeCompleted: jest.fn(),
+ markTradeSubmitted: jest.fn(),
+ }),
+}));
+
+jest.mock('./hooks/useQuickBuySetup', () => ({
useQuickBuySetup: jest.fn(),
}));
-jest.mock('./useSourceTokenOptions', () => ({
+jest.mock('./hooks/useSourceTokenOptions', () => ({
useSourceTokenOptions: jest.fn(),
}));
-jest.mock('./useQuickBuyQuotes', () => ({
+jest.mock('./hooks/useQuickBuyQuotes', () => ({
useQuickBuyQuotes: jest.fn(),
}));
@@ -141,6 +161,10 @@ jest.mock('../../../../../../selectors/accountsController', () => ({
selectSelectedInternalAccountFormattedAddress: jest.fn(),
}));
+jest.mock('../../../../../../selectors/currencyRateController', () => ({
+ selectCurrentCurrency: jest.fn(),
+}));
+
jest.mock('../../../../../../util/address', () => ({
isHardwareAccount: jest.fn(() => false),
}));
@@ -233,6 +257,7 @@ const setupDefaultMocks = () => {
(
selectSelectedInternalAccountFormattedAddress as unknown as jest.Mock
).mockReturnValue('0xWALLET');
+ (selectCurrentCurrency as unknown as jest.Mock).mockReturnValue('USD');
(usePriceImpactViewData as jest.Mock).mockReturnValue({
textColor: TextColor.TextAlternative,
icon: undefined,
@@ -282,7 +307,7 @@ const setupDefaultMocks = () => {
).mockResolvedValue(undefined);
};
-describe('useQuickBuyBottomSheet', () => {
+describe('useQuickBuyController', () => {
beforeEach(() => {
jest.clearAllMocks();
setupDefaultMocks();
@@ -295,7 +320,10 @@ describe('useQuickBuyBottomSheet', () => {
describe('handleAmountChange', () => {
it('accepts valid numeric input', () => {
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
@@ -307,7 +335,10 @@ describe('useQuickBuyBottomSheet', () => {
it('normalizes a leading decimal without digits', () => {
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
@@ -320,7 +351,10 @@ describe('useQuickBuyBottomSheet', () => {
it('normalizes a leading decimal with digits', () => {
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
@@ -330,26 +364,100 @@ describe('useQuickBuyBottomSheet', () => {
expect(result.current.usdAmount).toBe('0.5');
expect(result.current.hasValidAmount).toBe(true);
});
+
+ it('resets slider percent when the user types a custom amount', () => {
+ (useLatestBalance as jest.Mock).mockReturnValue({
+ displayBalance: '100',
+ atomicBalance: '100000000',
+ });
+ const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 });
+ (useSourceTokenOptions as jest.Mock).mockReturnValue({
+ options: [sourceWithRate],
+ });
+
+ const { result } = renderHook(() =>
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
+ );
+
+ act(() => {
+ result.current.handleSliderChange(50);
+ });
+
+ expect(result.current.sliderPercent).toBe(50);
+
+ act(() => {
+ result.current.handleAmountChange('25');
+ });
+
+ expect(result.current.usdAmount).toBe('25');
+ expect(result.current.sliderPercent).toBe(0);
+ });
});
- describe('handlePresetPress', () => {
- it('sets usdAmount to the preset value', () => {
+ describe('handleSliderChange', () => {
+ it('sets usdAmount from slider percent of available balance', () => {
+ (useLatestBalance as jest.Mock).mockReturnValue({
+ displayBalance: '100',
+ atomicBalance: '100000000',
+ });
+ const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 });
+ (useSourceTokenOptions as jest.Mock).mockReturnValue({
+ options: [sourceWithRate],
+ });
+
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
- result.current.handlePresetPress('50');
+ result.current.handleSliderChange(50);
});
- expect(result.current.usdAmount).toBe('50');
+ expect(result.current.sliderPercent).toBe(50);
+ expect(Number(result.current.usdAmount)).toBeGreaterThan(0);
+ });
+
+ it('tracks amount selected once when the snapped percent is unchanged', () => {
+ (useLatestBalance as jest.Mock).mockReturnValue({
+ displayBalance: '100',
+ atomicBalance: '100000000',
+ });
+ const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 });
+ (useSourceTokenOptions as jest.Mock).mockReturnValue({
+ options: [sourceWithRate],
+ });
+
+ const { result } = renderHook(() =>
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
+ );
+
+ act(() => {
+ result.current.handleSliderChange(48);
+ result.current.handleSliderChange(49);
+ result.current.handleSliderChange(51);
+ });
+
+ expect(result.current.sliderPercent).toBe(50);
+ expect(mockTrackAmountSelected).toHaveBeenCalledTimes(1);
});
});
describe('getButtonLabel', () => {
it('returns the buy label when all conditions are normal', () => {
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
expect(result.current.getButtonLabel()).toBe(
@@ -361,9 +469,16 @@ describe('useQuickBuyBottomSheet', () => {
(useIsInsufficientBalance as jest.Mock).mockReturnValue(true);
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
+ act(() => {
+ result.current.handleAmountChange('20');
+ });
+
expect(result.current.buttonError).toBe('insufficient_balance');
expect(result.current.getButtonLabel()).toBe('bridge.insufficient_funds');
});
@@ -372,9 +487,16 @@ describe('useQuickBuyBottomSheet', () => {
(useHasSufficientGas as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
+ act(() => {
+ result.current.handleAmountChange('20');
+ });
+
expect(result.current.buttonError).toBe('insufficient_gas');
expect(result.current.getButtonLabel()).toBe('bridge.insufficient_gas');
@@ -401,9 +523,16 @@ describe('useQuickBuyBottomSheet', () => {
});
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
+ act(() => {
+ result.current.handleAmountChange('20');
+ });
+
expect(result.current.buttonError).toBe('insufficient_balance');
expect(result.current.getButtonLabel()).toBe('bridge.insufficient_funds');
expect(useHasSufficientGas).toHaveBeenCalledWith({
@@ -414,7 +543,12 @@ describe('useQuickBuyBottomSheet', () => {
describe('quoteOverride wiring', () => {
it('passes null to useIsInsufficientBalance when there is no active quote', () => {
- renderHook(() => useQuickBuyBottomSheet(createPosition(), jest.fn()));
+ renderHook(() =>
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
+ );
expect(useIsInsufficientBalance).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -434,7 +568,12 @@ describe('useQuickBuyBottomSheet', () => {
isActiveQuoteForCurrentTokenPair: true,
});
- renderHook(() => useQuickBuyBottomSheet(createPosition(), jest.fn()));
+ renderHook(() =>
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
+ );
expect(useIsInsufficientBalance).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -447,7 +586,10 @@ describe('useQuickBuyBottomSheet', () => {
describe('isConfirmDisabled', () => {
it('is disabled when usdAmount is empty', () => {
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
expect(result.current.isConfirmDisabled).toBe(true);
@@ -455,7 +597,10 @@ describe('useQuickBuyBottomSheet', () => {
it('is disabled when amount is valid and there is no active quote', () => {
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
@@ -480,7 +625,10 @@ describe('useQuickBuyBottomSheet', () => {
});
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
@@ -503,7 +651,10 @@ describe('useQuickBuyBottomSheet', () => {
});
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
act(() => {
@@ -534,11 +685,11 @@ describe('useQuickBuyBottomSheet', () => {
(useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState);
const props = {
- position: createPosition(),
+ target: positionToQuickBuyTarget(createPosition()),
onClose: jest.fn(),
};
const { result, rerender } = renderHook(
- ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose),
+ ({ target, onClose }) => useQuickBuyController(target, onClose),
{
initialProps: props,
},
@@ -579,11 +730,11 @@ describe('useQuickBuyBottomSheet', () => {
(useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState);
const props = {
- position: createPosition(),
+ target: positionToQuickBuyTarget(createPosition()),
onClose: jest.fn(),
};
const { result, rerender } = renderHook(
- ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose),
+ ({ target, onClose }) => useQuickBuyController(target, onClose),
{
initialProps: props,
},
@@ -628,11 +779,11 @@ describe('useQuickBuyBottomSheet', () => {
(useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState);
const props = {
- position: createPosition(),
+ target: positionToQuickBuyTarget(createPosition()),
onClose: jest.fn(),
};
const { result, rerender } = renderHook(
- ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose),
+ ({ target, onClose }) => useQuickBuyController(target, onClose),
{ initialProps: props },
);
@@ -677,11 +828,11 @@ describe('useQuickBuyBottomSheet', () => {
}));
const props = {
- position: createPosition(),
+ target: positionToQuickBuyTarget(createPosition()),
onClose: jest.fn(),
};
const { result } = renderHook(
- ({ position, onClose }) => useQuickBuyBottomSheet(position, onClose),
+ ({ target, onClose }) => useQuickBuyController(target, onClose),
{ initialProps: props },
);
@@ -704,7 +855,10 @@ describe('useQuickBuyBottomSheet', () => {
});
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
// createPosition uses chain 'base' → destChainId '0x1' in default mock,
@@ -828,7 +982,10 @@ describe('useQuickBuyBottomSheet', () => {
it('calls the onClose prop', () => {
const onClose = jest.fn();
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), onClose),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ onClose,
+ ),
);
act(() => {
@@ -860,7 +1017,10 @@ describe('useQuickBuyBottomSheet', () => {
const onClose = jest.fn();
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), onClose),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ onClose,
+ ),
);
await act(async () => {
@@ -887,7 +1047,10 @@ describe('useQuickBuyBottomSheet', () => {
});
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
await act(async () => {
@@ -915,7 +1078,10 @@ describe('useQuickBuyBottomSheet', () => {
});
const { result } = renderHook(() =>
- useQuickBuyBottomSheet(createPosition(), jest.fn()),
+ useQuickBuyController(
+ positionToQuickBuyTarget(createPosition()),
+ jest.fn(),
+ ),
);
await act(async () => {
@@ -929,10 +1095,10 @@ describe('useQuickBuyBottomSheet', () => {
feature: 'social',
surface: 'quick_buy',
operation: 'submit_tx',
- source: 'useQuickBuyBottomSheet',
+ source: 'useQuickBuyController',
}),
extras: expect.objectContaining({
- message: 'Error submitting QuickBuy tx at useQuickBuyBottomSheet',
+ message: 'Error submitting QuickBuy tx at useQuickBuyController',
}),
}),
);
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts
similarity index 99%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.test.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts
index 97ba3b2587d..3b112fb69fe 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts
@@ -5,7 +5,7 @@ import { MetaMetricsEvents } from '../../../../../../core/Analytics';
import {
useQuickBuyQuotes,
QUICK_BUY_QUOTE_DEBOUNCE_MS,
-} from './useQuickBuyQuotes';
+} from './hooks/useQuickBuyQuotes';
import type { BridgeToken } from '../../../../../UI/Bridge/types';
import {
selectDestAddress,
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuySetup.test.ts
similarity index 98%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.test.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuySetup.test.ts
index e9a7126a376..023db282b49 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuySetup.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuySetup.test.ts
@@ -6,7 +6,7 @@ import {
AssetType,
} from '../../../../../UI/Bridge/hooks/useAssetMetadata';
import { selectIsBridgeEnabledSourceFactory } from '../../../../../../core/redux/slices/bridge';
-import { useQuickBuySetup } from './useQuickBuySetup';
+import { useQuickBuySetup } from './hooks/useQuickBuySetup';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useSourceTokenOptions.test.ts
similarity index 99%
rename from app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.test.ts
rename to app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useSourceTokenOptions.test.ts
index 26f79f57b02..6bc4581fd77 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useSourceTokenOptions.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useSourceTokenOptions.test.ts
@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { toChecksumAddress } from '../../../../../../util/address';
import type { BridgeToken } from '../../../../../UI/Bridge/types';
import { getSourceTokenCandidates } from './sourceTokenCandidates';
-import { useSourceTokenOptions } from './useSourceTokenOptions';
+import { useSourceTokenOptions } from './hooks/useSourceTokenOptions';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts
new file mode 100644
index 00000000000..2eb34c2d16c
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts
@@ -0,0 +1,48 @@
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { MINIMUM_DISPLAY_THRESHOLD } from '../../../../../../../util/number/bigint';
+import { formatExchangeRate } from './formatExchangeRate';
+
+const createToken = (
+ symbol: string,
+ currencyExchangeRate: number,
+): BridgeToken =>
+ ({
+ symbol,
+ currencyExchangeRate,
+ }) as BridgeToken;
+
+describe('formatExchangeRate', () => {
+ it('returns undefined when exchange rates are missing', () => {
+ expect(formatExchangeRate(undefined, createToken('ETH', 3000))).toBe(
+ undefined,
+ );
+ expect(formatExchangeRate(createToken('PEPE', 1), undefined)).toBe(
+ undefined,
+ );
+ });
+
+ it('formats large rates without spurious fraction digits', () => {
+ const result = formatExchangeRate(
+ createToken('ETH', 3000),
+ createToken('USDC', 1),
+ );
+
+ expect(result).toBe('1 ETH = 3000 USDC');
+ });
+
+ it('uses the dust threshold for sub-micro meme-coin rates', () => {
+ const destPepe = createToken('PEPE', 0.000008);
+ const sourceEth = createToken('ETH', 3000);
+
+ const result = formatExchangeRate(destPepe, sourceEth);
+
+ expect(result).toBe(`1 PEPE = < ${MINIMUM_DISPLAY_THRESHOLD} ETH`);
+ });
+
+ it('formats mid-range rates up to five decimal places', () => {
+ const dest = createToken('TOKEN', 0.05);
+ const source = createToken('USDC', 1);
+
+ expect(formatExchangeRate(dest, source)).toBe('1 TOKEN = 0.05 USDC');
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts
new file mode 100644
index 00000000000..08713ec7aa8
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts
@@ -0,0 +1,33 @@
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { formatAmountWithThreshold } from '../../../../../../../util/number/bigint';
+
+/** Matches social leaderboard token amount formatting (see `formatTokenAmount`). */
+const MAX_RATE_DECIMAL_PLACES = 5;
+
+/**
+ * Formats a human-readable exchange rate between destination and source tokens.
+ * Example: "1 ETH = 4,381 USDC"
+ *
+ * Very small positive rates use the shared dust threshold (`< 0.00001`).
+ */
+export function formatExchangeRate(
+ destToken: BridgeToken | undefined,
+ sourceToken: BridgeToken | undefined,
+): string | undefined {
+ if (!destToken?.currencyExchangeRate || !sourceToken?.currencyExchangeRate) {
+ return undefined;
+ }
+
+ const destUsd = destToken.currencyExchangeRate;
+ const sourceUsd = sourceToken.currencyExchangeRate;
+ if (destUsd <= 0 || sourceUsd <= 0) return undefined;
+
+ const sourcePerDest = destUsd / sourceUsd;
+ if (sourcePerDest <= 0) return undefined;
+
+ const formattedAmount = String(
+ formatAmountWithThreshold(sourcePerDest, MAX_RATE_DECIMAL_PLACES),
+ );
+
+ return `1 ${destToken.symbol} = ${formattedAmount} ${sourceToken.symbol}`;
+}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts
new file mode 100644
index 00000000000..86546f3aaca
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts
@@ -0,0 +1,26 @@
+import { BRIDGE_MM_FEE_RATE } from '@metamask/bridge-controller';
+import { isNullOrUndefined } from '@metamask/utils';
+
+/**
+ * Resolves the MM fee % from quote metadata, falling back to the default bridge rate.
+ *
+ * `quoteBpsFee` is not yet reflected in the bridge-controller TS types but is
+ * returned by the API at runtime — see the same @ts-expect-error pattern in
+ * BridgeViewFooter.tsx. We widen `metabridge` to `unknown` so any real quote
+ * is accepted, then extract the field via a safe runtime cast.
+ */
+export function getMetamaskFeePercent(
+ activeQuote:
+ | { quote?: { feeData?: { metabridge?: unknown } } }
+ | null
+ | undefined,
+): number {
+ const metabridge = activeQuote?.quote?.feeData?.metabridge as
+ | { quoteBpsFee?: number }
+ | undefined;
+ const quoteBpsFee = metabridge?.quoteBpsFee;
+ if (!isNullOrUndefined(quoteBpsFee)) {
+ return quoteBpsFee / 100;
+ }
+ return BRIDGE_MM_FEE_RATE;
+}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx
deleted file mode 100644
index 3f8f68e5e49..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import React from 'react';
-import { TextInput } from 'react-native';
-import { fireEvent, screen } from '@testing-library/react-native';
-import renderWithProvider from '../../../../../../util/test/renderWithProvider';
-import type { Position } from '@metamask/social-controllers';
-import QuickBuyAmountInput from './QuickBuyAmountInput';
-
-jest.mock('../../../../../../../locales/i18n', () => ({
- strings: (key: string) => key,
-}));
-
-const mockPosition = {
- tokenSymbol: 'BTC',
-} as unknown as Position;
-
-const { mockTheme } = jest.requireActual('../../../../../../util/theme');
-const mockColors = { text: { alternative: mockTheme.colors.text.alternative } };
-
-const createHiddenInputRef = () =>
- React.createRef() as unknown as React.RefObject;
-
-const defaultProps = {
- usdAmount: '',
- position: mockPosition,
- estimatedReceiveAmount: undefined as string | undefined,
- isQuoteLoading: false,
- hasValidAmount: false,
- hiddenInputRef: createHiddenInputRef(),
- onAmountAreaPress: jest.fn(),
- onAmountChange: jest.fn(),
- colors: mockColors,
-};
-
-describe('QuickBuyAmountInput', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders the zero state when no amount has been entered', () => {
- const { UNSAFE_queryByType } = renderWithProvider(
- ,
- );
- const { ActivityIndicator } = jest.requireActual('react-native');
-
- expect(screen.getByText('$0')).toBeOnTheScreen();
- expect(screen.getByText('0 BTC')).toBeOnTheScreen();
- expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull();
- });
-
- it('renders the entered USD amount', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByText('$50')).toBeOnTheScreen();
- });
-
- it('renders a leading decimal without dropping the zero prefix', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByText('$0.')).toBeOnTheScreen();
- expect(screen.queryByText('$.')).not.toBeOnTheScreen();
- });
-
- it('renders a leading decimal with digits without dropping the zero prefix', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByText('$0.5')).toBeOnTheScreen();
- expect(screen.queryByText('$.5')).not.toBeOnTheScreen();
- });
-
- it('shows a loading spinner while fetching a quote for a valid amount', () => {
- const { UNSAFE_getByType } = renderWithProvider(
- ,
- );
-
- const { ActivityIndicator } = jest.requireActual('react-native');
- expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
- expect(screen.queryByText('0 BTC')).toBeNull();
- });
-
- it('renders the estimated receive amount with the token symbol', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByText('1.23 BTC')).toBeOnTheScreen();
- });
-
- it('falls back to "0 ${symbol}" when there is no receive amount and not loading', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByText('0 BTC')).toBeOnTheScreen();
- });
-
- it('fires onAmountAreaPress when the amount area is tapped', () => {
- const onAmountAreaPress = jest.fn();
- renderWithProvider(
- ,
- );
-
- fireEvent.press(screen.getByTestId('quick-buy-amount-area'));
- expect(onAmountAreaPress).toHaveBeenCalledTimes(1);
- });
-
- it('fires onAmountChange when typing into the hidden input', () => {
- const onAmountChange = jest.fn();
- renderWithProvider(
- ,
- );
-
- fireEvent.changeText(screen.getByTestId('quick-buy-amount-input'), '42');
- expect(onAmountChange).toHaveBeenCalledWith('42');
- });
-});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx
deleted file mode 100644
index 97378ca1dcc..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import React from 'react';
-import {
- StyleSheet,
- TextInput,
- TouchableOpacity,
- ActivityIndicator,
-} from 'react-native';
-import {
- Box,
- Text,
- TextVariant,
- TextColor,
- FontWeight,
- BoxAlignItems,
- BoxJustifyContent,
-} from '@metamask/design-system-react-native';
-import type { Position } from '@metamask/social-controllers';
-
-const styles = StyleSheet.create({
- amountText: { fontSize: 48, lineHeight: 50 },
- hiddenInput: { position: 'absolute', opacity: 0, height: 0 },
-});
-
-interface QuickBuyAmountInputProps {
- usdAmount: string;
- position: Position;
- estimatedReceiveAmount: string | undefined;
- isQuoteLoading: boolean;
- hasValidAmount: boolean;
- hiddenInputRef: React.RefObject;
- onAmountAreaPress: () => void;
- onAmountChange: (text: string) => void;
- colors: { text: { alternative: string } };
-}
-
-const QuickBuyAmountInput: React.FC = ({
- usdAmount,
- position,
- estimatedReceiveAmount,
- isQuoteLoading,
- hasValidAmount,
- hiddenInputRef,
- onAmountAreaPress,
- onAmountChange,
- colors,
-}) => (
-
-
-
- {`$${usdAmount || '0'}`}
-
-
- {isQuoteLoading && hasValidAmount ? (
-
- ) : (
-
- {estimatedReceiveAmount
- ? `${estimatedReceiveAmount} ${position.tokenSymbol}`
- : `0 ${position.tokenSymbol}`}
-
- )}
-
- {/* Hidden TextInput for keyboard capture */}
-
-
-
-);
-
-export default QuickBuyAmountInput;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx
deleted file mode 100644
index 9a8f50b63c6..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-import {
- BottomSheet,
- Box,
- BoxAlignItems,
- Text,
- TextColor,
- TextVariant,
- type BottomSheetRef,
-} from '@metamask/design-system-react-native';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import type { Position } from '@metamask/social-controllers';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-// `react-native-gesture-handler` ScrollView is required for scrolling on
-// Android inside a gesture-handler-managed BottomSheet.
-import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler';
-import Animated from 'react-native-reanimated';
-import { useSelector } from 'react-redux';
-import { strings } from '../../../../../../../locales/i18n';
-import { selectIsSubmittingTx } from '../../../../../../core/redux/slices/bridge';
-import { useTheme } from '../../../../../../util/theme';
-import QuickBuyAmountInput from './QuickBuyAmountInput';
-import QuickBuyBanners from './QuickBuyBanners';
-import QuickBuyBottomSheetSkeleton from './QuickBuyBottomSheetSkeleton';
-import QuickBuyConfirmButton from './QuickBuyConfirmButton';
-import QuickBuyFooter from './QuickBuyFooter';
-import QuickBuyHeader from './QuickBuyHeader';
-import { useQuickBuyBottomSheet } from './useQuickBuyBottomSheet';
-
-export interface QuickBuyBottomSheetProps {
- isVisible: boolean;
- position: Position | null;
- onClose: () => void;
- /** Wallet address of the trader being copied; required for analytics. */
- traderAddress?: string;
- /** Destination-token market cap (in user currency); forwarded for analytics. */
- marketCap?: number;
- /** Surface that opened the sheet; forwarded for analytics. */
- source?: 'notification' | 'profile_position' | 'leaderboard';
-}
-
-interface InnerProps {
- position: Position;
- onClose: () => void;
- traderAddress?: string;
- marketCap?: number;
- source?: 'notification' | 'profile_position' | 'leaderboard';
-}
-
-const AnimatedScrollView = Animated.createAnimatedComponent(
- GestureHandlerScrollView,
-);
-
-/**
- * Heavy subtree — deferred until after the open animation so its hook
- * tree (bridge quotes, balances, rewards, metadata) does not starve the
- * JS thread while the sheet is animating in.
- */
-const QuickBuyBottomSheetContent: React.FC = ({
- position,
- onClose,
- traderAddress,
- marketCap,
- source,
-}) => {
- const tw = useTailwind();
- const { colors } = useTheme();
- const {
- hiddenInputRef,
- isUnsupportedChain,
- sourceToken,
- sourceChainId,
- sourceTokenOptions,
- selectedSourceToken,
- isSourcePickerOpen,
- setIsSourcePickerOpen,
- setSelectedSourceToken,
- usdAmount,
- estimatedReceiveAmount,
- sourceBalanceFiat,
- formattedNetworkFee,
- formattedSlippage,
- formattedMinimumReceived,
- formattedPriceImpact,
- totalAmountUsd,
- isQuoteLoading,
- isTotalLoading,
- isHardwareSolanaBlocked,
- priceImpactViewData,
- isPriceImpactError,
- hasValidAmount,
- isConfirmDisabled,
- confirmButtonState,
- getButtonLabel,
- handlePresetPress,
- handleAmountAreaPress,
- handleAmountChange,
- handleConfirm,
- } = useQuickBuyBottomSheet(position, onClose, {
- traderAddress,
- marketCap,
- source,
- });
-
- return (
- <>
- {isUnsupportedChain ? (
-
-
- {strings('social_leaderboard.quick_buy.unsupported_chain')}
-
-
- ) : (
- <>
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
- );
-};
-
-/**
- * Lightweight shell — opens the sheet immediately with just a placeholder
- * so the animation runs on an idle JS thread. The heavy content tree is
- * mounted after the sheet reports its open animation has finished.
- */
-const QuickBuyBottomSheetInner: React.FC = ({
- position,
- onClose,
- traderAddress,
- marketCap,
- source,
-}) => {
- const tw = useTailwind();
- const bottomSheetRef = useRef(null);
- const [isContentReady, setIsContentReady] = useState(false);
- const isSubmittingTx = useSelector(selectIsSubmittingTx);
-
- useEffect(() => {
- bottomSheetRef.current?.onOpenBottomSheet(() => {
- setIsContentReady(true);
- });
- }, []);
-
- const handleClose = useCallback(() => {
- bottomSheetRef.current?.onCloseBottomSheet();
- }, []);
-
- return (
-
-
- {isContentReady ? (
-
- ) : (
-
-
-
- )}
-
- );
-};
-
-/**
- * Outer gate component — only mounts the inner sheet when visible.
- * This prevents the bridge hooks from running on an empty Redux state,
- * which causes reselect stability warnings.
- */
-const QuickBuyBottomSheet: React.FC = ({
- isVisible,
- position,
- onClose,
- traderAddress,
- marketCap,
- source,
-}) => {
- if (!isVisible || !position) return null;
- return (
-
- );
-};
-
-export default QuickBuyBottomSheet;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx
deleted file mode 100644
index 6fab22a0971..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React from 'react';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import {
- Box,
- Text,
- TextVariant,
- TextColor,
- Button,
- ButtonVariant,
- ButtonBaseSize,
- BoxAlignItems,
- BoxFlexDirection,
- BoxJustifyContent,
-} from '@metamask/design-system-react-native';
-import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton';
-import { strings } from '../../../../../../../locales/i18n';
-
-const USD_PRESETS = ['1', '20', '50', '100'];
-
-interface QuickBuySkeletonRowProps {
- label: string;
- valueWidth: number;
- showTokenBadge?: boolean;
- showTrailingIcon?: boolean;
- showInfoIcon?: boolean;
-}
-
-const QuickBuySkeletonRow: React.FC = ({
- label,
- valueWidth,
- showTokenBadge = false,
- showTrailingIcon = false,
- showInfoIcon = false,
-}) => {
- const tw = useTailwind();
-
- return (
-
-
-
- {label}
-
- {showInfoIcon ? (
-
- ) : null}
-
-
- {showTokenBadge ? (
-
- ) : null}
-
- {showTrailingIcon ? (
-
- ) : null}
-
-
- );
-};
-
-const QuickBuyBottomSheetSkeleton: React.FC = () => {
- const tw = useTailwind();
-
- return (
-
-
-
-
-
-
-
-
- {USD_PRESETS.map((preset) => (
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default QuickBuyBottomSheetSkeleton;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx
deleted file mode 100644
index 302f468b157..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import React from 'react';
-import { fireEvent, screen } from '@testing-library/react-native';
-import {
- TextColor,
- IconColor,
- IconName,
-} from '@metamask/design-system-react-native';
-import renderWithProvider from '../../../../../../util/test/renderWithProvider';
-import type { Hex } from '@metamask/utils';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import QuickBuyFooter from './QuickBuyFooter';
-
-jest.mock('./SourceTokenPicker', () => {
- const ReactMock = jest.requireActual('react');
- const { View, Text, TouchableOpacity } = jest.requireActual('react-native');
- return ({
- options,
- onSelect,
- }: {
- options: BridgeToken[];
- onSelect: (token: BridgeToken) => void;
- }) =>
- ReactMock.createElement(
- View,
- { testID: 'mock-source-token-picker' },
- options.map((token: BridgeToken) =>
- ReactMock.createElement(
- TouchableOpacity,
- {
- key: token.symbol,
- testID: `picker-option-${token.symbol}`,
- onPress: () => onSelect(token),
- },
- ReactMock.createElement(Text, null, token.symbol),
- ),
- ),
- );
-});
-
-jest.mock('../../../../../../util/networks', () => ({
- getNetworkImageSource: jest.fn(() => 0),
-}));
-
-jest.mock('../../../../../UI/Rewards/components/RewardPointsAnimation', () => {
- const ReactMock = jest.requireActual('react');
- const { Text } = jest.requireActual('react-native');
- return {
- __esModule: true,
- default: ({ value }: { value: number }) =>
- ReactMock.createElement(
- Text,
- { testID: 'mock-rewards-animation' },
- `${value} pts`,
- ),
- RewardAnimationState: {
- Loading: 'loading',
- ErrorState: 'error',
- Idle: 'idle',
- },
- };
-});
-
-jest.mock(
- '../../../../../UI/Rewards/components/AddRewardsAccount/AddRewardsAccount',
- () => {
- const ReactMock = jest.requireActual('react');
- const { Text } = jest.requireActual('react-native');
- return ({ testID }: { testID?: string }) =>
- ReactMock.createElement(
- Text,
- { testID: testID ?? 'mock-add-rewards-account' },
- 'Add Rewards',
- );
- },
-);
-
-jest.mock('../../../../../../../locales/i18n', () => ({
- strings: (key: string) => key,
-}));
-
-const { mockTheme } = jest.requireActual('../../../../../../util/theme');
-const mockColors = { icon: { alternative: mockTheme.colors.icon.alternative } };
-
-const createSourceToken = (overrides: Partial = {}): BridgeToken =>
- ({
- address: '0x0000000000000000000000000000000000000000',
- chainId: '0x1' as Hex,
- decimals: 18,
- symbol: 'ETH',
- name: 'Ethereum',
- image: 'https://example.com/eth.png',
- currencyExchangeRate: 2000,
- balance: '1.0',
- balanceFiat: '$2000.00',
- tokenFiatAmount: 2000,
- ...overrides,
- }) as BridgeToken;
-
-const defaultProps = {
- usdAmount: '',
- formattedNetworkFee: '-',
- formattedSlippage: '-',
- formattedMinimumReceived: '-',
- formattedPriceImpact: '-',
- priceImpactViewData: {
- textColor: TextColor.TextAlternative,
- icon: undefined,
- title: 'bridge.price_impact_info_title',
- description: 'bridge.price_impact_info_description',
- },
- totalAmountUsd: '$0',
- sourceToken: createSourceToken(),
- sourceChainId: '0x1' as Hex,
- sourceTokenOptions: [createSourceToken()],
- selectedSourceToken: createSourceToken(),
- isSourcePickerOpen: false,
- setIsSourcePickerOpen: jest.fn(),
- setSelectedSourceToken: jest.fn(),
- sourceBalanceFiat: '$2000.00',
- isTotalLoading: false,
- onPresetPress: jest.fn(),
- colors: mockColors,
-};
-
-describe('QuickBuyFooter', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- describe('preset buttons', () => {
- it('renders all four presets and calls onPresetPress when tapped', () => {
- const onPresetPress = jest.fn();
- renderWithProvider(
- ,
- );
-
- expect(screen.getByTestId('quick-buy-preset-1')).toBeOnTheScreen();
- expect(screen.getByTestId('quick-buy-preset-20')).toBeOnTheScreen();
- expect(screen.getByTestId('quick-buy-preset-50')).toBeOnTheScreen();
- expect(screen.getByTestId('quick-buy-preset-100')).toBeOnTheScreen();
- fireEvent.press(screen.getByTestId('quick-buy-preset-50'));
- expect(onPresetPress).toHaveBeenCalledWith('50');
- });
- });
-
- describe('pay-with row', () => {
- it('renders the SourceTokenPicker when isSourcePickerOpen is true', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByTestId('mock-source-token-picker')).toBeOnTheScreen();
- });
-
- it('calls setSelectedSourceToken when a picker option is selected', () => {
- const setSelectedSourceToken = jest.fn();
- const setIsSourcePickerOpen = jest.fn();
- const usdcToken = createSourceToken({ symbol: 'USDC' });
-
- renderWithProvider(
- ,
- );
-
- fireEvent.press(screen.getByTestId('picker-option-USDC'));
-
- expect(setSelectedSourceToken).toHaveBeenCalledWith(usdcToken);
- expect(setIsSourcePickerOpen).toHaveBeenCalledWith(false);
- });
- });
-
- describe('total row', () => {
- it('shows skeleton when isTotalLoading is true', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByTestId('skeleton-view')).toBeOnTheScreen();
- expect(screen.queryByText('$20.50')).toBeNull();
- });
-
- it('shows the total value when isTotalLoading is false', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByText('$20.50')).toBeOnTheScreen();
- });
- });
-
- describe('price impact row', () => {
- it('renders the formatted percentage without an icon when impact is safe', () => {
- renderWithProvider();
-
- // safe impact starts collapsed; expand to reveal the subrow
- fireEvent.press(screen.getByTestId('quick-buy-total-row'));
-
- expect(screen.getByTestId('quick-buy-price-impact')).toBeOnTheScreen();
- });
-
- it('auto-expands the total breakdown when severity icon is set', () => {
- renderWithProvider(
- ,
- );
-
- // No tap on Total — should already be expanded
- expect(screen.getByTestId('quick-buy-price-impact')).toBeOnTheScreen();
- expect(screen.getByText('6.00%')).toBeOnTheScreen();
- });
- });
-});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx
deleted file mode 100644
index 5626e91565b..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx
+++ /dev/null
@@ -1,461 +0,0 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { type LayoutChangeEvent, TouchableOpacity, View } from 'react-native';
-import Animated, {
- Easing,
- useAnimatedStyle,
- useSharedValue,
- withTiming,
-} from 'react-native-reanimated';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import {
- Box,
- Text,
- TextVariant,
- TextColor,
- Button,
- ButtonVariant,
- ButtonBaseSize,
- BoxFlexDirection,
- BoxAlignItems,
- BoxJustifyContent,
- AvatarToken,
- AvatarTokenSize,
- BadgeWrapper,
- BadgeWrapperPosition,
- BadgeNetwork,
- Icon as IconDS,
- IconSize as IconSizeDS,
-} from '@metamask/design-system-react-native';
-import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton';
-import Icon, {
- IconName,
- IconSize,
-} from '../../../../../../component-library/components/Icons/Icon';
-import { getNetworkImageSource } from '../../../../../../util/networks';
-import type { Hex } from '@metamask/utils';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import type { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData';
-import SourceTokenPicker from './SourceTokenPicker';
-import { getBridgeTokenImageSource } from './getBridgeTokenImageSource';
-import { strings } from '../../../../../../../locales/i18n';
-
-const USD_PRESETS = ['1', '20', '50', '100'];
-
-const PAY_WITH_ANIMATION_DURATION_MS = 220;
-const PAY_WITH_ANIMATION_EASING = Easing.out(Easing.cubic);
-
-interface QuickBuyFooterProps {
- usdAmount: string;
- formattedNetworkFee: string;
- formattedSlippage: string;
- formattedMinimumReceived: string;
- formattedPriceImpact: string;
- priceImpactViewData: ReturnType;
- totalAmountUsd: string;
- sourceToken: BridgeToken | undefined;
- sourceChainId: Hex | undefined;
- sourceTokenOptions: BridgeToken[];
- selectedSourceToken: BridgeToken | undefined;
- isSourcePickerOpen: boolean;
- setIsSourcePickerOpen: React.Dispatch>;
- setSelectedSourceToken: React.Dispatch<
- React.SetStateAction
- >;
- sourceBalanceFiat: string | undefined;
- isTotalLoading: boolean;
- onPresetPress: (preset: string) => void;
- colors: { icon: { alternative: string } };
-}
-
-const QuickBuyFooter: React.FC = ({
- usdAmount,
- formattedNetworkFee,
- formattedSlippage,
- formattedMinimumReceived,
- formattedPriceImpact,
- priceImpactViewData,
- totalAmountUsd,
- sourceToken,
- sourceChainId,
- sourceTokenOptions,
- selectedSourceToken,
- isSourcePickerOpen,
- setIsSourcePickerOpen,
- setSelectedSourceToken,
- sourceBalanceFiat,
- isTotalLoading,
- onPresetPress,
- colors,
-}) => {
- const tw = useTailwind();
- const isPriceImpactSafe = !priceImpactViewData.icon;
- const [isTotalExpanded, setIsTotalExpanded] = useState(!isPriceImpactSafe);
-
- // Animate the source picker's real `height` so React Native's layout system
- // re-measures the parent BottomSheet on each frame; this lets the whole sheet
- // grow/shrink smoothly instead of jumping.
- const pickerHeight = useSharedValue(0);
- const measuredPickerHeight = useRef(0);
-
- const animatedPickerStyle = useAnimatedStyle(() => ({
- height: pickerHeight.value,
- }));
-
- const handlePickerLayout = useCallback(
- (event: LayoutChangeEvent) => {
- const { height } = event.nativeEvent.layout;
- if (height <= 0 || height === measuredPickerHeight.current) return;
- measuredPickerHeight.current = height;
- if (isSourcePickerOpen) {
- pickerHeight.value = withTiming(height, {
- duration: PAY_WITH_ANIMATION_DURATION_MS,
- easing: PAY_WITH_ANIMATION_EASING,
- });
- }
- },
- [isSourcePickerOpen, pickerHeight],
- );
-
- useEffect(() => {
- pickerHeight.value = withTiming(
- isSourcePickerOpen ? measuredPickerHeight.current : 0,
- {
- duration: PAY_WITH_ANIMATION_DURATION_MS,
- easing: PAY_WITH_ANIMATION_EASING,
- },
- );
- }, [isSourcePickerOpen, pickerHeight]);
-
- // Animate the Total fee-breakdown the same way. `isTotalExpanded` can start
- // `true` (when price impact is unsafe) so we use an `initialized` ref to
- // commit the first measurement without an animation; subsequent toggles run
- // through `withTiming` so the parent BottomSheet grows smoothly.
- const totalBreakdownHeight = useSharedValue(0);
- const measuredTotalBreakdownHeight = useRef(0);
- const totalBreakdownInitialized = useRef(false);
-
- const animatedTotalBreakdownStyle = useAnimatedStyle(() => ({
- height: totalBreakdownHeight.value,
- }));
-
- const handleTotalBreakdownLayout = useCallback(
- (event: LayoutChangeEvent) => {
- const { height } = event.nativeEvent.layout;
- if (height <= 0 || height === measuredTotalBreakdownHeight.current)
- return;
- measuredTotalBreakdownHeight.current = height;
- if (!totalBreakdownInitialized.current) {
- totalBreakdownInitialized.current = true;
- totalBreakdownHeight.value = isTotalExpanded ? height : 0;
- return;
- }
- if (isTotalExpanded) {
- totalBreakdownHeight.value = withTiming(height, {
- duration: PAY_WITH_ANIMATION_DURATION_MS,
- easing: PAY_WITH_ANIMATION_EASING,
- });
- }
- },
- [isTotalExpanded, totalBreakdownHeight],
- );
-
- useEffect(() => {
- if (!totalBreakdownInitialized.current) return;
- totalBreakdownHeight.value = withTiming(
- isTotalExpanded ? measuredTotalBreakdownHeight.current : 0,
- {
- duration: PAY_WITH_ANIMATION_DURATION_MS,
- easing: PAY_WITH_ANIMATION_EASING,
- },
- );
- }, [isTotalExpanded, totalBreakdownHeight]);
-
- const handleSourcePickerToggle = useCallback(() => {
- setIsSourcePickerOpen((prev) => !prev);
- }, [setIsSourcePickerOpen]);
-
- const handleSourceTokenSelect = useCallback(
- (token: BridgeToken) => {
- setSelectedSourceToken(token);
- setIsSourcePickerOpen(false);
- },
- [setSelectedSourceToken, setIsSourcePickerOpen],
- );
-
- return (
-
- {/* Preset pills */}
-
-
- {USD_PRESETS.map((preset) => (
-
-
-
- ))}
-
-
-
- {/* Footer details */}
-
-
- {/* Pay with card (tap to expand source picker inline) */}
-
-
-
-
- {strings('social_leaderboard.quick_buy.pay_with')}
-
-
-
- ) : null
- }
- >
-
-
-
- {sourceToken?.symbol ?? ''}
-
- {sourceBalanceFiat && (
-
- {`(${sourceBalanceFiat})`}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Total row (tap to expand fee breakdown) */}
-
- setIsTotalExpanded((prev) => !prev)}
- testID="quick-buy-total-row"
- >
-
-
-
- {strings('social_leaderboard.quick_buy.total')}
-
-
-
- {isTotalLoading ? (
-
- ) : (
-
- {totalAmountUsd}
-
- )}
-
-
-
- {/* Expanded fee breakdown (subsection of Total) */}
-
-
-
-
-
- {strings('social_leaderboard.quick_buy.network_fee')}
-
-
- {formattedNetworkFee}
-
-
-
-
- {strings('social_leaderboard.quick_buy.slippage')}
-
-
- {formattedSlippage}
-
-
-
-
- {strings('social_leaderboard.quick_buy.minimum_received')}
-
-
- {formattedMinimumReceived}
-
-
-
-
- {strings('social_leaderboard.quick_buy.price_impact')}
-
-
- {priceImpactViewData.icon && (
-
- )}
-
- {formattedPriceImpact}
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default QuickBuyFooter;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx
deleted file mode 100644
index b5027b30903..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React from 'react';
-import { fireEvent, screen } from '@testing-library/react-native';
-import type { Position } from '@metamask/social-controllers';
-import renderWithProvider from '../../../../../../util/test/renderWithProvider';
-import QuickBuyHeader from './QuickBuyHeader';
-
-jest.mock('../../../components/PositionTokenAvatar', () => {
- const ReactMock = jest.requireActual('react');
- const { Text } = jest.requireActual('react-native');
- return {
- __esModule: true,
- default: ({ position }: { position: Position }) =>
- ReactMock.createElement(
- Text,
- { testID: 'mock-position-token-avatar' },
- position.tokenSymbol,
- ),
- };
-});
-
-jest.mock('../../../../../../../locales/i18n', () => ({
- strings: (key: string, params?: Record) =>
- params ? `${key}:${JSON.stringify(params)}` : key,
-}));
-
-const createPosition = (overrides: Partial = {}): Position =>
- ({
- chain: 'base',
- tokenAddress: '0x1234567890123456789012345678901234567890',
- tokenSymbol: 'PEPE',
- tokenName: 'Pepe',
- positionAmount: 1000,
- boughtUsd: 500,
- soldUsd: 0,
- realizedPnl: 0,
- costBasis: 500,
- trades: [],
- lastTradeAt: 0,
- currentValueUSD: 900,
- pnlValueUsd: 400,
- pnlPercent: 80,
- ...overrides,
- }) as Position;
-
-describe('QuickBuyHeader', () => {
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders the title with the token symbol', () => {
- renderWithProvider(
- ,
- );
-
- expect(
- screen.getByText('social_leaderboard.quick_buy.title:{"symbol":"PEPE"}'),
- ).toBeOnTheScreen();
- });
-
- it('renders the position token avatar', () => {
- renderWithProvider(
- ,
- );
-
- expect(screen.getByTestId('mock-position-token-avatar')).toBeOnTheScreen();
- });
-
- describe('market cap subtitle', () => {
- it('renders only the label when marketCap is undefined', () => {
- renderWithProvider(
- ,
- );
-
- expect(
- screen.getByText('social_leaderboard.quick_buy.market_cap_label'),
- ).toBeOnTheScreen();
- });
-
- it('renders the formatted market cap with the label when provided in millions', () => {
- renderWithProvider(
- ,
- );
-
- expect(
- screen.getByText('$2.3M social_leaderboard.quick_buy.market_cap_label'),
- ).toBeOnTheScreen();
- });
-
- it('renders the formatted market cap with the label when provided in thousands', () => {
- renderWithProvider(
- ,
- );
-
- expect(
- screen.getByText('$25K social_leaderboard.quick_buy.market_cap_label'),
- ).toBeOnTheScreen();
- });
-
- it('treats a zero marketCap as a present value and prefixes "$0"', () => {
- renderWithProvider(
- ,
- );
-
- expect(
- screen.getByText('$0 social_leaderboard.quick_buy.market_cap_label'),
- ).toBeOnTheScreen();
- });
- });
-
- describe('close button', () => {
- it('calls onClose when the close button is pressed', () => {
- const onClose = jest.fn();
- renderWithProvider(
- ,
- );
-
- fireEvent.press(screen.getByTestId('quick-buy-close-button'));
- expect(onClose).toHaveBeenCalledTimes(1);
- });
- });
-});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx
deleted file mode 100644
index 048fdcb7c2d..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from 'react';
-import {
- Box,
- Text,
- TextVariant,
- TextColor,
- FontWeight,
- ButtonIcon,
- ButtonIconSize,
- IconName as DsIconName,
- BoxFlexDirection,
- BoxAlignItems,
-} from '@metamask/design-system-react-native';
-import type { Position } from '@metamask/social-controllers';
-import { strings } from '../../../../../../../locales/i18n';
-import { formatCompactUsd } from '../../../../../UI/Rewards/utils/formatUtils';
-import PositionTokenAvatar from '../../../components/PositionTokenAvatar';
-
-interface QuickBuyHeaderProps {
- position: Position;
- marketCap?: number;
- onClose: () => void;
-}
-
-const QuickBuyHeader: React.FC = ({
- position,
- marketCap,
- onClose,
-}) => (
-
-
-
-
-
-
- {strings('social_leaderboard.quick_buy.title', {
- symbol: position.tokenSymbol,
- })}
-
-
- {marketCap != null
- ? `${formatCompactUsd(marketCap)} ${strings('social_leaderboard.quick_buy.market_cap_label')}`
- : strings('social_leaderboard.quick_buy.market_cap_label')}
-
-
-
-
-);
-
-export default QuickBuyHeader;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx
deleted file mode 100644
index dbaa0b8d278..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import {
- AvatarToken,
- AvatarTokenSize,
- BadgeNetwork,
- BadgeWrapper,
- BadgeWrapperPosition,
- Box,
- BoxAlignItems,
- BoxFlexDirection,
- BoxJustifyContent,
- FontWeight,
- Text,
- TextColor,
- TextVariant,
-} from '@metamask/design-system-react-native';
-import React, { useCallback } from 'react';
-import { TouchableOpacity } from 'react-native';
-import Icon, {
- IconName,
- IconSize,
-} from '../../../../../../component-library/components/Icons/Icon';
-import { getNetworkImageSource } from '../../../../../../util/networks';
-import { useTheme } from '../../../../../../util/theme';
-import type { BridgeToken } from '../../../../../UI/Bridge/types';
-import { getBridgeTokenImageSource } from './getBridgeTokenImageSource';
-import { getTokenKey } from './sourceTokenCandidates';
-
-interface SourceTokenPickerProps {
- options: BridgeToken[];
- selectedToken: BridgeToken | undefined;
- onSelect: (token: BridgeToken) => void;
-}
-
-/**
- * Inline dropdown list of source token options.
- * Renders directly inside the parent bottom sheet — no nested sheets.
- */
-const SourceTokenPicker: React.FC = ({
- options,
- selectedToken,
- onSelect,
-}) => {
- const { colors } = useTheme();
- const selectedKey = selectedToken ? getTokenKey(selectedToken) : undefined;
-
- const handleSelect = useCallback(
- (token: BridgeToken) => {
- onSelect(token);
- },
- [onSelect],
- );
-
- return (
-
- {options.map((item) => {
- const key = getTokenKey(item);
- const isSelected = key === selectedKey;
-
- return (
- handleSelect(item)}
- testID={`source-token-option-${item.symbol}-${item.chainId}`}
- >
-
-
-
-
- }
- >
-
-
-
-
-
- {item.symbol}
-
- {item.name && (
-
- {item.name}
-
- )}
-
-
-
-
-
- {item.balanceFiat && (
-
- {item.balanceFiat}
-
- )}
-
- {isSelected && (
-
- )}
-
-
-
- );
- })}
-
- );
-};
-
-export default SourceTokenPicker;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts
deleted file mode 100644
index 4fa828e0e55..00000000000
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from './QuickBuyBottomSheet';
-export type { QuickBuyBottomSheetProps } from './QuickBuyBottomSheet';
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx
index 58dd99dc3cb..3b1a1b5736a 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx
@@ -1,10 +1,9 @@
-import React, { useRef, useEffect } from 'react';
-import { Platform } from 'react-native';
-import { screen, fireEvent, act } from '@testing-library/react-native';
+import React, { useRef, useEffect, useState } from 'react';
+import { Platform, Pressable } from 'react-native';
+import { screen, fireEvent } from '@testing-library/react-native';
import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller/notification-services';
import {
ImpactFeedbackStyle,
- ImpactMoment,
playImpact,
} from '../../../../../../util/haptics';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
@@ -231,16 +230,6 @@ describe('TraderNotificationsBottomSheet', () => {
),
).toBeOnTheScreen();
});
-
- it('renders the save button', () => {
- renderOpenedSheet();
-
- expect(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- ).toBeOnTheScreen();
- });
});
describe('toggle', () => {
@@ -270,7 +259,7 @@ describe('TraderNotificationsBottomSheet', () => {
expect(toggle.props.value).toBe(false);
});
- it('flips the toggle value locally but does NOT call toggleTraderNotification immediately', () => {
+ it('calls toggleTraderNotification immediately when the toggle is changed', () => {
renderOpenedSheet({
traderId: 'trader-1',
isTraderNotificationEnabled: () => true,
@@ -282,11 +271,66 @@ describe('TraderNotificationsBottomSheet', () => {
false,
);
- expect(mockToggleTraderNotification).not.toHaveBeenCalled();
+ expect(mockToggleTraderNotification).toHaveBeenCalledTimes(1);
+ expect(mockToggleTraderNotification).toHaveBeenCalledWith('trader-1');
+ });
+
+ it('mirrors the hook value on every render so the optimistic overlay (or rollback) drives the Switch directly', () => {
+ const Controllable: React.FC = () => {
+ const ref = useRef(null);
+ const [hookEnabled, setHookEnabled] = useState(true);
+ mockIsTraderNotificationEnabled.mockImplementation(() => hookEnabled);
+ useEffect(() => {
+ ref.current?.onOpenBottomSheet();
+ }, []);
+ return (
+ <>
+
+ ref.current?.onOpenBottomSheet()}
+ />
+ setHookEnabled(false)}
+ />
+ setHookEnabled(true)}
+ />
+ >
+ );
+ };
+
+ renderWithProvider();
+
+ expect(
+ screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE)
+ .props.value,
+ ).toBe(true);
+
+ fireEvent.press(screen.getByTestId('hook-flip-off'));
expect(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE)
.props.value,
).toBe(false);
+
+ fireEvent.press(
+ screen.getByTestId(
+ TraderNotificationsBottomSheetSelectorsIDs.CLOSE_BUTTON,
+ ),
+ );
+ fireEvent.press(screen.getByTestId('hook-flip-on'));
+ fireEvent.press(screen.getByTestId('reopen'));
+
+ expect(
+ screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE)
+ .props.value,
+ ).toBe(true);
});
it('disables the toggle when push notifications are off', () => {
@@ -356,93 +400,6 @@ describe('TraderNotificationsBottomSheet', () => {
});
});
- describe('save button', () => {
- it('calls toggleTraderNotification when the toggle was changed before saving', () => {
- renderOpenedSheet({
- traderId: 'trader-1',
- isTraderNotificationEnabled: () => true,
- });
-
- fireEvent(
- screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
- 'valueChange',
- false,
- );
-
- act(() => {
- fireEvent.press(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- );
- });
-
- expect(mockToggleTraderNotification).toHaveBeenCalledWith('trader-1');
- });
-
- it('does not call toggleTraderNotification when the toggle was not changed before saving', () => {
- renderOpenedSheet({
- traderId: 'trader-1',
- isTraderNotificationEnabled: () => true,
- });
-
- act(() => {
- fireEvent.press(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- );
- });
-
- expect(mockToggleTraderNotification).not.toHaveBeenCalled();
- });
-
- it('does not call toggleTraderNotification when the toggle was changed and then reverted before saving', () => {
- renderOpenedSheet({
- traderId: 'trader-1',
- isTraderNotificationEnabled: () => true,
- });
-
- fireEvent(
- screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
- 'valueChange',
- false,
- );
- fireEvent(
- screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
- 'valueChange',
- true,
- );
-
- act(() => {
- fireEvent.press(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- );
- });
-
- expect(mockToggleTraderNotification).not.toHaveBeenCalled();
- });
-
- it('closes the sheet and calls onDismiss when save is pressed', () => {
- const mockOnDismiss = jest.fn();
-
- renderOpenedSheet({ onDismiss: mockOnDismiss });
-
- act(() => {
- fireEvent.press(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- );
- });
-
- expect(mockOnDismiss).toHaveBeenCalledTimes(1);
- expect(mockNavigate).not.toHaveBeenCalled();
- });
- });
-
describe('haptic feedback', () => {
const originalPlatform = Platform.OS;
@@ -450,53 +407,6 @@ describe('TraderNotificationsBottomSheet', () => {
Platform.OS = originalPlatform;
});
- it('fires a medium impact when pressing save', () => {
- Platform.OS = 'ios';
- renderOpenedSheet({
- traderId: 'trader-1',
- isTraderNotificationEnabled: () => true,
- });
-
- fireEvent(
- screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
- 'valueChange',
- false,
- );
- mockImpactAsync.mockClear();
- mockPlayImpact.mockClear();
-
- act(() => {
- fireEvent.press(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- );
- });
-
- expect(mockPlayImpact).toHaveBeenCalledTimes(1);
- expect(mockPlayImpact).toHaveBeenCalledWith(ImpactMoment.PrimaryCTA);
- });
-
- it('fires a medium impact when pressing save even if the value did not change', () => {
- Platform.OS = 'ios';
- renderOpenedSheet({
- traderId: 'trader-1',
- isTraderNotificationEnabled: () => true,
- });
-
- act(() => {
- fireEvent.press(
- screen.getByTestId(
- TraderNotificationsBottomSheetSelectorsIDs.SAVE_BUTTON,
- ),
- );
- });
-
- expect(mockPlayImpact).toHaveBeenCalledTimes(1);
- expect(mockPlayImpact).toHaveBeenCalledWith(ImpactMoment.PrimaryCTA);
- expect(mockToggleTraderNotification).not.toHaveBeenCalled();
- });
-
it('fires a light impact when toggling the local switch on Android', () => {
Platform.OS = 'android';
renderOpenedSheet({
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts
index 9efa1d49064..89a1576d024 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.testIds.ts
@@ -3,5 +3,4 @@ export const TraderNotificationsBottomSheetSelectorsIDs = {
CLOSE_BUTTON: 'trader-notifications-bottom-sheet-close-button',
TOGGLE: 'trader-notifications-bottom-sheet-toggle',
MANAGE_TRADERS_ROW: 'trader-notifications-bottom-sheet-manage-traders-row',
- SAVE_BUTTON: 'trader-notifications-bottom-sheet-save-button',
};
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx
index 5150c1339cd..eea568b6bab 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx
@@ -1,4 +1,4 @@
-import React, { forwardRef, useCallback, useEffect, useState } from 'react';
+import React, { forwardRef, useCallback } from 'react';
import { TouchableOpacity, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -17,16 +17,12 @@ import {
IconColor,
} from '@metamask/design-system-react-native';
import BottomSheet from '../../../../../../component-library/components/BottomSheets/BottomSheet/BottomSheet';
-import BottomSheetFooter from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter';
import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard';
-import { ButtonVariants } from '../../../../../../component-library/components/Buttons/Button/Button.types';
import { strings } from '../../../../../../../locales/i18n';
import Routes from '../../../../../../constants/navigation/Routes';
import {
fireSwitchHaptic,
ImpactFeedbackStyle,
- playImpact,
- ImpactMoment,
} from '../../../../../../util/haptics';
import { useNotificationPreferences } from '../../../NotificationPreferences/hooks';
import AllowPushNotificationsRow from '../../../NotificationPreferences/components/AllowPushNotificationsRow';
@@ -54,9 +50,6 @@ const TraderNotificationsBottomSheet = forwardRef<
isTraderNotificationEnabled,
toggleTraderNotification,
} = useNotificationPreferences();
- const [localEnabled, setLocalEnabled] = useState(() =>
- isTraderNotificationEnabled(traderId),
- );
const tw = useTailwind();
const navigation = useNavigation();
@@ -66,14 +59,11 @@ const TraderNotificationsBottomSheet = forwardRef<
const pushNotificationsOff =
!hasNotificationPreferences || !preferences.pushNotificationsEnabled;
- // Snapshot the remote value each time the sheet opens so the toggle
- // always starts from the authoritative server state.
- useEffect(() => {
- if (isVisible) {
- setLocalEnabled(isTraderNotificationEnabled(traderId));
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isVisible]);
+ // The hook is the single source of truth: it serves an optimistic overlay
+ // that flips instantly on tap, drops only once the remote catches up, and
+ // rolls back on failed PUTs. Reading it directly avoids stale local state
+ // surviving across open/close cycles.
+ const enabled = isTraderNotificationEnabled(traderId);
const handleManageTradersPress = useCallback(() => {
sheetRef.current?.onCloseBottomSheet(() => {
@@ -97,23 +87,15 @@ const TraderNotificationsBottomSheet = forwardRef<
});
}, [hasNotificationPreferences, navigation, sheetRef]);
- // Only persist when the user explicitly confirms with Save.
- // If the local draft differs from the remote value, issue one toggle call.
- // Save is a deliberate primary-action commit, so always fire the haptic
- // — including when the value didn't change — to acknowledge the press.
- const handleSave = useCallback(() => {
- playImpact(ImpactMoment.PrimaryCTA);
- if (localEnabled !== isTraderNotificationEnabled(traderId)) {
- toggleTraderNotification(traderId);
+ const handleToggle = useCallback(() => {
+ if (pushNotificationsOff) {
+ return;
}
- closeSheet();
- }, [
- closeSheet,
- isTraderNotificationEnabled,
- localEnabled,
- toggleTraderNotification,
- traderId,
- ]);
+ // Subordinate switch: rely on iOS UISwitch's native tick on iOS,
+ // fire a Light impact only on Android where there is none.
+ fireSwitchHaptic(ImpactFeedbackStyle.Light);
+ toggleTraderNotification(traderId);
+ }, [pushNotificationsOff, toggleTraderNotification, traderId]);
if (!isVisible) {
return null;
@@ -149,16 +131,8 @@ const TraderNotificationsBottomSheet = forwardRef<
'social_leaderboard.trader_notifications.allow_push_notifications_desc',
{ traderName },
)}
- value={localEnabled}
- onValueChange={(next: boolean) => {
- if (pushNotificationsOff) {
- return;
- }
- // Subordinate switch: rely on iOS UISwitch's native tick on iOS,
- // fire a Light impact only on Android where there is none.
- fireSwitchHaptic(ImpactFeedbackStyle.Light);
- setLocalEnabled(next);
- }}
+ value={enabled}
+ onValueChange={handleToggle}
disabled={pushNotificationsOff}
toggleTestID={TraderNotificationsBottomSheetSelectorsIDs.TOGGLE}
/>
@@ -175,7 +149,7 @@ const TraderNotificationsBottomSheet = forwardRef<
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Between}
- twClassName="px-4 py-4"
+ twClassName="px-4 py-4 mb-4"
>
-
-
);
});
diff --git a/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"