diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a8cfc9878c5..e1ad4ff194c 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `uuid` from `^8.3.2` to `^9.0.1` ([#10117](https://github.com/MetaMask/core/pull/10117)) +### Fixed + +- Normalize Lighter order timestamps from seconds to milliseconds for client date displays. ([#10187](https://github.com/MetaMask/core/pull/10187)) +- Accept omitted Lighter fill PnL only when the account's validated pre-trade position is zero; retain strict PnL validation for existing positions and malformed supplied values. ([#10187](https://github.com/MetaMask/core/pull/10187)) + ## [17.0.0] ### Changed diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index 0f3e19345cb..0eb02b7dfdb 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -669,9 +669,9 @@ export type LighterRestTrade = { bidAccountId: number; isMakerAsk: boolean; timestamp: number; - /** Realized pnl for the ask-side account, signed USDC. */ + /** Realized pnl for the ask-side account, signed USDC; may be omitted on opens from flat. */ askAccountPnl?: string; - /** Realized pnl for the bid-side account, signed USDC. */ + /** Realized pnl for the bid-side account, signed USDC; may be omitted on opens from flat. */ bidAccountPnl?: string; /** * Taker/maker fees, present when nonzero. The official model types them @@ -790,6 +790,7 @@ export type LighterApiOrder = { reduceOnly: number | boolean; status: string; orderExpiry: number; + /** Unix seconds from order endpoints, unlike millisecond trade timestamps. */ timestamp: number; /** * Trigger level for stop-loss/take-profit orders. Note `price` on a diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index bb704796e02..2d91c08044f 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -410,17 +410,6 @@ export function adaptFillFromLighterTrade( ); } const fee = '0'; - const pnl = accountIsAsk ? trade.askAccountPnl : trade.bidAccountPnl; - const parsedPnl = parseLighterStrictDecimal(pnl); - if ( - typeof pnl !== 'string' || - parsedPnl === null || - !Number.isFinite(parsedPnl) - ) { - throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} is missing valid account pnl`, - ); - } 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 @@ -442,6 +431,21 @@ export function adaptFillFromLighterTrade( `${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; + const parsedPnl = parseLighterStrictDecimal(pnl); + if ( + typeof pnl !== 'string' || + parsedPnl === null || + !Number.isFinite(parsedPnl) + ) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} is missing valid account pnl`, + ); + } const direction = deriveLighterFillDirection({ isBuy, size, @@ -719,7 +723,10 @@ export function adaptOrderFromLighter( filledSize: String(filled), remainingSize: order.remainingBaseAmount, status: adaptOrderStatus(order.status), - timestamp: order.timestamp, + // Order endpoints report seconds; preserve already-normalized millisecond + // inputs. Trade timestamps are a separate millisecond wire contract. + timestamp: + order.timestamp < 1e12 ? order.timestamp * 1000 : order.timestamp, reduceOnly: Boolean(order.reduceOnly), providerId: 'lighter', }; diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index 34d699d5348..a21a7994c5e 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -524,7 +524,7 @@ describe('lighterAdapter', () => { ); }); - it('rejects missing account pnl instead of manufacturing zero', () => { + it('rejects missing account pnl for an existing position', () => { expect(() => adaptFillFromLighterTrade( { ...REAL_TRADE, askAccountPnl: undefined }, @@ -534,6 +534,49 @@ describe('lighterAdapter', () => { ).toThrow('Invalid Lighter venue data'); }); + it.each([ + [true, false, 'Open Short'], + [true, true, 'Open Short'], + [false, false, 'Open Long'], + [false, true, 'Open Long'], + ])( + 'accepts omitted opening pnl for ask=%s maker=%s', + (isAsk, isMaker, direction) => { + // Testnet trade 20887 omits both PnL fields; the account starts flat. + const trade = { + ...REAL_TRADE, + askAccountPnl: undefined, + bidAccountPnl: undefined, + isMakerAsk: isAsk === isMaker, + makerPositionSizeBefore: isMaker ? '0.00000' : '1', + takerPositionSizeBefore: isMaker ? '1' : '0.00000', + }; + + const fill = adaptFillFromLighterTrade(trade, 'SOL', isAsk ? 28 : 7); + + expect(fill).toMatchObject({ pnl: '0', direction, startPosition: '0' }); + }, + ); + + it.each(['', 'invalid', '1e999', null])( + 'rejects supplied malformed opening pnl %s', + (pnl) => { + const trade = { + ...REAL_TRADE, + askAccountPnl: pnl, + takerPositionSizeBefore: '0', + }; + + expect(() => + adaptFillFromLighterTrade( + trade as unknown as Parameters[0], + 'SOL', + 28, + ), + ).toThrow('Invalid Lighter venue data'); + }, + ); + it('accepts omitted counterparty pnl while requiring the selected account pnl', () => { expect( adaptFillFromLighterTrade( @@ -627,6 +670,19 @@ describe('lighterAdapter', () => { timestamp: 1700000000000, }; + it.each([ + [1789088736, 1789088736000], + [1789088736000, 1789088736000], + [0, 0], + ])( + 'normalizes order timestamp %s to milliseconds', + (timestamp, expected) => { + const adapted = adaptOrderFromLighter({ ...order, timestamp }, 'BTC'); + + expect(adapted.timestamp).toBe(expected); + }, + ); + it('maps an open limit buy order', () => { const adapted = adaptOrderFromLighter(order, 'BTC'); expect(adapted).toMatchObject({