From fb93909a6d09246c56f8c2d3d884717dc870b86f Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Thu, 11 Jun 2026 14:33:41 +0200 Subject: [PATCH 1/3] 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/3] 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 From 3934fc8b44b8d655b5c245bbca6bb8258a22fbc2 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Tue, 16 Jun 2026 11:04:55 +0200 Subject: [PATCH 3/3] docs: clarify positionAmountWithLeverage on social-controllers Position type positionAmountWithLeverage is the leveraged/notional size as reported by Clicker; it is not necessarily positionAmount x perpLeverage (the ratio varies for positions built across fills at different leverage) and it is notional exposure, not capital at risk (the margin is costBasis). Correct the JSDoc so consumers use the field directly and treat perpLeverage as the authoritative leverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/social-controllers/src/social-types.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/social-controllers/src/social-types.ts b/packages/social-controllers/src/social-types.ts index d2db8d1dab..f9e5a57158 100644 --- a/packages/social-controllers/src/social-types.ts +++ b/packages/social-controllers/src/social-types.ts @@ -167,8 +167,12 @@ export type Position = { /** 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. + * Leveraged/notional position size as reported by Clicker. NOT necessarily + * `positionAmount` × `perpLeverage` — the ratio varies for positions built + * across fills at different leverage, so use this field directly rather than + * deriving it, and treat `perpLeverage` as the authoritative leverage. This is + * notional exposure, not capital at risk (the margin/capital at risk is + * `costBasis`). Hyperliquid/perp positions only; absent for spot. */ positionAmountWithLeverage?: number | null; };