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
4 changes: 4 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Handle zero minimum order amounts and margin fractions reported by Lighter for inactive markets by omitting unusable retired rows, while keeping valid delisted metadata and active market values strict. ([#10110](https://github.com/MetaMask/core/pull/10110))

## [16.1.0]

### Fixed
Expand Down
61 changes: 50 additions & 11 deletions packages/perps-controller/src/providers/LighterProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import type {
} from '../types/index.js';
import type {
LighterApiOrder,
LighterApiPosition,
LighterAuthConfig,
LighterTxLookupResponse,
LighterTransferHistoryItem,
Expand Down Expand Up @@ -204,6 +205,18 @@ const deriveLighterMaxLeverage = (
return maxLeverage;
};

const isInactiveMarketWithoutUsableRiskMetadata = (market: {
status: string;
minInitialMarginFraction?: number;
maintenanceMarginFraction?: number;
}): boolean =>
// Lighter retains inactive rows for historical identity but can zero their
// trading constraints. They are valid venue records, not usable markets.
market.status === 'inactive' &&
(market.minInitialMarginFraction === undefined ||
market.minInitialMarginFraction === 0 ||
market.maintenanceMarginFraction === 0);

const adaptLighterTransferDelta = (
entry: LighterTransferHistoryItem,
): RawLedgerUpdate['delta'] => {
Expand Down Expand Up @@ -4702,9 +4715,14 @@ export class LighterProvider implements PerpsProvider {
await this.#ensureMarketMargins();
return markets
.filter((market) => market.marketType === 'perp')
.map((market) => {
.flatMap((market) => {
const margins = this.#marginBySymbol.get(market.symbol);
if (!margins) {
// #ensureMarketMargins deliberately omits inactive rows whose
// retired risk metadata cannot produce a canonical MarketInfo.
if (market.status === 'inactive') {
return [];
}
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} missing authoritative leverage for ${market.symbol}`,
);
Expand All @@ -4724,7 +4742,7 @@ export class LighterProvider implements PerpsProvider {
adapted.minimumOrderSize = oneTickUsd;
}
}
return adapted;
return [adapted];
});
} catch (caughtError) {
const wrappedError = ensureError(
Expand All @@ -4750,6 +4768,7 @@ export class LighterProvider implements PerpsProvider {
const response = await this.#clientService.getOrderBookDetails();
return response.orderBookDetails
.filter((detail) => detail.marketType === 'perp')
.filter((detail) => !isInactiveMarketWithoutUsableRiskMetadata(detail))
.map((detail) =>
adaptMarketDataFromLighter(detail, this.#deps.marketDataFormatters),
);
Expand Down Expand Up @@ -4802,7 +4821,7 @@ export class LighterProvider implements PerpsProvider {
.map((position) =>
adaptPositionFromLighter(
position,
this.#maxLeverageForMarketId(position.marketId),
this.#maxLeverageForPosition(position),
),
)
.filter((position) => parseFloat(position.size) !== 0);
Expand Down Expand Up @@ -7316,23 +7335,40 @@ export class LighterProvider implements PerpsProvider {
/**
* Synchronous per-market max leverage from the authoritative margin cache.
*
* @param marketId - Numeric Lighter market id.
* Inactive markets cannot increase exposure. When Lighter has retired their
* risk metadata, the position's current leverage is therefore the highest
* leverage that can be reported without inventing a tradable venue limit.
*
* @param position - Position carrying the market id and current margin.
* @returns Max leverage for the market.
* @throws If the market identity or margin metadata is unavailable.
*/
readonly #maxLeverageForMarketId = (marketId: number): number => {
readonly #maxLeverageForPosition = (
position: Pick<LighterApiPosition, 'marketId' | 'initialMarginFraction'>,
): number => {
const { marketId } = position;
const symbol = this.#marketsById.get(marketId)?.symbol;
const metadata =
(symbol ? this.#marginBySymbol.get(symbol) : undefined) ??
[...this.#marginBySymbol.values()].find(
(entry) => entry.marketId === marketId,
);
if (!metadata) {
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} margin metadata unavailable for market ${marketId}`,
);
if (metadata) {
return deriveLighterMaxLeverage(metadata.minInitial, marketId);
}
return deriveLighterMaxLeverage(metadata.minInitial, marketId);
if (this.#marketsById.get(marketId)?.status === 'inactive') {
const marginFraction = parseStrictDecimal(position.initialMarginFraction);
const currentLeverage =
marginFraction !== null && marginFraction > 0
? Math.round(100 / marginFraction)
: 0;
if (Number.isSafeInteger(currentLeverage) && currentLeverage > 0) {
return currentLeverage;
}
}
throw new Error(
`${LIGHTER_DATA_INTEGRITY_PREFIX} margin metadata unavailable for market ${marketId}`,
);
};

/**
Expand Down Expand Up @@ -7396,6 +7432,9 @@ export class LighterProvider implements PerpsProvider {
// The timestamp only advances on success.
const fresh = new Map<string, LighterMarginMetadata>();
for (const detail of details.orderBookDetails) {
if (isInactiveMarketWithoutUsableRiskMetadata(detail)) {
continue;
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (detail.minInitialMarginFraction !== undefined) {
deriveLighterMaxLeverage(
detail.minInitialMarginFraction,
Expand Down Expand Up @@ -7867,7 +7906,7 @@ export class LighterProvider implements PerpsProvider {
for (const [marketId, position] of Object.entries(message.positions)) {
const adapted = adaptPositionFromLighter(
position,
this.#maxLeverageForMarketId(position.marketId),
this.#maxLeverageForPosition(position),
);
if (parseFloat(adapted.size) === 0) {
nextPositions.delete(Number(marketId));
Expand Down
60 changes: 49 additions & 11 deletions packages/perps-controller/src/services/LighterClientService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
define,
optional,
record,
refine,
string,
type,
union,
Expand Down Expand Up @@ -113,12 +114,12 @@ const NonNegativeFiniteNumberStruct = define<number>(
'non-negative finite number',
(value) => typeof value === 'number' && Number.isFinite(value) && value >= 0,
);
const MarginFractionStruct = define<number>(
'margin fraction in (0, 10000]',
const NonNegativeMarginFractionStruct = define<number>(
'margin fraction in [0, 10000]',
(value) =>
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 1 &&
value >= 0 &&
value <= 10_000,
);
const NonNegativeFinancialNumberStruct = union([
Expand Down Expand Up @@ -156,25 +157,41 @@ const AccountStruct = type({
availableBalance: NonNegativeDecimalStringStruct,
positions: optional(array(PositionStruct)),
});
const MarketStruct = type({
const MarketBaseStruct = type({
symbol: string(),
marketId: NonNegativeIntegerStruct,
marketType: string(),
status: string(),
takerFee: SignedDecimalStringStruct,
makerFee: SignedDecimalStringStruct,
minBaseAmount: PositiveDecimalStringStruct,
minQuoteAmount: PositiveDecimalStringStruct,
minBaseAmount: NonNegativeDecimalStringStruct,
minQuoteAmount: NonNegativeDecimalStringStruct,
supportedSizeDecimals: NonNegativeIntegerStruct,
supportedPriceDecimals: NonNegativeIntegerStruct,
supportedQuoteDecimals: NonNegativeIntegerStruct,
});
const MarketDetailStruct = type({
...MarketStruct.schema,
const hasValidMarketMinimums = (
market: Pick<
LighterOrderBookMeta,
'status' | 'minBaseAmount' | 'minQuoteAmount'
>,
): boolean =>
market.status === 'inactive' ||
[market.minBaseAmount, market.minQuoteAmount].every((value) => {
const parsed = parseStrictDecimalString(value);
return parsed !== null && parsed > 0;
});
const MarketStruct = refine(
MarketBaseStruct,
'positive minimum amounts for tradable markets',
hasValidMarketMinimums,
);
const MarketDetailBaseStruct = type({
...MarketBaseStruct.schema,
lastTradePrice: NonNegativeFiniteNumberStruct,
defaultInitialMarginFraction: optional(MarginFractionStruct),
minInitialMarginFraction: optional(MarginFractionStruct),
maintenanceMarginFraction: optional(MarginFractionStruct),
defaultInitialMarginFraction: optional(NonNegativeMarginFractionStruct),
minInitialMarginFraction: optional(NonNegativeMarginFractionStruct),
maintenanceMarginFraction: optional(NonNegativeMarginFractionStruct),
dailyTradesCount: NonNegativeFiniteNumberStruct,
dailyBaseTokenVolume: NonNegativeFiniteNumberStruct,
dailyQuoteTokenVolume: NonNegativeFiniteNumberStruct,
Expand All @@ -184,6 +201,27 @@ const MarketDetailStruct = type({
openInterest: NonNegativeFiniteNumberStruct,
dailyChart: record(string(), FiniteNumberStruct),
});
const hasValidMarketMarginFractions = (market: {
status: string;
defaultInitialMarginFraction?: number;
minInitialMarginFraction?: number;
maintenanceMarginFraction?: number;
}): boolean =>
market.status === 'inactive' ||
[
market.defaultInitialMarginFraction,
market.minInitialMarginFraction,
market.maintenanceMarginFraction,
].every((value) => value === undefined || value > 0);
const MarketDetailStruct = refine(
refine(
MarketDetailBaseStruct,
'positive minimum amounts for tradable markets',
hasValidMarketMinimums,
),
'positive margin fractions for tradable markets',
hasValidMarketMarginFractions,
);
const OrderStruct = type({
orderIndex: NonNegativeIntegerStruct,
clientOrderIndex: NonNegativeIntegerStruct,
Expand Down
Loading