From 7610ba04ae30195caae847704f4fb163d0fbe489 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 14:27:27 +0800 Subject: [PATCH 1/8] test(perps): reproduce omitted Lighter fill context --- .../src/services/LighterClientService.test.ts | 47 +++++++++++++- .../tests/src/utils/lighterAdapter.test.ts | 65 +++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/tests/src/services/LighterClientService.test.ts b/packages/perps-controller/tests/src/services/LighterClientService.test.ts index 461d5af9f44..9a10d620dc1 100644 --- a/packages/perps-controller/tests/src/services/LighterClientService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterClientService.test.ts @@ -346,6 +346,49 @@ describe('LighterClientService', () => { }); describe('getTrades', () => { + it.each([ + { taker_position_sign_changed: undefined }, + { maker_position_sign_changed: undefined }, + { + taker_position_sign_changed: undefined, + maker_position_sign_changed: undefined, + ask_account_pnl: undefined, + bid_account_pnl: undefined, + }, + ])('accepts omitted optional trade fields %j', async (omitted) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + trades: [{ ...VALID_TRADE_WIRE, ...omitted }], + }), + ); + + const response = await buildService().getTrades(28, 'auth-token', { + limit: 50, + }); + + expect(response.trades).toHaveLength(1); + expect(response.trades[0].tradeId).toBe(VALID_TRADE_WIRE.trade_id); + }); + + it.each([null, 'false', 0])( + 'rejects malformed supplied maker sign flag %s', + async (flag) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + trades: [ + { ...VALID_TRADE_WIRE, maker_position_sign_changed: flag }, + ], + }), + ); + + await expect( + buildService().getTrades(28, 'auth-token', { limit: 50 }), + ).rejects.toThrow('Invalid Lighter venue data'); + }, + ); + it('encodes cursor, from, and market filters', async () => { fetchMock.mockResolvedValue( mockJsonResponse({ code: 200, next_cursor: 'next', trades: [] }), @@ -399,8 +442,8 @@ describe('LighterClientService', () => { ['negative position magnitude', { taker_position_size_before: '-0.1' }], ['missing maker role', { is_maker_ask: undefined }], [ - 'missing position sign context', - { taker_position_sign_changed: undefined }, + 'malformed position sign context', + { taker_position_sign_changed: 'false' }, ], ])('rejects %s in a trade payload', async (_label, override) => { fetchMock.mockResolvedValue( diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index c4cf5ce6b36..11eb8f1c737 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -342,6 +342,71 @@ describe('lighterAdapter', () => { makerPositionSignChanged: true, }; + describe('omitted venue fill context', () => { + it.each([true, false])( + 'normalizes omitted fields for maker=%s', + (isMaker) => { + for (const isAsk of [true, false]) { + for (const [before, size, pnl, expected] of [ + ['0', '1', undefined, isAsk ? 'Open Short' : 'Open Long'], + ['2', '1', undefined, isAsk ? 'Sell' : 'Buy'], + ['2', '1', '0', isAsk ? 'Sell' : 'Buy'], + ['2', '1', '-1', isAsk ? 'Close Long' : 'Close Short'], + ['2', '2', '1', isAsk ? 'Close Long' : 'Close Short'], + ['2', '3', '-1', isAsk ? 'Long > Short' : 'Short > Long'], + ]) { + const trade = { + ...REAL_TRADE, + isMakerAsk: isAsk === isMaker, + size: size as string, + makerPositionSizeBefore: isMaker ? (before as string) : '9', + takerPositionSizeBefore: isMaker ? '9' : (before as string), + makerPositionSignChanged: undefined, + takerPositionSignChanged: undefined, + askAccountPnl: isAsk ? pnl : '123', + bidAccountPnl: isAsk ? '123' : pnl, + }; + + const fill = adaptFillFromLighterTrade( + trade, + 'SOL', + isAsk ? 28 : 7, + ); + + expect(fill).toMatchObject({ + direction: expected, + pnl: pnl ?? '0', + }); + } + } + }, + ); + + it.each([null, 'false', 0])( + 'rejects malformed supplied sign flag %s', + (flag) => { + for (const isMaker of [true, false]) { + const trade = { + ...REAL_TRADE, + isMakerAsk: isMaker, + makerPositionSignChanged: flag, + takerPositionSignChanged: flag, + }; + + expect(() => + adaptFillFromLighterTrade( + trade as unknown as Parameters< + typeof adaptFillFromLighterTrade + >[0], + 'SOL', + 28, + ), + ).toThrow('Invalid Lighter venue data'); + } + }, + ); + }); + it('adapts the real venue payload: a taker sell of the full position is Close Long', () => { const fill = adaptFillFromLighterTrade(REAL_TRADE, 'SOL', 28); expect(fill).toMatchObject({ From 316a8b5dcde5fef8735e7c6e9332283538bf59e4 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 14:31:07 +0800 Subject: [PATCH 2/8] fix(perps): accept omitted Lighter fill context --- packages/perps-controller/CHANGELOG.md | 2 ++ .../src/services/LighterClientService.ts | 4 ++-- .../src/types/lighter-types.ts | 4 ++-- .../src/utils/lighterAdapter.ts | 23 +++++++++++-------- .../tests/src/utils/lighterAdapter.test.ts | 18 +++++++-------- 5 files changed, 29 insertions(+), 22 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0a966eca0aa..5dc016ca81f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -56,6 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Accept Lighter trades that omit position-sign flags or account PnL, retaining side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) + - HyperLiquid historical orders report `rejected` for the venue's specific rejection statuses (`tickRejected`, `perpMarginRejected`, `tooManyOpenOrdersRejected`, ...) and `canceled` for `outcomeSettledCanceled` and `internalCancel`, instead of `queued` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Only one HyperLiquid unified-account migration runs at a time when several callers wait on an attempt that ends without a cached result, such as a migration deferred at init for a hardware wallet ([#10591](https://github.com/MetaMask/core/pull/10591)) - HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) diff --git a/packages/perps-controller/src/services/LighterClientService.ts b/packages/perps-controller/src/services/LighterClientService.ts index 888061db332..fd0734a1143 100644 --- a/packages/perps-controller/src/services/LighterClientService.ts +++ b/packages/perps-controller/src/services/LighterClientService.ts @@ -275,8 +275,8 @@ const TradeStruct = type({ makerFee: optional(NonNegativeFinancialNumberStruct), takerPositionSizeBefore: NonNegativeDecimalStringStruct, makerPositionSizeBefore: NonNegativeDecimalStringStruct, - takerPositionSignChanged: boolean(), - makerPositionSignChanged: boolean(), + takerPositionSignChanged: optional(boolean()), + makerPositionSignChanged: optional(boolean()), }); const ResponseStructs = { diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index 1d4cf982cc9..b4c59174eca 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -672,8 +672,8 @@ export type LighterRestTrade = { takerPositionSizeBefore: string; makerPositionSizeBefore: string; /** Whether the side's position sign changed (crossed or left zero). */ - takerPositionSignChanged: boolean; - makerPositionSignChanged: boolean; + takerPositionSignChanged?: boolean; + makerPositionSignChanged?: boolean; }; /** diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index c139a4669ce..a8604faf3b2 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -288,13 +288,18 @@ export function deriveLighterFillDirection(context: { pnl: number; }): string { const { isBuy, size, positionBefore, signChanged, pnl } = context; - if (!Number.isFinite(positionBefore) || signChanged === undefined) { + if (!Number.isFinite(positionBefore)) { return isBuy ? 'Buy' : 'Sell'; } if (positionBefore === 0) { return isBuy ? 'Open Long' : 'Open Short'; } - if (signChanged) { + // A flat baseline proves an open. On an existing position, nonzero + // realized PnL proves reduction; otherwise an omitted flag is ambiguous. + if (signChanged === undefined && pnl === 0) { + return isBuy ? 'Buy' : 'Sell'; + } + if (signChanged || (signChanged === undefined && pnl !== 0)) { // Crossed past zero → flipped; landed exactly on zero → full close. if (size > positionBefore * 1.000001) { return isBuy ? 'Short > Long' : 'Long > Short'; @@ -412,8 +417,8 @@ export function adaptFillFromLighterTrade( const fee = '0'; const isBuy = !accountIsAsk; // The account's maker/taker role selects the matching pre-trade position - // context. Missing context is rejected above instead of becoming neutral - // history that looks authoritative. + // context. Omitted sign-change flags retain an unknown direction for + // existing positions; malformed supplied context still fails closed. const positionBeforeRaw = accountIsMaker ? trade.makerPositionSizeBefore : trade.takerPositionSizeBefore; @@ -425,17 +430,17 @@ export function adaptFillFromLighterTrade( positionBefore === null || !Number.isFinite(positionBefore) || positionBefore < 0 || - typeof signChanged !== 'boolean' + (signChanged !== undefined && typeof signChanged !== 'boolean') ) { throw new Error( `${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} is missing valid position context`, ); } const accountPnl = accountIsAsk ? trade.askAccountPnl : trade.bidAccountPnl; - // The venue omits PnL on opens from flat. Only a validated zero pre-trade - // position proves zero realized PnL; never assume it for an existing position. - const pnl = - accountPnl === undefined && positionBefore === 0 ? '0' : accountPnl; + // The trades API reports account PnL only when reducing a position; + // opens and adds omit it. Preserve malformed supplied values for validation. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Supplied null is malformed venue data and must not become zero. + const pnl = accountPnl === undefined ? '0' : accountPnl; const parsedPnl = parseLighterStrictDecimal(pnl); if ( typeof pnl !== 'string' || diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index 11eb8f1c737..ac434e8d5bb 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -354,13 +354,13 @@ describe('lighterAdapter', () => { ['2', '1', '-1', isAsk ? 'Close Long' : 'Close Short'], ['2', '2', '1', isAsk ? 'Close Long' : 'Close Short'], ['2', '3', '-1', isAsk ? 'Long > Short' : 'Short > Long'], - ]) { + ] as const) { const trade = { ...REAL_TRADE, isMakerAsk: isAsk === isMaker, - size: size as string, - makerPositionSizeBefore: isMaker ? (before as string) : '9', - takerPositionSizeBefore: isMaker ? '9' : (before as string), + size, + makerPositionSizeBefore: isMaker ? before : '9', + takerPositionSizeBefore: isMaker ? '9' : before, makerPositionSignChanged: undefined, takerPositionSignChanged: undefined, askAccountPnl: isAsk ? pnl : '123', @@ -614,14 +614,14 @@ describe('lighterAdapter', () => { ); }); - it('rejects missing account pnl for an existing position', () => { - expect(() => + it('normalizes missing account pnl for an existing position', () => { + expect( adaptFillFromLighterTrade( { ...REAL_TRADE, askAccountPnl: undefined }, 'SOL', 28, - ), - ).toThrow('Invalid Lighter venue data'); + ).pnl, + ).toBe('0'); }); it.each([ @@ -667,7 +667,7 @@ describe('lighterAdapter', () => { }, ); - it('accepts omitted counterparty pnl while requiring the selected account pnl', () => { + it('uses the selected participant pnl independently of the counterparty', () => { expect( adaptFillFromLighterTrade( { ...REAL_TRADE, bidAccountPnl: undefined }, From 00118d5e3866bb41df1c5d46c78f2f6f446fdd21 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 14:36:01 +0800 Subject: [PATCH 3/8] docs(perps): clarify optional Lighter trade fields --- packages/perps-controller/CHANGELOG.md | 1 - packages/perps-controller/src/types/lighter-types.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5dc016ca81f..9374b8b78b2 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -57,7 +57,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Accept Lighter trades that omit position-sign flags or account PnL, retaining side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) - - HyperLiquid historical orders report `rejected` for the venue's specific rejection statuses (`tickRejected`, `perpMarginRejected`, `tooManyOpenOrdersRejected`, ...) and `canceled` for `outcomeSettledCanceled` and `internalCancel`, instead of `queued` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Only one HyperLiquid unified-account migration runs at a time when several callers wait on an attempt that ends without a cached result, such as a migration deferred at init for a hardware wallet ([#10591](https://github.com/MetaMask/core/pull/10591)) - HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index b4c59174eca..c57f9bf3428 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -657,9 +657,9 @@ export type LighterRestTrade = { bidAccountId: number; isMakerAsk: boolean; timestamp: number; - /** Realized pnl for the ask-side account, signed USDC; may be omitted on opens from flat. */ + /** Realized pnl for the ask-side account, signed USDC; may be omitted on opens or adds. */ askAccountPnl?: string; - /** Realized pnl for the bid-side account, signed USDC; may be omitted on opens from flat. */ + /** Realized pnl for the bid-side account, signed USDC; may be omitted on opens or adds. */ bidAccountPnl?: string; /** * Taker/maker fees, present when nonzero. The official model types them @@ -671,7 +671,7 @@ export type LighterRestTrade = { /** Position size (absolute) of each side before the trade executed. */ takerPositionSizeBefore: string; makerPositionSizeBefore: string; - /** Whether the side's position sign changed (crossed or left zero). */ + /** Whether the side's position sign changed (crossed or left zero); omission is unknown. */ takerPositionSignChanged?: boolean; makerPositionSignChanged?: boolean; }; From 8e433e779483148d4376c4f51f19f930a2df8382 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 14:38:58 +0800 Subject: [PATCH 4/8] docs(perps): align websocket fill context notes --- packages/perps-controller/src/types/lighter-types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index c57f9bf3428..9f85def2997 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -618,7 +618,7 @@ export type LighterWsTrade = { bidAccountId: number; isMakerAsk: boolean; timestamp: number; - /** Realized pnl per side — same wire shape as the REST trade payload. */ + /** Realized pnl per side; may be omitted on opens or adds, as in REST trades. */ askAccountPnl?: string; bidAccountPnl?: string; /** Fees, present when nonzero; unit unproven — see LighterRestTrade. */ @@ -626,6 +626,7 @@ export type LighterWsTrade = { makerFee?: number | string; takerPositionSizeBefore?: string; makerPositionSizeBefore?: string; + /** Whether the side's position sign changed; omission is unknown. */ takerPositionSignChanged?: boolean; makerPositionSignChanged?: boolean; }; From 78c35e804f200d283a6947f65c07e50eedcb92d5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 16:03:49 +0800 Subject: [PATCH 5/8] fix(perps): preserve unknown Lighter fill pnl --- packages/perps-controller/CHANGELOG.md | 2 + packages/perps-controller/src/types/index.ts | 3 +- .../src/utils/lighterAdapter.ts | 31 ++++++----- .../tests/src/utils/lighterAdapter.test.ts | 53 +++++++++++++++---- 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 9374b8b78b2..f5c884ff2e9 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -42,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) + - **BREAKING:** `OrderFill.liquidation.liquidatedUser` is now optional, since HyperLiquid omits it on some liquidation fills ([#10591](https://github.com/MetaMask/core/pull/10591)) - **BREAKING:** The exported `FrontendOrder.orderType` union adds `Twap Slice`, `Vault Close` and `Spot Dust Conversion`, so exhaustive handling of it must cover these values; historical orders of these types report `orderType: 'market'` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Bump `@nktkas/hyperliquid` from `^0.33.1` to `^0.33.3` ([#10591](https://github.com/MetaMask/core/pull/10591)) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index c36124e89e7..38d1bed141c 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1234,7 +1234,8 @@ export type OrderFill = { side: string; // Normalized order side ('buy' or 'sell') size: string; // Fill size price: string; // Fill price - pnl: string; // PNL + /** Realized PnL reported by the venue; absent means unknown, never zero. */ + pnl?: string; direction: string; // Direction of the fill fee: string; // Fee paid feeToken: string; // Fee token symbol diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index a8604faf3b2..d9ff8a410f0 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -277,7 +277,7 @@ export function adaptAccountStateFromLighterUserStats( * @param context.size - Trade size (base units, absolute). * @param context.positionBefore - Our side's absolute position size before. * @param context.signChanged - Whether our side's position sign changed. - * @param context.pnl - Realized pnl for our side. + * @param context.pnl - Realized pnl for our side, or undefined when omitted. * @returns Client-facing direction string. */ export function deriveLighterFillDirection(context: { @@ -285,7 +285,7 @@ export function deriveLighterFillDirection(context: { size: number; positionBefore: number; signChanged: boolean | undefined; - pnl: number; + pnl: number | undefined; }): string { const { isBuy, size, positionBefore, signChanged, pnl } = context; if (!Number.isFinite(positionBefore)) { @@ -296,10 +296,13 @@ export function deriveLighterFillDirection(context: { } // A flat baseline proves an open. On an existing position, nonzero // realized PnL proves reduction; otherwise an omitted flag is ambiguous. - if (signChanged === undefined && pnl === 0) { + if (signChanged === undefined && (pnl === undefined || pnl === 0)) { return isBuy ? 'Buy' : 'Sell'; } - if (signChanged || (signChanged === undefined && pnl !== 0)) { + if ( + signChanged || + (signChanged === undefined && pnl !== undefined && pnl !== 0) + ) { // Crossed past zero → flipped; landed exactly on zero → full close. if (size > positionBefore * 1.000001) { return isBuy ? 'Short > Long' : 'Long > Short'; @@ -307,10 +310,10 @@ export function deriveLighterFillDirection(context: { return isBuy ? 'Close Short' : 'Close Long'; } // Partial fill on an existing position: realized pnl proves it reduced. - if (pnl !== 0) { + if (pnl !== undefined && pnl !== 0) { return isBuy ? 'Close Short' : 'Close Long'; } - // Zero-pnl partial with no sign change is genuinely ambiguous from this + // Zero or unknown pnl with no sign change is ambiguous from this // payload (a break-even partial close and an add both fit): fall back to // the side-only vocabulary instead of asserting Open without evidence. return isBuy ? 'Buy' : 'Sell'; @@ -436,16 +439,16 @@ export function adaptFillFromLighterTrade( `${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} is missing valid position context`, ); } - const accountPnl = accountIsAsk ? trade.askAccountPnl : trade.bidAccountPnl; - // The trades API reports account PnL only when reducing a position; - // opens and adds omit it. Preserve malformed supplied values for validation. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Supplied null is malformed venue data and must not become zero. - const pnl = accountPnl === undefined ? '0' : accountPnl; - const parsedPnl = parseLighterStrictDecimal(pnl); + const pnl = accountIsAsk ? trade.askAccountPnl : trade.bidAccountPnl; + // Omitted amounts remain unknown. A changed sign on an existing position + // proves reduction, so its realized PnL must be supplied by the venue. + const parsedPnl = + pnl === undefined ? undefined : parseLighterStrictDecimal(pnl); if ( - typeof pnl !== 'string' || parsedPnl === null || - !Number.isFinite(parsedPnl) + (pnl === undefined && positionBefore !== 0 && signChanged === true) || + (pnl !== undefined && + (typeof pnl !== 'string' || !Number.isFinite(parsedPnl))) ) { throw new Error( `${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} is missing valid account pnl`, diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index ac434e8d5bb..16cddfc327c 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -375,7 +375,7 @@ describe('lighterAdapter', () => { expect(fill).toMatchObject({ direction: expected, - pnl: pnl ?? '0', + pnl, }); } } @@ -614,14 +614,43 @@ describe('lighterAdapter', () => { ); }); - it('normalizes missing account pnl for an existing position', () => { - expect( - adaptFillFromLighterTrade( - { ...REAL_TRADE, askAccountPnl: undefined }, - 'SOL', - 28, - ).pnl, - ).toBe('0'); + it.each([true, false])( + 'rejects missing reduction pnl for maker=%s', + (isMaker) => { + for (const isAsk of [true, false]) { + for (const size of ['0.133', '0.2']) { + const trade = { + ...REAL_TRADE, + size, + isMakerAsk: isAsk === isMaker, + makerPositionSizeBefore: isMaker ? '0.133' : '0', + takerPositionSizeBefore: isMaker ? '0' : '0.133', + makerPositionSignChanged: isMaker, + takerPositionSignChanged: !isMaker, + askAccountPnl: isAsk ? undefined : '123', + bidAccountPnl: isAsk ? '123' : undefined, + }; + + expect(() => + adaptFillFromLighterTrade(trade, 'SOL', isAsk ? 28 : 7), + ).toThrow('is missing valid account pnl'); + } + } + }, + ); + + it('preserves unknown pnl with a supplied unchanged-sign flag', () => { + const fill = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + askAccountPnl: undefined, + takerPositionSignChanged: false, + }, + 'SOL', + 28, + ); + + expect(fill).toMatchObject({ pnl: undefined, direction: 'Sell' }); }); it.each([ @@ -644,7 +673,11 @@ describe('lighterAdapter', () => { const fill = adaptFillFromLighterTrade(trade, 'SOL', isAsk ? 28 : 7); - expect(fill).toMatchObject({ pnl: '0', direction, startPosition: '0' }); + expect(fill).toMatchObject({ + pnl: undefined, + direction, + startPosition: '0', + }); }, ); From aa69987f99f28917be543c068b60514e7028939a Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 16:09:58 +0800 Subject: [PATCH 6/8] fix(perps): omit unknown pnl field from fills --- packages/perps-controller/CHANGELOG.md | 3 +-- .../src/utils/lighterAdapter.ts | 2 +- .../tests/src/utils/lighterAdapter.test.ts | 19 +++++++++---------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f5c884ff2e9..e8a7d8399ef 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -43,7 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) - - **BREAKING:** `OrderFill.liquidation.liquidatedUser` is now optional, since HyperLiquid omits it on some liquidation fills ([#10591](https://github.com/MetaMask/core/pull/10591)) - **BREAKING:** The exported `FrontendOrder.orderType` union adds `Twap Slice`, `Vault Close` and `Spot Dust Conversion`, so exhaustive handling of it must cover these values; historical orders of these types report `orderType: 'market'` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Bump `@nktkas/hyperliquid` from `^0.33.1` to `^0.33.3` ([#10591](https://github.com/MetaMask/core/pull/10591)) @@ -58,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Accept Lighter trades that omit position-sign flags or account PnL, retaining side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) +- Accept Lighter trades that omit position-sign flags, preserve omitted account PnL as unknown, and reject known reductions without realized PnL. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) - HyperLiquid historical orders report `rejected` for the venue's specific rejection statuses (`tickRejected`, `perpMarginRejected`, `tooManyOpenOrdersRejected`, ...) and `canceled` for `outcomeSettledCanceled` and `internalCancel`, instead of `queued` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Only one HyperLiquid unified-account migration runs at a time when several callers wait on an attempt that ends without a cached result, such as a migration deferred at init for a hardware wallet ([#10591](https://github.com/MetaMask/core/pull/10591)) - HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index d9ff8a410f0..925757d8732 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -482,7 +482,7 @@ export function adaptFillFromLighterTrade( size: trade.size, price: trade.price, // The venue reports realized pnl per side of the trade. - pnl, + ...(pnl !== undefined ? { pnl } : {}), direction, ...(startPosition === undefined ? {} : { startPosition }), fee, diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index 16cddfc327c..0976fda9e08 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -373,10 +373,11 @@ describe('lighterAdapter', () => { isAsk ? 28 : 7, ); - expect(fill).toMatchObject({ - direction: expected, - pnl, - }); + expect(fill.direction).toBe(expected); + expect(fill.pnl).toBe(pnl); + if (pnl === undefined) { + expect(fill).not.toHaveProperty('pnl'); + } } } }, @@ -650,7 +651,8 @@ describe('lighterAdapter', () => { 28, ); - expect(fill).toMatchObject({ pnl: undefined, direction: 'Sell' }); + expect(fill.direction).toBe('Sell'); + expect(fill).not.toHaveProperty('pnl'); }); it.each([ @@ -673,11 +675,8 @@ describe('lighterAdapter', () => { const fill = adaptFillFromLighterTrade(trade, 'SOL', isAsk ? 28 : 7); - expect(fill).toMatchObject({ - pnl: undefined, - direction, - startPosition: '0', - }); + expect(fill).toMatchObject({ direction, startPosition: '0' }); + expect(fill).not.toHaveProperty('pnl'); }, ); From 3793b9c780428ecfc848d1f6b9a2717c6c417df7 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 16:23:51 +0800 Subject: [PATCH 7/8] fix(perps): comply with fill normalization lint rules --- packages/perps-controller/src/utils/lighterAdapter.ts | 2 +- .../perps-controller/tests/src/utils/lighterAdapter.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index 925757d8732..fa2475c1cb9 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -482,7 +482,7 @@ export function adaptFillFromLighterTrade( size: trade.size, price: trade.price, // The venue reports realized pnl per side of the trade. - ...(pnl !== undefined ? { pnl } : {}), + ...(pnl === undefined ? {} : { pnl }), direction, ...(startPosition === undefined ? {} : { startPosition }), fee, diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index 0976fda9e08..9fd837940e0 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -375,9 +375,7 @@ describe('lighterAdapter', () => { expect(fill.direction).toBe(expected); expect(fill.pnl).toBe(pnl); - if (pnl === undefined) { - expect(fill).not.toHaveProperty('pnl'); - } + expect(Object.hasOwn(fill, 'pnl')).toBe(pnl !== undefined); } } }, From d156a27a9cf778f8b553a1f820fe572666b07f9b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 16:25:07 +0800 Subject: [PATCH 8/8] docs(perps): retain fill changes in unreleased changelog --- packages/perps-controller/CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 46a52690026..81b930dd8c9 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) + +### Fixed + +- Accept Lighter trades that omit position-sign flags, preserve omitted account PnL as unknown, and reject known reductions without realized PnL. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) + ## [19.0.0] ### Added @@ -44,7 +52,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) - **BREAKING:** `OrderFill.liquidation.liquidatedUser` is now optional, since HyperLiquid omits it on some liquidation fills ([#10591](https://github.com/MetaMask/core/pull/10591)) - **BREAKING:** The exported `FrontendOrder.orderType` union adds `Twap Slice`, `Vault Close` and `Spot Dust Conversion`, so exhaustive handling of it must cover these values; historical orders of these types report `orderType: 'market'` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Bump `@nktkas/hyperliquid` from `^0.33.1` to `^0.33.3` ([#10591](https://github.com/MetaMask/core/pull/10591)) @@ -60,7 +67,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Accept Lighter trades that omit position-sign flags, preserve omitted account PnL as unknown, and reject known reductions without realized PnL. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) - HyperLiquid historical orders report `rejected` for the venue's specific rejection statuses (`tickRejected`, `perpMarginRejected`, `tooManyOpenOrdersRejected`, ...) and `canceled` for `outcomeSettledCanceled` and `internalCancel`, instead of `queued` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Only one HyperLiquid unified-account migration runs at a time when several callers wait on an attempt that ends without a cached result, such as a migration deferred at init for a hardware wallet ([#10591](https://github.com/MetaMask/core/pull/10591)) - HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559))