Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
a872f18
feat(perps): spike — investigate Lighter as second perps venue
abretonc7s Aug 15, 2026
938c297
feat(perps): add experimental Lighter venue provider POC
abretonc7s Aug 15, 2026
8c4257f
feat(perps): stream Lighter live data over WebSocket and extend the t…
abretonc7s Aug 15, 2026
c181dee
feat(perps-controller): Lighter TP/SL, margin/leverage, and history r…
abretonc7s Aug 15, 2026
6b024ee
fix(perps-controller): align Lighter editOrder/TPSL results with Orde…
abretonc7s Aug 15, 2026
17cf320
feat(perps-controller): enable Lighter mainnet
abretonc7s Aug 16, 2026
b0d6901
feat(perps-controller): Lighter HyperLiquid-parity history, routes, a…
abretonc7s Aug 16, 2026
a7dd45e
fix(perps-controller): review fixes for the Lighter provider
abretonc7s Aug 16, 2026
36868ec
fix(perps-controller): address external review of the Lighter provider
abretonc7s Aug 16, 2026
ff0cb13
fix(perps-controller): close re-review gaps in the Lighter provider
abretonc7s Aug 16, 2026
23ad604
fix(perps-controller): third-review round — races, sizing, and honest…
abretonc7s Aug 16, 2026
db20573
fix(perps-controller): cross-review round 1 — session fences, close s…
abretonc7s Aug 16, 2026
6f07a4d
fix(perps-controller): cross-review round 2 — serialized signer, hone…
abretonc7s Aug 16, 2026
4d105e8
fix(perps-controller): round-4 review — atomic session identity, hone…
abretonc7s Aug 16, 2026
0eae5f2
fix(perps-controller): round-5 review — ownership, WS identity, consi…
abretonc7s Aug 16, 2026
8e8fccd
fix(perps-controller): round-6 consolidated — leverage arity artifact…
abretonc7s Aug 16, 2026
8016124
fix(perps-controller): round-7 consolidated — random uint48 client id…
abretonc7s Aug 16, 2026
86489d8
fix(perps-controller): round-8 — bounded id allocation, market valida…
abretonc7s Aug 16, 2026
d52df7f
fix(perps-controller): round-9 — finite-positive intent and safe wire…
abretonc7s Aug 16, 2026
0aed042
fix(perps-controller): round-10 — execution-price wire parity, preval…
abretonc7s Aug 16, 2026
f325528
fix(perps-controller): round-11 — TP/SL full preflight and create-bef…
abretonc7s Aug 16, 2026
71fdba8
fix(perps-controller): round-12 — venue-input integrity boundary, ser…
abretonc7s Aug 16, 2026
fdac200
fix(perps-controller): round-13 — position sign contract, settlement …
abretonc7s Aug 16, 2026
21a30c5
fix(perps-controller): round-13/14 — durable per-attempt TP/SL journa…
abretonc7s Aug 16, 2026
1eb3357
fix(perps-controller): round-14/15 — authoritative tx-hash settlement…
abretonc7s Aug 16, 2026
6b393f4
fix(perps-controller): round-16 — durable operation intent, lifecycle…
abretonc7s Aug 17, 2026
59a8f76
fix(perps-controller): round-17 — journal operation identity (CAS), d…
abretonc7s Aug 17, 2026
f6a5e4f
fix(perps-controller): round-18 — pinned-WASM dispatch identity, dura…
abretonc7s Aug 17, 2026
7cba14d
fix(perps-controller): round-19 — process-wide serialization, complet…
abretonc7s Aug 17, 2026
ec087f0
fix(perps-controller)!: round-20 — financial idempotency quarantine, …
abretonc7s Aug 17, 2026
95aa571
fix(perps-controller)!: round-21 — quarantine persistence, selective …
abretonc7s Aug 17, 2026
7c4ce55
fix(perps-controller): round-22 — single ledger mutex for all RMW, po…
abretonc7s Aug 17, 2026
2f7c456
fix(perps-controller): round-23 — atomic post-dispatch ledger transit…
abretonc7s Aug 17, 2026
e96e2ae
fix(perps-controller): CI compatibility — regenerate messenger action…
abretonc7s Aug 17, 2026
1a69c7f
fix(perps-controller): mainnet write gate, aggregated recovery surfac…
abretonc7s Aug 17, 2026
23513a5
fix(perps-controller): repo-wide lint conformance for the aggregated …
abretonc7s Aug 17, 2026
1e8412b
fix(perps-controller): allow mainnet signer setup for authenticated r…
abretonc7s Aug 17, 2026
e0a5058
fix(perps-controller): refuse mainnet key registration before any sig…
abretonc7s Aug 17, 2026
3b5067b
docs(perps-controller): document the allowMainnetSignerSetup parameter
abretonc7s Aug 17, 2026
061793a
test(perps-controller): deterministic Lighter WASM signer build
abretonc7s Aug 18, 2026
8a7e8f2
fix(perps-controller): device-validation defects — empty-size full cl…
abretonc7s Aug 18, 2026
9f92859
fix(perps-controller): venue-derived route override and binding per-m…
abretonc7s Aug 18, 2026
83e3f6a
fix(perps-controller): grid-aware binding minimum order size for Ligh…
abretonc7s Aug 18, 2026
ad698dd
fix(perps-controller): serve venue market metadata instead of Termina…
abretonc7s Aug 18, 2026
269e49f
fix(perps-controller): validate USD-derived Lighter order sizes on th…
abretonc7s Aug 18, 2026
b17e911
fix(perps-controller): emit full order book level contract from the L…
abretonc7s Aug 18, 2026
1264fbe
fix(perps-controller): finite-guard Lighter order book and candle pay…
abretonc7s Aug 18, 2026
826a408
feat(perps-controller): enable Lighter mainnet venue writes (remove i…
abretonc7s Aug 19, 2026
c4f48e3
test(perps-controller): add temporary Lighter e2e recipe (DO-NOT-MERG…
abretonc7s Aug 19, 2026
d6cd0f6
feat(perps-controller): open Lighter positions with isolated margin b…
abretonc7s Aug 19, 2026
bf5883b
feat(perps-controller): isolated-margin liquidation price preview for…
abretonc7s Aug 19, 2026
4c097f8
fix(perps): address PR feedback
abretonc7s Aug 28, 2026
e38a686
fix: address CI feedback
abretonc7s Aug 28, 2026
07f530f
fix: address bugbot comments
abretonc7s Aug 28, 2026
95f7f5d
fix(perps): address lighter review findings
abretonc7s Aug 28, 2026
d60cd6e
fix(perps): address cross-review follow-ups
abretonc7s Aug 28, 2026
a4bddd7
chore(perps): merge main
abretonc7s Aug 28, 2026
d860bdf
fix(perps-controller): address Lighter review findings
abretonc7s Aug 28, 2026
a95b9b5
fix(perps-controller): validate Lighter market minimums
abretonc7s Aug 28, 2026
521b8c6
fix(perps-controller): address Lighter review feedback
abretonc7s Aug 29, 2026
61ee313
chore(perps): merge main
abretonc7s Aug 29, 2026
e1d609c
fix(perps-controller): address Lighter cross-review findings
abretonc7s Aug 29, 2026
42d8e28
fix(perps-controller): close Lighter review gaps
abretonc7s Aug 29, 2026
5c201b7
fix(perps-controller): make Lighter disconnect terminal
abretonc7s Aug 29, 2026
222ab45
fix(perps-controller): fence Lighter subscriptions after disconnect
abretonc7s Aug 29, 2026
ed4c504
test(perps-controller): prove Lighter disconnect cache fences
abretonc7s Aug 29, 2026
5e30310
fix(perps-controller): fail closed on incomplete Lighter data
abretonc7s Aug 31, 2026
5c3420f
chore(perps): merge main
abretonc7s Aug 31, 2026
f8a378e
fix(perps-controller): enforce Lighter data integrity
abretonc7s Aug 31, 2026
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
8 changes: 8 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add Lighter as a perps venue (initial implementation, disabled by default) ([#9889](https://github.com/MetaMask/core/pull/9889))
- **BREAKING:** `PerpsProviderType` gains `'lighter'`. Consumers with exhaustive provider switches must handle the new value. Enablement requires client opt-in: `providerCredentials.lighter.enabled`, or the `perpsLighterProviderEnabled` remote feature flag combined with a client-supplied `providerCredentials.lighter.signerBridge` (without a bridge the provider is read-only). Lighter follows the global network toggle on both testnet and mainnet (reads and writes).
- The client-supplied signer bridge owns venue-key creation and persistence; Core never derives or receives the venue-key seed/private key. Lighter deposits are not advertised until the provider can build the required approval plus bridge-deposit call.
- New Lighter types/constants exports, `KeyringController:signPersonalMessage` in the allowed messenger actions (type-only), and durable-settlement surfacing on the controller: `getPendingManualRecoveries`, `getRecoveredDispatches`, `acknowledgeRecoveredDispatch` actions with `PerpsPendingManualRecovery` / `PerpsRecoveredDispatch` exported types and `OrderResult.partialState`.
- `LiquidationPriceParams` and `MaintenanceMarginParams` gain an optional `providerId`, and `getMaxLeverage` accepts the same optional route, so aggregated calculations use the provider that owns the market or position.

### Fixed

- Preserve accepted HyperLiquid Scale orders when part of a batch is rejected ([#9989](https://github.com/MetaMask/core/pull/9989))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,43 @@ export type PerpsControllerGetOrderFillsAction = {
handler: PerpsController['getOrderFills'];
};

/**
* List TP/SL protection changes the active provider parked for
* explicit manual re-establishment. Providers without durable
* settlement state return an empty list.
*
* @returns Pending manual-recovery entries.
*/
export type PerpsControllerGetPendingManualRecoveriesAction = {
type: `PerpsController:getPendingManualRecoveries`;
handler: PerpsController['getPendingManualRecoveries'];
};

/**
* READ-ONLY list of the active provider's recovered-dispatch outcomes
* (previously ambiguous submissions later resolved). Providers without
* durable dispatch state return an empty list.
*
* @returns Pending recovered-dispatch outcomes.
*/
export type PerpsControllerGetRecoveredDispatchesAction = {
type: `PerpsController:getRecoveredDispatches`;
handler: PerpsController['getRecoveredDispatches'];
};

/**
* Acknowledge ONE recovered-dispatch outcome by its stable id, after
* refreshing venue state. Throws when the active provider has no
* durable dispatch state or the id no longer matches.
*
* @param recoveryId - Stable id from {@link getRecoveredDispatches}.
* @returns Resolves when the outcome is acknowledged.
*/
export type PerpsControllerAcknowledgeRecoveredDispatchAction = {
type: `PerpsController:acknowledgeRecoveredDispatch`;
handler: PerpsController['acknowledgeRecoveredDispatch'];
};

/**
* Get historical user orders (order lifecycle)
* Thin delegation to MarketDataService
Expand Down Expand Up @@ -569,6 +606,7 @@ export type PerpsControllerCalculateMaintenanceMarginAction = {
* Get maximum leverage allowed for an asset
*
* @param asset - The asset identifier.
* @param providerId - Optional provider route for aggregated markets.
* @returns A promise that resolves to the numeric result.
*/
export type PerpsControllerGetMaxLeverageAction = {
Expand Down Expand Up @@ -1313,6 +1351,9 @@ export type PerpsControllerMethodActions =
| PerpsControllerWithdrawAction
| PerpsControllerGetPositionsAction
| PerpsControllerGetOrderFillsAction
| PerpsControllerGetPendingManualRecoveriesAction
| PerpsControllerGetRecoveredDispatchesAction
| PerpsControllerAcknowledgeRecoveredDispatchAction
| PerpsControllerGetOrdersAction
| PerpsControllerGetOpenOrdersAction
| PerpsControllerGetFundingAction
Expand Down
210 changes: 202 additions & 8 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ import type {
PerpsControllerConfig,
PerpsMarketData,
PerpsOrderCapabilities,
PerpsPendingManualRecovery,
PerpsRecoveredDispatch,
Position,
SubscribeAccountParams,
SubscribeCandlesParams,
Expand Down Expand Up @@ -149,6 +151,10 @@ import type {
MYXCredentials,
} from './types/index.js';
import type { SortDirection } from './types/index.js';
import type {
LighterAuthConfig,
LighterSignerBridge,
} from './types/lighter-types.js';
import type {
PerpsControllerAllowedActions,
PerpsControllerAllowedEvents,
Expand Down Expand Up @@ -963,9 +969,12 @@ const MESSENGER_EXPOSED_METHODS = [
'getOrderCapabilities',
'getOrderFills',
'getOrders',
'getPendingManualRecoveries',
'getPendingTradeConfiguration',
'getPositions',
'getSelectedOrderType',
'getRecoveredDispatches',
'acknowledgeRecoveredDispatch',
'getTradeConfiguration',
'getRecentlyViewedMarkets',
'getVisibleCandleCount',
Expand Down Expand Up @@ -1057,6 +1066,8 @@ export class PerpsController extends BaseController<
/** Tracks the async MYX dynamic import so performInitialization can await it. */
#myxRegistrationPromise: Promise<void> | null = null;

#lighterRegistrationPromise: Promise<void> | null = null;

protected blockedRegionList: BlockedRegionList = {
list: [],
source: 'fallback',
Expand Down Expand Up @@ -1140,6 +1151,48 @@ export class PerpsController extends BaseController<
}
}

/**
* Check if the Lighter provider is enabled.
*
* Local override (`providerCredentials.lighter.enabled`) wins; otherwise
* the remote `perpsLighterProviderEnabled` feature flag decides — but only
* for clients that wired the venue signer bridge. A remote flag must not
* be able to register a trading provider the client never mounted a
* signer for (the gates would otherwise split between core and client).
*
* @returns True if the condition is met.
*/
#isLighterProviderEnabled(): boolean {
const lighter = this.#options.clientConfig?.providerCredentials?.lighter;

if (lighter?.enabled) {
return true;
}
if (!lighter?.signerBridge) {
return false;
}

try {
const remoteState = this.messenger.call(
'RemoteFeatureFlagController:getState',
);
const remoteFlag =
remoteState.remoteFeatureFlags?.perpsLighterProviderEnabled;

if (isVersionGatedFeatureFlag(remoteFlag)) {
const validated =
this.#options.infrastructure.featureFlags.validateVersionGated(
remoteFlag,
);
return validated ?? false;
}

return false;
} catch {
return false;
}
}

/**
* Active provider instance for routing operations.
* When activeProvider is 'hyperliquid' or 'myx': points to specific provider directly
Expand Down Expand Up @@ -1382,6 +1435,7 @@ export class PerpsController extends BaseController<
if (
providerId === 'hyperliquid' ||
(providerId === 'myx' && this.#isMYXProviderEnabled()) ||
(providerId === 'lighter' && this.#isLighterProviderEnabled()) ||
this.providers.has(providerId as PerpsProviderType)
) {
providerIds.add(providerId);
Expand Down Expand Up @@ -2244,8 +2298,10 @@ export class PerpsController extends BaseController<
await Promise.all([
wait(PERPS_CONSTANTS.ReconnectionCleanupDelayMs),
this.#myxRegistrationPromise,
this.#lighterRegistrationPromise,
]);
this.#myxRegistrationPromise = null;
this.#lighterRegistrationPromise = null;

this.#assignActiveProvider();

Expand Down Expand Up @@ -2384,6 +2440,24 @@ export class PerpsController extends BaseController<
})
.catch((error: unknown) => this.handleMYXImportError(error));
}

// Register Lighter provider if enabled (POC). Same dynamic-import pattern
// as MYX so clients that do not ship the Lighter files skip registration
// silently.
const isLighterEnabled = this.#isLighterProviderEnabled();
if (isLighterEnabled) {
// NOTE: Keep the path in a variable so ts-bridge does not rewrite the
// import argument and strip the webpackIgnore magic comment in core dist.
const lighterModulePath = './providers/LighterProvider';
this.#lighterRegistrationPromise = import(
/* webpackIgnore: true */ lighterModulePath
)
.then(({ LighterProvider }) => {
this.registerLighterProvider(LighterProvider);
return undefined;
})
.catch((error: unknown) => this.handleLighterImportError(error));
}
}

/**
Expand Down Expand Up @@ -2444,6 +2518,69 @@ export class PerpsController extends BaseController<
}
}

/**
* Registers the Lighter provider after dynamic import resolves.
*
* Extracted from the import().then() callback so it can be tested directly
* (Jest cannot resolve dynamic imports without --experimental-vm-modules).
*
* @param LighterProviderClass - Constructor class for the Lighter provider.
*/
protected registerLighterProvider(
LighterProviderClass: new (opts: {
isTestnet: boolean;
platformDependencies: PerpsPlatformDependencies;
messenger: PerpsControllerMessenger;
lighterAuthConfig: LighterAuthConfig;
signerBridge?: LighterSignerBridge;
}) => PerpsProvider,
): void {
const lighterIsTestnet =
PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY || this.state.isTestnet;
const lighter =
this.#options.clientConfig?.providerCredentials?.lighter ?? {};
const lighterProvider = new LighterProviderClass({
isTestnet: lighterIsTestnet,
platformDependencies: this.#options.infrastructure,
messenger: this.messenger,
signerBridge: lighter.signerBridge,
lighterAuthConfig: {
enabled: lighter.enabled,
accountIndex: lighterIsTestnet
? lighter.accountIndexTestnet
: lighter.accountIndexMainnet,
apiKeyIndex: lighter.apiKeyIndex,
},
});
this.providers.set('lighter', lighterProvider);
this.#debugLog('PerpsController: Lighter provider registered', {
isTestnet: lighterIsTestnet,
});
}

/**
* Handles errors from the Lighter dynamic import.
*
* Module-not-found errors are expected (clients may not ship Lighter) →
* debug log. Other errors indicate constructor/config problems → Sentry.
*
* @param error - The caught error from the dynamic import or constructor.
*/
protected handleLighterImportError(error: unknown): void {
const isModuleError =
(error as Record<string, unknown>)?.code === 'MODULE_NOT_FOUND';
if (isModuleError) {
this.#debugLog(
'PerpsController: Lighter provider module not available, skipping registration',
);
} else {
this.#logError(
error instanceof Error ? error : new Error(String(error)),
this.#getErrorContext('createProviders.lighter'),
);
}
}

