From e9b13a00a8503be037e68b2d07212da279514940 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Thu, 11 Jun 2026 16:40:19 +0200 Subject: [PATCH 1/4] feat: display Hyperliquid perp positions in Social Leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render perp metadata across the Social Leaderboard trader profile and position detail screens, consuming the new perp fields exposed by @metamask/social-controllers. - Add a shared PerpBadges component (leverage pill + LONG/SHORT pill) and perp helpers (isPerpPosition / direction resolution for positions and trades). - Trader profile position rows and the position detail header show the Hyperliquid network badge, leverage, and long/short direction for perps; spot rows are unchanged. - Trade rows read "opened/closed" with leverage + direction badges for perp fills. - The position detail footer swaps the single Buy CTA for Long/Short buttons on perps (placeholders — not wired to a flow yet); the chart shows its existing no-data placeholder since perp pricing isn't available. - Pin @metamask/social-controllers to the perps preview build so the new Position/Trade perp fields are available (paired with MetaMask/core#9094). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TraderPositionView.test.tsx | 47 ++++++ .../TraderPositionView.testIds.ts | 2 + .../TraderPositionView/TraderPositionView.tsx | 78 +++++++--- .../components/TradeRow.tsx | 56 +++++-- .../components/TraderTokenInfoRow.tsx | 13 +- .../components/PositionRow.test.tsx | 42 ++++++ .../components/PositionRow.tsx | 32 +++- .../components/PerpBadges.test.tsx | 41 +++++ .../components/PerpBadges.tsx | 70 +++++++++ .../components/PositionTokenAvatar.test.tsx | 22 +++ .../components/PositionTokenAvatar.tsx | 16 +- .../utils/chainMapping.test.ts | 40 ++++- .../SocialLeaderboard/utils/chainMapping.ts | 42 ++++++ .../SocialLeaderboard/utils/perp.test.ts | 141 ++++++++++++++++++ .../Views/SocialLeaderboard/utils/perp.ts | 75 ++++++++++ locales/languages/en.json | 6 +- package.json | 6 + yarn.lock | 40 +++-- 18 files changed, 714 insertions(+), 55 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/components/PerpBadges.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/components/PerpBadges.tsx create mode 100644 app/components/Views/SocialLeaderboard/utils/perp.test.ts create mode 100644 app/components/Views/SocialLeaderboard/utils/perp.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 18a8a5568eb6..5deedd4d04d4 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -373,6 +373,53 @@ describe('TraderPositionView', () => { ).toBeOnTheScreen(); }); + describe('perp positions', () => { + beforeEach(() => { + mockRouteParams.position = { + ...makeDefaultPosition(), + tokenSymbol: 'ETH', + chain: 'hyperliquid', + perpPositionType: 'short', + perpLeverage: 10, + }; + }); + + it('renders Long/Short buttons instead of the Buy button', () => { + renderWithProvider(, { state: mockState }); + + expect( + screen.getByTestId(TraderPositionViewSelectorsIDs.LONG_BUTTON), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(TraderPositionViewSelectorsIDs.SHORT_BUTTON), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId(TraderPositionViewSelectorsIDs.BUY_BUTTON), + ).not.toBeOnTheScreen(); + }); + + it('does not open QuickBuy when the Long/Short buttons are pressed', () => { + renderWithProvider(, { state: mockState }); + + fireEvent.press( + screen.getByTestId(TraderPositionViewSelectorsIDs.LONG_BUTTON), + ); + fireEvent.press( + screen.getByTestId(TraderPositionViewSelectorsIDs.SHORT_BUTTON), + ); + + // No navigation/CTA haptic — the buttons are intentional placeholders. + expect(mockPlayImpact).not.toHaveBeenCalled(); + }); + + it('renders the perp leverage and direction badges in the header', () => { + renderWithProvider(, { state: mockState }); + + expect(screen.getByText('10x')).toBeOnTheScreen(); + expect(screen.getByText('SHORT')).toBeOnTheScreen(); + }); + }); + it('forwards the filtered trades to the chart component', async () => { renderWithProvider(, { state: mockState }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts index f4e67a120651..43f20ad12865 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts @@ -3,6 +3,8 @@ export const TraderPositionViewSelectorsIDs = { BACK_BUTTON: 'trader-position-view-back-button', TRADER_NAME_LINK: 'trader-position-view-trader-name-link', BUY_BUTTON: 'trader-position-view-buy-button', + LONG_BUTTON: 'trader-position-view-long-button', + SHORT_BUTTON: 'trader-position-view-short-button', COPY_TOKEN_ADDRESS_BUTTON: 'trader-position-view-copy-token-address-button', SKELETON: 'trader-position-skeleton', FALLBACK: 'trader-position-fallback', diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index eae222ac5c65..ac76a26fef71 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -20,8 +20,12 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { playImpact, ImpactMoment } from '../../../../util/haptics'; import { Box, + BoxFlexDirection, + Button, ButtonHero, ButtonHeroSize, + ButtonSize, + ButtonVariant, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; @@ -53,6 +57,7 @@ import { } from '../analytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { chainNameToId } from '../utils/chainMapping'; +import { isPerpPosition } from '../utils/perp'; import { toAssetId } from '../../../UI/Bridge/hooks/useAssetMetadata/utils'; const TraderPositionView = () => { @@ -287,6 +292,12 @@ const TraderPositionView = () => { // TODO: update displayed price on scrub. }, []); + // Perp positions surface Long/Short CTAs instead of Buy. Perp trade entry + // isn't wired up yet, so these are intentional placeholders that don't + // navigate anywhere (a future ticket will hook them up). + const isPerp = resolvedPosition ? isPerpPosition(resolvedPosition) : false; + const handlePerpActionPress = useCallback(() => undefined, []); + const isInitialLoading = !resolvedPosition && (isPositionLoading || isProfileLoading); const hasFailed = @@ -364,25 +375,56 @@ const TraderPositionView = () => { /> - - - {strings('social_leaderboard.trader_position.buy')} - - - - + + + + ) : ( + <> + + + {strings('social_leaderboard.trader_position.buy')} + + + + + + )} )} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx index beb00e1d173f..cd692d746c5e 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx @@ -15,6 +15,8 @@ import { import type { Trade } from '@metamask/social-controllers'; import { strings } from '../../../../../../locales/i18n'; import { formatUsd, formatTradeDate } from '../../utils/formatters'; +import PerpBadges from '../../components/PerpBadges'; +import { getPerpTradeDirection, isPerpTrade } from '../../utils/perp'; export interface TradeRowProps { trade: Trade; @@ -29,6 +31,26 @@ const TradeRow: React.FC = ({ }) => { const tw = useTailwind(); const isEntry = trade.intent === 'enter'; + const isPerp = isPerpTrade(trade); + const perpDirection = getPerpTradeDirection(trade); + + // Perp fills read as "opened"/"closed" (vs spot "bought"/"sold"). + const actionLabel = isPerp + ? isEntry + ? strings('social_leaderboard.trader_position.opened', { + name: traderName, + }) + : strings('social_leaderboard.trader_position.closed_action', { + name: traderName, + }) + : isEntry + ? strings('social_leaderboard.trader_position.bought', { + name: traderName, + }) + : strings('social_leaderboard.trader_position.sold', { + name: traderName, + }); + return ( = ({ /> )} - - {isEntry - ? strings('social_leaderboard.trader_position.bought', { - name: traderName, - }) - : strings('social_leaderboard.trader_position.sold', { - name: traderName, - })} - + + {actionLabel} + + {perpDirection ? ( + + ) : null} + = ({ const canCopyTokenAddress = Boolean( position?.tokenAddress && onCopyTokenAddress, ); + const perpDirection = position ? getPerpPositionDirection(position) : null; const content = ( = ({ {symbol} + {perpDirection ? ( + + ) : null} {canCopyTokenAddress ? ( { expect(screen.getByText('+25%')).toBeOnTheScreen(); }); }); + + describe('perp positions', () => { + const perpPosition: Position = { + ...basePosition, + tokenSymbol: 'ETH', + chain: 'hyperliquid', + perpPositionType: 'long', + perpLeverage: 5, + positionAmountWithLeverage: 25, + }; + + it('renders the leverage and LONG direction badges for a long perp', () => { + renderWithProvider(); + + expect(screen.getByText('5x')).toBeOnTheScreen(); + expect(screen.getByText('LONG')).toBeOnTheScreen(); + }); + + it('renders a SHORT badge for a short perp', () => { + const position = { ...perpPosition, perpPositionType: 'short' as const }; + + renderWithProvider(); + + expect(screen.getByText('SHORT')).toBeOnTheScreen(); + }); + + it('omits the leverage badge when perpLeverage is null', () => { + const position = { ...perpPosition, perpLeverage: null }; + + renderWithProvider(); + + expect(screen.queryByText('5x')).not.toBeOnTheScreen(); + expect(screen.getByText('LONG')).toBeOnTheScreen(); + }); + + it('does not render perp badges for a spot position', () => { + renderWithProvider(); + + expect(screen.queryByText('LONG')).not.toBeOnTheScreen(); + expect(screen.queryByText('SHORT')).not.toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx index 33fd33be3144..10c2559150e8 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx @@ -12,6 +12,8 @@ import { } from '@metamask/design-system-react-native'; import type { Position } from '@metamask/social-controllers'; import PositionTokenAvatar from '../../components/PositionTokenAvatar'; +import PerpBadges from '../../components/PerpBadges'; +import { getPerpPositionDirection } from '../../utils/perp'; import { formatUsd, formatTokenAmount, @@ -41,6 +43,8 @@ const PositionRow: React.FC = ({ position, onPress }) => { const isPnlPositive = hasPnl && (displayPnlPercent ?? 0) >= 0; const testID = `position-row-${position.tokenSymbol}`; + const perpDirection = getPerpPositionDirection(position); + const content = ( = ({ position, onPress }) => { - - {position.tokenSymbol} - + + {position.tokenSymbol} + + {perpDirection ? ( + + ) : null} + { + it('renders an uppercase LONG badge', () => { + renderWithProvider(); + + expect(screen.getByText('LONG')).toBeOnTheScreen(); + expect(screen.getByText('10x')).toBeOnTheScreen(); + }); + + it('renders an uppercase SHORT badge', () => { + renderWithProvider(); + + expect(screen.getByText('SHORT')).toBeOnTheScreen(); + expect(screen.getByText('5x')).toBeOnTheScreen(); + }); + + it('omits the leverage pill when leverage is null', () => { + renderWithProvider(); + + expect(screen.getByText('LONG')).toBeOnTheScreen(); + expect(screen.queryByText(/x$/u)).not.toBeOnTheScreen(); + }); + + it('omits the leverage pill when leverage is undefined', () => { + renderWithProvider(); + + expect(screen.getByText('SHORT')).toBeOnTheScreen(); + }); + + it('forwards the testID', () => { + renderWithProvider( + , + ); + + expect(screen.getByTestId('my-badges')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/components/PerpBadges.tsx b/app/components/Views/SocialLeaderboard/components/PerpBadges.tsx new file mode 100644 index 000000000000..11be050199b0 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/components/PerpBadges.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { + Box, + Text, + TextVariant, + TextColor, + FontWeight, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; +import type { PerpDirection } from '../utils/perp'; + +export interface PerpBadgesProps { + direction: PerpDirection; + /** Leverage multiplier (e.g. `10` → "10x"). Hidden when null/undefined. */ + leverage?: number | null; + testID?: string; +} + +/** + * Renders the perp metadata badges shown next to a token symbol: an optional + * leverage pill (e.g. "10x") and a direction pill (LONG green / SHORT red). + * Used across the trader profile position list, the position detail header, + * and individual trade rows so perp positions read consistently. + */ +const PerpBadges: React.FC = ({ + direction, + leverage, + testID, +}) => { + const isLong = direction === 'long'; + const directionLabel = strings( + isLong + ? 'social_leaderboard.trader_position.long' + : 'social_leaderboard.trader_position.short', + ).toUpperCase(); + + return ( + + {leverage ? ( + + + {`${leverage}x`} + + + ) : null} + + + {directionLabel} + + + + ); +}; + +export default PerpBadges; diff --git a/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.test.tsx b/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.test.tsx index ab1659f6464a..783317285ca3 100644 --- a/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.test.tsx +++ b/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.test.tsx @@ -26,6 +26,15 @@ jest.mock('../utils/chainMapping', () => ({ chainNameToId: jest.fn((chain: string) => chain === 'base' ? 'eip155:8453' : undefined, ), + getPositionNetworkBadge: jest.fn((chain: string) => { + if (chain === 'base') { + return { name: 'base', imageSource: { uri: 'base.png' } }; + } + if (chain === 'hyperliquid') { + return { name: 'Hyperliquid', imageSource: { uri: 'hyperevm.png' } }; + } + return undefined; + }), })); jest.mock( @@ -275,5 +284,18 @@ describe('PositionTokenAvatar', () => { expect(MockBadgeWrapper).not.toHaveBeenCalled(); }); + + it('wraps the avatar in a Hyperliquid network badge for a hyperliquid position', () => { + const position = { ...basePosition, chain: 'hyperliquid' }; + + renderWithProvider( + , + ); + + expect(MockBadgeWrapper).toHaveBeenCalled(); + const badgeElement = MockBadgeWrapper.mock.calls[0][0] + .badgeElement as React.ReactElement<{ name: string }>; + expect(badgeElement.props.name).toBe('Hyperliquid'); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.tsx b/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.tsx index 509f7666cc75..b3dd52d8e20b 100644 --- a/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.tsx +++ b/app/components/Views/SocialLeaderboard/components/PositionTokenAvatar.tsx @@ -5,12 +5,11 @@ import { } from '@metamask/design-system-react-native'; import type { Position } from '@metamask/social-controllers'; import { getAssetImageUrl } from '../../../UI/Bridge/hooks/useAssetMetadata/utils'; -import { chainNameToId } from '../utils/chainMapping'; +import { chainNameToId, getPositionNetworkBadge } from '../utils/chainMapping'; import BadgeWrapper, { BadgePosition, } from '../../../../component-library/components/Badges/BadgeWrapper'; import BadgeNetwork from '../../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; -import { getNetworkImageSource } from '../../../../util/networks'; export interface PositionTokenAvatarProps { position: Position; @@ -36,6 +35,13 @@ const PositionTokenAvatar: React.FC = ({ [position.chain], ); + // Resolved separately from `caipChainId` so Hyperliquid (perps) — which is + // intentionally absent from the spot chain map — still gets its network badge. + const networkBadge = useMemo( + () => getPositionNetworkBadge(position.chain), + [position.chain], + ); + const metamaskUrl = useMemo(() => { if (!caipChainId) return undefined; return getAssetImageUrl(position.tokenAddress, caipChainId); @@ -87,14 +93,14 @@ const PositionTokenAvatar: React.FC = ({ /> ); - if (showChainBadge && caipChainId) { + if (showChainBadge && networkBadge) { return ( } > diff --git a/app/components/Views/SocialLeaderboard/utils/chainMapping.test.ts b/app/components/Views/SocialLeaderboard/utils/chainMapping.test.ts index 4f4541a9d793..0627f0ce2c7e 100644 --- a/app/components/Views/SocialLeaderboard/utils/chainMapping.test.ts +++ b/app/components/Views/SocialLeaderboard/utils/chainMapping.test.ts @@ -1,4 +1,18 @@ -import { chainNameToId, isSupportedChain } from './chainMapping'; +import { + chainNameToId, + getPositionNetworkBadge, + isSupportedChain, +} from './chainMapping'; + +jest.mock('../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(({ chainId }: { chainId: string }) => ({ + uri: `img-${chainId}`, + })), +})); + +jest.mock('../../../../constants/network', () => ({ + NETWORKS_CHAIN_ID: { HYPER_EVM: '0x3e7' }, +})); describe('chainNameToId', () => { it.each([ @@ -72,3 +86,27 @@ describe('isSupportedChain', () => { expect(isSupportedChain('BASE')).toBe(true); }); }); + +describe('getPositionNetworkBadge', () => { + it('resolves a Hyperliquid badge from the HyperEVM image source', () => { + expect(getPositionNetworkBadge('hyperliquid')).toEqual({ + name: 'Hyperliquid', + imageSource: { uri: 'img-0x3e7' }, + }); + }); + + it('is case-insensitive for hyperliquid', () => { + expect(getPositionNetworkBadge('Hyperliquid')?.name).toBe('Hyperliquid'); + }); + + it('resolves a badge for a supported EVM chain', () => { + expect(getPositionNetworkBadge('base')).toEqual({ + name: 'base', + imageSource: { uri: 'img-eip155:8453' }, + }); + }); + + it('returns undefined for an unsupported chain', () => { + expect(getPositionNetworkBadge('avalanche')).toBeUndefined(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/utils/chainMapping.ts b/app/components/Views/SocialLeaderboard/utils/chainMapping.ts index c47ed434d482..5517cea31dc5 100644 --- a/app/components/Views/SocialLeaderboard/utils/chainMapping.ts +++ b/app/components/Views/SocialLeaderboard/utils/chainMapping.ts @@ -1,4 +1,7 @@ import type { CaipChainId } from '@metamask/utils'; +import type { ImageSourcePropType } from 'react-native'; +import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; +import { getNetworkImageSource } from '../../../../util/networks'; const CHAIN_NAME_TO_ID: Record = { ethereum: 'eip155:1', @@ -11,8 +14,47 @@ const CHAIN_NAME_TO_ID: Record = { solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', }; +export const HYPERLIQUID_CHAIN_NAME = 'hyperliquid'; + export const chainNameToId = (chainName: string): CaipChainId | undefined => CHAIN_NAME_TO_ID[chainName.toLowerCase()]; export const isSupportedChain = (chainName: string): boolean => chainName.toLowerCase() in CHAIN_NAME_TO_ID; + +export interface PositionNetworkBadge { + name: string; + imageSource: ImageSourcePropType; +} + +/** + * Resolves the network badge (name + logo) shown on a position's token avatar. + * + * Hyperliquid is handled specially: it isn't part of {@link CHAIN_NAME_TO_ID} + * (its positions key on a perp symbol rather than a token contract, and we + * deliberately keep it out of the spot trade/price paths), but it has a + * network logo we surface via the HyperEVM image source. Other chains resolve + * through their CAIP id. Returns `undefined` when no badge can be resolved. + */ +export const getPositionNetworkBadge = ( + chainName: string, +): PositionNetworkBadge | undefined => { + if (chainName.toLowerCase() === HYPERLIQUID_CHAIN_NAME) { + return { + name: 'Hyperliquid', + imageSource: getNetworkImageSource({ + chainId: NETWORKS_CHAIN_ID.HYPER_EVM, + }), + }; + } + + const caipChainId = chainNameToId(chainName); + if (!caipChainId) { + return undefined; + } + + return { + name: chainName, + imageSource: getNetworkImageSource({ chainId: caipChainId }), + }; +}; diff --git a/app/components/Views/SocialLeaderboard/utils/perp.test.ts b/app/components/Views/SocialLeaderboard/utils/perp.test.ts new file mode 100644 index 000000000000..0d3b84e5f49a --- /dev/null +++ b/app/components/Views/SocialLeaderboard/utils/perp.test.ts @@ -0,0 +1,141 @@ +import type { Position, Trade } from '@metamask/social-controllers'; +import { + getPerpPositionDirection, + getPerpTradeDirection, + isPerpPosition, + isPerpTrade, +} from './perp'; + +const basePosition = { + perpPositionType: null, + chain: 'base', + positionAmount: 100, +} as unknown as Position; + +const baseTrade = { + classification: null, + perpPositionType: null, + direction: 'buy', +} as unknown as Trade; + +describe('perp utils', () => { + describe('isPerpPosition', () => { + it('returns false for a spot position', () => { + expect(isPerpPosition(basePosition)).toBe(false); + }); + + it('returns true when perpPositionType is set', () => { + expect( + isPerpPosition({ ...basePosition, perpPositionType: 'long' }), + ).toBe(true); + }); + + it('returns true for a hyperliquid position even without a perp marker', () => { + expect( + isPerpPosition({ + ...basePosition, + chain: 'hyperliquid', + perpPositionType: null, + }), + ).toBe(true); + }); + + it('is case-insensitive on the chain name', () => { + expect(isPerpPosition({ ...basePosition, chain: 'Hyperliquid' })).toBe( + true, + ); + }); + }); + + describe('getPerpPositionDirection', () => { + it('returns null for a spot position', () => { + expect(getPerpPositionDirection(basePosition)).toBeNull(); + }); + + it('prefers the explicit perpPositionType', () => { + expect( + getPerpPositionDirection({ + ...basePosition, + chain: 'hyperliquid', + perpPositionType: 'short', + positionAmount: 100, + }), + ).toBe('short'); + }); + + it('infers long from a positive positionAmount on hyperliquid', () => { + expect( + getPerpPositionDirection({ + ...basePosition, + chain: 'hyperliquid', + perpPositionType: null, + positionAmount: 5, + }), + ).toBe('long'); + }); + + it('infers short from a negative positionAmount on hyperliquid', () => { + expect( + getPerpPositionDirection({ + ...basePosition, + chain: 'hyperliquid', + perpPositionType: null, + positionAmount: -5, + }), + ).toBe('short'); + }); + }); + + describe('isPerpTrade', () => { + it('returns false for a spot trade', () => { + expect(isPerpTrade(baseTrade)).toBe(false); + }); + + it('returns true for a perp-classified trade', () => { + expect(isPerpTrade({ ...baseTrade, classification: 'perp' })).toBe(true); + }); + + it('returns true when perpPositionType is set', () => { + expect(isPerpTrade({ ...baseTrade, perpPositionType: 'long' })).toBe( + true, + ); + }); + }); + + describe('getPerpTradeDirection', () => { + it('returns null for a spot trade', () => { + expect(getPerpTradeDirection(baseTrade)).toBeNull(); + }); + + it('prefers the explicit perpPositionType', () => { + expect( + getPerpTradeDirection({ + ...baseTrade, + classification: 'perp', + perpPositionType: 'short', + direction: 'buy', + }), + ).toBe('short'); + }); + + it('infers long from a buy on a perp-classified trade', () => { + expect( + getPerpTradeDirection({ + ...baseTrade, + classification: 'perp', + direction: 'buy', + }), + ).toBe('long'); + }); + + it('infers short from a sell on a perp-classified trade', () => { + expect( + getPerpTradeDirection({ + ...baseTrade, + classification: 'perp', + direction: 'sell', + }), + ).toBe('short'); + }); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/utils/perp.ts b/app/components/Views/SocialLeaderboard/utils/perp.ts new file mode 100644 index 000000000000..3dc95963dd11 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/utils/perp.ts @@ -0,0 +1,75 @@ +import type { Position, Trade } from '@metamask/social-controllers'; + +/** + * Chain name the social API uses for Hyperliquid perps. Hyperliquid is + * perp-only, so a position on this chain is always a perp even when the + * explicit `perpPositionType` marker is absent. + */ +export const HYPERLIQUID_CHAIN_NAME = 'hyperliquid'; + +export type PerpDirection = 'long' | 'short'; + +type PerpPositionFields = Pick< + Position, + 'perpPositionType' | 'chain' | 'positionAmount' +>; + +type PerpTradeFields = Pick< + Trade, + 'classification' | 'perpPositionType' | 'direction' +>; + +/** + * True when a position represents a perpetual (leveraged) position rather than + * a spot holding. A position is a perp when it carries an explicit + * `perpPositionType` or lives on the Hyperliquid (perp-only) chain — mirroring + * the classification the social API / Clicker use upstream. + */ +export function isPerpPosition(position: PerpPositionFields): boolean { + return ( + position.perpPositionType != null || + position.chain?.toLowerCase() === HYPERLIQUID_CHAIN_NAME + ); +} + +/** + * Resolves the side (long/short) of a perp position. Prefers the explicit + * `perpPositionType`; for Hyperliquid positions that omit it, infers from the + * sign of `positionAmount` (negative → short). Returns `null` for spot. + */ +export function getPerpPositionDirection( + position: PerpPositionFields, +): PerpDirection | null { + if (position.perpPositionType) { + return position.perpPositionType; + } + if (position.chain?.toLowerCase() === HYPERLIQUID_CHAIN_NAME) { + return (position.positionAmount ?? 0) < 0 ? 'short' : 'long'; + } + return null; +} + +/** + * True when an individual trade is a perp fill (explicit `'perp'` + * classification or a `perpPositionType`). + */ +export function isPerpTrade(trade: PerpTradeFields): boolean { + return trade.classification === 'perp' || trade.perpPositionType != null; +} + +/** + * Resolves the side (long/short) of a perp trade. Prefers `perpPositionType`; + * for perp-classified fills that omit it, infers from `direction` + * (buy → long, sell → short). Returns `null` for spot trades. + */ +export function getPerpTradeDirection( + trade: PerpTradeFields, +): PerpDirection | null { + if (trade.perpPositionType) { + return trade.perpPositionType; + } + if (trade.classification === 'perp') { + return trade.direction === 'sell' ? 'short' : 'long'; + } + return null; +} diff --git a/locales/languages/en.json b/locales/languages/en.json index 78185653a368..b8cfe15e57a0 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1100,7 +1100,11 @@ "fallback_subtitle": "We couldn't load this position right now.", "fallback_back_to_profile": "Back to profile", "fallback_back_to_leaderboard": "Back to leaderboard", - "sell": "Sell" + "sell": "Sell", + "long": "Long", + "short": "Short", + "opened": "{{name}} opened", + "closed_action": "{{name}} closed" }, "quick_buy": { "title": "Buy {{symbol}}", diff --git a/package.json b/package.json index 00a348a96079..4e08fd58cfc3 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,12 @@ "worktree:remove": "yarn ts-node --transpile-only scripts/worktree-remove.ts", "skills:postinstall": "metamask-skills postinstall" }, + "previewBuilds": { + "@metamask/social-controllers": { + "type": "non-breaking", + "previewVersion": "2.2.1-preview-a3e0ef7eb" + } + }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "prettier --write", diff --git a/yarn.lock b/yarn.lock index da7f08b48392..f3317dfb855b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8285,6 +8285,28 @@ __metadata: languageName: node linkType: hard +"@metamask/controller-utils@npm:^12.2.0": + version: 12.2.0 + resolution: "@metamask/controller-utils@npm:12.2.0" + dependencies: + "@ethersproject/abi": "npm:^5.7.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/ethjs-unit": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@spruceid/siwe-parser": "npm:2.1.0" + "@types/bn.js": "npm:^5.1.5" + bignumber.js: "npm:^9.1.2" + bn.js: "npm:^5.2.1" + cockatiel: "npm:^3.1.2" + eth-ens-namehash: "npm:^2.0.8" + fast-deep-equal: "npm:^3.1.3" + lodash: "npm:^4.17.21" + peerDependencies: + "@babel/runtime": ^7.0.0 + checksum: 10/24551cec486319b39e0db6792e5d26bafa7fb87dce63d516cc80a5645f7c185b7911e89d21189e7270d338436074bc6e1e6e0983b008cb130292ee1e1902c28d + languageName: node + linkType: hard + "@metamask/core-backend@npm:^6.3.0, @metamask/core-backend@npm:^6.3.2, @metamask/core-backend@npm:^6.3.3": version: 6.3.3 resolution: "@metamask/core-backend@npm:6.3.3" @@ -9527,7 +9549,7 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.0.2, @metamask/profile-sync-controller@npm:^28.1.0, @metamask/profile-sync-controller@npm:^28.1.1": +"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.1.0, @metamask/profile-sync-controller@npm:^28.1.1": version: 28.1.1 resolution: "@metamask/profile-sync-controller@npm:28.1.1" dependencies: @@ -10141,17 +10163,17 @@ __metadata: languageName: node linkType: hard -"@metamask/social-controllers@npm:^2.2.0": - version: 2.2.0 - resolution: "@metamask/social-controllers@npm:2.2.0" +"@metamask/social-controllers@npm:@metamask-previews/social-controllers@2.2.1-preview-a3e0ef7eb": + version: 2.2.1-preview-a3e0ef7eb + resolution: "@metamask-previews/social-controllers@npm:2.2.1-preview-a3e0ef7eb" dependencies: "@metamask/base-controller": "npm:^9.1.0" - "@metamask/base-data-service": "npm:^0.1.1" - "@metamask/controller-utils": "npm:^11.20.0" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/base-data-service": "npm:^0.1.3" + "@metamask/controller-utils": "npm:^12.2.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/a9a3dd9c4af78e7712860c049045102cd246e844968610fa1acdabd11b71131af00f02fb2a322e651263e3c63a420752acd96463834550b40fb047cecd484ed9 + checksum: 10/d5f4be147b71fea8984b6aa60fb43be4b3700806868f035f83babc1f043f5c4db3371dba1d8ae6316c28f5b1b76eba0672c857e0802ddf35371f0bce65e5fe12 languageName: node linkType: hard From 3b93567b37ceefd319c14b05c8933228edc3f2c4 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Sun, 14 Jun 2026 22:16:35 -0600 Subject: [PATCH 2/4] refactor: single Trade CTA on perp positions --- .../TraderPositionView.test.tsx | 15 +++------ .../TraderPositionView.testIds.ts | 3 +- .../TraderPositionView/TraderPositionView.tsx | 32 ++++--------------- locales/languages/en.json | 1 + 4 files changed, 13 insertions(+), 38 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 5deedd4d04d4..c02529e851f2 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -384,31 +384,24 @@ describe('TraderPositionView', () => { }; }); - it('renders Long/Short buttons instead of the Buy button', () => { + it('renders the Trade button instead of the Buy button', () => { renderWithProvider(, { state: mockState }); expect( - screen.getByTestId(TraderPositionViewSelectorsIDs.LONG_BUTTON), - ).toBeOnTheScreen(); - expect( - screen.getByTestId(TraderPositionViewSelectorsIDs.SHORT_BUTTON), + screen.getByTestId(TraderPositionViewSelectorsIDs.TRADE_BUTTON), ).toBeOnTheScreen(); expect( screen.queryByTestId(TraderPositionViewSelectorsIDs.BUY_BUTTON), ).not.toBeOnTheScreen(); }); - it('does not open QuickBuy when the Long/Short buttons are pressed', () => { + it('does not open QuickBuy when the Trade button is pressed', () => { renderWithProvider(, { state: mockState }); fireEvent.press( - screen.getByTestId(TraderPositionViewSelectorsIDs.LONG_BUTTON), - ); - fireEvent.press( - screen.getByTestId(TraderPositionViewSelectorsIDs.SHORT_BUTTON), + screen.getByTestId(TraderPositionViewSelectorsIDs.TRADE_BUTTON), ); - // No navigation/CTA haptic — the buttons are intentional placeholders. expect(mockPlayImpact).not.toHaveBeenCalled(); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts index 43f20ad12865..1f4359edf188 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts @@ -3,8 +3,7 @@ export const TraderPositionViewSelectorsIDs = { BACK_BUTTON: 'trader-position-view-back-button', TRADER_NAME_LINK: 'trader-position-view-trader-name-link', BUY_BUTTON: 'trader-position-view-buy-button', - LONG_BUTTON: 'trader-position-view-long-button', - SHORT_BUTTON: 'trader-position-view-short-button', + TRADE_BUTTON: 'trader-position-view-trade-button', COPY_TOKEN_ADDRESS_BUTTON: 'trader-position-view-copy-token-address-button', SKELETON: 'trader-position-skeleton', FALLBACK: 'trader-position-fallback', diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index ac76a26fef71..bfd0b3f76354 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -20,12 +20,8 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { playImpact, ImpactMoment } from '../../../../util/haptics'; import { Box, - BoxFlexDirection, - Button, ButtonHero, ButtonHeroSize, - ButtonSize, - ButtonVariant, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; @@ -376,29 +372,15 @@ const TraderPositionView = () => { {isPerp ? ( - - - + {strings('social_leaderboard.trader_position.trade')} + ) : ( <> diff --git a/locales/languages/en.json b/locales/languages/en.json index b8cfe15e57a0..93abdc9e254a 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1092,6 +1092,7 @@ "position": "Position", "trades": "Trades", "buy": "Buy", + "trade": "Trade", "bought": "{{name}} bought", "sold": "{{name}} sold", "no_trades": "No trades yet", From b23e94164cf9e6b13216494084537d835ff3d1ae Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Mon, 15 Jun 2026 17:00:57 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20Social=20Leaderboard=20perp=20posit?= =?UTF-8?q?ions=20=E2=80=94=20consistent=20PnL=20coloring=20+=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make PnL coloring consistent across the trader profile list and the position detail card: the absolute $ amount stays neutral and the percentage carries the red/green, matching spot rows (PositionRow, TraderPositionPnLCard). Snapshot of in-progress TSA-628 Social Leaderboard work in the working tree (perp position rendering, Hyperliquid price utils, navigation and locale updates). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TopTradersView/TopTradersView.test.tsx | 32 +++- .../TopTradersView/TopTradersView.testIds.ts | 1 + .../TopTradersView/TopTradersView.tsx | 19 +- .../TraderPositionView.test.tsx | 13 +- .../TraderPositionView/TraderPositionView.tsx | 31 +++- .../components/TraderPositionPnLCard.tsx | 16 +- .../components/TraderTokenInfoRow.tsx | 69 ++++++-- .../useTraderPositionData.ts | 128 ++++++++++++-- .../TraderProfileView.test.tsx | 1 + .../TraderProfileView/TraderProfileView.tsx | 2 + .../components/PositionRow.test.tsx | 18 ++ .../components/PositionRow.tsx | 71 ++++++-- .../utils/hyperliquidPrices.test.ts | 165 ++++++++++++++++++ .../utils/hyperliquidPrices.ts | 100 +++++++++++ .../Views/SocialLeaderboard/utils/perp.ts | 28 +++ app/core/AppConstants.ts | 3 +- app/core/NavigationService/types.ts | 12 +- locales/languages/en.json | 1 + 18 files changed, 640 insertions(+), 70 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.test.ts create mode 100644 app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.ts diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx index c7440c026129..14a6db650e37 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -69,7 +69,7 @@ const fixtureTraders: TopTrader[] = [ }, ]; -type ChainKey = 'all' | 'base' | 'solana' | 'ethereum'; +type ChainKey = 'all' | 'base' | 'solana' | 'ethereum' | 'hyperliquid'; const buildResult = ( overrides: Partial = {}, @@ -90,6 +90,7 @@ const mockResultsByChain: Record = { base: buildResult(), solana: buildResult(), ethereum: buildResult(), + hyperliquid: buildResult(), }; const setChainResult = ( @@ -261,9 +262,9 @@ describe('TopTradersView', () => { expect(typeof refreshControl.props.refreshing).toBe('boolean'); }); - it('invalidates all four tab queries when the scroll view is pulled down', async () => { + it('invalidates every tab query when the scroll view is pulled down', async () => { // Each chain's result wraps the shared mockRefresh — pull-to-refresh - // should call it once per tab (4 total). + // should call it once per tab (all, base, solana, ethereum, hyperliquid). mockRefresh.mockResolvedValue(undefined); renderWithProvider(); const list = screen.getByTestId(TopTradersViewSelectorsIDs.TRADER_LIST); @@ -272,7 +273,7 @@ describe('TopTradersView', () => { await list.props.refreshControl.props.onRefresh(); }); - expect(mockRefresh).toHaveBeenCalledTimes(4); + expect(mockRefresh).toHaveBeenCalledTimes(5); }); it('logs an error when refresh fails', async () => { @@ -306,7 +307,7 @@ describe('TopTradersView', () => { expect(mockGoBack).toHaveBeenCalledTimes(1); }); - it('renders all four chain filter pills', () => { + it('renders all chain filter pills including Hyperliquid', () => { renderWithProvider(); expect( screen.getByTestId(TopTradersViewSelectorsIDs.CHAIN_FILTER_ALL), @@ -320,6 +321,9 @@ describe('TopTradersView', () => { expect( screen.getByTestId(TopTradersViewSelectorsIDs.CHAIN_FILTER_ETHEREUM), ).toBeOnTheScreen(); + expect( + screen.getByTestId(TopTradersViewSelectorsIDs.CHAIN_FILTER_HYPERLIQUID), + ).toBeOnTheScreen(); }); it('fires a separate query per chain on mount (parallel prefetch)', () => { @@ -329,13 +333,15 @@ describe('TopTradersView', () => { ([opts]) => opts?.chains, ); // "All" tab explicitly requests the spot chains so hyperliquid (perps) - // doesn't dominate the rankings; chain tabs each request their own chain. + // doesn't dominate the rankings; chain tabs each request their own chain, + // including a dedicated hyperliquid (perps) tab. expect(chainsArgs).toEqual( expect.arrayContaining([ ['base', 'solana', 'ethereum'], ['base'], ['solana'], ['ethereum'], + ['hyperliquid'], ]), ); }); @@ -358,6 +364,20 @@ describe('TopTradersView', () => { expect(screen.queryByText('nervousdegen')).not.toBeOnTheScreen(); }); + it('shows the Hyperliquid tab’s own (perps) traders when selected', () => { + setChainResult('hyperliquid', { + traders: [{ ...fixtureTraders[2], rank: 1 }], + }); + renderWithProvider(); + + fireEvent.press( + screen.getByTestId(TopTradersViewSelectorsIDs.CHAIN_FILTER_HYPERLIQUID), + ); + + expect(screen.getByText('baznocap')).toBeOnTheScreen(); + expect(screen.queryByText('nervousdegen')).not.toBeOnTheScreen(); + }); + it('uses the per-tab rank when navigating to a profile', () => { setChainResult('base', { traders: [{ ...fixtureTraders[0], rank: 2 }], diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts index 2ddf80f80f53..3509045c045d 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts @@ -7,4 +7,5 @@ export const TopTradersViewSelectorsIDs = { CHAIN_FILTER_BASE: 'chain-filter-base', CHAIN_FILTER_SOLANA: 'chain-filter-solana', CHAIN_FILTER_ETHEREUM: 'chain-filter-ethereum', + CHAIN_FILTER_HYPERLIQUID: 'chain-filter-hyperliquid', }; diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx index aa3901e07150..e028df2aa3cc 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx @@ -67,10 +67,15 @@ import { useTopTraders } from '../../Homepage/Sections/TopTraders/hooks'; import { SPOT_CHAINS } from '../../Homepage/Sections/TopTraders/constants'; import { TopTradersViewSelectorsIDs } from './TopTradersView.testIds'; -type ChainFilter = 'all' | 'base' | 'solana' | 'ethereum'; +type ChainFilter = 'all' | 'base' | 'solana' | 'ethereum' | 'hyperliquid'; const LEADERBOARD_LIMIT = 50; +// Hyperliquid (perps) is its own tab. It's deliberately kept out of SPOT_CHAINS +// / the "All" tab — see SPOT_CHAINS — so perps PnL doesn't dominate the spot +// rankings, but users can still browse the perps leaderboard directly here. +const HYPERLIQUID_DISPLAY_NAME = 'Hyperliquid'; + const getChainFilters = (): { key: ChainFilter; label: string }[] => [ { key: 'all', @@ -79,6 +84,7 @@ const getChainFilters = (): { key: ChainFilter; label: string }[] => [ { key: 'base', label: BASE_DISPLAY_NAME }, { key: 'solana', label: SOLANA_DISPLAY_NAME }, { key: 'ethereum', label: MAINNET_DISPLAY_NAME }, + { key: 'hyperliquid', label: HYPERLIQUID_DISPLAY_NAME }, ]; const styles = StyleSheet.create({ @@ -195,6 +201,11 @@ const TopTradersView = () => { chains: ['ethereum'], enabled: isEnabled, }); + const hyperliquidResult = useTopTraders({ + limit: LEADERBOARD_LIMIT, + chains: ['hyperliquid'], + enabled: isEnabled, + }); const resultsByChain = useMemo( () => ({ @@ -202,8 +213,9 @@ const TopTradersView = () => { base: baseResult, solana: solanaResult, ethereum: ethereumResult, + hyperliquid: hyperliquidResult, }), - [allResult, baseResult, solanaResult, ethereumResult], + [allResult, baseResult, solanaResult, ethereumResult, hyperliquidResult], ); const activeResult = resultsByChain[selectedChain]; @@ -293,6 +305,7 @@ const TopTradersView = () => { baseResult.refresh(), solanaResult.refresh(), ethereumResult.refresh(), + hyperliquidResult.refresh(), minDuration, ]); } catch (err) { @@ -309,7 +322,7 @@ const TopTradersView = () => { } finally { setRefreshing(false); } - }, [allResult, baseResult, solanaResult, ethereumResult]); + }, [allResult, baseResult, solanaResult, ethereumResult, hyperliquidResult]); const handleTraderPress = useCallback( (traderId: string, traderName: string) => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index c02529e851f2..a5b33fe175a5 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -395,14 +395,23 @@ describe('TraderPositionView', () => { ).not.toBeOnTheScreen(); }); - it('does not open QuickBuy when the Trade button is pressed', () => { + it('navigates to the Perps market page (not QuickBuy) when the Trade button is pressed', () => { renderWithProvider(, { state: mockState }); fireEvent.press( screen.getByTestId(TraderPositionViewSelectorsIDs.TRADE_BUTTON), ); - expect(mockPlayImpact).not.toHaveBeenCalled(); + // Perps has no long/short preselect on the market page, so the single + // Trade CTA lands the user on that market's Perps page with a minimal + // market object — it never opens the spot QuickBuy sheet. + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: 'ETH', name: 'ETH' }, + source: 'social_leaderboard', + }, + }); }); it('renders the perp leverage and direction badges in the header', () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index bfd0b3f76354..002daafd958d 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -55,6 +55,7 @@ import { MetaMetricsEvents } from '../../../../core/Analytics'; import { chainNameToId } from '../utils/chainMapping'; import { isPerpPosition } from '../utils/perp'; import { toAssetId } from '../../../UI/Bridge/hooks/useAssetMetadata/utils'; +import type { PerpsMarketData } from '@metamask/perps-controller'; const TraderPositionView = () => { const navigation = useNavigation>(); @@ -72,6 +73,7 @@ const TraderPositionView = () => { position: positionParam, positionId, source: sourceParam, + isClosed: isClosedParam, } = route.params; const { track } = useSocialLeaderboardAnalytics(); @@ -105,10 +107,15 @@ const TraderPositionView = () => { const traderAddress = traderAddressParam ?? fetchedProfile?.profile?.address ?? ''; - const positionData = useTraderPositionData(resolvedPosition, tokenSymbol); + const positionData = useTraderPositionData( + resolvedPosition, + tokenSymbol, + isClosedParam, + ); const { symbol, marketCap, + currentPrice, historicalPrices, priceDiff, isPricesLoading, @@ -288,11 +295,24 @@ const TraderPositionView = () => { // TODO: update displayed price on scrub. }, []); - // Perp positions surface Long/Short CTAs instead of Buy. Perp trade entry - // isn't wired up yet, so these are intentional placeholders that don't - // navigate anywhere (a future ticket will hook them up). + // Perp positions surface Long/Short CTAs instead of Buy. Hyperliquid has no + // long/short preselect param on the market page (direction only exists on the + // funded trade-entry flow), so both CTAs land the user on that market's Perps + // page. A minimal { symbol, name } market is enough — PerpsMarketDetailsView + // enriches it from usePerpsMarkets (same pattern as PerpsPositionTransactionView). const isPerp = resolvedPosition ? isPerpPosition(resolvedPosition) : false; - const handlePerpActionPress = useCallback(() => undefined, []); + const handlePerpActionPress = useCallback(() => { + if (!resolvedPosition) return; + playImpact(ImpactMoment.PrimaryCTA); + const market = { + symbol: resolvedPosition.tokenSymbol, + name: resolvedPosition.tokenSymbol, + } as PerpsMarketData; + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market, source: 'social_leaderboard' }, + }); + }, [navigation, resolvedPosition]); const isInitialLoading = !resolvedPosition && (isPositionLoading || isProfileLoading); @@ -334,6 +354,7 @@ const TraderPositionView = () => { symbol={symbol} position={resolvedPosition} marketCap={marketCap} + currentPrice={currentPrice} pricePercentChange={pricePercentChange} activeTimePeriodLabel={activeTimePeriod} onCopyTokenAddress={handleCopyTokenAddress} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPositionPnLCard.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPositionPnLCard.tsx index 04b75aafa82e..1bc9bc31f250 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPositionPnLCard.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPositionPnLCard.tsx @@ -61,19 +61,27 @@ const TraderPositionPnLCard: React.FC = ({ )} + {/* The absolute amount stays neutral; the percentage carries the + red/green so coloring is consistent with the positions list and + with spot positions. */} {pnlValue != null ? formatPnl(pnlValue) : '\u2014'} {formatPercent(pnlPercent)} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTokenInfoRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTokenInfoRow.tsx index 286b1540fbd1..3b9640134fb3 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTokenInfoRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTokenInfoRow.tsx @@ -19,14 +19,20 @@ import { import type { Position } from '@metamask/social-controllers'; import { strings } from '../../../../../../locales/i18n'; import { formatCompactUsd } from '../../../../UI/Rewards/utils/formatUtils'; +import { + formatPerpsFiat, + PRICE_RANGES_UNIVERSAL, +} from '../../../../UI/Perps/utils/formatUtils'; import PositionTokenAvatar from '../../components/PositionTokenAvatar'; import PerpBadges from '../../components/PerpBadges'; -import { getPerpPositionDirection } from '../../utils/perp'; +import { getPerpPositionDirection, isPerpPosition } from '../../utils/perp'; export interface TraderTokenInfoRowProps { symbol: string; position?: Position; marketCap: number | undefined; + /** Latest price; shown in place of market cap for perps. */ + currentPrice?: number | undefined; pricePercentChange: number | undefined; activeTimePeriodLabel: string; onCopyTokenAddress?: () => void; @@ -142,29 +148,54 @@ const TraderTokenIdentity: React.FC = ({ ); }; -interface TraderMarketCapProps { +interface TraderHeaderStatProps { + isPerp: boolean; marketCap: number | undefined; + currentPrice: number | undefined; } -const TraderMarketCap: React.FC = ({ marketCap }) => ( - - - {marketCap != null ? formatCompactUsd(marketCap) : '\u2014'} - - - {strings('social_leaderboard.trader_position.market_cap')} - - -); +/** + * Top-right header stat. Perps have no market cap, so they surface the current + * price instead; spot positions keep the market cap. + */ +const TraderHeaderStat: React.FC = ({ + isPerp, + marketCap, + currentPrice, +}) => { + const value = isPerp + ? currentPrice != null + ? formatPerpsFiat(currentPrice, { ranges: PRICE_RANGES_UNIVERSAL }) + : '\u2014' + : marketCap != null + ? formatCompactUsd(marketCap) + : '\u2014'; + + return ( + + + {value} + + + {strings( + isPerp + ? 'social_leaderboard.trader_position.price' + : 'social_leaderboard.trader_position.market_cap', + )} + + + ); +}; const TraderTokenInfoRow: React.FC = ({ symbol, position, marketCap, + currentPrice, pricePercentChange, activeTimePeriodLabel, onCopyTokenAddress, @@ -183,7 +214,11 @@ const TraderTokenInfoRow: React.FC = ({ onCopyTokenAddress={onCopyTokenAddress} copyTokenAddressTestID={copyTokenAddressTestID} /> - + ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts index 2f4541be09f6..59047f1222a0 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts @@ -5,6 +5,11 @@ import type { TokenPrice } from '../../../hooks/useTokenHistoricalPrices'; import type { Hex } from '@metamask/utils'; import { handleFetch } from '@metamask/controller-utils'; import { chainNameToId } from '../utils/chainMapping'; +import { isPerpPosition, isClosedPosition } from '../utils/perp'; +import { + fetchHyperliquidHistoricalPrices, + type HyperliquidCandleInterval, +} from '../utils/hyperliquidPrices'; import { getAssetImageUrl, toAssetId, @@ -37,6 +42,23 @@ const PERIOD_DURATION_MS: Record = { All: 3 * 365 * 24 * 60 * 60 * 1000, }; +/** + * Hyperliquid candle interval + count per chart period. Counts cover each + * period's window while keeping the line chart smooth (well above + * CHART_DATA_THRESHOLD). Unlike the spot API, each period maps to a distinct + * interval, so there's no 1D→1H reuse. + */ +const PERP_PERIOD_TO_CANDLES: Record< + TimePeriod, + { interval: HyperliquidCandleInterval; limit: number } +> = { + '1H': { interval: '1m', limit: 60 }, // 60 × 1m = 1 hour + '1D': { interval: '15m', limit: 96 }, // 96 × 15m = 24 hours + '1W': { interval: '1h', limit: 168 }, // 168 × 1h = 7 days + '1M': { interval: '4h', limit: 180 }, // 180 × 4h = 30 days + All: { interval: '1d', limit: 365 }, // 365 × 1d ≈ 1 year +}; + /** * Derives percentage change from historical price data points. * For "1H" we find the point closest to one hour ago within the data set. @@ -78,6 +100,9 @@ export interface TraderPositionData { symbol: string; tokenImageUrl: string | undefined; marketCap: number | undefined; + /** Latest price from the chart feed. Surfaced for perps (no market cap). */ + currentPrice: number | undefined; + isPerp: boolean; // Chart historicalPrices: TokenPrice[]; @@ -107,6 +132,11 @@ export { TIME_PERIODS }; export function useTraderPositionData( positionParam: Position | undefined, tokenSymbol?: string, + /** + * Authoritative closed/open flag from the caller's list context (e.g. the + * profile tab). Falls back to {@link isClosedPosition} when omitted. + */ + isClosedOverride?: boolean, ): TraderPositionData { const [activeTimePeriod, setActiveTimePeriod] = useState('1M'); @@ -185,15 +215,61 @@ export function useTraderPositionData( const [isPricesLoading, setIsPricesLoading] = useState(true); useEffect(() => { - if (!positionParam || !caipChainId) { + if (!positionParam) { + setAllPrices({}); + setIsPricesLoading(false); + return; + } + + const isPerp = isPerpPosition(positionParam); + + // Spot tokens resolve prices via the MetaMask price API, which needs a CAIP + // chain id. Hyperliquid perps have no CAIP id and instead use the + // exchange's candle feed directly (keyed by perp symbol). + if (!isPerp && !caipChainId) { setAllPrices({}); setIsPricesLoading(false); return; } + setIsPricesLoading(true); + let cancelled = false; + + // ── Hyperliquid perps: candleSnapshot REST feed ────────────────────────── + if (isPerp) { + const symbol = positionParam.tokenSymbol; + // One clock shared across all period fetches so their windows line up. + const nowMs = Date.now(); + + const fetchPerpPeriod = async (period: TimePeriod) => { + const { interval, limit } = PERP_PERIOD_TO_CANDLES[period]; + const prices = await fetchHyperliquidHistoricalPrices({ + symbol, + interval, + limit, + nowMs, + }); + return { period, prices }; + }; + + Promise.all(TIME_PERIODS.map(fetchPerpPeriod)).then((results) => { + if (cancelled) return; + const cache: Partial> = {}; + for (const { period, prices } of results) { + cache[period] = prices; + } + setAllPrices(cache); + setIsPricesLoading(false); + }); + + return () => { + cancelled = true; + }; + } + + // ── Spot tokens: MetaMask price API ────────────────────────────────────── const assetIdentifier = `erc20:${positionParam.tokenAddress}`; const vsCurrency = currentCurrency.toLowerCase(); - let cancelled = false; const fetchPeriod = async (period: TimePeriod) => { const apiPeriod = PERIOD_TO_API[period]; @@ -266,23 +342,47 @@ export function useTraderPositionData( }; }, [allPrices, activeTimePeriod]); + // Latest price for the header (perps show this in place of market cap). + // Prefers the freshest dataset so it's stable regardless of selected period. + const currentPrice = useMemo(() => { + const source = + allPrices['1H'] ?? + allPrices['1D'] ?? + allPrices['1W'] ?? + allPrices['1M'] ?? + allPrices.All; + if (!source?.length) return undefined; + return source[source.length - 1][1]; + }, [allPrices]); + // ── Position card ────────────────────────────────────────────────────── const symbol = positionParam?.tokenSymbol ?? tokenSymbol ?? ''; + const isPerp = positionParam != null && isPerpPosition(positionParam); const isClosed = - positionParam != null && - positionParam.positionAmount === 0 && - positionParam.soldUsd > 0; + isClosedOverride ?? + (positionParam != null && isClosedPosition(positionParam)); const positionValue = isClosed ? null : positionParam?.currentValueUSD; - const pnlValue = isClosed - ? positionParam?.realizedPnl - : positionParam?.pnlValueUsd; - const pnlPercent = isClosed - ? positionParam?.boughtUsd - ? (positionParam.realizedPnl / positionParam.boughtUsd) * 100 - : null - : (positionParam?.pnlPercent ?? null); + + // Perps reliably populate pnlValueUsd / pnlPercent (realized + unrealized) for + // both open and closed positions, so prefer those. Spot keeps the + // realized-on-close / unrealized-while-open split. + const pnlValue = isPerp + ? (positionParam?.pnlValueUsd ?? positionParam?.realizedPnl) + : isClosed + ? positionParam?.realizedPnl + : positionParam?.pnlValueUsd; + const pnlPercent = isPerp + ? (positionParam?.pnlPercent ?? + (positionParam?.boughtUsd + ? (positionParam.realizedPnl / positionParam.boughtUsd) * 100 + : null)) + : isClosed + ? positionParam?.boughtUsd + ? (positionParam.realizedPnl / positionParam.boughtUsd) * 100 + : null + : (positionParam?.pnlPercent ?? null); const isPnlPositive = (pnlValue ?? 0) >= 0; // ── Trades ───────────────────────────────────────────────────────────── @@ -309,6 +409,8 @@ export function useTraderPositionData( symbol, tokenImageUrl, marketCap, + currentPrice, + isPerp, historicalPrices, priceDiff, isPricesLoading, diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx index 1b3cef22b696..16ddadf54c89 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx @@ -407,6 +407,7 @@ describe('TraderProfileView', () => { tokenSymbol: fixtureOpenPositions[0].tokenSymbol, position: fixtureOpenPositions[0], source: 'profile_position', + isClosed: false, }, ); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx index e2df8c65a083..6813a07a20db 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx @@ -284,6 +284,7 @@ const TraderProfileView = () => { tokenSymbol: position.tokenSymbol, position, source: 'profile_position', + isClosed: activeTab === 'closed', }); }, [ @@ -474,6 +475,7 @@ const TraderProfileView = () => { key={`${position.tokenAddress}-${position.chain}-${index}`} position={position} onPress={handlePositionPress} + isClosed={activeTab === 'closed'} /> )) )} diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx index c78a456c3005..b8303c4d299e 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx @@ -271,5 +271,23 @@ describe('PositionRow', () => { expect(screen.queryByText('LONG')).not.toBeOnTheScreen(); expect(screen.queryByText('SHORT')).not.toBeOnTheScreen(); }); + + it('shows PnL as the value for perps instead of the current value', () => { + renderWithProvider(); + + // Perps surface realized/unrealized PnL ($1,059.96), not currentValueUSD. + expect(screen.getByText('+$1,059.96')).toBeOnTheScreen(); + expect(screen.queryByText('$2,259.96')).not.toBeOnTheScreen(); + }); + + it('shows the trade date (not the position amount) for a closed perp', () => { + const closedPerp = { ...perpPosition, currentValueUSD: 0 }; + + renderWithProvider(); + + expect(screen.getByText('Apr 15, 2026 at 2:00 PM')).toBeOnTheScreen(); + // Not the " ETH" subtitle that open positions show. + expect(screen.queryByText('1.50B ETH')).not.toBeOnTheScreen(); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx index 10c2559150e8..1e37b0eb7ea3 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx @@ -13,7 +13,12 @@ import { import type { Position } from '@metamask/social-controllers'; import PositionTokenAvatar from '../../components/PositionTokenAvatar'; import PerpBadges from '../../components/PerpBadges'; -import { getPerpPositionDirection } from '../../utils/perp'; +import { + getPerpPositionDirection, + isClosedPosition, + isPerpPosition, +} from '../../utils/perp'; +import { formatPnl } from '../../../../UI/Perps/utils/formatUtils'; import { formatUsd, formatTokenAmount, @@ -24,21 +29,42 @@ import { export interface PositionRowProps { position: Position; onPress?: (position: Position) => void; + /** + * Authoritative closed/open flag from the list the row belongs to (e.g. the + * profile's Closed tab). Falls back to {@link isClosedPosition} when omitted. + */ + isClosed?: boolean; } -const PositionRow: React.FC = ({ position, onPress }) => { - const isClosed = position.positionAmount === 0 && position.soldUsd > 0; - - const displayValue = isClosed - ? position.soldUsd - : (position.currentValueUSD ?? null); +const PositionRow: React.FC = ({ + position, + onPress, + isClosed: isClosedProp, +}) => { + const isClosed = isClosedProp ?? isClosedPosition(position); + const isPerp = isPerpPosition(position); const closedPnlPercent = - isClosed && position.boughtUsd > 0 + position.boughtUsd > 0 ? (position.realizedPnl / position.boughtUsd) * 100 : null; - const displayPnlPercent = isClosed ? closedPnlPercent : position.pnlPercent; + // Perps don't carry meaningful spot proceeds/current value, so surface their + // realized/unrealized PnL ($ + %) instead. Spot keeps soldUsd-on-close / + // currentValueUSD-while-open. + const perpPnlValue = position.pnlValueUsd ?? position.realizedPnl ?? null; + + const displayValue = isPerp + ? perpPnlValue + : isClosed + ? position.soldUsd + : (position.currentValueUSD ?? null); + + const displayPnlPercent = isPerp + ? (position.pnlPercent ?? closedPnlPercent) + : isClosed + ? closedPnlPercent + : position.pnlPercent; const hasPnl = displayPnlPercent != null; const isPnlPositive = hasPnl && (displayPnlPercent ?? 0) >= 0; const testID = `position-row-${position.tokenSymbol}`; @@ -97,13 +123,26 @@ const PositionRow: React.FC = ({ position, onPress }) => { - - {formatUsd(displayValue)} - + {isPerp ? ( + // Keep the absolute amount neutral and let the percentage below carry + // the red/green, matching spot rows (the $ figure is just a number; + // the % conveys the gain/loss). + + {perpPnlValue != null ? formatPnl(perpPnlValue) : '—'} + + ) : ( + + {formatUsd(displayValue)} + + )} ({ + error: jest.fn(), +})); + +const mockLoggerError = Logger.error as jest.Mock; + +/** A candleSnapshot entry — only `t` (open time) and `c` (close) are consumed. */ +const makeCandle = (t: number, c: string) => ({ + t, + T: t + 60_000, + s: 'BTC', + i: '1m', + o: c, + c, + h: c, + l: c, + v: '1', + n: 1, +}); + +const mockFetchResolving = (body: unknown, init?: { ok?: boolean }) => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: init?.ok ?? true, + json: () => Promise.resolve(body), + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +}; + +const NOW = 1_700_000_000_000; + +describe('fetchHyperliquidHistoricalPrices', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('maps candle close prices to [timestamp, price] tuples', async () => { + mockFetchResolving([ + makeCandle(1_000, '100.5'), + makeCandle(2_000, '101.25'), + ]); + + const result = await fetchHyperliquidHistoricalPrices({ + symbol: 'BTC', + interval: '1m', + limit: 60, + nowMs: NOW, + }); + + expect(result).toEqual([ + ['1000', 100.5], + ['2000', 101.25], + ]); + }); + + it('POSTs a candleSnapshot request with a window derived from limit × interval', async () => { + const fetchMock = mockFetchResolving([]); + + await fetchHyperliquidHistoricalPrices({ + symbol: 'ETH', + interval: '1h', + limit: 168, + nowMs: NOW, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe(HYPERLIQUID_INFO_URL); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ + type: 'candleSnapshot', + req: { + coin: 'ETH', + interval: '1h', + startTime: NOW - 168 * 60 * 60 * 1000, + endTime: NOW, + }, + }); + }); + + it('returns an empty array without fetching when the symbol is empty', async () => { + const fetchMock = mockFetchResolving([]); + + const result = await fetchHyperliquidHistoricalPrices({ + symbol: '', + interval: '1d', + limit: 365, + nowMs: NOW, + }); + + expect(result).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns an empty array when the response is not ok', async () => { + mockFetchResolving([makeCandle(1_000, '100')], { ok: false }); + + const result = await fetchHyperliquidHistoricalPrices({ + symbol: 'BTC', + interval: '1m', + limit: 60, + nowMs: NOW, + }); + + expect(result).toEqual([]); + }); + + it('returns an empty array when the payload is not an array', async () => { + mockFetchResolving({ error: 'rate limited' }); + + const result = await fetchHyperliquidHistoricalPrices({ + symbol: 'BTC', + interval: '1m', + limit: 60, + nowMs: NOW, + }); + + expect(result).toEqual([]); + }); + + it('drops candles whose close price is not a finite number', async () => { + mockFetchResolving([ + makeCandle(1_000, '100'), + makeCandle(2_000, 'not-a-number'), + makeCandle(3_000, '102'), + ]); + + const result = await fetchHyperliquidHistoricalPrices({ + symbol: 'BTC', + interval: '1m', + limit: 60, + nowMs: NOW, + }); + + expect(result).toEqual([ + ['1000', 100], + ['3000', 102], + ]); + }); + + it('logs and returns an empty array when the request throws', async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error('network down')) as unknown as typeof fetch; + + const result = await fetchHyperliquidHistoricalPrices({ + symbol: 'BTC', + interval: '1m', + limit: 60, + nowMs: NOW, + }); + + expect(result).toEqual([]); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + expect.stringContaining('BTC'), + ); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.ts b/app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.ts new file mode 100644 index 000000000000..5ccfa3282e4b --- /dev/null +++ b/app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.ts @@ -0,0 +1,100 @@ +import type { TokenPrice } from '../../../hooks/useTokenHistoricalPrices'; +import Logger from '../../../../util/Logger'; + +/** + * Hyperliquid's public REST "info" endpoint. Hyperliquid is perp-only and is + * deliberately kept out of the spot price API path (see chainMapping); its + * historical prices come straight from the exchange's candle-snapshot endpoint + * — the same data source the Perps feature ultimately uses — fetched once, with + * no WebSocket or connection setup. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candle-snapshot + */ +export const HYPERLIQUID_INFO_URL = 'https://api.hyperliquid.xyz/info'; + +/** Candle intervals we request to cover the chart's time periods. */ +export type HyperliquidCandleInterval = '1m' | '15m' | '1h' | '4h' | '1d'; + +/** Milliseconds per candle interval — used to derive the snapshot start time. */ +const INTERVAL_MS: Record = { + '1m': 60_000, + '15m': 15 * 60_000, + '1h': 60 * 60_000, + '4h': 4 * 60 * 60_000, + '1d': 24 * 60 * 60_000, +}; + +/** Single entry from the candleSnapshot response (only the fields we consume). */ +interface HyperliquidCandle { + /** Open time, ms since epoch. */ + t: number; + /** Close price as a decimal string. */ + c: string; +} + +export interface FetchHyperliquidPricesParams { + /** Perp coin symbol, e.g. "BTC" — the position's `tokenSymbol`. */ + symbol: string; + interval: HyperliquidCandleInterval; + /** Number of candles to request; also sets the look-back window. */ + limit: number; + /** + * Current epoch ms, injected so callers can share one clock across periods + * and tests stay deterministic. + */ + nowMs: number; +} + +/** + * Fetches historical prices for a Hyperliquid perp symbol via the public + * `candleSnapshot` REST endpoint and maps them to the chart's + * `[timestamp, price]` tuples using each candle's close price. + * + * One-shot request (no WebSocket). Returns an empty array on any error or empty + * response so the chart falls back to its no-data state rather than throwing. + * + * @param params - See {@link FetchHyperliquidPricesParams}. + * @returns Historical prices ordered oldest-to-newest, or `[]`. + */ +export async function fetchHyperliquidHistoricalPrices({ + symbol, + interval, + limit, + nowMs, +}: FetchHyperliquidPricesParams): Promise { + if (!symbol) { + return []; + } + + const startTime = nowMs - limit * INTERVAL_MS[interval]; + + try { + const response = await fetch(HYPERLIQUID_INFO_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'candleSnapshot', + req: { coin: symbol, interval, startTime, endTime: nowMs }, + }), + }); + + if (!response.ok) { + return []; + } + + const candles = (await response.json()) as HyperliquidCandle[] | null; + if (!Array.isArray(candles)) { + return []; + } + + return candles + .map((candle) => [String(candle.t), Number(candle.c)] as TokenPrice) + .filter((point) => Number.isFinite(point[1])); + } catch (error) { + Logger.error( + error as Error, + `fetchHyperliquidHistoricalPrices: failed to fetch ${symbol} ${interval} candles`, + ); + return []; + } +} diff --git a/app/components/Views/SocialLeaderboard/utils/perp.ts b/app/components/Views/SocialLeaderboard/utils/perp.ts index 3dc95963dd11..92d3fe726550 100644 --- a/app/components/Views/SocialLeaderboard/utils/perp.ts +++ b/app/components/Views/SocialLeaderboard/utils/perp.ts @@ -14,6 +14,15 @@ type PerpPositionFields = Pick< 'perpPositionType' | 'chain' | 'positionAmount' >; +type ClosedPositionFields = Pick< + Position, + | 'perpPositionType' + | 'chain' + | 'positionAmount' + | 'soldUsd' + | 'currentValueUSD' +>; + type PerpTradeFields = Pick< Trade, 'classification' | 'perpPositionType' | 'direction' @@ -32,6 +41,25 @@ export function isPerpPosition(position: PerpPositionFields): boolean { ); } +/** + * True when a position has been fully closed. + * + * Spot positions zero out their `positionAmount` once sold, so the presence of + * realized proceeds (`soldUsd`) marks them closed. Perps are different: a closed + * perp keeps its (non-zero) `positionAmount` in the historical record, so we + * instead key off the absence of remaining exposure — a closed perp reports no + * `currentValueUSD`. + * + * Callers that already know the position's open/closed list membership (e.g. + * the profile tab) should prefer that signal and use this only as a fallback. + */ +export function isClosedPosition(position: ClosedPositionFields): boolean { + if (isPerpPosition(position)) { + return (position.currentValueUSD ?? 0) === 0; + } + return position.positionAmount === 0 && position.soldUsd > 0; +} + /** * Resolves the side (long/short) of a perp position. Prefers the explicit * `perpPositionType`; for Hyperliquid positions that omit it, infers from the diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index ce2396f936c9..3d865d49f6ca 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -228,8 +228,7 @@ export default { 'https://signature-insights.api.cx.metamask.io/v1', DIGEST_API_URL: process.env.DIGEST_API_URL ?? 'https://digest.api.cx.metamask.io/api/v1', - SOCIAL_API_URL: - process.env.SOCIAL_API_URL ?? 'https://social.api.cx.metamask.io', + SOCIAL_API_URL: process.env.SOCIAL_API_URL ?? 'http://localhost:3000', // Rewards/Baanx: GH Actions use builds.yml (env set per build). Fallback mapping for local when env not set. REWARDS_API_URL: { DEV: 'https://rewards.dev-api.cx.metamask.io', diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 9afc08584838..212e4cb96bca 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -246,6 +246,9 @@ type TraderPositionViewParams = /** Analytics entry-point that opened the position view. Narrowed at the * receiver into the QuickBuy / FollowTradingToken source enums. */ source?: string; + /** Whether the tapped position came from the closed list. Authoritative + * closed/open signal (more reliable than re-deriving from fields). */ + isClosed?: boolean; } | { /** Deep-link path: triggers useTraderPosition to fetch by UUID. */ @@ -260,6 +263,8 @@ type TraderPositionViewParams = /** Analytics entry-point that opened the position view. Narrowed at the * receiver into the QuickBuy / FollowTradingToken source enums. */ source?: string; + /** Deep links have no list context; resolved heuristically downstream. */ + isClosed?: never; }; /** @@ -553,8 +558,11 @@ export interface RootStackParamList extends ParamListBase { | undefined; BridgeTransactionDetails: BridgeTransactionDetailsParams | undefined; - // Perps routes - use PerpsNavigationParamList for type-safe perps navigation - Perps: PerpsNavigationParamList['Perps']; + // Perps routes - use PerpsNavigationParamList for type-safe perps navigation. + // The `Perps` root is a nested stack navigator, so it also accepts the + // `{ screen, params }` form for cross-stack navigation (e.g. from the social + // leaderboard into PerpsMarketDetails). + Perps: NestedNavigationParams | PerpsNavigationParamList['Perps']; PerpsTradingView: PerpsNavigationParamList['PerpsTradingView']; PerpsWithdraw: PerpsNavigationParamList['PerpsWithdraw']; PerpsPositions: PerpsNavigationParamList['PerpsPositions']; diff --git a/locales/languages/en.json b/locales/languages/en.json index 93abdc9e254a..cb3e7450f4c8 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1089,6 +1089,7 @@ }, "trader_position": { "market_cap": "Market cap", + "price": "Price", "position": "Position", "trades": "Trades", "buy": "Buy", From 631389216ff274f256ef4a2abb0096b99b8bf5ad Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Mon, 15 Jun 2026 18:09:31 +0200 Subject: [PATCH 4/4] fix: default SOCIAL_API_URL to prod host instead of localhost SOCIAL_API_URL fell back to http://localhost:3000, which would point shipped builds at a local dev server. Default to the production social-api host (https://social.api.cx.metamask.io), matching the DIGEST/SECURITY_ALERTS pattern; set SOCIAL_API_URL locally to override. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/AppConstants.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index ecaa95671b91..d127df46c576 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -37,6 +37,9 @@ const PRICE_ALERTS_API_URL = process.env.PRICE_ALERTS_API_URL ?? 'https://price-alerts.dev-api.cx.metamask.io'; +const SOCIAL_API_URL = + process.env.SOCIAL_API_URL ?? 'https://social.api.cx.metamask.io'; + export default { IS_DEV: process.env?.NODE_ENV === DEVELOPMENT, METAMASK_BUILD_TYPE: process.env.METAMASK_BUILD_TYPE, @@ -239,7 +242,7 @@ export default { 'https://signature-insights.api.cx.metamask.io/v1', DIGEST_API_URL: process.env.DIGEST_API_URL ?? 'https://digest.api.cx.metamask.io/api/v1', - SOCIAL_API_URL: process.env.SOCIAL_API_URL ?? 'http://localhost:3000', + SOCIAL_API_URL, // Rewards/Baanx: GH Actions use builds.yml (env set per build). Fallback mapping for local when env not set. REWARDS_API_URL: { DEV: 'https://rewards.dev-api.cx.metamask.io',