diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx index 62c44877919..846ce6d3f2d 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 2ddf80f80f5..3509045c045 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 aa3901e0715..e028df2aa3c 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 18a8a5568eb..a5b33fe175a 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -373,6 +373,55 @@ describe('TraderPositionView', () => { ).toBeOnTheScreen(); }); + describe('perp positions', () => { + beforeEach(() => { + mockRouteParams.position = { + ...makeDefaultPosition(), + tokenSymbol: 'ETH', + chain: 'hyperliquid', + perpPositionType: 'short', + perpLeverage: 10, + }; + }); + + it('renders the Trade button instead of the Buy button', () => { + renderWithProvider(, { state: mockState }); + + expect( + screen.getByTestId(TraderPositionViewSelectorsIDs.TRADE_BUTTON), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId(TraderPositionViewSelectorsIDs.BUY_BUTTON), + ).not.toBeOnTheScreen(); + }); + + 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), + ); + + // 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', () => { + 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 f4e67a12065..1f4359edf18 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.testIds.ts @@ -3,6 +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', + 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 86459102772..be4b3d2930c 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -54,7 +54,9 @@ 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'; +import type { PerpsMarketData } from '@metamask/perps-controller'; const TraderPositionView = () => { const navigation = useNavigation>(); @@ -72,6 +74,7 @@ const TraderPositionView = () => { position: positionParam, positionId, source: sourceParam, + isClosed: isClosedParam, } = route.params; const { track } = useSocialLeaderboardAnalytics(); @@ -105,10 +108,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,6 +296,25 @@ const TraderPositionView = () => { // TODO: update displayed price on scrub. }, []); + // 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(() => { + 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); const hasFailed = @@ -328,6 +355,7 @@ const TraderPositionView = () => { symbol={symbol} position={resolvedPosition} marketCap={marketCap} + currentPrice={currentPrice} pricePercentChange={pricePercentChange} activeTimePeriodLabel={activeTimePeriod} onCopyTokenAddress={handleCopyTokenAddress} @@ -365,26 +393,44 @@ const TraderPositionView = () => { /> - - - - - + {isPerp ? ( + + + + ) : ( + <> + + + + + + + )} )} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx index beb00e1d173..cd692d746c5 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} + = ({ )} + {/* 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 1186688745a..3b9640134fb 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTokenInfoRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTokenInfoRow.tsx @@ -19,12 +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, 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; @@ -52,6 +60,7 @@ const TraderTokenIdentity: React.FC = ({ const canCopyTokenAddress = Boolean( position?.tokenAddress && onCopyTokenAddress, ); + const perpDirection = position ? getPerpPositionDirection(position) : null; const content = ( = ({ {symbol} + {perpDirection ? ( + + ) : null} {canCopyTokenAddress ? ( = ({ ); }; -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, @@ -172,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 2f4541be09f..59047f1222a 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 1b3cef22b69..16ddadf54c8 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 e2df8c65a08..6813a07a20d 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 091e9507710..16b696e97bd 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx @@ -286,4 +286,64 @@ describe('PositionRow', () => { expect(screen.getByText('-$300.00')).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(); + }); + + 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 fcd0eded79e..8732c870c9b 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, isPerpPosition } from '../../utils/perp'; import { formatUsd, formatSignedUsd, @@ -23,21 +25,39 @@ 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 PositionRow: React.FC = ({ + position, + onPress, + isClosed: isClosedProp, +}) => { + // Honor main's spot closed-detection; the explicit prop (from the profile's + // Open/Closed tab) overrides it, which perps rely on. + const isClosed = + isClosedProp ?? (position.positionAmount === 0 && position.soldUsd > 0); + 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; const hasPnlPercent = displayPnlPercent != null; - const pnlSignSource = isClosed - ? position.realizedPnl - : (position.pnlValueUsd ?? displayPnlPercent ?? null); + // Perps surface realized/unrealized PnL ($) as the headline figure rather + // than spot proceeds / current value. + const perpPnlValue = position.pnlValueUsd ?? position.realizedPnl ?? null; + const pnlSignSource = isPerp + ? perpPnlValue + : isClosed + ? position.realizedPnl + : (position.pnlValueUsd ?? displayPnlPercent ?? null); const isPnlZero = pnlSignSource === 0; const isPnlPositive = pnlSignSource != null && pnlSignSource > 0; const pnlColorClass = @@ -49,7 +69,18 @@ const PositionRow: React.FC = ({ position, onPress }) => { const testID = `position-row-${position.tokenSymbol}`; - const topRight = isClosed ? ( + const perpDirection = getPerpPositionDirection(position); + + const topRight = isPerp ? ( + + {perpPnlValue != null ? formatSignedUsd(perpPnlValue) : '—'} + + ) : isClosed ? ( = ({ position, onPress }) => { ); - const bottomRight = isClosed ? ( - - {!hasPnlPercent ? null : isPnlZero ? ( + const bottomRight = + !isPerp && isClosed ? ( + + {!hasPnlPercent ? null : isPnlZero ? ( + + {'−'} + + ) : ( + + {isPnlPositive ? '▲' : '▼'} + + )} - {'−'} + {formatPercent(displayPnlPercent).replace(/^[+-]/, '')} - ) : ( - - {isPnlPositive ? '▲' : '▼'} - - )} - - {formatPercent(displayPnlPercent).replace(/^[+-]/, '')} + + ) : !isPerp ? ( + + {position.pnlValueUsd != null + ? `${formatSignedUsd(position.pnlValueUsd)} (${formatPercent(displayPnlPercent)})` + : formatPercent(displayPnlPercent)} - - ) : ( - - {position.pnlValueUsd != null - ? `${formatSignedUsd(position.pnlValueUsd)} (${formatPercent(displayPnlPercent)})` - : formatPercent(displayPnlPercent)} - - ); + ) : ( + + {formatPercent(displayPnlPercent)} + + ); 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 00000000000..11be050199b --- /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 ab1659f6464..783317285ca 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 509f7666cc7..b3dd52d8e20 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 4f4541a9d79..0627f0ce2c7 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 c47ed434d48..5517cea31dc 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/hyperliquidPrices.test.ts b/app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.test.ts new file mode 100644 index 00000000000..ac08aa88176 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/utils/hyperliquidPrices.test.ts @@ -0,0 +1,165 @@ +import Logger from '../../../../util/Logger'; +import { + HYPERLIQUID_INFO_URL, + fetchHyperliquidHistoricalPrices, +} from './hyperliquidPrices'; + +jest.mock('../../../../util/Logger', () => ({ + 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 00000000000..5ccfa3282e4 --- /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.test.ts b/app/components/Views/SocialLeaderboard/utils/perp.test.ts new file mode 100644 index 00000000000..0d3b84e5f49 --- /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 00000000000..92d3fe72655 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/utils/perp.ts @@ -0,0 +1,103 @@ +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 ClosedPositionFields = Pick< + Position, + | 'perpPositionType' + | 'chain' + | 'positionAmount' + | 'soldUsd' + | 'currentValueUSD' +>; + +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 + ); +} + +/** + * 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 + * 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/app/core/AppConstants.ts b/app/core/AppConstants.ts index 46d5d4a09d0..d127df46c57 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,8 +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 ?? 'https://social.api.cx.metamask.io', + 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', diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 9542b4be5f7..ee00d042a94 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; }; /** @@ -554,8 +559,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 aab18351879..1590adc689a 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1093,9 +1093,11 @@ }, "trader_position": { "market_cap": "Market cap", + "price": "Price", "position": "Position", "trades": "Trades", "buy": "Buy", + "trade": "Trade", "bought": "{{name}} bought", "sold": "{{name}} sold", "no_trades": "No trades yet", @@ -1104,7 +1106,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 8e24af40a51..5cbddf96284 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 82f588eb557..259708d41e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9609,7 +9609,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.2.0": +"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.1.0, @metamask/profile-sync-controller@npm:^28.1.1, @metamask/profile-sync-controller@npm:^28.2.0": version: 28.2.0 resolution: "@metamask/profile-sync-controller@npm:28.2.0" dependencies: @@ -10223,17 +10223,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