/**
* Assigns the active provider instance based on the current activeProvider state.
* Separated from #createProviders so it runs after async MYX registration settles.
Expand Down Expand Up @@ -2473,13 +2610,13 @@ export class PerpsController extends BaseController<
this.#debugLog(
`PerpsController: Using direct provider (${activeProvider})`,
);
} else if (activeProvider === 'myx') {
const myxProvider = this.providers.get('myx');
if (myxProvider) {
this.activeProviderInstance = myxProvider;
} else if (activeProvider === 'myx' || activeProvider === 'lighter') {
const directProvider = this.providers.get(activeProvider);
if (directProvider) {
this.activeProviderInstance = directProvider;
} else {
this.#debugLog(
'PerpsController: MYX provider not available, falling back to hyperliquid',
`PerpsController: ${activeProvider} provider not available, falling back to hyperliquid`,
);
this.activeProviderInstance = hyperLiquidProvider;
this.update((state) => {
Expand All @@ -2491,7 +2628,7 @@ export class PerpsController extends BaseController<
);
} else {
throw new Error(
`Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', and 'aggregated' are supported.`,
`Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', 'lighter', and 'aggregated' are supported.`,
);
}
}
Expand Down Expand Up @@ -3701,6 +3838,54 @@ export class PerpsController extends BaseController<
});
}

/**
* List TP/SL protection changes the active provider parked for
* explicit manual re-establishment. Providers without durable
* settlement state return an empty list.
*
* @returns Pending manual-recovery entries.
*/
async getPendingManualRecoveries(): Promise<PerpsPendingManualRecovery[]> {
const provider = await this.#getActiveProviderWhenReady();
if (!provider.getPendingManualRecoveries) {
return [];
}
return provider.getPendingManualRecoveries();
}

