diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 37f40082a3..78a0855b05 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- Resolve Lighter accounts from sparse address-discovery rows and every API-key slot, settle confirmed missing accounts to an empty state, and preserve the last authoritative state across transport, authentication, and malformed-response failures. ([#10119](https://github.com/MetaMask/core/pull/10119)) +- Accept Lighter trade rows that omit the counterparty's realized PnL while continuing to require a valid PnL for the selected account. ([#10119](https://github.com/MetaMask/core/pull/10119)) +- Stop emitting a debug log for every Lighter price-stream frame. ([#10119](https://github.com/MetaMask/core/pull/10119)) ## [16.1.0] diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index f9d1b61ce1..442571d483 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -57,6 +57,7 @@ import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; import type { PerpsControllerMessenger } from '../PerpsController.js'; import { convertKeysToCamelCase, + LighterApiError, LighterClientService, } from '../services/LighterClientService.js'; import { LighterWalletService } from '../services/LighterWalletService.js'; @@ -125,6 +126,7 @@ import type { import type { LighterApiOrder, LighterApiPosition, + LighterAccountsByL1AddressResponse, LighterAuthConfig, LighterTxLookupResponse, LighterTransferHistoryItem, @@ -979,6 +981,16 @@ const LIGHTER_SIGNER_UNAVAILABLE_ERROR = 'Lighter signer bridge not configured'; const LIGHTER_MAINNET_EXPLORER_URL = 'https://scan.lighter.xyz'; const LIGHTER_TESTNET_EXPLORER_URL = 'https://testnet.zklighter.elliot.ai'; +/** A definitive venue response that the selected wallet has no account. */ +class LighterAccountNotFoundError extends Error { + constructor(address: string) { + super( + `No Lighter account exists for ${address}; fund it via the bridge (or the testnet faucet) first`, + ); + this.name = 'LighterAccountNotFoundError'; + } +} + /** * Empty account state returned when reads fail or no account exists. */ @@ -1048,9 +1060,6 @@ export class LighterProvider implements PerpsProvider { #priceWs: LighterWebSocketLike | null = null; - /** Monotonic poll counter — surfaced in debug logs so e2e can assert liveness. */ - #pricePollCycle = 0; - /** Injectable WebSocket constructor (null → REST polling fallback). */ readonly #webSocketCtor: LighterWebSocketCtor | null; @@ -1570,7 +1579,15 @@ export class LighterProvider implements PerpsProvider { } const generation = this.#sessionGeneration; const address = this.#walletService.getUserAddress(); - const response = await this.#clientService.getAccountsByL1Address(address); + let response: LighterAccountsByL1AddressResponse; + try { + response = await this.#clientService.getAccountsByL1Address(address); + } catch (error) { + if (error instanceof LighterApiError && error.code === 21100) { + throw new LighterAccountNotFoundError(address); + } + throw error; + } // Re-run the binding so an EXTERNAL switch nothing else observed also // advances the generation, then compare: caching after any switch // would poison the new session with the old account. Retry instead. @@ -1579,9 +1596,7 @@ export class LighterProvider implements PerpsProvider { return await this.#ensureAccountIndex(); } if (!response.subAccounts?.length) { - throw new Error( - `No Lighter account exists for ${address}; fund it via the bridge (or the testnet faucet) first`, - ); + throw new LighterAccountNotFoundError(address); } const master = response.subAccounts.reduce((min, account) => account.index < min.index ? account : min, @@ -4250,10 +4265,11 @@ export class LighterProvider implements PerpsProvider { readonly #isVenueKeyRegistered = async ( accountIndex: number, ): Promise => { - const response = await this.#clientService.getApiKeys( - accountIndex, - this.#apiKeyIndex, - ); + // Query all slots. Lighter returns `api key not found` when a missing + // slot is requested directly, which would make first-time registration + // impossible. The all-slots response is successful and represents an + // unused slot by omitting it from `apiKeys`. + const response = await this.#clientService.getApiKeys(accountIndex); const configuredSlot = response.apiKeys.find( (key) => key.apiKeyIndex === this.#apiKeyIndex, ); @@ -7610,8 +7626,8 @@ export class LighterProvider implements PerpsProvider { /** * Resolve the Lighter account index and request the account-scoped - * channels. Without a Lighter account, account-scoped subscribers receive - * one empty emission unless the failure is a capability refusal. + * channels. When the venue definitively reports no Lighter account, + * account-scoped subscribers receive an empty emission. */ readonly #ensureAccountChannels = (): void => { if (this.#isDisconnected) { @@ -7657,11 +7673,23 @@ export class LighterProvider implements PerpsProvider { '[LighterProvider] account channels unavailable', { error: String(error) }, ); + // A venue-confirmed absent account is authoritative empty state for + // this exact wallet binding. It must settle initial subscribers so + // clients do not render loading skeletons forever. All other failures + // preserve the last snapshot: transport, malformed data, auth and + // capability errors cannot prove that the account is empty. + this.#ensureSessionBinding(); + if ( + error instanceof LighterAccountNotFoundError && + generation === this.#sessionGeneration + ) { + this.#emitAccountBindingReset(); + } // An aborted previous-account setup has no authority over the new // session. Current-session failures also preserve the last known data. - // Discovery, transport, auth, capability, and integrity failures are - // not authoritative empty account state. Explicit account switches - // and deselection already emit their synchronous reset. + // Transport, auth, capability, and integrity failures are not + // authoritative empty account state. Explicit account switches and + // deselection already emit their synchronous reset. } })(); this.#accountChannelsPromise = setupPromise; @@ -7883,7 +7911,7 @@ export class LighterProvider implements PerpsProvider { const updates = Object.values(message.marketStats).map((stat) => adaptPriceUpdateFromLighterWsStat(stat, timestamp), ); - this.#dispatchPriceUpdates(updates, 'ws'); + this.#dispatchPriceUpdates(updates); this.#dispatchOICaps(Object.values(message.marketStats)); return; } @@ -8268,29 +8296,21 @@ export class LighterProvider implements PerpsProvider { const updates = (response.orderBookDetails ?? []).map((detail) => adaptPriceUpdateFromLighter(detail, timestamp), ); - this.#dispatchPriceUpdates(updates, 'poll'); + this.#dispatchPriceUpdates(updates); }; /** * Fan price updates out to every subscriber, honoring symbol filters. * * @param updates - Adapted price updates for this cycle. - * @param transport - Which transport produced the cycle (ws or poll). */ - readonly #dispatchPriceUpdates = ( - updates: PriceUpdate[], - transport: string, - ): void => { + readonly #dispatchPriceUpdates = (updates: PriceUpdate[]): void => { if (this.#isDisconnected || updates.length === 0) { return; } for (const update of updates) { this.#lastPriceBySymbol.set(update.symbol, update); } - this.#pricePollCycle += 1; - this.#deps.debugLogger.log( - `[LighterProvider] price stream cycle=${this.#pricePollCycle} transport=${transport} updates=${updates.length}`, - ); for (const subscriber of this.#priceSubscribers) { this.#deliverPrices(subscriber, updates); } diff --git a/packages/perps-controller/src/services/LighterClientService.ts b/packages/perps-controller/src/services/LighterClientService.ts index 3f0dc14645..7909516420 100644 --- a/packages/perps-controller/src/services/LighterClientService.ts +++ b/packages/perps-controller/src/services/LighterClientService.ts @@ -157,6 +157,15 @@ const AccountStruct = type({ availableBalance: NonNegativeDecimalStringStruct, positions: optional(array(PositionStruct)), }); +// The address-discovery endpoint is an identity lookup, not a financial read: +// live responses can carry sparse balance fields (for example an empty +// `availableBalance`). Validate only the fields its sole consumer needs. Full +// account reads retain AccountStruct and its strict financial validation. +const AccountSummaryStruct = type({ + accountType: SafeIntegerStruct, + index: NonNegativeIntegerStruct, + l1Address: string(), +}); const MarketBaseStruct = type({ symbol: string(), marketId: NonNegativeIntegerStruct, @@ -259,8 +268,8 @@ const TradeStruct = type({ bidAccountId: NonNegativeIntegerStruct, isMakerAsk: boolean(), timestamp: NonNegativeIntegerStruct, - askAccountPnl: SignedDecimalStringStruct, - bidAccountPnl: SignedDecimalStringStruct, + askAccountPnl: optional(SignedDecimalStringStruct), + bidAccountPnl: optional(SignedDecimalStringStruct), takerFee: optional(NonNegativeFinancialNumberStruct), makerFee: optional(NonNegativeFinancialNumberStruct), takerPositionSizeBefore: NonNegativeDecimalStringStruct, @@ -285,7 +294,7 @@ const ResponseStructs = { accountsByAddress: type({ ...BaseResponseStruct.schema, l1Address: string(), - subAccounts: array(AccountStruct), + subAccounts: array(AccountSummaryStruct), }), apiKeys: type({ ...BaseResponseStruct.schema, diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index 94e6b528c1..0f3e19345c 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -401,6 +401,19 @@ export type LighterSubAccount = { positions?: LighterApiPosition[]; }; +/** + * Identity fields returned by `GET /api/v1/accountsByL1Address`. + * + * Lighter's address-discovery endpoint may leave balance fields empty even + * though the full `account` endpoint returns validated decimal values. Account + * discovery only consumes these identity fields; financial reads continue to + * use {@link LighterSubAccount} and its stricter response validation. + */ +export type LighterAccountSummary = Pick< + LighterSubAccount, + 'accountType' | 'index' | 'l1Address' +>; + /** * Response of `GET /api/v1/accountsByL1Address`. */ @@ -408,7 +421,7 @@ export type LighterAccountsByL1AddressResponse = { code: number; message?: string; l1Address: string; - subAccounts: LighterSubAccount[]; + subAccounts: LighterAccountSummary[]; }; /** @@ -657,9 +670,9 @@ export type LighterRestTrade = { isMakerAsk: boolean; timestamp: number; /** Realized pnl for the ask-side account, signed USDC. */ - askAccountPnl: string; + askAccountPnl?: string; /** Realized pnl for the bid-side account, signed USDC. */ - bidAccountPnl: string; + bidAccountPnl?: string; /** * Taker/maker fees, present when nonzero. The official model types them * as StrictInt with NO documented unit or scale; until a captured diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index 6a5a597362..3203661eb6 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -942,10 +942,13 @@ describe('LighterProvider', () => { expect(result.error).toContain('signer bridge'); }); - it('sets up the signer and registers the venue key when missing', async () => { + it('queries all API-key slots and registers the venue key when the configured slot is missing', async () => { const { provider, clientInstance, calls, bridge } = buildProvider(); const result = await provider.isReadyToTrade(); expect(result.ready).toBe(true); + // A direct lookup of an unused slot returns venue error 21109 + // (`api key not found`); querying all slots returns an empty list. + expect(clientInstance.getApiKeys).toHaveBeenCalledWith(28); expect(bridge.createClient).toHaveBeenCalledWith({ chainId: 300, accountIndex: 28, @@ -1837,6 +1840,35 @@ describe('LighterProvider', () => { await provider.disconnect(); }); + it('does not log every price-stream frame', async () => { + const infra = createMockInfrastructure(); + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + platformDependencies: infra, + }); + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback: jest.fn(), + }); + const socket = FakeWebSocket.instances[0]; + socket.open(); + + socket.receive({ + type: 'subscribed/market_stats', + market_stats: { '1': wsStat('BTC', 1, '63000.5') }, + }); + socket.receive({ + type: 'update/market_stats', + market_stats: { '1': wsStat('BTC', 1, '63001.5') }, + }); + + expect(infra.debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('[LighterProvider] price stream cycle='), + ); + unsubscribe(); + await provider.disconnect(); + }); + it('replays the merged snapshot to late subscribers with symbol filters', async () => { const { provider } = buildProvider({ webSocketCtor: fakeCtor }); const unsubscribeFirst = provider.subscribeToPrices({ @@ -2185,6 +2217,74 @@ describe('LighterProvider', () => { await provider.disconnect(); }); + it('emits authoritative empty state when the selected wallet has no Lighter account', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockRejectedValue( + new LighterApiError('account not found', 21100), + ); + const accountCallback = jest.fn(); + const positionsCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(accountCallback).toHaveBeenCalledWith( + expect.objectContaining({ + totalBalance: '0', + spendableBalance: '0', + providerId: 'lighter', + }), + ); + expect(positionsCallback).toHaveBeenCalledWith([]); + expect(ordersCallback).toHaveBeenCalledWith([]); + unsubscribeAccount(); + unsubscribePositions(); + unsubscribeOrders(); + await provider.disconnect(); + }); + + it('also settles account subscribers when discovery succeeds with no accounts', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [], + }); + const positionsCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(positionsCallback).toHaveBeenCalledWith([]); + expect(ordersCallback).toHaveBeenCalledWith([]); + unsubscribePositions(); + unsubscribeOrders(); + await provider.disconnect(); + }); + it('does not clear orders when authenticated channel setup fails', async () => { const { provider, bridge } = buildProvider({ webSocketCtor: fakeCtor, diff --git a/packages/perps-controller/tests/src/services/LighterClientService.test.ts b/packages/perps-controller/tests/src/services/LighterClientService.test.ts index e361c3741e..4ea23fd52b 100644 --- a/packages/perps-controller/tests/src/services/LighterClientService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterClientService.test.ts @@ -342,7 +342,6 @@ describe('LighterClientService', () => { ['negative size', { size: '-0.1' }], ['zero price', { price: '0' }], ['negative position magnitude', { taker_position_size_before: '-0.1' }], - ['missing account pnl', { ask_account_pnl: undefined }], ['missing maker role', { is_maker_ask: undefined }], [ 'missing position sign context', @@ -360,6 +359,28 @@ describe('LighterClientService', () => { buildService().getTrades(28, 'auth-token', { limit: 50 }), ).rejects.toThrow('Invalid Lighter venue data'); }); + + it('accepts omitted account pnl fields for later participant-side validation', async () => { + const { + ask_account_pnl: _askPnl, + bid_account_pnl: _bidPnl, + ...trade + } = VALID_TRADE_WIRE; + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + trades: [trade], + }), + ); + + const response = await buildService().getTrades(28, 'auth-token', { + limit: 50, + }); + + expect(response.trades).toHaveLength(1); + expect(response.trades[0]).not.toHaveProperty('askAccountPnl'); + expect(response.trades[0]).not.toHaveProperty('bidAccountPnl'); + }); }); describe('financial history decoders', () => { @@ -576,6 +597,49 @@ describe('LighterClientService', () => { ); }); + it('accepts sparse account-discovery balances without weakening full account reads', async () => { + const discoveryAccount = { + code: 0, + account_type: 0, + index: 629696, + l1_address: '0xabc', + cancel_all_time: 0, + total_order_count: 0, + pending_order_count: 0, + status: 0, + collateral: '5.000000', + available_balance: '', + }; + fetchMock.mockResolvedValueOnce( + mockJsonResponse({ + code: 200, + l1_address: '0xabc', + sub_accounts: [discoveryAccount], + }), + ); + const service = buildService(false); + + const discovered = await service.getAccountsByL1Address('0xabc'); + expect(discovered).toStrictEqual( + expect.objectContaining({ + subAccounts: [ + expect.objectContaining({ + accountType: 0, + index: 629696, + l1Address: '0xabc', + }), + ], + }), + ); + + fetchMock.mockResolvedValueOnce( + mockJsonResponse({ code: 200, accounts: [discoveryAccount] }), + ); + await expect(service.getAccountByIndex(629696)).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + it('queries the account by index', async () => { fetchMock.mockResolvedValue( mockJsonResponse({ code: 200, accounts: [] }), diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index 3b00f4fd30..34d699d534 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -534,6 +534,23 @@ describe('lighterAdapter', () => { ).toThrow('Invalid Lighter venue data'); }); + it('accepts omitted counterparty pnl while requiring the selected account pnl', () => { + expect( + adaptFillFromLighterTrade( + { ...REAL_TRADE, bidAccountPnl: undefined }, + 'SOL', + 28, + ).pnl, + ).toBe('-0.012901'); + expect( + adaptFillFromLighterTrade( + { ...REAL_TRADE, askAccountPnl: undefined }, + 'SOL', + 7, + ).pnl, + ).toBe('0'); + }); + it('keeps a Standard fill whose Premium counterparty paid the fee', () => { // Account 28 is the taker; the MAKER (counterparty) fee being nonzero // must not drop our valid zero-fee fill.