Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
23f9b72
feat(perps): spike — investigate Lighter as second perps venue
abretonc7s Aug 15, 2026
1e5160b
feat(perps): add experimental Lighter venue provider POC
abretonc7s Aug 15, 2026
848a69d
feat(perps): stream Lighter live data over WebSocket and extend the t…
abretonc7s Aug 15, 2026
62ab9b7
feat(perps-controller): Lighter TP/SL, margin/leverage, and history r…
abretonc7s Aug 15, 2026
c2f0de5
fix(perps-controller): align Lighter editOrder/TPSL results with Orde…
abretonc7s Aug 15, 2026
22027ea
feat(perps-controller): enable Lighter mainnet
abretonc7s Aug 16, 2026
200943f
feat(perps-controller): Lighter HyperLiquid-parity history, routes, a…
abretonc7s Aug 16, 2026
55b90e6
fix(perps-controller): review fixes for the Lighter provider
abretonc7s Aug 16, 2026
14e50c5
fix(perps-controller): address external review of the Lighter provider
abretonc7s Aug 16, 2026
8b910c8
fix(perps-controller): close re-review gaps in the Lighter provider
abretonc7s Aug 16, 2026
3f0608a
fix(perps-controller): third-review round — races, sizing, and honest…
abretonc7s Aug 16, 2026
f13031e
fix(perps-controller): cross-review round 1 — session fences, close s…
abretonc7s Aug 16, 2026
279374a
fix(perps-controller): cross-review round 2 — serialized signer, hone…
abretonc7s Aug 16, 2026
378c75f
fix(perps-controller): round-4 review — atomic session identity, hone…
abretonc7s Aug 16, 2026
9e1d27f
fix(perps-controller): round-5 review — ownership, WS identity, consi…
abretonc7s Aug 16, 2026
95fc311
fix(perps-controller): round-6 consolidated — leverage arity artifact…
abretonc7s Aug 16, 2026
29671c3
fix(perps-controller): round-7 consolidated — random uint48 client id…
abretonc7s Aug 16, 2026
7de45e6
fix(perps-controller): round-8 — bounded id allocation, market valida…
abretonc7s Aug 16, 2026
40688a2
fix(perps-controller): round-9 — finite-positive intent and safe wire…
abretonc7s Aug 16, 2026
15f082b
fix(perps-controller): round-10 — execution-price wire parity, preval…
abretonc7s Aug 16, 2026
f8a4e7a
fix(perps-controller): round-11 — TP/SL full preflight and create-bef…
abretonc7s Aug 16, 2026
f0fbd90
fix(perps-controller): round-12 — venue-input integrity boundary, ser…
abretonc7s Aug 16, 2026
b87e515
fix(perps-controller): round-13 — position sign contract, settlement …
abretonc7s Aug 16, 2026
d0ac021
fix(perps-controller): round-13/14 — durable per-attempt TP/SL journa…
abretonc7s Aug 16, 2026
e16d9cb
fix(perps-controller): round-14/15 — authoritative tx-hash settlement…
abretonc7s Aug 16, 2026
a6a4dbe
fix(perps-controller): round-16 — durable operation intent, lifecycle…
abretonc7s Aug 17, 2026
34422da
fix(perps-controller): round-17 — journal operation identity (CAS), d…
abretonc7s Aug 17, 2026
4a0c583
fix(perps-controller): round-18 — pinned-WASM dispatch identity, dura…
abretonc7s Aug 17, 2026
f8d0ed0
fix(perps-controller): round-19 — process-wide serialization, complet…
abretonc7s Aug 17, 2026
1c2ae2d
fix(perps-controller)!: round-20 — financial idempotency quarantine, …
abretonc7s Aug 17, 2026
574a102
fix(perps-controller)!: round-21 — quarantine persistence, selective …
abretonc7s Aug 17, 2026
233be5a
fix(perps-controller): round-22 — single ledger mutex for all RMW, po…
abretonc7s Aug 17, 2026
4bbf916
fix(perps-controller): round-23 — atomic post-dispatch ledger transit…
abretonc7s Aug 17, 2026
5420849
Merge remote-tracking branch 'origin/main' into TAT-3766-feat-spike-l…
abretonc7s Aug 17, 2026
da8d4f9
fix(perps-controller): CI compatibility — regenerate messenger action…
abretonc7s Aug 17, 2026
50624d3
fix(perps-controller): mainnet write gate, aggregated recovery surfac…
abretonc7s Aug 17, 2026
9006493
fix(perps-controller): repo-wide lint conformance for the aggregated …
abretonc7s Aug 17, 2026
a045856
fix(perps-controller): allow mainnet signer setup for authenticated r…
abretonc7s Aug 17, 2026
ce81a72
fix(perps-controller): refuse mainnet key registration before any sig…
abretonc7s Aug 17, 2026
9d3128e
docs(perps-controller): document the allowMainnetSignerSetup parameter
abretonc7s Aug 17, 2026
3034be0
test(perps-controller): deterministic Lighter WASM signer build
abretonc7s Aug 18, 2026
82166c4
fix(perps-controller): device-validation defects — empty-size full cl…
abretonc7s Aug 18, 2026
40fd9d0
fix(perps-controller): venue-derived route override and binding per-m…
abretonc7s Aug 18, 2026
90c6fe1
fix(perps-controller): grid-aware binding minimum order size for Ligh…
abretonc7s Aug 18, 2026
5a8ade9
fix(perps-controller): serve venue market metadata instead of Termina…
abretonc7s Aug 18, 2026
6ba1e14
fix(perps-controller): validate USD-derived Lighter order sizes on th…
abretonc7s Aug 18, 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
3 changes: 3 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add Lighter as a perps venue (initial implementation, disabled by default) ([#9889](https://github.com/MetaMask/core/pull/9889))
- `PerpsProviderType` gains `'lighter'`. 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). Venue writes are limited to testnet in this release; mainnet is read-only.
- 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`.
- Add `PERPS_EVENT_PROPERTY.PREVIOUS_LEVERAGE` (`previous_leverage`) for Perp UI Interaction `leverage_changed` events so clients can import the Segment property key from `@metamask/perps-controller` instead of a local interim constant ([#9881](https://github.com/MetaMask/core/pull/9881))

## [12.0.0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,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 @@ -1185,6 +1222,9 @@ export type PerpsControllerMethodActions =
| PerpsControllerWithdrawAction
| PerpsControllerGetPositionsAction
| PerpsControllerGetOrderFillsAction
| PerpsControllerGetPendingManualRecoveriesAction
| PerpsControllerGetRecoveredDispatchesAction
| PerpsControllerAcknowledgeRecoveredDispatchAction
| PerpsControllerGetOrdersAction
| PerpsControllerGetOpenOrdersAction
| PerpsControllerGetFundingAction
Expand Down
197 changes: 191 additions & 6 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ import type {
OrderResult,
PerpsControllerConfig,
PerpsMarketData,
PerpsPendingManualRecovery,
PerpsRecoveredDispatch,
Position,
SubscribeAccountParams,
SubscribeCandlesParams,
Expand Down Expand Up @@ -134,6 +136,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 @@ -900,8 +906,11 @@ const MESSENGER_EXPOSED_METHODS = [
'getOrderBookGrouping',
'getOrderFills',
'getOrders',
'getPendingManualRecoveries',
'getPendingTradeConfiguration',
'getPositions',
'getRecoveredDispatches',
'acknowledgeRecoveredDispatch',
'getTradeConfiguration',
'getRecentlyViewedMarkets',
'getWatchlistMarkets',
Expand Down Expand Up @@ -983,6 +992,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 @@ -1066,6 +1077,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 @@ -1309,6 +1362,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 @@ -2121,8 +2175,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 @@ -2260,6 +2316,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 @@ -2317,6 +2391,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 @@ -2346,13 +2483,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 @@ -2364,7 +2501,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 @@ -3347,6 +3484,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
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