Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/perps-controller/src/types/lighter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 19 additions & 12 deletions packages/perps-controller/src/utils/lighterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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',
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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<typeof adaptFillFromLighterTrade>[0],
'SOL',
28,
),
).toThrow('Invalid Lighter venue data');
},
);

it('accepts omitted counterparty pnl while requiring the selected account pnl', () => {
expect(
adaptFillFromLighterTrade(
Expand Down Expand Up @@ -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({
Expand Down