/**
* READ-ONLY list of the active provider's recovered-dispatch outcomes
* (previously ambiguous submissions later resolved). Providers without
* durable dispatch state return an empty list.
*
* @returns Pending recovered-dispatch outcomes.
*/
async getRecoveredDispatches(): Promise<PerpsRecoveredDispatch[]> {
const provider = await this.#getActiveProviderWhenReady();
if (!provider.getRecoveredDispatches) {
return [];
}
return provider.getRecoveredDispatches();
}

/**
* Acknowledge ONE recovered-dispatch outcome by its stable id, after
* refreshing venue state. Throws when the active provider has no
* durable dispatch state or the id no longer matches.
*
* @param recoveryId - Stable id from {@link getRecoveredDispatches}.
* @returns Resolves when the outcome is acknowledged.
*/
async acknowledgeRecoveredDispatch(recoveryId: string): Promise<void> {
const provider = await this.#getActiveProviderWhenReady();
if (!provider.acknowledgeRecoveredDispatch) {
throw new Error(
'The active perps provider has no recovered dispatches to acknowledge',
);
}
return provider.acknowledgeRecoveredDispatch(recoveryId);
}
Comment thread
abretonc7s marked this conversation as resolved.

/**
* Get historical user orders (order lifecycle)
* Thin delegation to MarketDataService
Expand Down Expand Up @@ -4869,12 +5054,21 @@ export class PerpsController extends BaseController<
* Get maximum leverage allowed for an asset
*
* @param asset - The asset identifier.
* @param providerId - Optional provider route for aggregated markets.
* @returns A promise that resolves to the numeric result.
*/
async getMaxLeverage(asset: string): Promise<number> {
async getMaxLeverage(
asset: string,
providerId?: PerpsProviderType,
): Promise<number> {
const provider = this.getActiveProvider();
const context = this.#createServiceContext('getMaxLeverage');
return this.#marketDataService.getMaxLeverage({ provider, asset, context });
return this.#marketDataService.getMaxLeverage({
provider,
asset,
...(providerId === undefined ? {} : { providerId }),
context,
});
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from './perpsConfig.js';
export * from './transactionsHistoryConfig.js';
export * from './performanceMetrics.js';
export * from './myxConfig.js';
export * from './lighterConfig.js';
Loading