diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js
index e532ebcb742..398722c95af 100644
--- a/.storybook/storybook.requires.js
+++ b/.storybook/storybook.requires.js
@@ -49,6 +49,7 @@ const getStories = () => {
"./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"),
"./app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx": require("../app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx"),
"./app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx"),
+ "./app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx"),
"./app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx": require("../app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx"),
"./app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx"),
"./app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx"),
diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx
new file mode 100644
index 00000000000..fdc02acf095
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx
@@ -0,0 +1,255 @@
+/* eslint-disable no-console */
+import React from 'react';
+import { View, ScrollView } from 'react-native';
+
+import {
+ Box,
+ Text,
+ TextVariant,
+ IconName,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { SharedValue } from 'react-native-reanimated';
+
+import HeaderWithTitleLeftScrollable from './HeaderWithTitleLeftScrollable';
+import useHeaderWithTitleLeftScrollable from './useHeaderWithTitleLeftScrollable';
+import { UseHeaderWithTitleLeftScrollableOptions } from './HeaderWithTitleLeftScrollable.types';
+
+const HeaderWithTitleLeftScrollableMeta = {
+ title: 'Components Temp / HeaderWithTitleLeftScrollable',
+ component: HeaderWithTitleLeftScrollable,
+ argTypes: {
+ title: {
+ control: 'text',
+ },
+ twClassName: {
+ control: 'text',
+ },
+ },
+};
+
+export default HeaderWithTitleLeftScrollableMeta;
+
+const SampleNFTImage = () => (
+
+ NFT
+
+);
+
+const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => (
+ <>
+ {Array.from({ length: itemCount }).map((_, index) => (
+
+ Item {index + 1}
+
+ This is sample content to demonstrate scrolling behavior.
+
+
+ ))}
+ >
+);
+
+interface ScrollableStoryContainerProps {
+ children: (props: {
+ scrollYValue: SharedValue;
+ setExpandedHeight: (h: number) => void;
+ }) => React.ReactNode;
+ hookOptions?: UseHeaderWithTitleLeftScrollableOptions;
+}
+
+const ScrollableStoryContainer = ({
+ children,
+ hookOptions,
+}: ScrollableStoryContainerProps) => {
+ const tw = useTailwind();
+ const {
+ onScroll,
+ scrollY: scrollYValue,
+ expandedHeight,
+ setExpandedHeight,
+ } = useHeaderWithTitleLeftScrollable(hookOptions);
+
+ return (
+
+ {children({ scrollYValue, setExpandedHeight })}
+
+
+
+
+ );
+};
+
+export const Default = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Back pressed')}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ }}
+ scrollY={scrollYValue}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
+
+export const OnBack = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Back pressed')}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ endAccessory: ,
+ }}
+ scrollY={scrollYValue}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
+
+export const WithBottomLabel = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Back pressed')}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ bottomLabel: '0.002 ETH',
+ endAccessory: ,
+ }}
+ scrollY={scrollYValue}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
+
+export const EndButtonIconProps = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Back pressed')}
+ endButtonIconProps={[
+ {
+ iconName: IconName.Close,
+ onPress: () => console.log('Close pressed'),
+ },
+ ]}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ endAccessory: ,
+ }}
+ scrollY={scrollYValue}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
+
+export const BackButtonProps = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Custom back pressed'),
+ }}
+ titleLeftProps={{
+ topLabel: 'Receive',
+ title: '$1,234.56',
+ }}
+ scrollY={scrollYValue}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
+
+export const TitleLeft = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Back pressed')}
+ titleLeft={
+
+ Custom Title Section
+
+ This is a completely custom title section
+
+
+ }
+ scrollY={scrollYValue}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
+
+export const NoBackButton = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+
+ )}
+
+ ),
+};
+
+export const FastCollapse = {
+ render: () => (
+
+ {({ scrollYValue, setExpandedHeight }) => (
+ console.log('Back pressed')}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ endAccessory: ,
+ }}
+ scrollY={scrollYValue}
+ scrollTriggerPosition={60}
+ onExpandedHeightChange={setExpandedHeight}
+ />
+ )}
+
+ ),
+};
diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx
new file mode 100644
index 00000000000..f1f8c7afa14
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx
@@ -0,0 +1,190 @@
+// Third party dependencies.
+import React from 'react';
+import { render, renderHook } from '@testing-library/react-native';
+import { useSharedValue, SharedValue } from 'react-native-reanimated';
+import { Text } from 'react-native';
+
+// Internal dependencies.
+import HeaderWithTitleLeftScrollable from './HeaderWithTitleLeftScrollable';
+import useHeaderWithTitleLeftScrollable from './useHeaderWithTitleLeftScrollable';
+
+// Mock react-native-reanimated
+jest.mock('react-native-reanimated', () => {
+ const Reanimated = jest.requireActual('react-native-reanimated/mock');
+ Reanimated.useSharedValue = jest.fn((initial) => ({
+ value: initial,
+ }));
+ Reanimated.useAnimatedStyle = jest.fn((fn) => fn());
+ Reanimated.interpolate = jest.fn(
+ (_value, _inputRange, outputRange) => outputRange[0],
+ );
+ Reanimated.Extrapolation = { CLAMP: 'clamp' };
+ return Reanimated;
+});
+
+// Test wrapper component that provides scrollY
+const TestWrapper: React.FC<{
+ children: (scrollYValue: SharedValue) => React.ReactNode;
+}> = ({ children }) => {
+ const scrollYValue = useSharedValue(0);
+ return <>{children(scrollYValue)}>;
+};
+
+describe('HeaderWithTitleLeftScrollable', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders with title', () => {
+ const { getAllByText } = render(
+
+ {(scrollYValue) => (
+
+ )}
+ ,
+ );
+
+ expect(getAllByText('Test Title').length).toBeGreaterThan(0);
+ });
+
+ it('renders container with testID when provided', () => {
+ const { getByTestId } = render(
+
+ {(scrollYValue) => (
+
+ )}
+ ,
+ );
+
+ expect(getByTestId('test-container')).toBeOnTheScreen();
+ });
+ });
+
+ describe('back button', () => {
+ it('renders back button when onBack provided', () => {
+ const { getByTestId } = render(
+
+ {(scrollYValue) => (
+
+ )}
+ ,
+ );
+
+ expect(getByTestId('test-back-button')).toBeOnTheScreen();
+ });
+
+ it('renders back button when backButtonProps provided', () => {
+ const { getByTestId } = render(
+
+ {(scrollYValue) => (
+
+ )}
+ ,
+ );
+
+ expect(getByTestId('test-back-button')).toBeOnTheScreen();
+ });
+ });
+
+ describe('titleLeft and titleLeftProps', () => {
+ it('renders custom titleLeft node', () => {
+ const { getByText } = render(
+
+ {(scrollYValue) => (
+ Custom Content}
+ />
+ )}
+ ,
+ );
+
+ expect(getByText('Custom Content')).toBeOnTheScreen();
+ });
+
+ it('titleLeft takes priority over titleLeftProps', () => {
+ const { getByText, queryByText } = render(
+
+ {(scrollYValue) => (
+ Custom Node}
+ titleLeftProps={{ title: 'Props Title' }}
+ />
+ )}
+ ,
+ );
+
+ expect(getByText('Custom Node')).toBeOnTheScreen();
+ expect(queryByText('Props Title')).toBeNull();
+ });
+ });
+});
+
+describe('useHeaderWithTitleLeftScrollable', () => {
+ it('returns onScroll handler', () => {
+ const { result } = renderHook(() => useHeaderWithTitleLeftScrollable());
+
+ expect(typeof result.current.onScroll).toBe('function');
+ });
+
+ it('returns scrollY shared value with initial value of 0', () => {
+ const { result } = renderHook(() => useHeaderWithTitleLeftScrollable());
+
+ expect(result.current.scrollY.value).toBe(0);
+ });
+
+ it('returns expandedHeight', () => {
+ const { result } = renderHook(() => useHeaderWithTitleLeftScrollable());
+
+ expect(result.current.expandedHeight).toBe(140);
+ });
+
+ it('returns scrollTriggerPosition defaulting to expandedHeight', () => {
+ const { result } = renderHook(() => useHeaderWithTitleLeftScrollable());
+
+ expect(result.current.scrollTriggerPosition).toBe(
+ result.current.expandedHeight,
+ );
+ });
+
+ it('uses custom scrollTriggerPosition when provided', () => {
+ const { result } = renderHook(() =>
+ useHeaderWithTitleLeftScrollable({
+ scrollTriggerPosition: 80,
+ }),
+ );
+
+ expect(result.current.scrollTriggerPosition).toBe(80);
+ });
+
+ it('uses custom expandedHeight when provided', () => {
+ const { result } = renderHook(() =>
+ useHeaderWithTitleLeftScrollable({ expandedHeight: 200 }),
+ );
+
+ expect(result.current.expandedHeight).toBe(200);
+ });
+});
diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.tsx b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.tsx
new file mode 100644
index 00000000000..e5a03696b4f
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.tsx
@@ -0,0 +1,225 @@
+// Third party dependencies.
+import React, { useMemo, useState, useCallback } from 'react';
+import { View, LayoutChangeEvent } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useDerivedValue,
+ interpolate,
+ Extrapolation,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+
+// External dependencies.
+import {
+ Box,
+ Text,
+ TextVariant,
+ FontWeight,
+ IconName,
+ ButtonIconProps,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+
+// Internal dependencies.
+import HeaderBase from '../../components/HeaderBase';
+import TitleLeft from '../TitleLeft';
+import { HeaderWithTitleLeftScrollableProps } from './HeaderWithTitleLeftScrollable.types';
+
+const DEFAULT_EXPANDED_HEIGHT = 140;
+const DEFAULT_COLLAPSED_HEIGHT = 48;
+
+/**
+ * HeaderWithTitleLeftScrollable is a collapsing header component that transitions
+ * between an expanded state (with TitleLeft) and a compact sticky state (with HeaderBase)
+ * based on scroll position.
+ *
+ * Uses Reanimated for performant scroll-linked animations.
+ *
+ * @example
+ * ```tsx
+ * const { onScroll, scrollY, expandedHeight } = useHeaderWithTitleLeftScrollable();
+ *
+ * return (
+ *
+ *
+ * }}
+ * scrollY={scrollY}
+ * />
+ *
+ *
+ *
+ *
+ * );
+ * ```
+ */
+const HeaderWithTitleLeftScrollable: React.FC<
+ HeaderWithTitleLeftScrollableProps
+> = ({
+ title,
+ onBack,
+ backButtonProps,
+ titleLeft,
+ titleLeftProps,
+ scrollTriggerPosition,
+ scrollY,
+ startButtonIconProps,
+ twClassName,
+ onExpandedHeightChange,
+ testID,
+ ...headerBaseProps
+}) => {
+ const tw = useTailwind();
+
+ // Measure actual content height for dynamic sizing
+ const [measuredHeight, setMeasuredHeight] = useState(DEFAULT_EXPANDED_HEIGHT);
+ const animatedMeasuredHeight = useSharedValue(DEFAULT_EXPANDED_HEIGHT);
+
+ const handleLayout = useCallback(
+ (e: LayoutChangeEvent) => {
+ const { height } = e.nativeEvent.layout;
+ if (height > 0 && height !== measuredHeight) {
+ setMeasuredHeight(height);
+ animatedMeasuredHeight.value = height;
+ onExpandedHeightChange?.(height);
+ }
+ },
+ [measuredHeight, animatedMeasuredHeight, onExpandedHeightChange],
+ );
+
+ // Use scrollTriggerPosition if provided, otherwise use measured height
+ const effectiveScrollTriggerPosition =
+ scrollTriggerPosition ?? measuredHeight;
+
+ // Build startButtonIconProps with back button if onBack or backButtonProps is provided
+ const resolvedStartButtonIconProps = useMemo(() => {
+ if (startButtonIconProps) {
+ return startButtonIconProps;
+ }
+
+ if (onBack || backButtonProps) {
+ const backProps: ButtonIconProps = {
+ iconName: IconName.ArrowLeft,
+ ...(backButtonProps || {}),
+ onPress: backButtonProps?.onPress ?? onBack,
+ };
+ return backProps;
+ }
+
+ return undefined;
+ }, [startButtonIconProps, onBack, backButtonProps]);
+
+ // Animated style for the header container height (uses measured content height)
+ const headerAnimatedStyle = useAnimatedStyle(() => {
+ const height = interpolate(
+ scrollY.value,
+ [0, effectiveScrollTriggerPosition],
+ [animatedMeasuredHeight.value, DEFAULT_COLLAPSED_HEIGHT],
+ Extrapolation.CLAMP,
+ );
+
+ return {
+ height,
+ };
+ });
+
+ // Derived value: triggers timed animation when large content is fully hidden
+ const compactTitleProgress = useDerivedValue(() => {
+ // Use effectiveScrollTriggerPosition to sync with header collapse animation
+ const triggerPosition =
+ effectiveScrollTriggerPosition - DEFAULT_COLLAPSED_HEIGHT;
+ const isFullyHidden = scrollY.value >= triggerPosition;
+
+ // Animate to 1 when hidden, 0 when visible (with timing)
+ return withTiming(isFullyHidden ? 1 : 0, { duration: 150 });
+ });
+
+ // Animated style for the compact title (timed fade up when large content is fully hidden)
+ const compactTitleAnimatedStyle = useAnimatedStyle(() => {
+ const progress = compactTitleProgress.value;
+
+ return {
+ opacity: progress,
+ transform: [{ translateY: (1 - progress) * 8 }], // Fade up from 8px below
+ };
+ });
+
+ // Animated style for the large header content (moves up behind header, synced 1:1 with scroll)
+ const largeContentAnimatedStyle = useAnimatedStyle(() => {
+ const largeContentHeight =
+ animatedMeasuredHeight.value - DEFAULT_COLLAPSED_HEIGHT;
+ // Move up 1:1 with scroll, clamped between 0 and -largeContentHeight
+ // Math.min(..., 0) prevents moving down on overscroll
+ // Math.max(..., -largeContentHeight) prevents moving up too far
+ const translateY = Math.min(
+ Math.max(-scrollY.value, -largeContentHeight),
+ 0,
+ );
+
+ return {
+ transform: [{ translateY }],
+ };
+ });
+
+ // Render large content section
+ // Render large content: custom node > titleLeftProps > default title
+ const renderLargeContent = () => {
+ if (titleLeft) {
+ return titleLeft;
+ }
+ // Spread titleLeftProps over default title (titleLeftProps.title overrides if provided)
+ return ;
+ };
+
+ return (
+
+ {/* Header content - measured for dynamic height */}
+
+ {/* HeaderBase with compact title */}
+
+ {/* Compact title - fades in when collapsed */}
+
+
+ {title}
+
+
+
+
+ {/* Large header content - clips as it moves up behind header */}
+
+
+ {renderLargeContent()}
+
+
+
+
+ );
+};
+
+export default HeaderWithTitleLeftScrollable;
diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.types.ts b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.types.ts
new file mode 100644
index 00000000000..0d469f6c8c1
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.types.ts
@@ -0,0 +1,106 @@
+// Third party dependencies.
+import { ReactNode } from 'react';
+import { SharedValue } from 'react-native-reanimated';
+
+// External dependencies.
+import { ButtonIconProps } from '@metamask/design-system-react-native';
+
+// Internal dependencies.
+import { HeaderBaseProps } from '../../components/HeaderBase';
+import { TitleLeftProps } from '../TitleLeft/TitleLeft.types';
+
+/**
+ * Props for the HeaderWithTitleLeftScrollable component.
+ */
+export interface HeaderWithTitleLeftScrollableProps
+ extends Omit {
+ /**
+ * Title text displayed in the compact header state.
+ * Also used as default title for TitleLeft if titleLeftProps.title is not provided.
+ */
+ title: string;
+ /**
+ * Callback when the back button is pressed.
+ * If provided, a back button will be rendered as startAccessory.
+ */
+ onBack?: () => void;
+ /**
+ * Additional props to pass to the back ButtonIcon.
+ * If provided, a back button will be rendered with these props spread.
+ */
+ backButtonProps?: Omit;
+ /**
+ * Custom node to render in the large title section.
+ * If provided, takes priority over titleLeftProps.
+ */
+ titleLeft?: ReactNode;
+ /**
+ * Props to pass to the TitleLeft component for the large title section.
+ * Only used if titleLeft is not provided.
+ */
+ titleLeftProps?: TitleLeftProps;
+ /**
+ * Scroll position (in pixels) at which the header fully collapses.
+ * @default measured content height
+ */
+ scrollTriggerPosition?: number;
+ /**
+ * Reanimated shared value tracking scroll position.
+ * Obtained from useHeaderWithTitleLeftScrollable hook.
+ */
+ scrollY: SharedValue;
+ /**
+ * Callback fired when the expanded height is measured.
+ * Use this to update ScrollView's contentContainerStyle paddingTop.
+ */
+ onExpandedHeightChange?: (height: number) => void;
+}
+
+/**
+ * Options for the useHeaderWithTitleLeftScrollable hook.
+ */
+export interface UseHeaderWithTitleLeftScrollableOptions {
+ /**
+ * Height of the header in its expanded (large) state.
+ * @default 140
+ */
+ expandedHeight?: number;
+ /**
+ * Scroll position at which the header fully collapses.
+ * @default expandedHeight
+ */
+ scrollTriggerPosition?: number;
+}
+
+/**
+ * Return type for the useHeaderWithTitleLeftScrollable hook.
+ */
+export interface UseHeaderWithTitleLeftScrollableReturn {
+ /**
+ * Scroll handler to attach to ScrollView's onScroll prop.
+ */
+ onScroll: (
+ event: import('react-native').NativeSyntheticEvent<
+ import('react-native').NativeScrollEvent
+ >,
+ ) => void;
+ /**
+ * Shared value tracking current scroll position.
+ */
+ scrollY: SharedValue;
+ /**
+ * Expanded header height for initial padding.
+ * Use this for ScrollView's contentContainerStyle paddingTop.
+ */
+ expandedHeight: number;
+ /**
+ * Function to update the expanded height when content is measured.
+ * Pass this to HeaderWithTitleLeftScrollable's onExpandedHeightChange prop.
+ */
+ setExpandedHeight: (height: number) => void;
+ /**
+ * The scroll position at which the header fully collapses.
+ * Defaults to expandedHeight if not provided.
+ */
+ scrollTriggerPosition: number;
+}
diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/index.ts b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/index.ts
new file mode 100644
index 00000000000..3277b02bcb7
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/index.ts
@@ -0,0 +1,7 @@
+export { default } from './HeaderWithTitleLeftScrollable';
+export { default as useHeaderWithTitleLeftScrollable } from './useHeaderWithTitleLeftScrollable';
+export type {
+ HeaderWithTitleLeftScrollableProps,
+ UseHeaderWithTitleLeftScrollableOptions,
+ UseHeaderWithTitleLeftScrollableReturn,
+} from './HeaderWithTitleLeftScrollable.types';
diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/useHeaderWithTitleLeftScrollable.ts b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/useHeaderWithTitleLeftScrollable.ts
new file mode 100644
index 00000000000..c6d502674e6
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/useHeaderWithTitleLeftScrollable.ts
@@ -0,0 +1,72 @@
+// Third party dependencies.
+import { useCallback, useState } from 'react';
+import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
+import { useSharedValue } from 'react-native-reanimated';
+
+// Internal dependencies.
+import {
+ UseHeaderWithTitleLeftScrollableOptions,
+ UseHeaderWithTitleLeftScrollableReturn,
+} from './HeaderWithTitleLeftScrollable.types';
+
+/**
+ * Hook for managing HeaderWithTitleLeftScrollable scroll-linked animations.
+ *
+ * @param options - Configuration options for the hook.
+ * @returns Object containing scroll handler, scrollY value, and header heights.
+ *
+ * @example
+ * ```tsx
+ * const { onScroll, scrollY, expandedHeight, setExpandedHeight } = useHeaderWithTitleLeftScrollable();
+ *
+ * return (
+ *
+ *
+ *
+ *
+ *
+ *
+ * );
+ * ```
+ */
+const useHeaderWithTitleLeftScrollable = (
+ options: UseHeaderWithTitleLeftScrollableOptions = {},
+): UseHeaderWithTitleLeftScrollableReturn => {
+ const { expandedHeight: initialExpandedHeight = 140, scrollTriggerPosition } =
+ options;
+
+ // Track expanded height - can be updated by component via onExpandedHeightChange
+ const [expandedHeight, setExpandedHeight] = useState(initialExpandedHeight);
+
+ // Default scrollTriggerPosition to expandedHeight if not provided
+ const effectiveScrollTriggerPosition =
+ scrollTriggerPosition ?? expandedHeight;
+
+ const scrollYValue = useSharedValue(0);
+
+ const onScroll = useCallback(
+ (scrollEvent: NativeSyntheticEvent) => {
+ scrollYValue.value = scrollEvent.nativeEvent.contentOffset.y;
+ },
+ [scrollYValue],
+ );
+
+ return {
+ onScroll,
+ scrollY: scrollYValue,
+ expandedHeight,
+ setExpandedHeight,
+ scrollTriggerPosition: effectiveScrollTriggerPosition,
+ };
+};
+
+export default useHeaderWithTitleLeftScrollable;
diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx
index 65032822930..a5d6ae97764 100644
--- a/app/components/UI/AssetOverview/AssetOverview.tsx
+++ b/app/components/UI/AssetOverview/AssetOverview.tsx
@@ -96,7 +96,10 @@ import { selectTokenMarketData } from '../../../selectors/tokenRatesController';
import { getTokenExchangeRate } from '../Bridge/utils/exchange-rates';
import { isNonEvmChainId } from '../../../core/Multichain/utils';
///: BEGIN:ONLY_INCLUDE_IF(tron)
-import { selectTronResourcesBySelectedAccountGroup } from '../../../selectors/assets/assets-list';
+import {
+ selectTronResourcesBySelectedAccountGroup,
+ selectAsset,
+} from '../../../selectors/assets/assets-list';
import { createStakedTrxAsset } from './utils/createStakedTrxAsset';
///: END:ONLY_INCLUDE_IF
import { getDetectedGeolocation } from '../../../reducers/fiatOrders';
@@ -168,6 +171,18 @@ const AssetOverview: React.FC = ({
const strxBandwidth = tronResources.find(
(a) => a.symbol.toLowerCase() === 'strx-bandwidth',
);
+
+ // Use selector to get live Tron asset balance (not static navigation params)
+ const isTronChain = String(asset.chainId).startsWith('tron:');
+ const liveAsset = useSelector((state: RootState) =>
+ isTronChain && asset.address && asset.chainId
+ ? selectAsset(state, {
+ address: asset.address,
+ chainId: asset.chainId,
+ isStaked: false,
+ })
+ : undefined,
+ );
///: END:ONLY_INCLUDE_IF
const currentAddress = asset.address as Hex;
@@ -497,13 +512,21 @@ const AssetOverview: React.FC = ({
: undefined;
///: END:ONLY_INCLUDE_IF
- if (isMultichainAccountsState2Enabled && asset.balance != null) {
+ // Determine the balance source - prefer live data for Tron, otherwise use asset prop
+ let balanceSource = asset.balance;
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ if (isTronChain && liveAsset?.balance != null) {
+ balanceSource = liveAsset.balance;
+ }
+ ///: END:ONLY_INCLUDE_IF
+
+ if (isMultichainAccountsState2Enabled && balanceSource != null) {
// When state2 is enabled and asset has balance, use it directly
- balance = asset.balance;
+ balance = balanceSource;
} else if (isMultichainAsset) {
- balance = asset.balance
+ balance = balanceSource
? formatWithThreshold(
- parseFloat(asset.balance),
+ parseFloat(balanceSource),
minimumDisplayThreshold,
I18n.locale,
{ minimumFractionDigits: 0, maximumFractionDigits: 5 },
@@ -568,7 +591,13 @@ const AssetOverview: React.FC = ({
}
// Calculate fiat balance if not provided in asset (e.g., when coming from trending view)
- let mainBalance = asset.balanceFiat || '';
+ let balanceFiatSource = asset.balanceFiat;
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ if (isTronChain && liveAsset?.balanceFiat != null) {
+ balanceFiatSource = liveAsset.balanceFiat;
+ }
+ ///: END:ONLY_INCLUDE_IF
+ let mainBalance = balanceFiatSource || '';
if (!mainBalance && balance != null) {
// Convert balance to number for calculations
const balanceNumber =
diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx
index 0a9d9ecda40..d8cdb3cb981 100644
--- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx
+++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx
@@ -713,6 +713,55 @@ describe('EarnInputView', () => {
expect(queryByText('Max')).toBeNull();
expect(getByText(strings('onboarding_success.done'))).toBeOnTheScreen();
});
+
+ it('does not show MaxInputModal when Max button is pressed for TRX', async () => {
+ (selectTrxStakingEnabled as unknown as jest.Mock).mockReturnValue(true);
+
+ const TRX_TOKEN = {
+ name: 'TRON',
+ symbol: 'TRX',
+ ticker: 'TRX',
+ chainId: 'tron:728126428',
+ isNative: true,
+ address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB',
+ balance: '100',
+ balanceFiat: '$100',
+ decimals: 6,
+ isETH: false,
+ } as unknown as typeof MOCK_ETH_MAINNET_ASSET;
+
+ (useEarnTokens as jest.Mock).mockReturnValue({
+ getEarnToken: jest.fn(() => ({
+ ...TRX_TOKEN,
+ balanceMinimalUnit: '100000000',
+ balanceFormatted: '100 TRX',
+ balanceFiatNumber: 100,
+ tokenUsdExchangeRate: 1,
+ experiences: [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }],
+ experience: { type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' },
+ })),
+ getOutputToken: jest.fn(() => undefined),
+ });
+
+ const { getByText } = render(EarnInputView, {
+ params: {
+ token: TRX_TOKEN,
+ },
+ key: Routes.STAKING.STAKE,
+ name: 'params',
+ });
+
+ await act(async () => {
+ fireEvent.press(getByText('Max'));
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ 'StakeModals',
+ expect.objectContaining({
+ screen: Routes.STAKING.MODALS.MAX_INPUT,
+ }),
+ );
+ });
});
describe('when values are entered in the keypad', () => {
diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx
index 24a5cba784d..1a9ffe86908 100644
--- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx
+++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx
@@ -133,6 +133,7 @@ const EarnInputView = () => {
preview: tronPreview,
validateStakeAmount: tronValidateStakeAmount,
confirmStake: tronConfirmStake,
+ tronAccountId,
} = useTronStake({ token });
const { apyPercent: tronApyPercent } = useTronStakeApy();
///: END:ONLY_INCLUDE_IF
@@ -625,7 +626,12 @@ const EarnInputView = () => {
///: BEGIN:ONLY_INCLUDE_IF(tron)
if (isTronEnabled) {
const result = await tronConfirmStake?.(amountToken);
- handleTronStakingNavigationResult(navigation, result, 'stake');
+ handleTronStakingNavigationResult(
+ navigation,
+ result,
+ 'stake',
+ tronAccountId,
+ );
return;
}
///: END:ONLY_INCLUDE_IF
@@ -649,6 +655,7 @@ const EarnInputView = () => {
isTronEnabled,
navigation,
tronConfirmStake,
+ tronAccountId,
///: END:ONLY_INCLUDE_IF
handlePooledStakingFlow,
handleLendingFlow,
@@ -713,8 +720,15 @@ const EarnInputView = () => {
// Right action press: act as "Done" in TRON editing with non-zero amount; otherwise behave as Max
const onRightActionPress = React.useCallback(() => {
- if (isTronEnabled && isTronNative && isNonZeroAmount && !isPreviewVisible) {
- setIsPreviewVisible(true);
+ // For TRON: if we have a non-zero amount, show preview; otherwise just set max directly (skip modal)
+ if (isTronEnabled && isTronNative) {
+ if (isNonZeroAmount && !isPreviewVisible) {
+ setIsPreviewVisible(true);
+ } else {
+ // Directly call handleMax for Tron - the MaxInputModal is EVM-specific
+ lastQuickAmountButtonPressed.current = 'MAX';
+ handleMax();
+ }
return;
}
handleMaxPressWithTracking();
@@ -724,6 +738,7 @@ const EarnInputView = () => {
isNonZeroAmount,
isPreviewVisible,
handleMaxPressWithTracking,
+ handleMax,
]);
const handleCurrencySwitchWithTracking = useCallback(() => {
diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap
index 0d93008e77c..5576f5cd616 100644
--- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap
+++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap
@@ -1879,7 +1879,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia
"flexGrow": 1,
}
}
- handlerTag={12}
+ handlerTag={14}
handlerType="NativeViewGestureHandler"
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
diff --git a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx
index 929531fda42..e2b2bdbea00 100644
--- a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx
+++ b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx
@@ -92,6 +92,7 @@ const EarnWithdrawInputView = () => {
preview: tronPreview,
validateUnstakeAmount: tronValidateUnstakeAmount,
confirmUnstake: tronConfirmUnstake,
+ tronAccountId,
} = useTronUnstake({ token });
const { apyPercent: tronApyPercent } = useTronStakeApy();
///: END:ONLY_INCLUDE_IF
@@ -579,7 +580,12 @@ const EarnWithdrawInputView = () => {
///: BEGIN:ONLY_INCLUDE_IF(tron)
if (isTronEnabled) {
const result = await tronConfirmUnstake?.(amountToken);
- handleTronStakingNavigationResult(navigation, result, 'unstake');
+ handleTronStakingNavigationResult(
+ navigation,
+ result,
+ 'unstake',
+ tronAccountId,
+ );
return;
}
///: END:ONLY_INCLUDE_IF
@@ -598,6 +604,7 @@ const EarnWithdrawInputView = () => {
tronConfirmUnstake,
amountToken,
navigation,
+ tronAccountId,
///: END:ONLY_INCLUDE_IF
withdrawalToken?.experience?.type,
handleLendingWithdrawalFlow,
diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx
index 9fc6dac0a61..0340ae8ec9a 100644
--- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx
+++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx
@@ -84,7 +84,7 @@ describe('TronStakingButtons', () => {
,
);
- expect(getByText('stake.stake')).toBeTruthy();
+ expect(getByText('stake.stake_your_trx_cta.earn_button')).toBeOnTheScreen();
fireEvent.press(getByTestId('stake-more-button'));
diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx
index e15c71beb98..ccddf717370 100644
--- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx
+++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx
@@ -93,7 +93,7 @@ const TronStakingButtons = ({
label={
hasStakedPositions
? strings('stake.stake_more')
- : strings('stake.stake')
+ : strings('stake.stake_your_trx_cta.earn_button')
}
onPress={onStakePress}
/>
diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.styles.ts
new file mode 100644
index 00000000000..2aef53ade15
--- /dev/null
+++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.styles.ts
@@ -0,0 +1,19 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ marginTop: 16,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ justifyContent: 'center',
+ marginTop: 4,
+ marginBottom: 8,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx
index c4d9dc3bc42..c938331e2f8 100644
--- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx
+++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx
@@ -7,26 +7,26 @@ import TronStakingCta from './TronStakingCta';
jest.mock('../../../../../../../locales/i18n', () => ({
strings: (key: string) => {
const map: Record = {
- 'stake.earn': 'Earn',
- 'stake.stake_your_trx_cta.base': 'Stake your TRX and earn',
- 'stake.stake_your_trx_cta.annually': 'annually',
- 'stake.stake_your_trx_cta.learn_more_with_period': 'Learn more.',
+ 'stake.stake_your_trx_cta.title': 'Lend Tron and earn',
+ 'stake.stake_your_trx_cta.description_start': 'Stake your Tron and earn ',
+ 'stake.stake_your_trx_cta.description_end': ' annually.',
+ 'stake.stake_your_trx_cta.learn_more': 'Learn more.',
};
return map[key] ?? key;
},
}));
describe('TronStakingCta', () => {
- it('renders title and base description without APR or Learn More', () => {
+ it('renders title and description without APR or learn more button', () => {
const { getByText, queryByText } = renderWithProvider();
- expect(getByText('Earn')).toBeOnTheScreen();
- expect(getByText('Stake your TRX and earn')).toBeOnTheScreen();
- expect(queryByText('annually')).toBeNull();
+ expect(getByText('Lend Tron and earn')).toBeOnTheScreen();
+ expect(getByText('Stake your Tron and earn ')).toBeOnTheScreen();
+ expect(getByText(' annually.')).toBeOnTheScreen();
expect(queryByText('Learn more.')).toBeNull();
});
- it('renders APR and annually when aprText is provided', () => {
+ it('renders APR when aprText is provided', () => {
const aprText = '4.5%';
const { getByText } = renderWithProvider(
@@ -34,18 +34,18 @@ describe('TronStakingCta', () => {
);
expect(getByText(aprText)).toBeOnTheScreen();
- expect(getByText('annually')).toBeOnTheScreen();
+ expect(getByText(' annually.')).toBeOnTheScreen();
});
- it('calls onLearnMore when Learn more is pressed', () => {
- const onLearnMore = jest.fn();
+ it('calls onEarn when learn more button is pressed', () => {
+ const onEarn = jest.fn();
const { getByText } = renderWithProvider(
- ,
+ ,
);
fireEvent.press(getByText('Learn more.'));
- expect(onLearnMore).toHaveBeenCalledTimes(1);
+ expect(onEarn).toHaveBeenCalledTimes(1);
});
});
diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx
index fff1b12dce9..a8e8be862ae 100644
--- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx
+++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx
@@ -7,41 +7,37 @@ import Text, {
import Button, {
ButtonVariants,
} from '../../../../../../component-library/components/Buttons/Button';
+import { useStyles } from '../../../../../../component-library/hooks';
import { strings } from '../../../../../../../locales/i18n';
-
-const styles = {
- row: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' },
-} as const;
+import styleSheet from './TronStakingCta.styles';
interface TronStakingCtaProps extends Pick {
aprText?: string;
- onLearnMore?: () => void;
+ onEarn?: () => void;
}
-const TronStakingCta = ({
- style,
- aprText,
- onLearnMore,
-}: TronStakingCtaProps) => (
-
- {strings('stake.earn')}
-
- {strings('stake.stake_your_trx_cta.base')}
- {aprText ? (
- <>
- {aprText}
- {` ${strings('stake.stake_your_trx_cta.annually')} `}
- >
- ) : null}
- {onLearnMore ? (
+const TronStakingCta = ({ style, aprText, onEarn }: TronStakingCtaProps) => {
+ const { styles } = useStyles(styleSheet, {});
+
+ return (
+
+
+ {strings('stake.stake_your_trx_cta.title')}
+
+
+ {strings('stake.stake_your_trx_cta.description_start')}
+ {aprText ? {aprText} : null}
+ {strings('stake.stake_your_trx_cta.description_end')}
+
+ {onEarn ? (
) : null}
-
-);
+ );
+};
export default TronStakingCta;
diff --git a/app/components/UI/Earn/hooks/useTronStake.test.ts b/app/components/UI/Earn/hooks/useTronStake.test.ts
index edfa8fc87d5..e611b3da7d0 100644
--- a/app/components/UI/Earn/hooks/useTronStake.test.ts
+++ b/app/components/UI/Earn/hooks/useTronStake.test.ts
@@ -134,6 +134,24 @@ describe('useTronStake', () => {
expect(result.current.resourceType).toBe('bandwidth');
});
+
+ it('returns tronAccountId from selected account', () => {
+ const { result } = renderHook(() =>
+ useTronStake({ token: mockTrxToken }),
+ );
+
+ expect(result.current.tronAccountId).toBe(mockAccount.id);
+ });
+
+ it('returns undefined tronAccountId when account is not selected', () => {
+ mockSelectSelectedInternalAccountByScope.mockReturnValue(undefined);
+
+ const { result } = renderHook(() =>
+ useTronStake({ token: mockTrxToken }),
+ );
+
+ expect(result.current.tronAccountId).toBeUndefined();
+ });
});
describe('validate', () => {
diff --git a/app/components/UI/Earn/hooks/useTronStake.ts b/app/components/UI/Earn/hooks/useTronStake.ts
index f735ab79f18..d140cebf323 100644
--- a/app/components/UI/Earn/hooks/useTronStake.ts
+++ b/app/components/UI/Earn/hooks/useTronStake.ts
@@ -42,6 +42,8 @@ interface UseTronStakeReturn {
validateStakeAmount: (amount: string) => Promise;
/** Confirm stake with current resource type */
confirmStake: (amount: string) => Promise;
+ /** The Tron account ID for balance refresh */
+ tronAccountId?: string;
}
/**
@@ -156,6 +158,7 @@ const useTronStake = ({ token }: UseTronStakeParams): UseTronStakeReturn => {
preview,
validateStakeAmount,
confirmStake,
+ tronAccountId: selectedTronAccount?.id,
};
};
diff --git a/app/components/UI/Earn/hooks/useTronUnstake.test.ts b/app/components/UI/Earn/hooks/useTronUnstake.test.ts
index 3267b8177e1..1477b72ef88 100644
--- a/app/components/UI/Earn/hooks/useTronUnstake.test.ts
+++ b/app/components/UI/Earn/hooks/useTronUnstake.test.ts
@@ -169,6 +169,24 @@ describe('useTronUnstake', () => {
expect(result.current.tronWithdrawalToken).toBeDefined();
expect(result.current.tronWithdrawalToken?.symbol).toBe('TRX');
});
+
+ it('returns tronAccountId from selected account', () => {
+ const { result } = renderHook(() =>
+ useTronUnstake({ token: mockTrxToken }),
+ );
+
+ expect(result.current.tronAccountId).toBe(mockAccount.id);
+ });
+
+ it('returns undefined tronAccountId when account is not selected', () => {
+ mockSelectSelectedInternalAccountByScope.mockReturnValue(undefined);
+
+ const { result } = renderHook(() =>
+ useTronUnstake({ token: mockTrxToken }),
+ );
+
+ expect(result.current.tronAccountId).toBeUndefined();
+ });
});
describe('validate', () => {
diff --git a/app/components/UI/Earn/hooks/useTronUnstake.ts b/app/components/UI/Earn/hooks/useTronUnstake.ts
index bedaf0184e0..70cc40cc145 100644
--- a/app/components/UI/Earn/hooks/useTronUnstake.ts
+++ b/app/components/UI/Earn/hooks/useTronUnstake.ts
@@ -52,6 +52,8 @@ interface UseTronUnstakeReturn {
validateUnstakeAmount: (amount: string) => Promise;
/** Confirm unstake with current resource type */
confirmUnstake: (amount: string) => Promise;
+ /** The Tron account ID for balance refresh */
+ tronAccountId?: string;
}
/**
@@ -202,6 +204,7 @@ const useTronUnstake = ({
preview,
validateUnstakeAmount,
confirmUnstake,
+ tronAccountId: selectedTronAccount?.id,
};
};
diff --git a/app/components/UI/Earn/utils/tron.test.ts b/app/components/UI/Earn/utils/tron.test.ts
index 5e91619a9d9..8beaa4034fb 100644
--- a/app/components/UI/Earn/utils/tron.test.ts
+++ b/app/components/UI/Earn/utils/tron.test.ts
@@ -6,6 +6,7 @@ import type { EarnTokenDetails } from '../types/lending.types';
import type { TokenI } from '../../Tokens/types';
import {
buildTronEarnTokenIfEligible,
+ getLocalizedErrorMessage,
getStakedTrxTotalFromResources,
handleTronStakingNavigationResult,
hasStakedTrxPositions,
@@ -24,6 +25,19 @@ jest.mock('../../../../../locales/i18n', () => ({
strings: (key: string) => key,
}));
+const mockUpdateBalance = jest.fn().mockResolvedValue(undefined);
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ MultichainBalancesController: {
+ updateBalance: (accountId: string) => mockUpdateBalance(accountId),
+ },
+ },
+}));
+
+jest.mock('../../../../util/Logger', () => ({
+ error: jest.fn(),
+}));
+
describe('tron utils', () => {
interface RafGlobal {
requestAnimationFrame?: (callback: () => void) => void;
@@ -163,6 +177,43 @@ describe('tron utils', () => {
});
});
+ describe('getLocalizedErrorMessage', () => {
+ it('returns empty string when errors is undefined', () => {
+ const result = getLocalizedErrorMessage(undefined);
+
+ expect(result).toBe('');
+ });
+
+ it('returns empty string when errors array is empty', () => {
+ const result = getLocalizedErrorMessage([]);
+
+ expect(result).toBe('');
+ });
+
+ it('returns localized message for InsufficientBalance error', () => {
+ const result = getLocalizedErrorMessage(['InsufficientBalance']);
+
+ expect(result).toBe('stake.tron.errors.insufficient_balance');
+ });
+
+ it('returns raw error message for unknown error codes', () => {
+ const result = getLocalizedErrorMessage(['UnknownError']);
+
+ expect(result).toBe('UnknownError');
+ });
+
+ it('returns mixed messages when errors contain both known and unknown codes', () => {
+ const result = getLocalizedErrorMessage([
+ 'InsufficientBalance',
+ 'SomeOtherError',
+ ]);
+
+ expect(result).toBe(
+ 'stake.tron.errors.insufficient_balance\nSomeOtherError',
+ );
+ });
+ });
+
describe('handleTronStakingNavigationResult', () => {
const createNavigation = (): NavigationProp =>
({
@@ -172,6 +223,7 @@ describe('tron utils', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockUpdateBalance.mockClear();
});
it('navigates to success sheet for stake when result is valid and has no errors', () => {
@@ -198,6 +250,32 @@ describe('tron utils', () => {
);
});
+ it('refreshes multichain balance when accountId is provided on success', () => {
+ const navigation = createNavigation();
+ const result = {
+ valid: true,
+ errors: undefined,
+ };
+ const accountId = 'test-tron-account-id';
+
+ handleTronStakingNavigationResult(navigation, result, 'stake', accountId);
+
+ expect(mockUpdateBalance).toHaveBeenCalledWith(accountId);
+ expect(navigation.goBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not refresh balance when accountId is not provided', () => {
+ const navigation = createNavigation();
+ const result = {
+ valid: true,
+ errors: undefined,
+ };
+
+ handleTronStakingNavigationResult(navigation, result, 'stake');
+
+ expect(mockUpdateBalance).not.toHaveBeenCalled();
+ });
+
it('navigates to error sheet for stake when result has errors', () => {
const navigation = createNavigation();
const result = {
@@ -267,5 +345,27 @@ describe('tron utils', () => {
},
);
});
+
+ it('displays localized error message for InsufficientBalance error', () => {
+ const navigation = createNavigation();
+ const result = {
+ valid: false,
+ errors: ['InsufficientBalance'],
+ };
+
+ handleTronStakingNavigationResult(navigation, result, 'unstake');
+
+ expect(navigation.navigate).toHaveBeenCalledWith(
+ Routes.MODAL.ROOT_MODAL_FLOW,
+ {
+ screen: Routes.SHEET.SUCCESS_ERROR_SHEET,
+ params: {
+ title: 'stake.tron.unstake_failed',
+ description: 'stake.tron.errors.insufficient_balance',
+ type: 'error',
+ },
+ },
+ );
+ });
});
});
diff --git a/app/components/UI/Earn/utils/tron.ts b/app/components/UI/Earn/utils/tron.ts
index d3cd5cb58b3..f40f1bfc619 100644
--- a/app/components/UI/Earn/utils/tron.ts
+++ b/app/components/UI/Earn/utils/tron.ts
@@ -10,6 +10,8 @@ import { EARN_EXPERIENCES } from '../constants/experiences';
import type { EarnTokenDetails } from '../types/lending.types';
import type { TronStakeResult, TronUnstakeResult } from './tron-staking-snap';
import { TokenI } from '../../Tokens/types';
+import Engine from '../../../../core/Engine';
+import Logger from '../../../../util/Logger';
interface TronResource {
symbol?: string;
@@ -120,14 +122,50 @@ const TRON_STAKING_COPY: Record<
},
};
+/**
+ * Maps known TRON error codes to localization keys.
+ * Falls back to the raw error message for unrecognized errors.
+ */
+const TRON_ERROR_LOCALIZATION_KEYS: Record = {
+ InsufficientBalance: 'stake.tron.errors.insufficient_balance',
+};
+
+export const getLocalizedErrorMessage = (errors?: string[]): string => {
+ if (!errors || errors.length === 0) {
+ return '';
+ }
+
+ const localizedMessages = errors.map((error) => {
+ const localizationKey = TRON_ERROR_LOCALIZATION_KEYS[error];
+ return localizationKey ? strings(localizationKey) : error;
+ });
+
+ return localizedMessages.join('\n');
+};
+
export const handleTronStakingNavigationResult = (
navigation: NavigationProp,
result: TronStakingNavigationResult,
action: TronStakingAction,
+ accountId?: string,
) => {
const copy = TRON_STAKING_COPY[action];
if (result?.valid && (!result.errors || result.errors.length === 0)) {
+ // Refreshes the multichain balance after successful stake/unstake
+ // to make sure that the asset overview displays the updated staked balance right away
+ if (accountId) {
+ const { MultichainBalancesController } = Engine.context;
+ MultichainBalancesController.updateBalance(accountId).catch(
+ (error: Error) => {
+ Logger.error(
+ error,
+ `[Tron ${action}] Failed to refresh multichain balance`,
+ );
+ },
+ );
+ }
+
navigation.goBack();
requestAnimationFrame(() => {
navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
@@ -145,7 +183,7 @@ export const handleTronStakingNavigationResult = (
screen: Routes.SHEET.SUCCESS_ERROR_SHEET,
params: {
title: strings(copy.errorTitleKey),
- description: result?.errors?.join('\n') ?? '',
+ description: getLocalizedErrorMessage(result?.errors),
type: 'error',
},
});
diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
index c3205741d68..15767bb1600 100644
--- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
@@ -66,6 +66,7 @@ import { usePerpsOrderBookGrouping } from '../../hooks/usePerpsOrderBookGrouping
import { selectPerpsButtonColorTestVariant } from '../../selectors/featureFlags';
import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests';
import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest';
+import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
import {
calculateAggregationParams,
calculateGroupingOptions,
@@ -86,6 +87,7 @@ const PerpsOrderBookView: React.FC = ({
const route =
useRoute>();
const { symbol } = route.params || {};
+ const displaySymbol = getPerpsDisplaySymbol(symbol || '');
const { styles } = useStyles(styleSheet, {});
const { navigateToOrder } = usePerpsNavigation();
const { track } = usePerpsEventTracking();
@@ -388,7 +390,7 @@ const PerpsOrderBookView: React.FC = ({
unitDisplay === 'base' ? TextColor.Inverse : TextColor.Default
}
>
- {symbol}
+ {displaySymbol}
{
// Should still render date row
expect(getByText('Date')).toBeOnTheScreen();
});
+
+ it('navigates to market details when Trade again button is pressed', () => {
+ const { getByText } = renderWithProvider(, {
+ state: mockInitialState,
+ });
+
+ const tradeAgainButton = getByText('Trade again');
+ fireEvent.press(tradeAgainButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.MARKET_DETAILS,
+ params: {
+ market: { symbol: 'ETH', name: 'ETH' },
+ source: 'trade_details',
+ },
+ });
+ });
+
+ it('does not render Trade again button when transaction asset is undefined', () => {
+ const transactionWithoutAsset = {
+ ...mockTransaction,
+ asset: undefined,
+ };
+
+ mockUseRoute.mockReturnValue({
+ params: { transaction: transactionWithoutAsset },
+ });
+
+ const { queryByText } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ expect(queryByText('Trade again')).toBeNull();
+ });
});
diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
index 2c59753eaaa..6ce9b8c76fe 100644
--- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
+++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
@@ -3,7 +3,7 @@ import {
useNavigation,
useRoute,
} from '@react-navigation/native';
-import React from 'react';
+import React, { useMemo } from 'react';
import { ScrollView, View } from 'react-native';
import { strings } from '../../../../../../locales/i18n';
import Text, {
@@ -20,6 +20,7 @@ import Button, {
} from '../../../../../component-library/components/Buttons/Button';
import { useStyles } from '../../../../../component-library/hooks';
import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts';
+import Routes from '../../../../../constants/navigation/Routes';
import ScreenView from '../../../../Base/ScreenView';
import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar';
import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero';
@@ -36,6 +37,7 @@ import {
PRICE_RANGES_UNIVERSAL,
} from '../../utils/formatUtils';
import { styleSheet } from './PerpsPositionTransactionView.styles';
+import type { PerpsMarketData } from '../../controllers/types';
const PerpsPositionTransactionView: React.FC = () => {
const { styles } = useStyles(styleSheet, {});
@@ -49,6 +51,16 @@ const PerpsPositionTransactionView: React.FC = () => {
// Get transaction from route params
const transaction = route.params?.transaction as PerpsTransaction;
+ // Create a minimal market object from transaction asset for navigation
+ // This is used to navigate to the market details page without requiring the stream provider
+ const market = useMemo | undefined>(
+ () =>
+ transaction?.asset
+ ? { symbol: transaction.asset, name: transaction.asset }
+ : undefined,
+ [transaction?.asset],
+ );
+
navigation.setOptions(
getPerpsTransactionsDetailsNavbar(
navigation,
@@ -83,6 +95,19 @@ const PerpsPositionTransactionView: React.FC = () => {
});
};
+ const handleTradeAgain = () => {
+ if (!market) {
+ return;
+ }
+ navigation.navigate(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.MARKET_DETAILS,
+ params: {
+ market,
+ source: 'trade_details',
+ },
+ });
+ };
+
// Main detail rows - only show if values exist
const mainDetailRows = [
{
@@ -215,6 +240,16 @@ const PerpsPositionTransactionView: React.FC = () => {
+ {/* Trade again button */}
+ {market && (
+
+ )}
{/* Block explorer button */}