diff --git a/.cursor/rules/feature-flag-guidelines.mdc b/.cursor/rules/feature-flag-guidelines.mdc
deleted file mode 100644
index 424dae1d93e..00000000000
--- a/.cursor/rules/feature-flag-guidelines.mdc
+++ /dev/null
@@ -1,91 +0,0 @@
----
-globs: "**/*"
-alwaysApply: true
----
-
-# Feature Flag Guidelines
-
-## Core Principle
-
-**ALWAYS** use the `useFeatureFlag` hook instead of creating new feature flag selectors.
-
-## Forbidden Patterns
-
-### ❌ NEVER Create New Feature Flag Selectors
-
-**DO NOT** create new selectors using `createSelector` for feature flags:
-
-```typescript
-// ❌ FORBIDDEN - Do not create new feature flag selectors
-export const selectMyFeatureEnabledFlag = createSelector(
- selectRemoteFeatureFlags,
- (remoteFeatureFlags) => {
- // ... selector logic
- },
-);
-```
-
-**DO NOT** add new feature flag selectors in:
-- `app/selectors/featureFlagController/**/*.ts`
-- `app/components/**/selectors/featureFlags/**/*.ts`
-- Any other location that creates feature flag selectors
-
-## Required Pattern
-
-### ✅ ALWAYS Use the `useFeatureFlag` Hook
-
-**MUST** use the `useFeatureFlag` hook from `app/components/hooks/FeatureFlags/useFeatureFlag.ts`:
-
-```typescript
-// ✅ REQUIRED - Use the hook instead
-import { useFeatureFlag, FeatureFlagNames } from '../../../hooks/FeatureFlags/useFeatureFlag';
-
-const MyComponent = () => {
- const isFeatureEnabled = useFeatureFlag(FeatureFlagNames.rewardsEnabled);
-
- // Use the flag value
- if (isFeatureEnabled) {
- // ... feature logic
- }
-};
-```
-
-## Steps to Use Feature Flags
-
-1. **Add the flag name** to the `FeatureFlagNames` enum in `app/components/hooks/FeatureFlags/useFeatureFlag.ts`:
- ```typescript
- export enum FeatureFlagNames {
- rewardsEnabled = 'rewardsEnabled',
- myNewFeature = 'myNewFeature', // Add your new flag here
- }
- ```
-
-2. **Use the hook** in your component:
- ```typescript
- const isMyFeatureEnabled = useFeatureFlag(FeatureFlagNames.myNewFeature);
- ```
-
-3. **Do NOT** create a selector for the feature flag
-
-## Migration Pattern
-
-If you encounter existing feature flag selectors, prefer migrating to the hook:
-
-```typescript
-// ❌ Old pattern (existing code - do not replicate)
-const isFeatureEnabled = useSelector(selectMyFeatureEnabledFlag);
-
-// ✅ New pattern (use this instead)
-const isFeatureEnabled = useFeatureFlag(FeatureFlagNames.myNewFeature);
-```
-
-## Enforcement
-
-- **REJECT** any code that creates new `createSelector` instances for feature flags
-- **REJECT** any new files in `app/selectors/featureFlagController/` directories
-- **REQUIRE** use of `useFeatureFlag` hook for all feature flag access
-- **REQUIRE** adding flag names to `FeatureFlagNames` enum before use
-
-## Exception
-
-The only exception is the base selector `selectRemoteFeatureFlags` in `app/selectors/featureFlagController/index.ts`, which is used internally by the `useFeatureFlag` hook infrastructure.
diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx
index 75bad8b1331..6e8668efef8 100644
--- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx
+++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx
@@ -22,7 +22,9 @@ import {
ButtonSize,
} from '@metamask/design-system-react-native';
import { useStyles } from '../../../../../component-library/hooks';
+import { TextColor } from '../../../../../component-library/components/Texts/Text';
import { strings } from '../../../../../../locales/i18n';
+import { formatPnl, formatPercentage } from '../../utils/formatUtils';
import Routes from '../../../../../constants/navigation/Routes';
import {
usePerpsHomeData,
@@ -93,6 +95,10 @@ const PerpsHomeView = () => {
const totalBalance = perpsAccount?.totalBalance || '0';
const isBalanceEmpty = BigNumber(totalBalance).isZero();
+ // Calculate P&L for positions subtitle
+ const unrealizedPnl = perpsAccount?.unrealizedPnl || '0';
+ const roe = parseFloat(perpsAccount?.returnOnEquity || '0');
+
// Fetch all home screen data
const {
positions,
@@ -106,6 +112,40 @@ const PerpsHomeView = () => {
isLoading,
} = usePerpsHomeData({});
+ // Calculate positions subtitle with P&L
+ const hasPositions = positions.length > 0;
+ const { positionsSubtitle, positionsSubtitleColor, positionsSubtitleSuffix } =
+ useMemo(() => {
+ const pnlNum = parseFloat(unrealizedPnl);
+ const isPnlZero = BigNumber(unrealizedPnl).isZero();
+
+ // Only show subtitle when there are positions and P&L is non-zero
+ if (!hasPositions || isPnlZero) {
+ return {
+ positionsSubtitle: undefined,
+ positionsSubtitleColor: undefined,
+ positionsSubtitleSuffix: undefined,
+ };
+ }
+
+ const color =
+ pnlNum > 0
+ ? TextColor.Success
+ : pnlNum < 0
+ ? TextColor.Error
+ : TextColor.Alternative;
+
+ // Format: "-$18.47 (2.1%)" colored + "Unrealized PnL" in default color
+ const subtitle = `${formatPnl(pnlNum)} (${formatPercentage(roe, 1)})`;
+ const suffix = strings('perps.unrealized_pnl');
+
+ return {
+ positionsSubtitle: subtitle,
+ positionsSubtitleColor: color,
+ positionsSubtitleSuffix: suffix,
+ };
+ }, [hasPositions, unrealizedPnl, roe]);
+
// Determine if any data is loading for initial load tracking
// Orders and activity load via WebSocket instantly, only track positions and markets
const isAnyLoading = isLoading.positions || isLoading.markets;
@@ -248,13 +288,16 @@ const PerpsHomeView = () => {
>
{/* Balance Actions Component */}
{/* Positions Section */}
= () => {
throttleMs: 1000,
});
+ // Get current price from the last candle's close price for chart synchronization
+ // This ensures the current price line matches the live candle close price exactly
+ const chartCurrentPrice = useMemo(() => {
+ if (!candleData?.candles?.length) return 0;
+ const lastCandle = candleData.candles.at(-1);
+ return lastCandle?.close ? Number.parseFloat(lastCandle.close) : 0;
+ }, [candleData]);
+
// Auto-zoom to latest candle when interval changes and new data arrives
// This ensures the chart shows the most recent data after interval change
useEffect(() => {
@@ -394,10 +402,10 @@ const PerpsMarketDetailsView: React.FC = () => {
}, [existingPosition, orderFills]);
// Compute TP/SL lines for the chart based on existing position
- // Always include currentPrice to ensure chart price line matches header (TAT-2112)
+ // Use chartCurrentPrice (from candle close) to ensure price line syncs with live candle
const tpslLines = useMemo(() => {
- const currentPriceStr =
- currentPrice > 0 ? currentPrice.toString() : undefined;
+ const chartPriceStr =
+ chartCurrentPrice > 0 ? chartCurrentPrice.toString() : undefined;
if (existingPosition) {
return {
@@ -405,13 +413,13 @@ const PerpsMarketDetailsView: React.FC = () => {
takeProfitPrice: existingPosition.takeProfitPrice,
stopLossPrice: existingPosition.stopLossPrice,
liquidationPrice: existingPosition.liquidationPrice || undefined,
- currentPrice: currentPriceStr,
+ currentPrice: chartPriceStr,
};
}
// Even without position, show current price line on chart
- return currentPriceStr ? { currentPrice: currentPriceStr } : undefined;
- }, [existingPosition, currentPrice]);
+ return chartPriceStr ? { currentPrice: chartPriceStr } : undefined;
+ }, [existingPosition, chartCurrentPrice]);
// Stop loss prompt banner logic
// Hook handles visibility orchestration including fade-out animation
@@ -906,6 +914,7 @@ const PerpsMarketDetailsView: React.FC = () => {
onFullscreenPress={handleFullscreenChartOpen}
isFavorite={isWatchlist}
testID={PerpsMarketDetailsViewSelectorsIDs.HEADER}
+ currentPrice={chartCurrentPrice}
/>
diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
index 15767bb1600..8cbe5d202d3 100644
--- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
@@ -341,7 +341,11 @@ const PerpsOrderBookView: React.FC = ({
return (
{market ? (
-
+
) : (
= ({
return (
{/* Market Header */}
- {market && }
+ {market && (
+
+ )}
{/* Controls Row - Unit Toggle and Grouping */}
diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx
index b5ffc657404..b42af592c17 100644
--- a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx
+++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx
@@ -30,26 +30,10 @@ describe('LivePriceHeader', () => {
it('should render without crashing', () => {
mockUsePerpsLivePrices.mockReturnValue({});
- const component = render();
+ const component = render();
expect(component).toBeDefined();
});
- it('should show placeholders when no price data available', () => {
- mockUsePerpsLivePrices.mockReturnValue({});
- const { getByText } = render();
- expect(getByText('$---')).toBeTruthy();
- expect(getByText('--%')).toBeTruthy();
- });
-
- it('should show placeholders when price data is undefined', () => {
- mockUsePerpsLivePrices.mockReturnValue({
- ETH: undefined as unknown as PriceUpdate,
- });
- const { getByText } = render();
- expect(getByText('$---')).toBeTruthy();
- expect(getByText('--%')).toBeTruthy();
- });
-
it('should show placeholders when price is invalid (zero)', () => {
mockUsePerpsLivePrices.mockReturnValue({
ETH: {
@@ -59,35 +43,25 @@ describe('LivePriceHeader', () => {
timestamp: Date.now(),
},
});
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$---')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
});
it('should show placeholders when price is invalid (negative)', () => {
- mockUsePerpsLivePrices.mockReturnValue({
- ETH: {
- coin: 'ETH',
- price: '-100',
- percentChange24h: '5',
- timestamp: Date.now(),
- },
- });
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$---')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
});
it('should show placeholders when price is invalid (NaN)', () => {
- mockUsePerpsLivePrices.mockReturnValue({
- ETH: {
- coin: 'ETH',
- price: 'invalid',
- percentChange24h: '5',
- timestamp: Date.now(),
- },
- });
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$---')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
});
@@ -101,7 +75,9 @@ describe('LivePriceHeader', () => {
timestamp: Date.now(),
},
});
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$3,000')).toBeTruthy(); // 4 sig figs, no trailing zeros
expect(getByText('+5.50%')).toBeTruthy();
});
@@ -115,7 +91,9 @@ describe('LivePriceHeader', () => {
timestamp: Date.now(),
},
});
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$2,500')).toBeTruthy(); // 4 sig figs, no trailing zeros
expect(getByText('-3.20%')).toBeTruthy();
});
@@ -129,7 +107,9 @@ describe('LivePriceHeader', () => {
timestamp: Date.now(),
},
});
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$2,000')).toBeTruthy(); // 4 sig figs, no trailing zeros
expect(getByText('+0.00%')).toBeTruthy();
});
@@ -143,45 +123,22 @@ describe('LivePriceHeader', () => {
timestamp: Date.now(),
},
});
- const { getByText } = render();
- expect(getByText('$100')).toBeTruthy(); // 4 sig figs, no trailing zeros
- expect(getByText('+2.10%')).toBeTruthy();
- });
-
- it('uses fallback price but shows loading state for change when no live data', () => {
- mockUsePerpsLivePrices.mockReturnValue({});
const { getByText } = render(
- ,
+ ,
);
- expect(getByText('$1,500')).toBeTruthy(); // 4 sig figs, no trailing zeros
- expect(getByText('--%')).toBeTruthy();
+ expect(getByText('$100')).toBeTruthy(); // 4 sig figs, no trailing zeros
+ expect(getByText('+2.10%')).toBeTruthy();
});
it('should show placeholders when fallback price is invalid', () => {
mockUsePerpsLivePrices.mockReturnValue({});
const { getByText } = render(
- ,
+ ,
);
expect(getByText('$---')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
});
- it('should prefer live data over fallback', () => {
- mockUsePerpsLivePrices.mockReturnValue({
- BTC: {
- coin: 'BTC',
- price: '50000',
- percentChange24h: '3.0',
- timestamp: Date.now(),
- },
- });
- const { getByText } = render(
- ,
- );
- expect(getByText('$50,000')).toBeTruthy(); // 4 sig figs, no trailing zeros
- expect(getByText('+3.00%')).toBeTruthy();
- });
-
describe('Color behavior for percentage change', () => {
it('uses neutral color for loading state when percentChange is undefined', () => {
mockUsePerpsLivePrices.mockReturnValue({
@@ -193,7 +150,9 @@ describe('LivePriceHeader', () => {
},
});
- const { UNSAFE_getAllByType } = render();
+ const { UNSAFE_getAllByType } = render(
+ ,
+ );
const textElements = UNSAFE_getAllByType(Text);
const changeText = textElements.find((el) => el.props.children === '--%');
@@ -205,7 +164,7 @@ describe('LivePriceHeader', () => {
mockUsePerpsLivePrices.mockReturnValue({});
const { UNSAFE_getAllByType } = render(
- ,
+ ,
);
const textElements = UNSAFE_getAllByType(Text);
@@ -224,7 +183,9 @@ describe('LivePriceHeader', () => {
},
});
- const { UNSAFE_getAllByType } = render();
+ const { UNSAFE_getAllByType } = render(
+ ,
+ );
const textElements = UNSAFE_getAllByType(Text);
const changeText = textElements.find(
@@ -244,7 +205,9 @@ describe('LivePriceHeader', () => {
},
});
- const { UNSAFE_getAllByType } = render();
+ const { UNSAFE_getAllByType } = render(
+ ,
+ );
const textElements = UNSAFE_getAllByType(Text);
const changeText = textElements.find(
@@ -264,7 +227,9 @@ describe('LivePriceHeader', () => {
},
});
- const { UNSAFE_getAllByType } = render();
+ const { UNSAFE_getAllByType } = render(
+ ,
+ );
const textElements = UNSAFE_getAllByType(Text);
const changeText = textElements.find(
@@ -286,7 +251,9 @@ describe('LivePriceHeader', () => {
},
});
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$3,000')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
@@ -301,7 +268,9 @@ describe('LivePriceHeader', () => {
} as PriceUpdate,
});
- const { getByText } = render();
+ const { getByText } = render(
+ ,
+ );
expect(getByText('$2,500')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
@@ -318,7 +287,7 @@ describe('LivePriceHeader', () => {
});
const { getByText, queryByText } = render(
- ,
+ ,
);
expect(getByText('$2,000')).toBeTruthy();
@@ -330,32 +299,13 @@ describe('LivePriceHeader', () => {
mockUsePerpsLivePrices.mockReturnValue({});
const { getByText } = render(
- ,
+ ,
);
expect(getByText('$1,800')).toBeTruthy();
expect(getByText('--%')).toBeTruthy();
});
- it('uses live percentChange when available', () => {
- mockUsePerpsLivePrices.mockReturnValue({
- BTC: {
- coin: 'BTC',
- price: '55000',
- percentChange24h: '4.2',
- timestamp: Date.now(),
- },
- });
-
- const { getByText, queryByText } = render(
- ,
- );
-
- expect(getByText('$55,000')).toBeTruthy();
- expect(getByText('+4.20%')).toBeTruthy();
- expect(queryByText('--%')).toBeNull();
- });
-
it('displays loading state when live price exists but percentChange is undefined', () => {
mockUsePerpsLivePrices.mockReturnValue({
ETH: {
@@ -367,7 +317,7 @@ describe('LivePriceHeader', () => {
});
const { getByText } = render(
- ,
+ ,
);
expect(getByText('$3,100')).toBeTruthy();
diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx
index acf17b8fb1e..15faa66a86e 100644
--- a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx
+++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx
@@ -15,10 +15,11 @@ import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
interface LivePriceHeaderProps {
symbol: string;
- fallbackPrice?: string;
testIDPrice?: string;
testIDChange?: string;
throttleMs?: number;
+ /** Current price from candle stream - syncs header with chart */
+ currentPrice: number;
}
const styleSheet = () =>
@@ -32,16 +33,17 @@ const styleSheet = () =>
/**
* Component that displays live price and change for header
- * Subscribes to price stream independently to avoid parent re-renders
+ * Uses currentPrice prop from candle stream, subscribes to price stream for 24h change only
*/
const LivePriceHeader: React.FC = ({
symbol,
- fallbackPrice = '0',
testIDPrice,
testIDChange,
throttleMs = 1000, // Balanced updates for header (1 update per second)
+ currentPrice,
}) => {
const { styles } = useStyles(styleSheet, {});
+ // Subscribe to price stream only for 24h change percentage
const prices = usePerpsLivePrices({
symbols: [symbol],
throttleMs,
@@ -49,18 +51,13 @@ const LivePriceHeader: React.FC = ({
const priceData = prices[symbol];
- // Use fallback data if no live data yet
- const displayPrice = priceData
- ? parseFloat(priceData.price)
- : parseFloat(fallbackPrice);
-
// Use null to indicate loading state - only use actual values (including 0) when available
// When we have live price data, only use percentChange from that data - don't fall back
- const displayChange = priceData
- ? priceData.percentChange24h !== undefined
- ? parseFloat(priceData.percentChange24h)
- : null
- : null;
+ const displayChange = useMemo(() => {
+ if (!priceData) return null;
+ if (priceData.percentChange24h === undefined) return null;
+ return Number.parseFloat(priceData.percentChange24h);
+ }, [priceData]);
// Only determine change color when we have actual data (not loading)
const isPositiveChange = displayChange !== null && displayChange >= 0;
@@ -74,19 +71,19 @@ const LivePriceHeader: React.FC = ({
// Format price display with edge case handling
const formattedPrice = useMemo(() => {
// Handle invalid or edge case values
- if (!displayPrice || displayPrice <= 0 || !Number.isFinite(displayPrice)) {
+ if (!currentPrice || currentPrice <= 0 || !Number.isFinite(currentPrice)) {
return PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY;
}
try {
- return formatPerpsFiat(displayPrice, {
+ return formatPerpsFiat(currentPrice, {
ranges: PRICE_RANGES_UNIVERSAL,
});
} catch {
// Fallback if formatPrice throws
return PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY;
}
- }, [displayPrice]);
+ }, [currentPrice]);
const formattedChange = useMemo(() => {
// If displayChange is null, we're still loading - show loading indicator
@@ -94,7 +91,7 @@ const LivePriceHeader: React.FC = ({
return PERPS_CONSTANTS.FALLBACK_PERCENTAGE_DISPLAY;
}
- if (!displayPrice || displayPrice <= 0 || !Number.isFinite(displayPrice)) {
+ if (!currentPrice || currentPrice <= 0 || !Number.isFinite(currentPrice)) {
return PERPS_CONSTANTS.FALLBACK_PERCENTAGE_DISPLAY;
}
@@ -103,7 +100,7 @@ const LivePriceHeader: React.FC = ({
} catch {
return PERPS_CONSTANTS.FALLBACK_PERCENTAGE_DISPLAY;
}
- }, [displayPrice, displayChange]);
+ }, [currentPrice, displayChange]);
return (
diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx
index d5886f08949..af78f06b81d 100644
--- a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx
+++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx
@@ -3,6 +3,8 @@ import { render, fireEvent } from '@testing-library/react-native';
import { View, Text } from 'react-native';
import PerpsHomeSection from './PerpsHomeSection';
+import { TextColor } from '../../../../../component-library/components/Texts/Text';
+
describe('PerpsHomeSection', () => {
const mockSkeleton = () => ;
const mockChildren = Content;
@@ -403,4 +405,137 @@ describe('PerpsHomeSection', () => {
expect(mockOnActionPress).not.toHaveBeenCalled();
});
});
+
+ describe('subtitle rendering', () => {
+ it('renders subtitle when provided', () => {
+ const { getByText } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ expect(getByText('-$18.47 (2.1%) Unrealized P&L')).toBeTruthy();
+ });
+
+ it('does not render subtitle when not provided', () => {
+ const { queryByText } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ // Should only have the title, no subtitle
+ expect(queryByText('Test Section')).toBeTruthy();
+ });
+
+ it('renders subtitle with custom color', () => {
+ const { getByText } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ expect(getByText('+$50.00 (5.0%) Unrealized P&L')).toBeTruthy();
+ });
+
+ it('applies subtitleTestID when provided', () => {
+ const { getByTestId } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ expect(getByTestId('custom-subtitle-testid')).toBeTruthy();
+ });
+
+ it('renders subtitle alongside title and action button', () => {
+ const mockOnActionPress = jest.fn();
+
+ const { getByText } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ expect(getByText('Positions')).toBeTruthy();
+ expect(getByText('-$18.47 (2.1%)')).toBeTruthy();
+
+ // Action should still work
+ fireEvent.press(getByText('Positions'));
+ expect(mockOnActionPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders subtitle with suffix', () => {
+ const { getByTestId } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ // Verify both subtitle and suffix are rendered via testIDs
+ expect(getByTestId('test-subtitle')).toBeTruthy();
+ expect(getByTestId('test-subtitle-suffix')).toBeTruthy();
+ });
+
+ it('does not render suffix when subtitle is not provided', () => {
+ const { queryByTestId } = render(
+
+ {mockChildren}
+ ,
+ );
+
+ // Suffix should not render without a subtitle
+ expect(queryByTestId('test-subtitle')).toBeNull();
+ expect(queryByTestId('test-subtitle-suffix')).toBeNull();
+ });
+ });
});
diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx
index fde4687a1e4..0e45614a26e 100644
--- a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx
+++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx
@@ -15,6 +15,22 @@ export interface PerpsHomeSectionProps {
* Section title
*/
title: string;
+ /**
+ * Optional subtitle text (e.g., P&L value and percentage)
+ */
+ subtitle?: string;
+ /**
+ * Color for subtitle text (e.g., Success for profit, Error for loss)
+ */
+ subtitleColor?: TextColor;
+ /**
+ * Optional suffix for subtitle (rendered in default color, e.g., "Unrealized PnL")
+ */
+ subtitleSuffix?: string;
+ /**
+ * Test ID for subtitle element
+ */
+ subtitleTestID?: string;
/**
* Whether the section is loading
*/
@@ -51,14 +67,16 @@ const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
+ headerContainer: {
paddingHorizontal: 16,
marginBottom: 8,
marginTop: 12,
},
+ titleRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
content: {
// Content styling handled by children
},
@@ -89,6 +107,10 @@ const styles = StyleSheet.create({
*/
const PerpsHomeSection: React.FC = ({
title,
+ subtitle,
+ subtitleColor = TextColor.Alternative,
+ subtitleSuffix,
+ subtitleTestID,
isLoading,
isEmpty,
showWhenEmpty = false,
@@ -104,7 +126,8 @@ const PerpsHomeSection: React.FC = ({
const showAction = onActionPress && !isLoading && !isEmpty;
- const headerContent = (
+ // Title row content (pressable when action is available)
+ const titleRowContent = (
<>
{title}
@@ -122,13 +145,37 @@ const PerpsHomeSection: React.FC = ({
return (
{/* Section Header */}
- {showAction ? (
-
- {headerContent}
-
- ) : (
- {headerContent}
- )}
+
+ {/* Title row - only this is pressable */}
+ {showAction ? (
+
+ {titleRowContent}
+
+ ) : (
+ {titleRowContent}
+ )}
+
+ {/* Subtitle - NOT pressable */}
+ {subtitle && (
+
+ {subtitle}
+ {subtitleSuffix && (
+
+ {' '}
+ {subtitleSuffix}
+
+ )}
+
+ )}
+
{/* Section Content */}
diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx
index bf25e778eb6..fc0a8ddbdd3 100644
--- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx
@@ -29,14 +29,9 @@ import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip';
import { usePerpsLiveAccount } from '../../hooks/stream';
import {
formatPerpsFiat,
- formatPnl,
- formatPercentage,
PRICE_RANGES_MINIMAL_VIEW,
} from '../../utils/formatUtils';
-import type {
- PerpsNavigationParamList,
- Position,
-} from '../../controllers/types';
+import type { PerpsNavigationParamList } from '../../controllers/types';
import { PerpsMarketBalanceActionsSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import { BigNumber } from 'bignumber.js';
import { INITIAL_AMOUNT_UI_PROGRESS } from '../../constants/hyperLiquidConfig';
@@ -51,7 +46,6 @@ import { RootState } from '../../../../../reducers';
import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts';
interface PerpsMarketBalanceActionsProps {
- positions?: Position[];
showActionButtons?: boolean;
}
@@ -76,7 +70,6 @@ const PerpsMarketBalanceActionsSkeleton: React.FC = () => {
};
const PerpsMarketBalanceActions: React.FC = ({
- positions = [],
showActionButtons = true,
}) => {
const tw = useTailwind();
@@ -218,17 +211,7 @@ const PerpsMarketBalanceActions: React.FC = ({
const totalBalance = perpsAccount?.totalBalance || '0';
const availableBalance = perpsAccount?.availableBalance || '0';
- const unrealizedPnl = perpsAccount?.unrealizedPnl || '0';
- const roe = parseFloat(perpsAccount?.returnOnEquity || '0');
const isBalanceEmpty = BigNumber(totalBalance).isZero();
- const hasPositions = positions.length > 0;
-
- const pnlNum = useMemo(() => parseFloat(unrealizedPnl), [unrealizedPnl]);
- const pnlColor = useMemo(() => {
- if (pnlNum > 0) return TextColor.Success;
- if (pnlNum < 0) return TextColor.Error;
- return TextColor.Alternative;
- }, [pnlNum]);
const handleLearnMore = useCallback(() => {
navigation.navigate(Routes.PERPS.TUTORIAL, {
@@ -346,38 +329,20 @@ const PerpsMarketBalanceActions: React.FC = ({
{formatPerpsFiat(totalBalance)}
-
-
- {formatPerpsFiat(availableBalance, {
- ranges: PRICE_RANGES_MINIMAL_VIEW,
- stripTrailingZeros: false,
- })}{' '}
- {strings('perps.available')}
-
- {hasPositions && !BigNumber(unrealizedPnl).isZero() && (
- <>
-
- {' · P&L '}
-
-
- {formatPnl(pnlNum)} ({formatPercentage(roe, 1)})
-
- >
- )}
-
+
+ {formatPerpsFiat(availableBalance, {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ stripTrailingZeros: false,
+ })}{' '}
+ {strings('perps.available')}
+
{/* Action Buttons */}
{showActionButtons && (
{
,
{ state: initialState },
);
@@ -47,6 +48,7 @@ describe('PerpsMarketHeader', () => {
market={mockMarket}
onBackPress={onBackPress}
testID={PerpsMarketHeaderSelectorsIDs.CONTAINER}
+ currentPrice={45000}
/>,
{ state: initialState },
);
@@ -64,6 +66,7 @@ describe('PerpsMarketHeader', () => {
market={mockMarket}
onMorePress={onMorePress}
testID={PerpsMarketHeaderSelectorsIDs.CONTAINER}
+ currentPrice={45000}
/>,
{ state: initialState },
);
diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx
index d64cf2a074e..839887f9b94 100644
--- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx
@@ -29,6 +29,8 @@ interface PerpsMarketHeaderProps {
onFullscreenPress?: () => void;
isFavorite?: boolean;
testID?: string;
+ /** Current price from candle stream - syncs header with chart */
+ currentPrice: number;
}
const PerpsMarketHeader: React.FC = ({
@@ -39,6 +41,7 @@ const PerpsMarketHeader: React.FC = ({
onFullscreenPress,
isFavorite = false,
testID,
+ currentPrice,
}) => {
const { styles } = useStyles(styleSheet, {});
@@ -80,10 +83,10 @@ const PerpsMarketHeader: React.FC = ({
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 2bfe7e8d46f..bfcec001b34 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
@@ -10,7 +10,7 @@ import {
type PublishBatchHookTransaction,
} from '@metamask/transaction-controller';
-import { toHex } from '@metamask/controller-utils';
+import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils';
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';
import { Hex } from '@metamask/utils';
import { selectSwapsChainFeatureFlags } from '../../../../reducers/swaps';
@@ -487,20 +487,87 @@ describe('Transaction Controller Init', () => {
expect(updateTransactionsProp).toBe(true);
});
- it('determines if automatic gas fee update is enabled based on transaction type', () => {
- const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled');
- const isEnabledFn = option as ({ type }: { type: string }) => boolean;
+ describe('isAutomaticGasFeeUpdateEnabled', () => {
+ it('returns true for redesigned transaction types', () => {
+ const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled');
+ const isEnabledFn = option as ({
+ type,
+ }: {
+ type: string;
+ origin?: string;
+ }) => boolean;
+
+ expect(isEnabledFn({ type: TransactionType.stakingDeposit })).toBe(true);
+ expect(isEnabledFn({ type: TransactionType.stakingUnstake })).toBe(true);
+ expect(isEnabledFn({ type: TransactionType.stakingClaim })).toBe(true);
+ expect(isEnabledFn({ type: TransactionType.contractInteraction })).toBe(
+ true,
+ );
+ });
- // Redesigned transaction types
- expect(isEnabledFn({ type: TransactionType.stakingDeposit })).toBe(true);
- expect(isEnabledFn({ type: TransactionType.stakingUnstake })).toBe(true);
- expect(isEnabledFn({ type: TransactionType.stakingClaim })).toBe(true);
- expect(isEnabledFn({ type: TransactionType.contractInteraction })).toBe(
- true,
- );
+ it('returns false for non-redesigned transaction types', () => {
+ const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled');
+ const isEnabledFn = option as ({
+ type,
+ }: {
+ type: string;
+ origin?: string;
+ }) => boolean;
+
+ expect(isEnabledFn({ type: TransactionType.bridge })).toBe(false);
+ });
+
+ it('returns false for transaction with nested relayDeposit type', () => {
+ const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled');
+ const isEnabledFn = option as (transaction: {
+ type: string;
+ origin?: string;
+ nestedTransactions?: { type: string }[];
+ }) => boolean;
+
+ const result = isEnabledFn({
+ type: TransactionType.contractInteraction,
+ nestedTransactions: [{ type: TransactionType.relayDeposit }],
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for tokenMethodApprove with ORIGIN_METAMASK', () => {
+ const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled');
+ const isEnabledFn = option as ({
+ type,
+ origin,
+ }: {
+ type: string;
+ origin?: string;
+ }) => boolean;
+
+ const result = isEnabledFn({
+ type: TransactionType.tokenMethodApprove,
+ origin: ORIGIN_METAMASK,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('returns true for tokenMethodApprove with non-MetaMask origin', () => {
+ const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled');
+ const isEnabledFn = option as ({
+ type,
+ origin,
+ }: {
+ type: string;
+ origin?: string;
+ }) => boolean;
+
+ const result = isEnabledFn({
+ type: TransactionType.tokenMethodApprove,
+ origin: 'https://external-dapp.com',
+ });
- // Non-redesigned transaction types
- expect(isEnabledFn({ type: TransactionType.bridge })).toBe(false);
+ expect(result).toBe(true);
+ });
});
it('gets network state from network controller on option getNetworkState', () => {
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 057ebe79764..b80b7371779 100644
--- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts
+++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts
@@ -50,7 +50,8 @@ import { trace } from '../../../../util/trace';
import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish';
import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api';
import { NetworkClientId } from '@metamask/network-controller';
-import { toHex } from '@metamask/controller-utils';
+import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils';
+import { hasTransactionType } from '../../../../components/Views/confirmations/utils/transaction';
export const TransactionControllerInit: ControllerInitFunction<
TransactionController,
@@ -78,8 +79,7 @@ export const TransactionControllerInit: ControllerInitFunction<
try {
const transactionController: TransactionController =
new TransactionController({
- isAutomaticGasFeeUpdateEnabled: ({ type }) =>
- REDESIGNED_TRANSACTION_TYPES.includes(type as TransactionType),
+ isAutomaticGasFeeUpdateEnabled,
disableHistory: true,
disableSendFlowHistory: true,
disableSwaps: true,
@@ -359,6 +359,23 @@ function beforeSign(
return predictController.beforeSign(hookRequest);
}
+function isAutomaticGasFeeUpdateEnabled(transaction: TransactionMeta) {
+ if (hasTransactionType(transaction, [TransactionType.relayDeposit])) {
+ return false;
+ }
+
+ if (
+ transaction.origin === ORIGIN_METAMASK &&
+ transaction.type === TransactionType.tokenMethodApprove
+ ) {
+ return false;
+ }
+
+ return REDESIGNED_TRANSACTION_TYPES.includes(
+ transaction.type as TransactionType,
+ );
+}
+
function addTransactionControllerListeners(
transactionEventHandlerRequest: TransactionEventHandlerRequest,
) {
diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts
index 4f09fe707f3..deabae97d83 100644
--- a/e2e/selectors/Perps/Perps.selectors.ts
+++ b/e2e/selectors/Perps/Perps.selectors.ts
@@ -220,6 +220,7 @@ export const PerpsHomeViewSelectorsIDs = {
SCROLL_CONTENT: 'scroll-content',
WITHDRAW_BUTTON: 'perps-home-withdraw-button',
ADD_FUNDS_BUTTON: 'perps-home-add-funds-button',
+ POSITIONS_PNL_VALUE: 'perps-home-positions-pnl-value',
// TabBar mock items (for testing)
TAB_BAR_WALLET: 'tab-bar-item-wallet',
TAB_BAR_BROWSER: 'tab-bar-item-browser',