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
3 changes: 3 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
74 changes: 47 additions & 27 deletions packages/perps-controller/src/providers/LighterProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -125,6 +126,7 @@ import type {
import type {
LighterApiOrder,
LighterApiPosition,
LighterAccountsByL1AddressResponse,
LighterAuthConfig,
LighterTxLookupResponse,
LighterTransferHistoryItem,
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -4250,10 +4265,11 @@ export class LighterProvider implements PerpsProvider {
readonly #isVenueKeyRegistered = async (
accountIndex: number,
): Promise<boolean> => {
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,
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
15 changes: 12 additions & 3 deletions packages/perps-controller/src/services/LighterClientService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -285,7 +294,7 @@ const ResponseStructs = {
accountsByAddress: type({
...BaseResponseStruct.schema,
l1Address: string(),
subAccounts: array(AccountStruct),
subAccounts: array(AccountSummaryStruct),
}),
apiKeys: type({
...BaseResponseStruct.schema,
Expand Down
19 changes: 16 additions & 3 deletions packages/perps-controller/src/types/lighter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,14 +401,27 @@ 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`.
*/
export type LighterAccountsByL1AddressResponse = {
code: number;
message?: string;
l1Address: string;
subAccounts: LighterSubAccount[];
subAccounts: LighterAccountSummary[];
};

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
Loading