diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 27175fbe8b5..37f40082a30 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -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 diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index b3ed7ccb421..f9d1b61ce1c 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -124,6 +124,7 @@ import type { } from '../types/index.js'; import type { LighterApiOrder, + LighterApiPosition, LighterAuthConfig, LighterTxLookupResponse, LighterTransferHistoryItem, @@ -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'] => { @@ -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}`, ); @@ -4724,7 +4742,7 @@ export class LighterProvider implements PerpsProvider { adapted.minimumOrderSize = oneTickUsd; } } - return adapted; + return [adapted]; }); } catch (caughtError) { const wrappedError = ensureError( @@ -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), ); @@ -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); @@ -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, + ): 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}`, + ); }; /** @@ -7396,6 +7432,9 @@ export class LighterProvider implements PerpsProvider { // The timestamp only advances on success. const fresh = new Map(); for (const detail of details.orderBookDetails) { + if (isInactiveMarketWithoutUsableRiskMetadata(detail)) { + continue; + } if (detail.minInitialMarginFraction !== undefined) { deriveLighterMaxLeverage( detail.minInitialMarginFraction, @@ -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)); diff --git a/packages/perps-controller/src/services/LighterClientService.ts b/packages/perps-controller/src/services/LighterClientService.ts index 5525e67ac57..3f0dc146459 100644 --- a/packages/perps-controller/src/services/LighterClientService.ts +++ b/packages/perps-controller/src/services/LighterClientService.ts @@ -24,6 +24,7 @@ import { define, optional, record, + refine, string, type, union, @@ -113,12 +114,12 @@ const NonNegativeFiniteNumberStruct = define( 'non-negative finite number', (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0, ); -const MarginFractionStruct = define( - 'margin fraction in (0, 10000]', +const NonNegativeMarginFractionStruct = define( + 'margin fraction in [0, 10000]', (value) => typeof value === 'number' && Number.isSafeInteger(value) && - value >= 1 && + value >= 0 && value <= 10_000, ); const NonNegativeFinancialNumberStruct = union([ @@ -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, @@ -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, diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index d6daafa2a46..6a5a597362e 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1063,6 +1063,127 @@ describe('LighterProvider', () => { expect(data[0].symbol).toBe('BTC'); }); + it('omits inactive markets without usable risk metadata while preserving active markets', async () => { + const built = buildProvider(); + const inactiveMarket = { + ...BTC_MARKET, + symbol: 'MKR', + marketId: 28, + status: 'inactive', + minBaseAmount: '0.0000', + minQuoteAmount: '0.000000', + }; + const activeDetail = { + ...BTC_MARKET, + lastTradePrice: 100000, + dailyTradesCount: 10, + dailyBaseTokenVolume: 1, + dailyQuoteTokenVolume: 100000, + dailyPriceLow: 99000, + dailyPriceHigh: 101000, + dailyPriceChange: 1, + openInterest: 1000000, + dailyChart: {}, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }; + built.clientInstance.getOrderBooks.mockResolvedValue([ + BTC_MARKET, + inactiveMarket, + ]); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + activeDetail, + { + ...activeDetail, + ...inactiveMarket, + defaultInitialMarginFraction: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }, + ], + }); + + const markets = await built.provider.getMarkets(); + const marketData = await built.provider.getMarketDataWithPrices(); + + expect(markets.map((market) => market.name)).toStrictEqual(['BTC']); + expect(marketData.map((market) => market.symbol)).toStrictEqual(['BTC']); + }); + + it('preserves inactive markets when their risk metadata remains usable', async () => { + const built = buildProvider(); + const inactiveMarket = { + ...BTC_MARKET, + symbol: 'MKR', + marketId: 28, + status: 'inactive', + }; + built.clientInstance.getOrderBooks.mockResolvedValue([inactiveMarket]); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...inactiveMarket, + lastTradePrice: 2500, + dailyTradesCount: 0, + dailyBaseTokenVolume: 0, + dailyQuoteTokenVolume: 0, + dailyPriceLow: 2500, + dailyPriceHigh: 2500, + dailyPriceChange: 0, + openInterest: 0, + dailyChart: {}, + minInitialMarginFraction: 500, + maintenanceMarginFraction: 250, + }, + ], + }); + + const markets = await built.provider.getMarkets(); + const marketData = await built.provider.getMarketDataWithPrices(); + + expect(markets).toHaveLength(1); + expect(markets[0]).toMatchObject({ + name: 'MKR', + isDelisted: true, + maxLeverage: 20, + }); + expect(marketData.map((market) => market.symbol)).toStrictEqual(['MKR']); + }); + + it('continues to reject zero risk metadata for active markets', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + dailyTradesCount: 10, + dailyBaseTokenVolume: 1, + dailyQuoteTokenVolume: 100000, + dailyPriceLow: 99000, + dailyPriceHigh: 101000, + dailyPriceChange: 1, + openInterest: 1000000, + dailyChart: {}, + defaultInitialMarginFraction: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }, + ], + }); + + await expect(built.provider.getMarkets()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + await expect(built.provider.getMarketDataWithPrices()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + it('does not publish market metadata without verified per-market leverage', async () => { const markets = buildProvider(); markets.clientInstance.getOrderBookDetails.mockResolvedValue({ @@ -1157,6 +1278,68 @@ describe('LighterProvider', () => { }); }); + it('preserves retired-market positions when risk metadata is zeroed', async () => { + const built = buildProvider(); + const inactiveMarket = { + ...BTC_MARKET, + symbol: 'MKR', + marketId: 28, + status: 'inactive', + minBaseAmount: '0.0000', + minQuoteAmount: '0.000000', + }; + built.clientInstance.getOrderBooks.mockResolvedValue([ + BTC_MARKET, + inactiveMarket, + ]); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + { + ...inactiveMarket, + lastTradePrice: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }, + ], + }); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + ...(ACCOUNT.positions ?? []), + { + ...ACCOUNT.positions?.[0], + marketId: 28, + symbol: 'MKR', + initialMarginFraction: '10', + }, + ], + }, + ], + }); + await built.provider.initialize(); + + const positions = await built.provider.getPositions(); + + expect(positions).toStrictEqual([ + expect.objectContaining({ symbol: 'BTC', maxLeverage: 50 }), + expect.objectContaining({ + symbol: 'MKR', + leverage: expect.objectContaining({ value: 10 }), + maxLeverage: 10, + }), + ]); + }); + it('surfaces margin metadata transport failures from position reads', async () => { const { provider, clientInstance } = buildProvider(); clientInstance.getOrderBookDetails.mockRejectedValue( diff --git a/packages/perps-controller/tests/src/services/LighterClientService.test.ts b/packages/perps-controller/tests/src/services/LighterClientService.test.ts index f7f5006f6cf..e361c3741ed 100644 --- a/packages/perps-controller/tests/src/services/LighterClientService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterClientService.test.ts @@ -18,6 +18,22 @@ const ORDER_BOOK = { supportedQuoteDecimals: 6, }; +const ORDER_BOOK_DETAIL = { + ...ORDER_BOOK, + lastTradePrice: 100000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + dailyTradesCount: 1, + dailyBaseTokenVolume: 1, + dailyQuoteTokenVolume: 1, + dailyPriceLow: 1, + dailyPriceHigh: 1, + dailyPriceChange: 0, + openInterest: 1, + dailyChart: {}, +}; + const VALID_TRADE_WIRE = { trade_id: 1, tx_hash: '0xabc', @@ -105,6 +121,94 @@ describe('LighterClientService', () => { await service.getOrderBooks(true); expect(fetchMock).toHaveBeenCalledTimes(2); }); + + it('accepts zero minimum amounts reported for inactive markets', async () => { + const inactiveMarket = { + ...ORDER_BOOK, + status: 'inactive', + minBaseAmount: '0.0000', + minQuoteAmount: '0.000000', + }; + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, orderBooks: [inactiveMarket] }), + ); + + const markets = await buildService(false).getOrderBooks(); + + expect(markets).toStrictEqual([inactiveMarket]); + }); + + it.each(['minBaseAmount', 'minQuoteAmount'] as const)( + 'rejects a zero %s for an active market', + async (field) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orderBooks: [{ ...ORDER_BOOK, [field]: '0.0000' }], + }), + ); + + await expect(buildService().getOrderBooks()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }, + ); + + it('rejects negative minimum amounts for inactive markets', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orderBooks: [ + { ...ORDER_BOOK, status: 'inactive', minBaseAmount: '-0.0001' }, + ], + }), + ); + + await expect(buildService().getOrderBooks()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + }); + + describe('getOrderBookDetails', () => { + it('accepts zero margin fractions reported for inactive markets', async () => { + const inactiveMarket = { + ...ORDER_BOOK_DETAIL, + status: 'inactive', + minBaseAmount: '0.0000', + minQuoteAmount: '0.000000', + defaultInitialMarginFraction: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }; + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, orderBookDetails: [inactiveMarket] }), + ); + + const response = await buildService(false).getOrderBookDetails(); + + expect(response).toStrictEqual({ + code: 200, + orderBookDetails: [inactiveMarket], + }); + }); + + it.each([ + 'defaultInitialMarginFraction', + 'minInitialMarginFraction', + 'maintenanceMarginFraction', + ] as const)('rejects a zero %s for an active market', async (field) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orderBookDetails: [{ ...ORDER_BOOK_DETAIL, [field]: 0 }], + }), + ); + + await expect(buildService().getOrderBookDetails()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); }); describe('getTx', () => {