Skip to content
Merged
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
69 changes: 53 additions & 16 deletions packages/perps-controller/src/providers/LighterProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,29 @@ const NOOP_UNSUBSCRIBE = (): void => undefined;

const LIGHTER_INACTIVE_HISTORY_ROW_LIMIT = 10_000;

const deriveLighterMaxLeverage = (
minInitialMarginFraction: number | undefined,
market: string | number,
): number => {
if (
typeof minInitialMarginFraction !== 'number' ||
!Number.isSafeInteger(minInitialMarginFraction) ||
minInitialMarginFraction < 1 ||
minInitialMarginFraction > 10_000
) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} invalid initial margin fraction for market ${String(market)}`,
);
}
const maxLeverage = Math.floor(10_000 / minInitialMarginFraction);
if (!Number.isSafeInteger(maxLeverage) || maxLeverage < 1) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} invalid max leverage for market ${String(market)}`,
);
}
return maxLeverage;
};

const adaptLighterTransferDelta = (
entry: LighterTransferHistoryItem,
): RawLedgerUpdate['delta'] => {
Expand Down Expand Up @@ -4681,19 +4704,14 @@ export class LighterProvider implements PerpsProvider {
.filter((market) => market.marketType === 'perp')
.map((market) => {
const margins = this.#marginBySymbol.get(market.symbol);
if (
!margins ||
!Number.isFinite(margins.minInitial) ||
!margins.minInitial ||
margins.minInitial <= 0
) {
if (!margins) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} missing authoritative leverage for ${market.symbol}`,
);
}
const adapted = adaptMarketFromLighter(
market,
Math.floor(10_000 / margins.minInitial),
deriveLighterMaxLeverage(margins.minInitial, market.symbol),
);
// minBaseAmount and minQuoteAmount are maker-only. Market and
// IOC orders can use one base-size tick, so the public universal
Expand Down Expand Up @@ -7309,13 +7327,12 @@ export class LighterProvider implements PerpsProvider {
[...this.#marginBySymbol.values()].find(
(entry) => entry.marketId === marketId,
);
const minInitial = metadata?.minInitial;
if (!minInitial || !(minInitial > 0)) {
if (!metadata) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} margin metadata unavailable for market ${marketId}`,
);
}
return Math.floor(10_000 / minInitial);
return deriveLighterMaxLeverage(metadata.minInitial, marketId);
};

/**
Expand All @@ -7334,11 +7351,15 @@ export class LighterProvider implements PerpsProvider {
} catch {
return null;
}
const minInitial = this.#marginBySymbol.get(symbol)?.minInitial;
if (typeof minInitial !== 'number' || !(minInitial > 0)) {
const metadata = this.#marginBySymbol.get(symbol);
if (!metadata) {
return null;
}
try {
return deriveLighterMaxLeverage(metadata.minInitial, symbol);
} catch {
return null;
}
return Math.floor(10_000 / minInitial);
};

/** When the margin-metadata cache was last refreshed (0 = never). */
Expand Down Expand Up @@ -7375,6 +7396,22 @@ export class LighterProvider implements PerpsProvider {
// The timestamp only advances on success.
const fresh = new Map<string, LighterMarginMetadata>();
for (const detail of details.orderBookDetails) {
if (detail.minInitialMarginFraction !== undefined) {
deriveLighterMaxLeverage(
detail.minInitialMarginFraction,
detail.symbol,
);
}
if (
detail.maintenanceMarginFraction !== undefined &&
(!Number.isSafeInteger(detail.maintenanceMarginFraction) ||
detail.maintenanceMarginFraction < 1 ||
detail.maintenanceMarginFraction > 10_000)
) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} invalid maintenance margin fraction for market ${detail.symbol}`,
);
}
fresh.set(detail.symbol, {
marketId: detail.marketId,
minInitial: detail.minInitialMarginFraction,
Expand All @@ -7400,9 +7437,9 @@ export class LighterProvider implements PerpsProvider {
// (hundredths of a percent): 400 → 25x. Missing metadata is unavailable,
// never evidence that the global maximum applies to this market.
await this.#ensureMarketMargins();
const minInitial = this.#marginBySymbol.get(asset)?.minInitial;
if (minInitial && minInitial > 0) {
return Math.floor(10_000 / minInitial);
const metadata = this.#marginBySymbol.get(asset);
if (metadata) {
return deriveLighterMaxLeverage(metadata.minInitial, asset);
}
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} margin metadata unavailable for ${asset}`,
Expand Down
16 changes: 10 additions & 6 deletions packages/perps-controller/src/services/LighterClientService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,13 @@ const NonNegativeFiniteNumberStruct = define<number>(
'non-negative finite number',
(value) => typeof value === 'number' && Number.isFinite(value) && value >= 0,
);
const PositiveFiniteNumberStruct = define<number>(
'positive finite number',
(value) => typeof value === 'number' && Number.isFinite(value) && value > 0,
const MarginFractionStruct = define<number>(
'margin fraction in (0, 10000]',
(value) =>
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 1 &&
value <= 10_000,
);
const NonNegativeFinancialNumberStruct = union([
NonNegativeFiniteNumberStruct,
Expand Down Expand Up @@ -168,9 +172,9 @@ const MarketStruct = type({
const MarketDetailStruct = type({
...MarketStruct.schema,
lastTradePrice: NonNegativeFiniteNumberStruct,
defaultInitialMarginFraction: optional(PositiveFiniteNumberStruct),
minInitialMarginFraction: optional(PositiveFiniteNumberStruct),
maintenanceMarginFraction: optional(PositiveFiniteNumberStruct),
defaultInitialMarginFraction: optional(MarginFractionStruct),
minInitialMarginFraction: optional(MarginFractionStruct),
maintenanceMarginFraction: optional(MarginFractionStruct),
dailyTradesCount: NonNegativeFiniteNumberStruct,
dailyBaseTokenVolume: NonNegativeFiniteNumberStruct,
dailyQuoteTokenVolume: NonNegativeFiniteNumberStruct,
Expand Down
72 changes: 53 additions & 19 deletions packages/perps-controller/src/utils/lighterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,58 @@ export function deriveLighterFillDirection(context: {
return isBuy ? 'Buy' : 'Sell';
}

/**
* Parse one optional venue fee without treating malformed supplied values as
* an omitted zero.
*
* @param value - Raw maker or taker fee.
* @param tradeId - Trade identity for error context.
* @param role - Fee role for error context.
* @returns The finite nonnegative fee, with undefined mapped to venue-zero.
*/
function parseLighterTradeFee(
value: unknown,
tradeId: number,
role: 'maker' | 'taker',
): number {
let parsed: number | null;
if (value === undefined) {
parsed = 0;
} else if (typeof value === 'number') {
parsed = value;
} else if (typeof value === 'string') {
parsed = parseLighterStrictDecimal(value);
} else {
parsed = null;
}
if (parsed === null || !Number.isFinite(parsed) || parsed < 0) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${tradeId} has invalid ${role} fee`,
);
}
return parsed;
}

export function adaptFillFromLighterTrade(
trade: LighterRestTrade | LighterWsTrade,
symbol: string,
accountIndex: number,
): OrderFill {
const identifiers = [
['trade id', trade.tradeId],
['market id', trade.marketId],
['ask id', trade.askId],
['bid id', trade.bidId],
['ask account id', trade.askAccountId],
['bid account id', trade.bidAccountId],
] as const;
for (const [field, value] of identifiers) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${String(trade.tradeId)} has invalid ${field}`,
);
}
}
const accountIsAsk = trade.askAccountId === accountIndex;
const accountIsBid = trade.bidAccountId === accountIndex;
if (accountIsAsk === accountIsBid) {
Expand All @@ -341,7 +388,7 @@ export function adaptFillFromLighterTrade(
`${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} has invalid timestamp`,
);
}
if (trade.isMakerAsk === undefined) {
if (typeof trade.isMakerAsk !== 'boolean') {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} is missing maker-role context`,
);
Expand All @@ -353,24 +400,11 @@ export function adaptFillFromLighterTrade(
// Premium at account resolution) pay zero fees, so zero is venue truth.
// A PRESENT nonzero fee ON OUR SIDE contradicts that gate and its wire
// unit is unverified: refusing loudly beats silently coercing a real fee
// to $0. The counterparty's fee is irrelevant — a Standard user trading
// against a Premium account must keep their valid fill.
const ourFee = accountIsMaker ? trade.makerFee : trade.takerFee;
let ourFeeNumeric: number | null = 0;
if (typeof ourFee === 'number') {
ourFeeNumeric = ourFee;
} else if (typeof ourFee === 'string') {
ourFeeNumeric = parseLighterStrictDecimal(ourFee);
}
if (
ourFeeNumeric === null ||
!Number.isFinite(ourFeeNumeric) ||
ourFeeNumeric < 0
) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} trade ${trade.tradeId} has invalid account fee`,
);
}
// to $0. A valid nonzero counterparty fee is allowed, but malformed
// supplied counterparty data still invalidates the venue row.
const makerFee = parseLighterTradeFee(trade.makerFee, trade.tradeId, 'maker');
const takerFee = parseLighterTradeFee(trade.takerFee, trade.tradeId, 'taker');
const ourFeeNumeric = accountIsMaker ? makerFee : takerFee;
if (ourFeeNumeric !== 0) {
throw new Error(
`${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} nonzero fee in trade ${trade.tradeId}: fee unit is unverified`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,51 @@ describe('LighterProvider', () => {
).rejects.toThrow('Invalid Lighter venue data');
});

it('rejects margin fractions that would derive leverage below 1x', async () => {
const built = buildProvider();
built.clientInstance.getOrderBookDetails.mockResolvedValue({
code: 200,
orderBookDetails: [
{
...BTC_MARKET,
lastTradePrice: 100000,
minInitialMarginFraction: 10001,
maintenanceMarginFraction: 10001,
},
],
});
await expect(built.provider.getMarkets()).rejects.toThrow(
'Invalid Lighter venue data',
);
await expect(built.provider.getPositions()).rejects.toThrow(
'Invalid Lighter venue data',
);
});

it.each([Number.MIN_VALUE, 1.5])(
'rejects non-integer margin fraction %p through market and position reads',
async (marginFraction) => {
const built = buildProvider();
built.clientInstance.getOrderBookDetails.mockResolvedValue({
code: 200,
orderBookDetails: [
{
...BTC_MARKET,
lastTradePrice: 100000,
minInitialMarginFraction: marginFraction,
maintenanceMarginFraction: marginFraction,
},
],
});
await expect(built.provider.getMarkets()).rejects.toThrow(
'Invalid Lighter venue data',
);
await expect(built.provider.getPositions()).rejects.toThrow(
'Invalid Lighter venue data',
);
},
);

it('surfaces malformed successful order-book details instead of reporting no market data', async () => {
const { provider, clientInstance } = buildProvider();
clientInstance.getOrderBookDetails.mockRejectedValue(
Expand Down Expand Up @@ -4162,6 +4207,34 @@ describe('LighterProvider', () => {
expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(2);
});

it('fails closed when inactive history advances without returning rows', async () => {
const built = buildProvider();
const venue = setupTriggerVenue(built.clientInstance, built.bridge);
venue.setCreateTerminal('filled');
venue.failResponseOnce(14);
expect(
(
await built.provider.updatePositionTPSL({
symbol: 'BTC',
stopLossPrice: '85000',
})
).success,
).toBe(false);
venue.setCreateTerminal('none');
built.clientInstance.getInactiveOrders.mockResolvedValue({
code: 200,
orders: [],
nextCursor: 'advanced',
});

const retry = await built.provider.updatePositionTPSL({
symbol: 'BTC',
stopLossPrice: '86000',
});
expect(retry.success).toBe(false);
expect(retry.error).toContain('advanced without returning rows');
});

it("exact status matching: 'unfilled' and 'execution-failed' are failures, never executions", async () => {
for (const trickStatus of ['unfilled', 'execution-failed']) {
const { provider, clientInstance, bridge } = buildProvider();
Expand Down Expand Up @@ -9781,6 +9854,13 @@ describe('LighterProvider', () => {
it.each([
['non-finite size', { size: '1e999' }],
['nonparticipant account', { ask_account_id: 7, bid_account_id: 8 }],
['wrong maker role type', { is_maker_ask: 1 }],
['null fee', { taker_fee: null }],
['null counterparty fee', { maker_fee: null }],
['unsafe trade id', { trade_id: Number.MAX_SAFE_INTEGER + 1 }],
['negative market id', { market_id: -1 }],
['negative ask id', { ask_id: -1 }],
['unsafe bid id', { bid_id: Number.MAX_SAFE_INTEGER + 1 }],
])('drops a WebSocket fill with %s', async (_scenario, overrides) => {
const { provider, clientInstance } = buildProvider({
webSocketCtor: fakeStreamCtor,
Expand Down
Loading