From fb93909a6d09246c56f8c2d3d884717dc870b86f Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Thu, 11 Jun 2026 14:33:41 +0200 Subject: [PATCH 1/2] feat: expose perp metadata on social-controllers Position and Trade types Adds optional Hyperliquid/perp fields to the `SocialService` response types and their superstruct validation schemas so consumers (mobile) get typed, validated access to the perp metadata that social-api now returns: - `Trade`: `classification`, `perpPositionType`, `perpLeverage` - `Position`: `perpPositionType`, `perpLeverage`, `positionAmountWithLeverage` All fields are optional and nullable, so spot responses remain backward compatible. Part of the Hyperliquid perps leaderboard/positions work (TSA-629). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/social-controllers/CHANGELOG.md | 5 + .../src/SocialService.test.ts | 111 ++++++++++++++++++ .../social-controllers/src/SocialService.ts | 4 + .../social-controllers/src/social-types.ts | 18 +++ 4 files changed, 138 insertions(+) diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index fdd98dc448..3b86109bfb 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional perp fields to the `Trade` type (and `TradeStruct`): `classification` (`'spot' | 'perp' | 'send' | 'receive' | null`), `perpPositionType` (`'long' | 'short' | null`), and `perpLeverage` (`number | null`) — exposing Hyperliquid/perp trade metadata to consumers ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add optional perp fields to the `Position` type (and `PositionStruct`): `perpPositionType` (`'long' | 'short' | null`), `perpLeverage` (`number | null`), and `positionAmountWithLeverage` (`number | null`) — exposing Hyperliquid/perp position metadata to consumers ([#0000](https://github.com/MetaMask/core/pull/0000)) + ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.2.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) diff --git a/packages/social-controllers/src/SocialService.test.ts b/packages/social-controllers/src/SocialService.test.ts index 43defcc5fc..4ddf666f2e 100644 --- a/packages/social-controllers/src/SocialService.test.ts +++ b/packages/social-controllers/src/SocialService.test.ts @@ -53,6 +53,37 @@ const mockPosition = { tokenImageUrl: 'https://assets.daylight.xyz/images/token-eth.png', }; +const mockPerpTrade = { + direction: 'buy', + intent: 'enter', + classification: 'perp', + perpPositionType: 'long', + perpLeverage: 10, + tokenAmount: 1.5, + usdCost: 3000, + timestamp: 1700000000, + transactionHash: + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', +}; + +const mockPerpPosition = { + positionId: 'position-perp-1', + tokenSymbol: 'BTC', + tokenName: 'Bitcoin', + tokenAddress: 'BTC', + chain: 'hyperliquid', + positionAmount: 2.5, + boughtUsd: 112500, + soldUsd: 0, + realizedPnl: 0, + costBasis: 112500, + trades: [mockPerpTrade], + lastTradeAt: 1700000000, + perpPositionType: 'long', + perpLeverage: 10, + positionAmountWithLeverage: 25, +}; + const MOCK_TOKEN = 'mock-bearer-token'; type RootMessenger = Messenger< @@ -497,6 +528,86 @@ describe('SocialService', () => { SocialServiceErrorMessage.FETCH_OPEN_POSITIONS_INVALID_RESPONSE, ); }); + + it('passes through perp metadata on positions and trades', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + positions: [mockPerpPosition], + pagination: { hasMore: false }, + }), + }); + + const service = createService(); + const result = await service.fetchOpenPositions({ + addressOrId: '0x1234', + }); + + expect(result.positions[0]).toStrictEqual(mockPerpPosition); + expect(result.positions[0].perpPositionType).toBe('long'); + expect(result.positions[0].perpLeverage).toBe(10); + expect(result.positions[0].positionAmountWithLeverage).toBe(25); + expect(result.positions[0].trades[0].classification).toBe('perp'); + expect(result.positions[0].trades[0].perpPositionType).toBe('long'); + expect(result.positions[0].trades[0].perpLeverage).toBe(10); + }); + + it('accepts null perp fields for spot positions', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + positions: [ + { + ...mockPosition, + perpPositionType: null, + perpLeverage: null, + positionAmountWithLeverage: null, + trades: [ + { + ...mockTrade, + classification: null, + perpPositionType: null, + perpLeverage: null, + }, + ], + }, + ], + pagination: { hasMore: false }, + }), + }); + + const service = createService(); + const result = await service.fetchOpenPositions({ + addressOrId: '0x1234', + }); + + expect(result.positions[0].perpPositionType).toBeNull(); + expect(result.positions[0].trades[0].classification).toBeNull(); + }); + + it('rejects an invalid perpPositionType', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + positions: [{ ...mockPerpPosition, perpPositionType: 'sideways' }], + pagination: { hasMore: false }, + }), + }); + + const service = createService(); + + await expect( + service.fetchOpenPositions({ addressOrId: '0x1234' }), + ).rejects.toThrow( + SocialServiceErrorMessage.FETCH_OPEN_POSITIONS_INVALID_RESPONSE, + ); + }); }); describe('fetchClosedPositions', () => { diff --git a/packages/social-controllers/src/SocialService.ts b/packages/social-controllers/src/SocialService.ts index 2442c37ecf..3401fe7bf1 100644 --- a/packages/social-controllers/src/SocialService.ts +++ b/packages/social-controllers/src/SocialService.ts @@ -11,6 +11,7 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller import { array, boolean, + enums, is, nullable, number, @@ -76,6 +77,9 @@ const PositionStruct = structType({ currentValueUSD: optional(nullable(number())), pnlValueUsd: optional(nullable(number())), pnlPercent: optional(nullable(number())), + perpPositionType: optional(nullable(enums(['long', 'short']))), + perpLeverage: optional(nullable(number())), + positionAmountWithLeverage: optional(nullable(number())), }); const PaginationStruct = structType({ diff --git a/packages/social-controllers/src/social-types.ts b/packages/social-controllers/src/social-types.ts index c7de2384fb..d2db8d1dab 100644 --- a/packages/social-controllers/src/social-types.ts +++ b/packages/social-controllers/src/social-types.ts @@ -1,6 +1,7 @@ import type { Infer } from '@metamask/superstruct'; import { enums, + nullable, number, optional, string, @@ -39,6 +40,14 @@ export const TradeStruct = structType({ direction: enums(['buy', 'sell']), intent: enums(['enter', 'exit']), category: optional(string()), + /** High-level trade classification. `null` when Clicker does not classify. */ + classification: optional( + nullable(enums(['spot', 'perp', 'send', 'receive'])), + ), + /** Perp side for this fill. `null` for spot trades. */ + perpPositionType: optional(nullable(enums(['long', 'short']))), + /** Leverage multiplier for perp trades (e.g. `5` for 5x). `null` for spot. */ + perpLeverage: optional(nullable(number())), tokenAmount: number(), usdCost: number(), timestamp: number(), @@ -153,6 +162,15 @@ export type Position = { pnlValueUsd?: number | null; /** PnL as a percentage of cost basis. */ pnlPercent?: number | null; + /** Perp side of the position. `null`/absent for spot positions. */ + perpPositionType?: 'long' | 'short' | null; + /** Leverage multiplier for perp positions. `null`/absent for spot. */ + perpLeverage?: number | null; + /** + * Leveraged position size (un-leveraged `positionAmount` × leverage), i.e. + * the capital at risk. Hyperliquid/perp positions only; absent for spot. + */ + positionAmountWithLeverage?: number | null; }; export type Pagination = { From a3e0ef7eb7ce5f6fb401bad29bd75c463290d014 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Thu, 11 Jun 2026 14:35:51 +0200 Subject: [PATCH 2/2] docs: link changelog entries to PR #9094 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/social-controllers/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index 3b86109bfb..557fa88110 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional perp fields to the `Trade` type (and `TradeStruct`): `classification` (`'spot' | 'perp' | 'send' | 'receive' | null`), `perpPositionType` (`'long' | 'short' | null`), and `perpLeverage` (`number | null`) — exposing Hyperliquid/perp trade metadata to consumers ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add optional perp fields to the `Position` type (and `PositionStruct`): `perpPositionType` (`'long' | 'short' | null`), `perpLeverage` (`number | null`), and `positionAmountWithLeverage` (`number | null`) — exposing Hyperliquid/perp position metadata to consumers ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add optional perp fields to the `Trade` type (and `TradeStruct`): `classification` (`'spot' | 'perp' | 'send' | 'receive' | null`), `perpPositionType` (`'long' | 'short' | null`), and `perpLeverage` (`number | null`) — exposing Hyperliquid/perp trade metadata to consumers ([#9094](https://github.com/MetaMask/core/pull/9094)) +- Add optional perp fields to the `Position` type (and `PositionStruct`): `perpPositionType` (`'long' | 'short' | null`), `perpLeverage` (`number | null`), and `positionAmountWithLeverage` (`number | null`) — exposing Hyperliquid/perp position metadata to consumers ([#9094](https://github.com/MetaMask/core/pull/9094)) ### Changed