diff --git a/.js.env.example b/.js.env.example
index 44b2933f1140..db4926206f43 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -235,6 +235,14 @@ export MM_PERPS_GTM_MODAL_ENABLED="true"
export MM_PERPS_ORDER_BOOK_ENABLED="true"
export MM_PERPS_FEEDBACK_ENABLED="true"
export MM_PERPS_RELATED_MARKETS_ENABLED="true"
+# Build-time opt-in, including local dev. False excludes the embedded signer.
+# Restart Metro with a cleared cache after changing this flag.
+export MM_PERPS_LIGHTER_PROVIDER_ENABLED="false"
+# Optional Lighter testnet account override: decimal integer 0..9007199254740991.
+# Empty selects normal account discovery; malformed enabled overrides fail configuration.
+export MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET=""
+# Optional signer key slot: decimal integer 0..254. Empty uses the controller default.
+export MM_PERPS_LIGHTER_API_KEY_INDEX=""
export MM_PERPS_MYX_PROVIDER_ENABLED="true"
export MM_PERPS_MYX_APP_ID_TESTNET=""
export MM_PERPS_MYX_API_SECRET_TESTNET=""
diff --git a/app/__mocks__/lighterSignerHtml.ts b/app/__mocks__/lighterSignerHtml.ts
new file mode 100644
index 000000000000..cb8cb1f6ee3b
--- /dev/null
+++ b/app/__mocks__/lighterSignerHtml.ts
@@ -0,0 +1 @@
+export default '
';
diff --git a/app/components/UI/Perps/Lighter/LighterSignerWebView.test.tsx b/app/components/UI/Perps/Lighter/LighterSignerWebView.test.tsx
new file mode 100644
index 000000000000..4e1ba3e3bf00
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/LighterSignerWebView.test.tsx
@@ -0,0 +1,569 @@
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import React from 'react';
+import SecureKeychain from '../../../../core/SecureKeychain';
+import Logger from '../../../../util/Logger';
+import { act, render } from '@testing-library/react-native';
+import type { WebViewMessageEvent } from '@metamask/react-native-webview/src/WebViewTypes';
+
+import {
+ isValidLighterSignerResult,
+ LIGHTER_SIGNER_RELOAD_BASE_DELAY_MS,
+ LighterSignerWebView,
+ MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS,
+ parseLighterPageMessage,
+} from './LighterSignerWebView';
+import {
+ lighterSignerBridge,
+ resetLighterBridge,
+ reviveLighterBridge,
+} from './lighterSignerBridge';
+
+interface MockWebViewProps {
+ onMessage?: (event: WebViewMessageEvent) => void;
+ onError?: (event: never) => void;
+ onContentProcessDidTerminate?: () => void;
+ onShouldStartLoadWithRequest?: (request: { url: string }) => boolean;
+ setSupportMultipleWindows?: boolean;
+ allowFileAccess?: boolean;
+ allowUniversalAccessFromFileURLs?: boolean;
+}
+
+let mockWebViewProps: MockWebViewProps = {};
+let mockWebViewRenderCount = 0;
+let mockAttachWebViewRef = true;
+const mockPostMessage = jest.fn();
+const mockWebViewUnmount = jest.fn();
+
+jest.mock('react-redux', () => ({ useSelector: jest.fn(() => true) }));
+
+jest.mock('@metamask/react-native-webview', () => {
+ const react = jest.requireActual('react') as typeof import('react');
+ return {
+ WebView: react.forwardRef(
+ (props: MockWebViewProps, ref: React.ForwardedRef) => {
+ mockWebViewProps = props;
+ mockWebViewRenderCount += 1;
+ react.useEffect(() => () => mockWebViewUnmount(), []);
+ react.useImperativeHandle(
+ ref,
+ () =>
+ mockAttachWebViewRef ? { postMessage: mockPostMessage } : null,
+ [],
+ );
+ return null;
+ },
+ ),
+ };
+});
+
+jest.mock('../../../../util/Logger', () => ({
+ __esModule: true,
+ default: { error: jest.fn() },
+}));
+
+jest.mock('./wasm-wrapper.standalone.html', () => ({
+ __esModule: true,
+ default: '',
+}));
+
+jest.mock('../../../../core/Engine', () => ({
+ __esModule: true,
+ default: {
+ context: { KeyringController: { isUnlocked: jest.fn(() => true) } },
+ controllerMessenger: { subscribe: jest.fn(), tryUnsubscribe: jest.fn() },
+ },
+}));
+
+jest.mock('react-native-quick-crypto', () => ({
+ __esModule: true,
+ default: { randomBytes: jest.fn(() => Buffer.alloc(32, 0xab)) },
+}));
+
+jest.mock('../../../../core/SecureKeychain', () => ({
+ __esModule: true,
+ default: {
+ ACCESSIBLE: { WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'device-only' },
+ getSecureItem: jest.fn().mockResolvedValue(null),
+ setSecureItem: jest.fn().mockResolvedValue(true),
+ },
+}));
+
+jest.mock('../../../../util/test/utils', () => ({
+ isTestEnvironment: false,
+}));
+
+function messageEvent(message: unknown): WebViewMessageEvent {
+ return {
+ nativeEvent: { data: JSON.stringify(message) },
+ } as WebViewMessageEvent;
+}
+
+describe('LighterSignerWebView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(true);
+ jest.mocked(useSelector).mockReturnValue(true);
+ mockWebViewProps = {};
+ mockWebViewRenderCount = 0;
+ mockAttachWebViewRef = true;
+ });
+
+ afterEach(() => {
+ reviveLighterBridge();
+ resetLighterBridge();
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+ });
+
+ const emitWalletEvent = (
+ name: 'KeyringController:lock' | 'KeyringController:unlock',
+ ) => {
+ const subscription = jest
+ .mocked(Engine.controllerMessenger.subscribe)
+ .mock.calls.find(([event]) => event === name);
+ expect(subscription).toBeDefined();
+ (subscription?.[1] as () => void)();
+ };
+
+ it('does not mount the native signer while the wallet is locked', () => {
+ jest.mocked(useSelector).mockReturnValue(false);
+
+ render();
+
+ expect(mockWebViewRenderCount).toBe(0);
+ });
+
+ it('retires pending signing and destroys the native page immediately on lock', async () => {
+ render();
+ act(() => mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' })));
+ const pending = lighterSignerBridge
+ .execute({ function: '_createAuthToken', params: [28, 7] })
+ .catch((error: Error) => error);
+ await act(async () => undefined);
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
+ const previousPage = mockWebViewProps;
+
+ act(() => {
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(false);
+ emitWalletEvent('KeyringController:lock');
+ });
+
+ await expect(pending).resolves.toEqual(
+ new Error('Lighter signer wallet is locked'),
+ );
+ expect(mockWebViewUnmount).toHaveBeenCalledTimes(1);
+ act(() => previousPage.onMessage?.(messageEvent({ type: 'ready' })));
+ await expect(
+ lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ }),
+ ).rejects.toThrow('wallet is locked');
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not replace a live page for a repeated unlock notification', () => {
+ render();
+
+ act(() => emitWalletEvent('KeyringController:unlock'));
+
+ expect(mockWebViewRenderCount).toBe(1);
+ expect(mockWebViewUnmount).not.toHaveBeenCalled();
+ });
+
+ it('uses a fresh native page after a lock and unlock in one render batch', async () => {
+ render();
+ act(() => mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' })));
+
+ const retiredPage = mockWebViewProps;
+ act(() => {
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(false);
+ emitWalletEvent('KeyringController:lock');
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(true);
+ emitWalletEvent('KeyringController:unlock');
+ retiredPage.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+
+ expect(mockWebViewUnmount).toHaveBeenCalledTimes(1);
+ expect(mockWebViewRenderCount).toBe(2);
+ const pending = lighterSignerBridge
+ .execute({ function: '_createAuthToken', params: [28, 7] })
+ .catch((error: Error) => error);
+ await act(async () => undefined);
+ expect(mockPostMessage).not.toHaveBeenCalled();
+ act(() => mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' })));
+ await act(async () => undefined);
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
+ const { executeId } = JSON.parse(mockPostMessage.mock.calls[0][0]);
+ act(() =>
+ mockWebViewProps.onMessage?.(
+ messageEvent({
+ type: 'executeResult',
+ executeId,
+ result: { token: 'fresh', deadline: 123 },
+ }),
+ ),
+ );
+ await expect(pending).resolves.toEqual({ token: 'fresh', deadline: 123 });
+ });
+
+ it('parses only supported message discriminants and field types', () => {
+ const ready = parseLighterPageMessage('{"type":"ready"}');
+ const malformed = parseLighterPageMessage(
+ '{"type":"executeError","executeId":4,"message":[]}',
+ );
+
+ expect(ready).toStrictEqual({ type: 'ready' });
+ expect(malformed).toBeNull();
+ expect(parseLighterPageMessage('not-json')).toBeNull();
+ });
+
+ it('parses console forwarding messages only when the payload is string-shaped', () => {
+ // The page forwards console output as either a single string or an array
+ // of strings; anything else is a malformed frame and must be dropped.
+ expect(
+ parseLighterPageMessage('{"type":"log","message":"hello"}'),
+ ).toStrictEqual({
+ type: 'log',
+ message: 'hello',
+ });
+ expect(
+ parseLighterPageMessage('{"type":"warn","message":["a","b"]}'),
+ ).toStrictEqual({ type: 'warn', message: ['a', 'b'] });
+ expect(
+ parseLighterPageMessage('{"type":"error","message":"boom"}'),
+ ).toStrictEqual({ type: 'error', message: 'boom' });
+ expect(
+ parseLighterPageMessage('{"type":"log","message":[1,2]}'),
+ ).toBeNull();
+ expect(parseLighterPageMessage('{"type":"log","message":{}}')).toBeNull();
+ });
+
+ it('drops messages with an unknown or non-string discriminant', () => {
+ expect(parseLighterPageMessage('{"type":"unsupported"}')).toBeNull();
+ expect(parseLighterPageMessage('{"type":7}')).toBeNull();
+ expect(parseLighterPageMessage('[]')).toBeNull();
+ });
+
+ it('validates function-specific signer result shapes', () => {
+ expect(
+ isValidLighterSignerResult('_createClient', {
+ success: true,
+ pk: 'public-key',
+ pubKeySuccess: true,
+ body: 'body',
+ }),
+ ).toBe(true);
+ expect(
+ isValidLighterSignerResult('_createAuthToken', {
+ token: 'token',
+ deadline: 'tomorrow',
+ }),
+ ).toBe(false);
+ expect(
+ isValidLighterSignerResult('_signCreateOrder', { txInfo: 'signed' }),
+ ).toBe(true);
+ });
+
+ it('rejects immediately when the ready executor has no mounted WebView', async () => {
+ mockAttachWebViewRef = false;
+ render();
+ act(() => {
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+
+ await expect(pending).rejects.toThrow(
+ 'Lighter signer WebView is not mounted',
+ );
+ });
+
+ it('rejects pending execution when the signer unmounts', async () => {
+ const { unmount } = render();
+ act(() => {
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+ const pending = lighterSignerBridge
+ .execute({ function: '_createAuthToken', params: [28, 7] })
+ .catch((error: Error) => error);
+ await act(async () => undefined);
+
+ unmount();
+
+ await expect(pending).resolves.toEqual(
+ new Error('Lighter signer WebView unmounted'),
+ );
+ });
+
+ it('rejects setup when the host unmounts during keychain retrieval', async () => {
+ jest.useFakeTimers();
+ let finishRead!: (
+ value: Awaited>,
+ ) => void;
+ jest.mocked(SecureKeychain.getSecureItem).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRead = resolve;
+ }),
+ );
+ const { unmount } = render();
+ const rejected = jest.fn();
+ const pending = lighterSignerBridge
+ .createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ })
+ .catch(rejected);
+
+ unmount();
+ await jest.advanceTimersByTimeAsync(0);
+
+ expect(rejected).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining('reloaded') }),
+ );
+ finishRead(null);
+ await pending;
+ await jest.advanceTimersByTimeAsync(0);
+ expect(mockPostMessage).not.toHaveBeenCalled();
+ expect(jest.getTimerCount()).toBe(0);
+ });
+
+ it('rejects a response whose result does not match the requested function', async () => {
+ render();
+ act(() => {
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+ const pending = lighterSignerBridge
+ .execute({ function: '_createAuthToken', params: [28, 7] })
+ .catch((error: Error) => error);
+ await act(async () => undefined);
+ const posted = JSON.parse(mockPostMessage.mock.calls[0][0]) as {
+ executeId: string;
+ };
+
+ act(() => {
+ mockWebViewProps.onMessage?.(
+ messageEvent({
+ type: 'executeResult',
+ executeId: posted.executeId,
+ result: { token: 'missing-deadline' },
+ }),
+ );
+ });
+
+ await expect(pending).resolves.toEqual(
+ new Error('Invalid Lighter signer result for _createAuthToken'),
+ );
+ });
+
+ it.each([
+ [{ error: 'MarketIndex is not valid' }, 'MarketIndex is not valid'],
+ [
+ { error: 'MarketIndex is not valid', txInfo: 'unused' },
+ 'MarketIndex is not valid',
+ ],
+ [{ error: '' }, 'Invalid Lighter signer result for _signCreateOrder'],
+ [{ error: ' ' }, 'Invalid Lighter signer result for _signCreateOrder'],
+ [{ error: 42 }, 'Invalid Lighter signer result for _signCreateOrder'],
+ ])(
+ 'rejects signer failure %j without accepting a success payload',
+ async (result, expected) => {
+ render();
+ act(() => {
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+ const pending = lighterSignerBridge
+ .execute({
+ function: '_signCreateOrder',
+ params: [59, 4096, 123456, '13', '808550', 0, 1, 0, 0, '0', 0, 2],
+ })
+ .catch((error: Error) => error);
+ await act(async () => undefined);
+ const posted = JSON.parse(mockPostMessage.mock.calls[0][0]) as {
+ executeId: string;
+ };
+
+ act(() => {
+ mockWebViewProps.onMessage?.(
+ messageEvent({
+ type: 'executeResult',
+ executeId: posted.executeId,
+ result,
+ }),
+ );
+ });
+
+ await expect(pending).resolves.toEqual(new Error(expected));
+ },
+ );
+
+ it('records dev readiness without creating a throwaway WASM client', () => {
+ render();
+
+ act(() => {
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+
+ expect(mockPostMessage).not.toHaveBeenCalled();
+ });
+
+ it('blocks external and new-window navigation', () => {
+ render();
+
+ expect(mockWebViewProps.setSupportMultipleWindows).toBe(false);
+ expect(mockWebViewProps.allowFileAccess).toBe(false);
+ expect(mockWebViewProps.allowUniversalAccessFromFileURLs).toBe(false);
+ expect(
+ mockWebViewProps.onShouldStartLoadWithRequest?.({
+ url: 'https://attacker.example',
+ }),
+ ).toBe(false);
+ expect(
+ mockWebViewProps.onShouldStartLoadWithRequest?.({
+ url: 'https://localhost/',
+ }),
+ ).toBe(true);
+ });
+
+ it('does not exhaust reload attempts when each failure recovers', async () => {
+ // Regression: reloadAttemptsRef climbed for the whole WebView lifetime and
+ // was never cleared on a successful `ready`, so transient content-process
+ // deaths spread across a long session accumulated and tripped terminal
+ // unavailability even though the signer recovered every time.
+ jest.useFakeTimers();
+ render();
+
+ // Hoisted out of the loop: closing over `mockWebViewProps` inside a loop
+ // body trips `no-loop-func`.
+ const terminateRenderer = () => {
+ mockWebViewProps.onContentProcessDidTerminate?.();
+ };
+ const signalReady = () => {
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ };
+
+ // Twice the threshold, each failure followed by a successful ready.
+ for (
+ let attempt = 0;
+ attempt < MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS * 2;
+ attempt++
+ ) {
+ act(terminateRenderer);
+ await act(async () => {
+ await jest.advanceTimersByTimeAsync(
+ LIGHTER_SIGNER_RELOAD_BASE_DELAY_MS * 2,
+ );
+ });
+ act(signalReady);
+ }
+
+ // Still alive: the executor answers instead of a terminal rejection.
+ mockPostMessage.mockClear();
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+ await act(async () => undefined);
+ const posted = JSON.parse(mockPostMessage.mock.calls[0][0]) as {
+ executeId: string;
+ };
+
+ act(() => {
+ mockWebViewProps.onMessage?.(
+ messageEvent({
+ type: 'executeResult',
+ executeId: posted.executeId,
+ result: { token: 'ok', deadline: 1 },
+ }),
+ );
+ });
+ await expect(pending).resolves.toStrictEqual({ token: 'ok', deadline: 1 });
+ });
+
+ it('keeps the signer unavailable after a same-tick terminal ready event', async () => {
+ jest.useFakeTimers();
+ render();
+ const terminateRenderer = () => {
+ mockWebViewProps.onContentProcessDidTerminate?.();
+ };
+
+ for (
+ let attempt = 0;
+ attempt < MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS;
+ attempt++
+ ) {
+ act(terminateRenderer);
+ await act(async () => {
+ await jest.advanceTimersByTimeAsync(
+ LIGHTER_SIGNER_RELOAD_BASE_DELAY_MS * 2 ** attempt,
+ );
+ });
+ }
+ act(() => {
+ mockWebViewProps.onContentProcessDidTerminate?.();
+ mockWebViewProps.onMessage?.(messageEvent({ type: 'ready' }));
+ });
+
+ expect(mockWebViewRenderCount).toBe(MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS + 1);
+ await expect(
+ lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ }),
+ ).rejects.toThrow('unavailable after repeated WebView load failures');
+ });
+
+ it('retries WASM initialization failures until the reload limit is reached', async () => {
+ jest.useFakeTimers();
+ const { toJSON } = render();
+ const failInitialization = () => {
+ mockWebViewProps.onMessage?.(
+ messageEvent({ type: 'initError', message: 'Allocation failed' }),
+ );
+ };
+
+ for (
+ let attempt = 0;
+ attempt < MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS;
+ attempt++
+ ) {
+ act(failInitialization);
+ await act(async () => {
+ await jest.advanceTimersByTimeAsync(
+ LIGHTER_SIGNER_RELOAD_BASE_DELAY_MS * 2 ** attempt,
+ );
+ });
+ expect(mockWebViewRenderCount).toBe(attempt + 2);
+ }
+ act(failInitialization);
+
+ expect(toJSON()).toBeNull();
+ expect(Logger.error).toHaveBeenCalledTimes(1);
+ expect(Logger.error).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.objectContaining({ tags: { feature: 'perps' } }),
+ );
+ expect(jest.getTimerCount()).toBe(0);
+ await expect(
+ lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ }),
+ ).rejects.toThrow('unavailable after repeated WebView load failures');
+ });
+});
diff --git a/app/components/UI/Perps/Lighter/LighterSignerWebView.tsx b/app/components/UI/Perps/Lighter/LighterSignerWebView.tsx
new file mode 100644
index 000000000000..e55f40beaf5f
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/LighterSignerWebView.tsx
@@ -0,0 +1,480 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { View, StyleSheet } from 'react-native';
+import { WebView } from '@metamask/react-native-webview';
+import type {
+ WebViewMessageEvent,
+ WebViewErrorEvent,
+} from '@metamask/react-native-webview/src/WebViewTypes';
+// Inlined as a string by babel-plugin-inline-import (scoped override in
+// babel.config.js). The page embeds the Lighter Go/WASM signer as base64 —
+// fully local, NO network access.
+// @ts-expect-error HTML import handled by babel-plugin-inline-import.
+import lighterSdkHtml from './wasm-wrapper.standalone.html';
+import {
+ connectLighterExecutor,
+ reviveLighterBridge,
+ resetLighterBridge,
+ setLighterBridgeUnavailable,
+ LIGHTER_SIGNER_LOCKED_ERROR,
+ type LighterExecutorCall,
+ type LighterExecutor,
+} from './lighterSignerBridge';
+import DevLogger from '../../../../core/SDKConnect/utils/DevLogger';
+import { isTestEnvironment } from '../../../../util/test/utils';
+import Logger from '../../../../util/Logger';
+import { PERPS_CONSTANTS } from '@metamask/perps-controller';
+import Engine from '../../../../core/Engine';
+import { selectIsUnlocked } from '../../../../selectors/keyringController';
+
+/**
+ * Log dev-only signer readiness without mutating the page's live WASM client.
+ */
+function logReadyLatency(mountedAt: number): void {
+ if (!__DEV__ || isTestEnvironment) {
+ return;
+ }
+ const readyAt = Date.now();
+ DevLogger.log(
+ `[LighterSignerBridge] perf mount→ready ${readyAt - mountedAt}ms`,
+ );
+}
+
+const styles = StyleSheet.create({
+ hidden: {
+ height: 0,
+ width: 0,
+ flex: 0,
+ maxWidth: 0,
+ maxHeight: 0,
+ overflow: 'hidden',
+ },
+});
+
+export const MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS = 3;
+export const LIGHTER_SIGNER_RELOAD_BASE_DELAY_MS = 1_000;
+export const LIGHTER_SIGNER_WEBVIEW_TEST_ID = 'lighter-signer-webview';
+const UNAVAILABLE_ERROR =
+ 'Lighter signer is unavailable after repeated WebView load failures';
+const LIGHTER_SIGNER_DOCUMENT_URL = 'https://localhost/';
+
+const executePromises: Record<
+ string,
+ | {
+ resolve: (value: unknown) => void;
+ reject: (reason?: unknown) => void;
+ timer: ReturnType;
+ functionName: LighterExecutorCall['function'];
+ }
+ | undefined
+> = {};
+
+// Correlates a `execute` message with its `executeResult`/`executeError`
+// reply. A module-scoped counter is collision-free by construction, so it
+// needs no randomness — and keeps this off the security-review surface for
+// a file that drives a signer.
+let nextExecuteSequence = 0;
+
+/**
+ * Reject and drop every in-flight call. Must run whenever the WebView
+ * reloads (crash recovery, content-process loss): the page-side WASM state
+ * is gone, so pending calls can never resolve and callers must retry
+ * against the re-initialized signer instead of hanging forever.
+ *
+ * @param reason - Error message forwarded to each pending caller.
+ */
+function rejectAllPending(reason: string): void {
+ for (const executeId of Object.keys(executePromises)) {
+ const pending = executePromises[executeId];
+ if (pending) {
+ clearTimeout(pending.timer);
+ pending.reject(new Error(reason));
+ }
+ delete executePromises[executeId];
+ }
+}
+
+type LighterPageMessage =
+ | { type: 'ready' }
+ | { type: 'initError'; message: string }
+ | { type: 'executeResult'; executeId: string; result: unknown }
+ | { type: 'executeError'; executeId: string; message: string }
+ | { type: 'log' | 'warn' | 'error'; message: string | string[] };
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isLogMessage(value: unknown): value is string | string[] {
+ return (
+ typeof value === 'string' ||
+ (Array.isArray(value) && value.every((item) => typeof item === 'string'))
+ );
+}
+
+function hasOptionalString(
+ value: Record,
+ key: string,
+): boolean {
+ return value[key] === undefined || typeof value[key] === 'string';
+}
+
+export function parseLighterPageMessage(
+ data: string,
+): LighterPageMessage | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data) as unknown;
+ } catch {
+ return null;
+ }
+ if (!isRecord(parsed) || typeof parsed.type !== 'string') {
+ return null;
+ }
+
+ switch (parsed.type) {
+ case 'ready':
+ return { type: 'ready' };
+ case 'initError':
+ return typeof parsed.message === 'string'
+ ? { type: 'initError', message: parsed.message }
+ : null;
+ case 'executeResult':
+ return typeof parsed.executeId === 'string' && 'result' in parsed
+ ? {
+ type: 'executeResult',
+ executeId: parsed.executeId,
+ result: parsed.result,
+ }
+ : null;
+ case 'executeError':
+ return typeof parsed.executeId === 'string' &&
+ typeof parsed.message === 'string'
+ ? {
+ type: 'executeError',
+ executeId: parsed.executeId,
+ message: parsed.message,
+ }
+ : null;
+ case 'log':
+ case 'warn':
+ case 'error':
+ return isLogMessage(parsed.message)
+ ? { type: parsed.type, message: parsed.message }
+ : null;
+ default:
+ return null;
+ }
+}
+
+export function isValidLighterSignerResult(
+ functionName: LighterExecutorCall['function'],
+ result: unknown,
+): boolean {
+ if (!isRecord(result) || !hasOptionalString(result, 'error')) {
+ return false;
+ }
+ if (functionName === '_createClient') {
+ return (
+ typeof result.success === 'boolean' &&
+ typeof result.pk === 'string' &&
+ typeof result.pubKeySuccess === 'boolean' &&
+ typeof result.body === 'string'
+ );
+ }
+ if (functionName === '_createAuthToken') {
+ return (
+ typeof result.token === 'string' &&
+ typeof result.deadline === 'number' &&
+ Number.isFinite(result.deadline)
+ );
+ }
+ return (
+ typeof result.txInfo === 'string' && hasOptionalString(result, 'txHash')
+ );
+}
+
+/**
+ * Hidden off-screen WebView hosting the Lighter Go/WASM signer.
+ *
+ * Follows the SnapsExecutionWebView precedent (0x0 view mounted in Root before
+ * Engine interactions) and the reference Lighter RN SDK postMessage protocol:
+ * page → RN: ready | initError | log | warn | error | executeResult | executeError;
+ * RN → page: { type: 'execute', function, params, executeId }.
+ *
+ * On `ready` it connects the module-level lighterSignerBridge executor, which
+ * releases any calls PerpsController queued before mount.
+ */
+const UnlockedLighterSignerWebView = () => {
+ const webviewRef = useRef(null);
+ const mountedAtRef = useRef(Date.now());
+ const [reloadKey, setReloadKey] = useState(0);
+ const pageGenerationRef = useRef(0);
+ const [isUnavailable, setIsUnavailable] = useState(false);
+ const reloadAttemptsRef = useRef(0);
+ const reloadTimerRef = useRef | null>(null);
+ const isMountedRef = useRef(true);
+ const isUnavailableRef = useRef(false);
+ const [isWalletLocked, setIsWalletLocked] = useState(false);
+ const isWalletLockedRef = useRef(false);
+
+ const handleFailure = useCallback(() => {
+ if (
+ reloadTimerRef.current ||
+ !isMountedRef.current ||
+ isWalletLockedRef.current ||
+ isUnavailableRef.current
+ ) {
+ return;
+ }
+ rejectAllPending('Lighter signer WebView reloaded; retry the operation');
+ pageGenerationRef.current += 1;
+ if (reloadAttemptsRef.current >= MAX_LIGHTER_SIGNER_RELOAD_ATTEMPTS) {
+ isUnavailableRef.current = true;
+ setIsUnavailable(true);
+ setLighterBridgeUnavailable(UNAVAILABLE_ERROR);
+ Logger.error(new Error(UNAVAILABLE_ERROR), {
+ tags: { feature: PERPS_CONSTANTS.FeatureName },
+ context: {
+ name: 'LighterSignerWebView',
+ data: { reloadAttempts: reloadAttemptsRef.current },
+ },
+ });
+ return;
+ }
+ resetLighterBridge();
+
+ const delay =
+ LIGHTER_SIGNER_RELOAD_BASE_DELAY_MS * 2 ** reloadAttemptsRef.current;
+ reloadAttemptsRef.current += 1;
+ reloadTimerRef.current = setTimeout(() => {
+ reloadTimerRef.current = null;
+ if (isMountedRef.current) {
+ setReloadKey(pageGenerationRef.current);
+ }
+ }, delay);
+ }, []);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ // A previous host instance may have exhausted its reloads and left the
+ // bridge terminally unavailable. This mount is a real remount, so the
+ // signer can serve calls again.
+ const messenger = Engine.controllerMessenger;
+ const onLock = () => {
+ pageGenerationRef.current += 1;
+ isWalletLockedRef.current = true;
+ setIsWalletLocked(true);
+ if (reloadTimerRef.current) {
+ clearTimeout(reloadTimerRef.current);
+ reloadTimerRef.current = null;
+ }
+ rejectAllPending(LIGHTER_SIGNER_LOCKED_ERROR);
+ setLighterBridgeUnavailable(LIGHTER_SIGNER_LOCKED_ERROR);
+ };
+ const onUnlock = () => {
+ if (!isWalletLockedRef.current) return;
+ isWalletLockedRef.current = false;
+ setIsWalletLocked(false);
+ isUnavailableRef.current = false;
+ setIsUnavailable(false);
+ reloadAttemptsRef.current = 0;
+ // A new native page cannot retain the prior unlocked session's key.
+ pageGenerationRef.current += 1;
+ setReloadKey(pageGenerationRef.current);
+ reviveLighterBridge();
+ };
+ messenger.subscribe('KeyringController:lock', onLock);
+ messenger.subscribe('KeyringController:unlock', onUnlock);
+ if (Engine.context.KeyringController.isUnlocked()) {
+ reviveLighterBridge();
+ } else {
+ onLock();
+ }
+ return () => {
+ isMountedRef.current = false;
+ messenger.tryUnsubscribe('KeyringController:lock', onLock);
+ messenger.tryUnsubscribe('KeyringController:unlock', onUnlock);
+ if (reloadTimerRef.current) {
+ clearTimeout(reloadTimerRef.current);
+ reloadTimerRef.current = null;
+ }
+ rejectAllPending('Lighter signer WebView unmounted');
+ resetLighterBridge();
+ };
+ }, []);
+
+ const onMessage = useCallback(
+ (event: WebViewMessageEvent) => {
+ if (
+ !isMountedRef.current ||
+ isWalletLockedRef.current ||
+ reloadKey !== pageGenerationRef.current
+ )
+ return;
+ const message = parseLighterPageMessage(event.nativeEvent.data);
+ if (!message) {
+ DevLogger.log(
+ '[LighterSignerWebView] Invalid message',
+ event.nativeEvent.data,
+ );
+ return;
+ }
+
+ switch (message.type) {
+ case 'ready': {
+ if (reloadTimerRef.current || isUnavailableRef.current) {
+ return;
+ }
+ DevLogger.log('[LighterSignerWebView] WASM signer ready');
+ // The threshold counts CONSECUTIVE failed reloads. Without this reset
+ // the counter climbs for the whole WebView lifetime, so a handful of
+ // transient content-process deaths spread across a long session — each
+ // one fully recovered — would eventually trip terminal unavailability.
+ reloadAttemptsRef.current = 0;
+ const execute: LighterExecutor = (call, timeoutMs) => {
+ const webview = webviewRef.current;
+ if (!webview) {
+ return Promise.reject(
+ new Error('Lighter signer WebView is not mounted'),
+ );
+ }
+ return new Promise((resolve, reject) => {
+ nextExecuteSequence += 1;
+ const executeId = `${call.function}_${nextExecuteSequence}`;
+ const timer = setTimeout(() => {
+ delete executePromises[executeId];
+ reject(
+ new Error(
+ `Lighter signer call ${call.function} timed out after ${timeoutMs}ms`,
+ ),
+ );
+ }, timeoutMs);
+ executePromises[executeId] = {
+ resolve,
+ reject,
+ timer,
+ functionName: call.function,
+ };
+ try {
+ webview.postMessage(
+ JSON.stringify({ type: 'execute', ...call, executeId }),
+ );
+ } catch (error) {
+ clearTimeout(timer);
+ delete executePromises[executeId];
+ reject(error);
+ }
+ });
+ };
+ connectLighterExecutor(execute);
+ logReadyLatency(mountedAtRef.current);
+ break;
+ }
+ case 'executeResult':
+ {
+ const pendingResult = executePromises[message.executeId];
+ if (pendingResult) {
+ clearTimeout(pendingResult.timer);
+ if (
+ isRecord(message.result) &&
+ typeof message.result.error === 'string' &&
+ message.result.error.trim().length > 0
+ ) {
+ // WASM failures return an error without the success payload.
+ pendingResult.reject(new Error(message.result.error));
+ } else if (
+ isValidLighterSignerResult(
+ pendingResult.functionName,
+ message.result,
+ )
+ ) {
+ pendingResult.resolve(message.result);
+ } else {
+ pendingResult.reject(
+ new Error(
+ `Invalid Lighter signer result for ${pendingResult.functionName}`,
+ ),
+ );
+ }
+ }
+ delete executePromises[message.executeId];
+ }
+ break;
+ case 'executeError':
+ {
+ const pendingError = executePromises[message.executeId];
+ if (pendingError) {
+ clearTimeout(pendingError.timer);
+ pendingError.reject(new Error(message.message));
+ }
+ delete executePromises[message.executeId];
+ }
+ break;
+ case 'log':
+ case 'warn':
+ case 'error':
+ case 'initError':
+ DevLogger.log(
+ `[LighterSignerWebView] page ${message.type}:`,
+ message.message,
+ );
+ if (message.type === 'initError') {
+ handleFailure();
+ }
+ break;
+ }
+ },
+ [handleFailure, reloadKey],
+ );
+
+ if (isUnavailable || isWalletLocked) {
+ return null;
+ }
+
+ return (
+
+
+ url === LIGHTER_SIGNER_DOCUMENT_URL || url === 'about:blank'
+ }
+ // Android otherwise lets target=_blank and window.open bypass the
+ // navigation callback through a separate WebView.
+ setSupportMultipleWindows={false}
+ javaScriptCanOpenWindowsAutomatically={false}
+ allowFileAccess={false}
+ allowFileAccessFromFileURLs={false}
+ allowUniversalAccessFromFileURLs={false}
+ javaScriptEnabled
+ webviewDebuggingEnabled={__DEV__}
+ onMessage={onMessage}
+ onContentProcessDidTerminate={handleFailure}
+ // Android equivalent of iOS content-process termination.
+ onRenderProcessGone={handleFailure}
+ onError={(event: WebViewErrorEvent) => {
+ DevLogger.log(
+ '[LighterSignerWebView] load error',
+ event.nativeEvent.description,
+ );
+ // A failed load leaves the page without a WASM runtime; reload and
+ // fail pending callers rather than letting them hang.
+ handleFailure();
+ }}
+ />
+
+ );
+};
+
+/** Keep the key-holding native page outside locked wallet sessions. */
+export const LighterSignerWebView = () => {
+ const isUnlocked = useSelector(selectIsUnlocked);
+ return isUnlocked ? : null;
+};
diff --git a/app/components/UI/Perps/Lighter/lighterOrderNormalization.test.ts b/app/components/UI/Perps/Lighter/lighterOrderNormalization.test.ts
new file mode 100644
index 000000000000..b0dcec62540a
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/lighterOrderNormalization.test.ts
@@ -0,0 +1,67 @@
+import { adaptOrderFromLighter } from '@metamask/perps-controller/utils/lighterAdapter';
+
+type LighterOrder = Parameters[0];
+
+const createOrder = (overrides: Partial = {}): LighterOrder => ({
+ orderIndex: 123,
+ clientOrderIndex: 456,
+ marketIndex: 4096,
+ ownerAccountIndex: 1,
+ initialBaseAmount: '0.00029',
+ remainingBaseAmount: '0.00000',
+ price: '70000.0',
+ isAsk: false,
+ type: 'limit',
+ timeInForce: 'good-till-time',
+ reduceOnly: false,
+ status: 'canceled',
+ orderExpiry: 0,
+ timestamp: 1789951987,
+ ...overrides,
+});
+
+// Exercise the installed controller to guard the upstream fix for the
+// regression observed in the Lighter testnet cancellation proof.
+describe('Lighter order normalization', () => {
+ it.each([
+ ['canceled', '0.00000', '0'],
+ ['canceled', '0.00010', '0.0001'],
+ ['filled', '0.00029', '0.00029'],
+ ])(
+ 'uses the venue filled amount for a %s order with %s filled',
+ (status, filledBaseAmount, expectedFilledSize) => {
+ const order = createOrder({ status, filledBaseAmount });
+
+ const result = adaptOrderFromLighter(order, 'BTC');
+
+ expect(result.filledSize).toBe(expectedFilledSize);
+ expect(result.status).toBe(status);
+ expect(result.providerId).toBe('lighter');
+ },
+ );
+
+ it('preserves the explicit filled amount for an active partial order', () => {
+ const order = createOrder({
+ status: 'open',
+ filledBaseAmount: '0.00010',
+ remainingBaseAmount: '0.00019',
+ });
+
+ const result = adaptOrderFromLighter(order, 'BTC');
+
+ expect(result.filledSize).toBe('0.0001');
+ expect(result.remainingSize).toBe('0.00019');
+ });
+
+ it('retains the size-difference fallback when the venue omits filled amount', () => {
+ const order = createOrder({
+ status: 'open',
+ initialBaseAmount: '1',
+ remainingBaseAmount: '0.75',
+ });
+
+ const result = adaptOrderFromLighter(order, 'BTC');
+
+ expect(result.filledSize).toBe('0.25');
+ });
+});
diff --git a/app/components/UI/Perps/Lighter/lighterRegistration.test.ts b/app/components/UI/Perps/Lighter/lighterRegistration.test.ts
new file mode 100644
index 000000000000..a1a0284ef28b
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/lighterRegistration.test.ts
@@ -0,0 +1,55 @@
+import {
+ assertLighterClientParameters,
+ assertLighterRegistrationMessage,
+} from './lighterRegistration';
+
+// Actual pinned-WASM output for a disposable offline key; no wallet key used.
+const params = { chainId: 300, accountIndex: 59, nonce: 123, apiKeyIndex: 8 };
+const publicKey =
+ 'bb562af383a8e24c9443aa29f7130fab895f8d2cea7f9ae7abf747f32d1cf62f8bf2ed9c296e979b';
+const body = `Register Lighter Account\n\npubkey: 0x${publicKey}\nnonce: 0x000000000000007b\naccount index: 0x000000000000003b\napi key index: 0x0000000000000008\nOnly sign this message for a trusted client!`;
+const result = { success: true, pubKeySuccess: true, pk: publicKey, body };
+
+describe('Lighter registration authorization', () => {
+ it('accepts the pinned signer registration format for the requested metadata', () => {
+ expect(() =>
+ assertLighterRegistrationMessage(params, result),
+ ).not.toThrow();
+ });
+
+ it.each([
+ 'Sign this unrelated message',
+ body + '\nAdditional authorization',
+ body.replace('000000000000007b', '000000000000007c'),
+ body.replace('000000000000003b', '000000000000003c'),
+ body.replace('0000000000000008', '0000000000000009'),
+ body.replace(publicKey, 'a'.repeat(80)),
+ ])('rejects an unrelated or mismatched registration body', (message) => {
+ expect(() =>
+ assertLighterRegistrationMessage(params, { ...result, body: message }),
+ ).toThrow('does not match');
+ });
+
+ it.each([{ pk: 'short' }, { pubKeySuccess: false }, { success: false }])(
+ 'rejects unsuccessful or malformed public-key output',
+ (override) => {
+ expect(() =>
+ assertLighterRegistrationMessage(params, { ...result, ...override }),
+ ).toThrow('public key');
+ },
+ );
+
+ it.each([
+ { chainId: 1 },
+ { nonce: -1 },
+ { nonce: Number.NaN },
+ { accountIndex: 1.5 },
+ { accountIndex: Number.MAX_SAFE_INTEGER + 1 },
+ { apiKeyIndex: 255 },
+ { apiKeyIndex: Number.POSITIVE_INFINITY },
+ ])('rejects unsupported client metadata', (override) => {
+ expect(() =>
+ assertLighterClientParameters({ ...params, ...override }),
+ ).toThrow('parameters');
+ });
+});
diff --git a/app/components/UI/Perps/Lighter/lighterRegistration.ts b/app/components/UI/Perps/Lighter/lighterRegistration.ts
new file mode 100644
index 000000000000..4fd8c816784a
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/lighterRegistration.ts
@@ -0,0 +1,62 @@
+import type { LighterCreateClientParams } from '@metamask/perps-controller';
+import {
+ LIGHTER_MAINNET_CHAIN_ID,
+ LIGHTER_TESTNET_CHAIN_ID,
+} from '@metamask/perps-controller/constants/lighterConfig';
+
+/** Validate public client metadata before key storage or registration approval. */
+export function assertLighterClientParameters(
+ params: LighterCreateClientParams,
+): void {
+ if (
+ ![LIGHTER_TESTNET_CHAIN_ID, LIGHTER_MAINNET_CHAIN_ID].includes(
+ params.chainId,
+ ) ||
+ ![params.accountIndex, params.apiKeyIndex, params.nonce].every(
+ (value) => Number.isSafeInteger(value) && value >= 0,
+ ) ||
+ params.apiKeyIndex > 254
+ ) {
+ throw new Error('Invalid Lighter signer client parameters');
+ }
+}
+
+/**
+ * Permit only the protocol's registration plaintext for this client and key.
+ * The pinned signer remains trusted for public-key derivation. Lighter's L1
+ * plaintext has no chain field; the client key scope and L2 signature bind the
+ * chain separately. This does not claim L1-message chain-domain separation.
+ */
+export function assertLighterRegistrationMessage(
+ params: LighterCreateClientParams,
+ result: {
+ success: boolean;
+ pubKeySuccess: boolean;
+ pk: string;
+ body: string;
+ },
+): void {
+ assertLighterClientParameters(params);
+ if (
+ !result.success ||
+ !result.pubKeySuccess ||
+ !/^[0-9a-f]{80}$/u.test(result.pk)
+ ) {
+ throw new Error('Invalid Lighter registration public key');
+ }
+ const hex = (value: number) => value.toString(16).padStart(16, '0');
+ const expected = [
+ 'Register Lighter Account',
+ '',
+ `pubkey: 0x${result.pk}`,
+ `nonce: 0x${hex(params.nonce)}`,
+ `account index: 0x${hex(params.accountIndex)}`,
+ `api key index: 0x${hex(params.apiKeyIndex)}`,
+ 'Only sign this message for a trusted client!',
+ ].join('\n');
+ if (result.body !== expected) {
+ throw new Error(
+ 'Lighter registration message does not match the requested client',
+ );
+ }
+}
diff --git a/app/components/UI/Perps/Lighter/lighterSignerBridge.test.ts b/app/components/UI/Perps/Lighter/lighterSignerBridge.test.ts
new file mode 100644
index 000000000000..9ff4d62dc194
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/lighterSignerBridge.test.ts
@@ -0,0 +1,517 @@
+import Engine from '../../../../core/Engine';
+import {
+ connectLighterExecutor,
+ LIGHTER_SIGNER_TIMEOUT_MS,
+ lighterSignerBridge,
+ resetLighterBridge,
+ reviveLighterBridge,
+ setLighterBridgeUnavailable,
+} from './lighterSignerBridge';
+import QuickCrypto from 'react-native-quick-crypto';
+import type { Result as KeychainResult } from 'react-native-keychain';
+import SecureKeychain from '../../../../core/SecureKeychain';
+
+jest.mock('../../../../core/Engine', () => ({
+ __esModule: true,
+ default: {
+ context: { KeyringController: { isUnlocked: jest.fn(() => true) } },
+ controllerMessenger: { subscribe: jest.fn(), tryUnsubscribe: jest.fn() },
+ },
+}));
+
+jest.mock('react-native-quick-crypto', () => ({
+ __esModule: true,
+ default: {
+ randomBytes: jest.fn(() => Buffer.alloc(32, 0xab)),
+ },
+}));
+
+jest.mock('../../../../core/SecureKeychain', () => ({
+ __esModule: true,
+ default: {
+ ACCESSIBLE: { WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'device-only' },
+ getSecureItem: jest.fn(),
+ setSecureItem: jest.fn(),
+ },
+}));
+
+const mockSecureKeychain = jest.mocked(SecureKeychain);
+const clientResult = {
+ success: true,
+ pubKeySuccess: true,
+ pk: 'a'.repeat(80),
+ body: `Register Lighter Account\n\npubkey: 0x${'a'.repeat(80)}\nnonce: 0x0000000000000009\naccount index: 0x000000000000001c\napi key index: 0x0000000000000007\nOnly sign this message for a trusted client!`,
+};
+
+describe('lighterSignerBridge', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(true);
+ mockSecureKeychain.getSecureItem.mockResolvedValue(null);
+ mockSecureKeychain.setSecureItem.mockResolvedValue({
+ service: 'test',
+ storage: 'KeystoreAESGCM_NoAuth',
+ } as KeychainResult);
+ });
+
+ afterEach(() => {
+ reviveLighterBridge();
+ resetLighterBridge();
+ jest.useRealTimers();
+ });
+
+ it('rejects work arriving after wallet lock before reading the key', async () => {
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(false);
+
+ await expect(
+ lighterSignerBridge.createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ }),
+ ).rejects.toThrow('wallet is locked');
+
+ expect(mockSecureKeychain.getSecureItem).not.toHaveBeenCalled();
+ });
+
+ it('checks wallet state again before returning an executor result', async () => {
+ connectLighterExecutor(
+ jest.fn().mockImplementation(async () => {
+ jest
+ .mocked(Engine.context.KeyringController.isUnlocked)
+ .mockReturnValue(false);
+ return { token: 'retired', deadline: 123 };
+ }),
+ );
+
+ await expect(
+ lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ }),
+ ).rejects.toThrow('wallet is locked');
+ });
+
+ it('queues calls until the executor connects, then executes them', async () => {
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+ const executor = jest
+ .fn()
+ .mockResolvedValue({ token: 'ok', deadline: 123 });
+ connectLighterExecutor(executor);
+
+ await expect(pending).resolves.toStrictEqual({
+ token: 'ok',
+ deadline: 123,
+ });
+ expect(executor).toHaveBeenCalledWith(
+ {
+ function: '_createAuthToken',
+ params: [28, 7],
+ },
+ expect.any(Number),
+ );
+ });
+
+ it('rejects callers waiting on readiness when the WebView reloads', async () => {
+ // The page-side WASM state is gone on reload; a queued caller must fail
+ // fast and retry, not hang on a readiness promise that was re-armed.
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+ resetLighterBridge();
+ await expect(pending).rejects.toThrow(
+ 'Lighter signer WebView reloaded; retry the operation',
+ );
+ });
+
+ it('retires setup immediately when reset interrupts key retrieval', async () => {
+ jest.useFakeTimers();
+ let finishRead!: (
+ value: Awaited>,
+ ) => void;
+ mockSecureKeychain.getSecureItem.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRead = resolve;
+ }),
+ );
+ const rejected = jest.fn();
+ const executor = jest.fn();
+ const pending = lighterSignerBridge
+ .createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ })
+ .catch(rejected);
+
+ resetLighterBridge();
+ await jest.advanceTimersByTimeAsync(0);
+
+ expect(rejected).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining('reloaded') }),
+ );
+ connectLighterExecutor(executor);
+ finishRead(null);
+ await pending;
+ await Promise.resolve();
+ expect(mockSecureKeychain.setSecureItem).not.toHaveBeenCalled();
+ expect(executor).not.toHaveBeenCalled();
+ });
+
+ it('retires connected execution when the bridge resets', async () => {
+ jest.useFakeTimers();
+ const executor = jest.fn(() => new Promise(() => undefined));
+ connectLighterExecutor(executor);
+ const rejected = jest.fn();
+ const pending = lighterSignerBridge
+ .execute({ function: '_createAuthToken', params: [28, 7] })
+ .catch(rejected);
+ await jest.advanceTimersByTimeAsync(0);
+ expect(executor).toHaveBeenCalledTimes(1);
+
+ resetLighterBridge();
+ await jest.advanceTimersByTimeAsync(0);
+
+ expect(rejected).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining('reloaded') }),
+ );
+ await pending;
+ });
+
+ it('times out instead of queueing forever when the signer never mounts', async () => {
+ jest.useFakeTimers();
+ try {
+ let failure: Error | null = null;
+ const pending = lighterSignerBridge
+ .execute({ function: '_createAuthToken', params: [28, 7] })
+ .catch((error: Error) => {
+ failure = error;
+ });
+ jest.advanceTimersByTime(90_001);
+ await pending;
+ expect(String(failure)).toContain('Lighter signer not ready within');
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('passes only the remaining shared deadline to page execution', async () => {
+ jest.useFakeTimers();
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+ await jest.advanceTimersByTimeAsync(60_000);
+ const executor = jest
+ .fn()
+ .mockResolvedValue({ token: 'ok', deadline: 123 });
+
+ connectLighterExecutor(executor);
+ await pending;
+
+ expect(executor).toHaveBeenCalledWith(
+ expect.objectContaining({ function: '_createAuthToken' }),
+ LIGHTER_SIGNER_TIMEOUT_MS - 60_000,
+ );
+ });
+
+ it('rejects future calls immediately after terminal unavailability', async () => {
+ const executor = jest.fn();
+ connectLighterExecutor(executor);
+ setLighterBridgeUnavailable('Lighter signer unavailable');
+
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+
+ await expect(pending).rejects.toThrow('Lighter signer unavailable');
+ expect(executor).not.toHaveBeenCalled();
+ });
+
+ it('ignores late executor connections after terminal unavailability', async () => {
+ const zombieExecutor = jest.fn();
+ setLighterBridgeUnavailable('Lighter signer unavailable');
+
+ connectLighterExecutor(zombieExecutor);
+ const pending = lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ });
+
+ await expect(pending).rejects.toThrow('Lighter signer unavailable');
+ expect(zombieExecutor).not.toHaveBeenCalled();
+ });
+
+ it('keeps failing fast when reset follows terminal unavailability', async () => {
+ // Regression: reset() cleared unavailableError and re-armed readiness, so a
+ // controller reset after exhaustion left callers waiting on a signer page
+ // that never remounts — they hung until the 90s deadline instead of
+ // failing immediately.
+ setLighterBridgeUnavailable('Lighter signer unavailable');
+ resetLighterBridge();
+
+ await expect(
+ lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ }),
+ ).rejects.toThrow('Lighter signer unavailable');
+ });
+
+ it('serves calls again after an explicit revive', async () => {
+ // Only a real host remount may revive the bridge.
+ setLighterBridgeUnavailable('Lighter signer unavailable');
+ reviveLighterBridge();
+
+ const executor = jest.fn().mockResolvedValue({ token: 'ok', deadline: 1 });
+ connectLighterExecutor(executor);
+
+ await expect(
+ lighterSignerBridge.execute({
+ function: '_createAuthToken',
+ params: [28, 7],
+ }),
+ ).resolves.toStrictEqual({ token: 'ok', deadline: 1 });
+ });
+
+ it('persists a generated key and keeps it inside createClient transport params', async () => {
+ const executor = jest.fn().mockResolvedValue(clientResult);
+ connectLighterExecutor(executor);
+
+ await lighterSignerBridge.createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ });
+
+ const privateKey = Buffer.alloc(32, 0xab).toString('hex');
+ expect(QuickCrypto.randomBytes).toHaveBeenCalledWith(32);
+ expect(mockSecureKeychain.setSecureItem).toHaveBeenCalledWith(
+ 'LIGHTER_SIGNER_PRIVATE_KEY',
+ privateKey,
+ expect.objectContaining({
+ service: 'com.metamask.PERPS_LIGHTER_SIGNER.300.28.7',
+ }),
+ );
+ expect(executor).toHaveBeenCalledWith(
+ {
+ function: '_createClient',
+ params: [privateKey, 300, 28, 9, 7],
+ },
+ expect.any(Number),
+ );
+ });
+ it('reuses a valid stored key instead of generating a new one', async () => {
+ const storedKey = 'a'.repeat(64);
+ mockSecureKeychain.getSecureItem.mockResolvedValue({
+ value: storedKey,
+ } as Awaited>);
+ const executor = jest.fn().mockResolvedValue(clientResult);
+ connectLighterExecutor(executor);
+
+ await lighterSignerBridge.createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ });
+
+ expect(QuickCrypto.randomBytes).not.toHaveBeenCalled();
+ expect(mockSecureKeychain.setSecureItem).not.toHaveBeenCalled();
+ expect(executor).toHaveBeenCalledWith(
+ {
+ function: '_createClient',
+ params: [storedKey, 300, 28, 9, 7],
+ },
+ expect.any(Number),
+ );
+ });
+
+ it.each([false, true])(
+ 'keeps one persisted key when setup overlaps a pending write (reset: %s)',
+ async (reset) => {
+ let stored: Awaited> =
+ null;
+ let finishWrite!: () => void;
+ const writing = new Promise((resolve) => {
+ finishWrite = resolve;
+ });
+ let notifyWrite!: () => void;
+ const writeStarted = new Promise((resolve) => {
+ notifyWrite = resolve;
+ });
+ mockSecureKeychain.getSecureItem.mockImplementation(async () => stored);
+ mockSecureKeychain.setSecureItem.mockImplementation(
+ async (_key, value) => {
+ notifyWrite();
+ await writing;
+ stored = { key: 'LIGHTER_SIGNER_PRIVATE_KEY', value };
+ return {
+ service: 'test',
+ storage: 'KeystoreAESGCM_NoAuth',
+ } as KeychainResult;
+ },
+ );
+ const executor = jest.fn().mockResolvedValue(clientResult);
+ connectLighterExecutor(executor);
+ const params = {
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ };
+
+ const first = lighterSignerBridge
+ .createClient(params)
+ .catch((error: unknown) => error);
+ await writeStarted;
+ if (reset) {
+ resetLighterBridge();
+ connectLighterExecutor(executor);
+ }
+ const second = lighterSignerBridge.createClient(params);
+ finishWrite();
+ const firstResult = await first;
+ if (reset) {
+ expect(firstResult).toEqual(
+ new Error('Lighter signer WebView reloaded; retry the operation'),
+ );
+ } else {
+ expect(firstResult).toStrictEqual(clientResult);
+ }
+ await expect(second).resolves.toStrictEqual(clientResult);
+
+ expect(QuickCrypto.randomBytes).toHaveBeenCalledTimes(1);
+ expect(mockSecureKeychain.setSecureItem).toHaveBeenCalledTimes(1);
+ expect(executor).toHaveBeenCalledTimes(reset ? 1 : 2);
+ const persisted = await mockSecureKeychain.getSecureItem({
+ service: 'com.metamask.PERPS_LIGHTER_SIGNER.300.28.7',
+ });
+ for (const [call] of executor.mock.calls) {
+ expect(call.params[0]).toBe(persisted?.value);
+ }
+ },
+ );
+
+ it('rejects an unrelated registration body before returning it to the controller', async () => {
+ connectLighterExecutor(
+ jest.fn().mockResolvedValue({
+ ...clientResult,
+ body: 'Approve an unrelated message',
+ }),
+ );
+
+ const pending = lighterSignerBridge.createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ });
+
+ await expect(pending).rejects.toThrow('does not match');
+ });
+
+ it('rejects a stored key that is not a 64-character hex string', async () => {
+ // A corrupted or truncated keychain entry must fail loudly rather than
+ // reach the WASM signer, where it would produce invalid signatures.
+ mockSecureKeychain.getSecureItem.mockResolvedValue({
+ value: 'not-a-valid-key',
+ } as Awaited>);
+ connectLighterExecutor(jest.fn());
+
+ await expect(
+ lighterSignerBridge.createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ }),
+ ).rejects.toThrow('Stored Lighter signer key is invalid');
+ });
+
+ it('surfaces a keychain write failure instead of returning an unpersisted key', async () => {
+ // setSecureItem returning false means the key never reached the keychain;
+ // continuing would sign with a key that cannot be recovered next launch.
+ mockSecureKeychain.setSecureItem.mockResolvedValue(
+ false as unknown as Awaited<
+ ReturnType
+ >,
+ );
+ connectLighterExecutor(jest.fn());
+
+ await expect(
+ lighterSignerBridge.createClient({
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ }),
+ ).rejects.toThrow('Unable to persist Lighter signer key');
+ });
+
+ it('allows another setup after a keychain write rejects', async () => {
+ mockSecureKeychain.setSecureItem.mockRejectedValueOnce(
+ new Error('Keychain write failed'),
+ );
+ connectLighterExecutor(jest.fn().mockResolvedValue(clientResult));
+ const params = {
+ chainId: 300,
+ accountIndex: 28,
+ nonce: 9,
+ apiKeyIndex: 7,
+ };
+
+ await expect(lighterSignerBridge.createClient(params)).rejects.toThrow(
+ 'Keychain write failed',
+ );
+ await expect(
+ lighterSignerBridge.createClient(params),
+ ).resolves.toStrictEqual(clientResult);
+
+ expect(mockSecureKeychain.setSecureItem).toHaveBeenCalledTimes(2);
+ });
+
+ it('notifies reset listeners and stops after unsubscribe', () => {
+ const listener = jest.fn();
+ if (!lighterSignerBridge.onReset) {
+ throw new Error('Mobile Lighter bridge must support reset listeners');
+ }
+ const unsubscribe = lighterSignerBridge.onReset(listener);
+
+ resetLighterBridge();
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ unsubscribe();
+ resetLighterBridge();
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps notifying remaining listeners when one throws', () => {
+ // A misbehaving listener must not abort bridge recovery for the others.
+ const failing = jest.fn(() => {
+ throw new Error('listener boom');
+ });
+ const healthy = jest.fn();
+ if (!lighterSignerBridge.onReset) {
+ throw new Error('Mobile Lighter bridge must support reset listeners');
+ }
+ lighterSignerBridge.onReset(failing);
+ lighterSignerBridge.onReset(healthy);
+
+ expect(() => resetLighterBridge()).not.toThrow();
+ expect(healthy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/UI/Perps/Lighter/lighterSignerBridge.ts b/app/components/UI/Perps/Lighter/lighterSignerBridge.ts
new file mode 100644
index 000000000000..269b8c289b7e
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/lighterSignerBridge.ts
@@ -0,0 +1,313 @@
+import QuickCrypto from 'react-native-quick-crypto';
+import type {
+ LighterCreateClientParams,
+ LighterSignerBridge,
+ LighterWasmCall,
+} from '@metamask/perps-controller';
+
+import SecureKeychain from '../../../../core/SecureKeychain';
+import Engine from '../../../../core/Engine';
+import {
+ assertLighterClientParameters,
+ assertLighterRegistrationMessage,
+} from './lighterRegistration';
+
+export interface LighterExecutorCall {
+ function: LighterWasmCall['function'];
+ params: unknown[];
+}
+
+export type LighterExecutor = (
+ call: LighterExecutorCall,
+ timeoutMs: number,
+) => Promise;
+
+/** Upper bound covering both the pre-ready wait and the page round-trip. */
+export const LIGHTER_SIGNER_TIMEOUT_MS = 90_000;
+const LIGHTER_SIGNER_KEY_PREFIX = 'com.metamask.PERPS_LIGHTER_SIGNER';
+const LIGHTER_SIGNER_KEY_NAME = 'LIGHTER_SIGNER_PRIVATE_KEY';
+const PRIVATE_KEY_PATTERN = /^[0-9a-f]{64}$/u;
+const RELOAD_ERROR = 'Lighter signer WebView reloaded; retry the operation';
+export const LIGHTER_SIGNER_LOCKED_ERROR = 'Lighter signer wallet is locked';
+
+const resetListeners = new Set<() => void>();
+// Keep these across bridge resets: native keychain writes can outlive callers.
+const keychainOperations = new Map>();
+let generation = 0;
+let executor: LighterExecutor | null = null;
+let unavailableError: Error | null = null;
+// Terminal unavailability outlives a reset: once the WebView exhausts its
+// reload attempts it unmounts for good, so only a real remount (which
+// reconnects an executor) can revive the bridge. Without this, a controller
+// reset() would clear `unavailableError` and re-arm readiness against a page
+// that never comes back, leaving callers to hang until the 90s deadline.
+let isTerminallyUnavailable = false;
+let readyResolve: () => void;
+let readyReject: (reason: Error) => void;
+let ready: Promise;
+
+function armReadiness(): void {
+ ready = new Promise((resolve, reject) => {
+ readyResolve = resolve;
+ readyReject = reject;
+ });
+ // A reset can happen before a caller attaches to the readiness promise.
+ ready.catch(() => undefined);
+}
+
+armReadiness();
+
+function notifyResetListeners(): void {
+ for (const listener of resetListeners) {
+ try {
+ listener();
+ } catch {
+ // Listener errors must not break bridge recovery.
+ }
+ }
+}
+
+function timeoutAfter(
+ promise: Promise,
+ timeoutMs: number,
+ message: string,
+): Promise {
+ if (timeoutMs <= 0) {
+ return Promise.reject(new Error(message));
+ }
+ let timer: ReturnType;
+ return Promise.race([
+ promise,
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
+ }),
+ ]).finally(() => clearTimeout(timer));
+}
+
+function signerKeyScope(params: LighterCreateClientParams) {
+ return {
+ service: `${LIGHTER_SIGNER_KEY_PREFIX}.${params.chainId}.${params.accountIndex}.${params.apiKeyIndex}`,
+ accessible: SecureKeychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
+ };
+}
+
+function assertGeneration(owner: number): void {
+ if (!Engine.context.KeyringController.isUnlocked()) {
+ throw new Error(LIGHTER_SIGNER_LOCKED_ERROR);
+ }
+ if (unavailableError) throw unavailableError;
+ if (owner !== generation) throw new Error(RELOAD_ERROR);
+}
+
+async function withOwnership(
+ operation: (owner: number, retired: Promise) => Promise,
+): Promise {
+ const owner = generation;
+ assertGeneration(owner);
+ let rejectRetired!: (error: Error) => void;
+ const retired = new Promise((_resolve, reject) => {
+ rejectRetired = reject;
+ });
+ retired.catch(() => undefined);
+ const onReset = () => {
+ if (owner !== generation) {
+ rejectRetired(unavailableError ?? new Error(RELOAD_ERROR));
+ }
+ };
+ resetListeners.add(onReset);
+ try {
+ const result = await operation(owner, retired);
+ assertGeneration(owner);
+ return result;
+ } finally {
+ resetListeners.delete(onReset);
+ }
+}
+
+async function getOrCreatePrivateKey(
+ params: LighterCreateClientParams,
+ owner: number,
+): Promise {
+ const scope = signerKeyScope(params);
+ const previous = keychainOperations.get(scope.service);
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ keychainOperations.set(scope.service, pending);
+ try {
+ if (previous) await previous;
+ assertGeneration(owner);
+ const stored = await SecureKeychain.getSecureItem(scope);
+ assertGeneration(owner);
+ if (stored) {
+ if (!PRIVATE_KEY_PATTERN.test(stored.value)) {
+ throw new Error('Stored Lighter signer key is invalid');
+ }
+ return stored.value;
+ }
+
+ const privateKey = QuickCrypto.randomBytes(32).toString('hex');
+ const storedKey = await SecureKeychain.setSecureItem(
+ LIGHTER_SIGNER_KEY_NAME,
+ privateKey,
+ scope,
+ );
+ assertGeneration(owner);
+ if (storedKey === false) {
+ throw new Error('Unable to persist Lighter signer key');
+ }
+ return privateKey;
+ } finally {
+ release();
+ if (keychainOperations.get(scope.service) === pending) {
+ keychainOperations.delete(scope.service);
+ }
+ }
+}
+
+async function executeWithDeadline(
+ call: LighterExecutorCall,
+ deadline: number,
+ owner: number,
+ retired: Promise,
+): Promise {
+ assertGeneration(owner);
+
+ await timeoutAfter(
+ Promise.race([ready, retired]),
+ deadline - Date.now(),
+ `Lighter signer not ready within ${LIGHTER_SIGNER_TIMEOUT_MS}ms for ${call.function}`,
+ );
+
+ assertGeneration(owner);
+ const connectedExecutor = executor;
+ if (!connectedExecutor) {
+ throw new Error('Lighter signer bridge executor not connected');
+ }
+
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) {
+ throw new Error(
+ `Lighter signer call ${call.function} exceeded the ${LIGHTER_SIGNER_TIMEOUT_MS}ms deadline`,
+ );
+ }
+ return timeoutAfter(
+ Promise.race([connectedExecutor(call, remainingMs), retired]),
+ remainingMs,
+ `Lighter signer call ${call.function} exceeded the ${LIGHTER_SIGNER_TIMEOUT_MS}ms deadline`,
+ );
+}
+
+/** Connect the executor after the WASM page reports ready. */
+export function connectLighterExecutor(newExecutor: LighterExecutor): void {
+ if (unavailableError) {
+ return;
+ }
+ executor = newExecutor;
+ readyResolve();
+}
+
+/**
+ * Revive the bridge after terminal unavailability. Only the signer host may
+ * call this, and only when it is actually remounting the WebView — a late
+ * executor from the dying page must never resurrect a dead signer.
+ */
+export function reviveLighterBridge(): void {
+ if (!isTerminallyUnavailable) {
+ return;
+ }
+ isTerminallyUnavailable = false;
+ unavailableError = null;
+ executor = null;
+ armReadiness();
+}
+
+/** Re-arm the bridge while the WebView reloads. */
+export function resetLighterBridge(): void {
+ generation += 1;
+ executor = null;
+ if (isTerminallyUnavailable) {
+ // Keep failing fast rather than re-arming readiness for a dead page.
+ notifyResetListeners();
+ return;
+ }
+ unavailableError = null;
+ readyReject(new Error(RELOAD_ERROR));
+ armReadiness();
+ notifyResetListeners();
+}
+
+/** Fail current and future calls after the WebView exhausts reload attempts. */
+export function setLighterBridgeUnavailable(reason: string): void {
+ const error = new Error(reason);
+ executor = null;
+ isTerminallyUnavailable = true;
+ unavailableError = error;
+ generation += 1;
+ readyReject(error);
+ notifyResetListeners();
+}
+
+type LighterCreateClientResult = Awaited<
+ ReturnType
+>;
+
+const createClient: LighterSignerBridge['createClient'] = (params) =>
+ withOwnership(async (owner, retired) => {
+ assertLighterClientParameters(params);
+ const deadline = Date.now() + LIGHTER_SIGNER_TIMEOUT_MS;
+ const privateKey = await timeoutAfter(
+ Promise.race([getOrCreatePrivateKey(params, owner), retired]),
+ deadline - Date.now(),
+ `Lighter signer client setup exceeded the ${LIGHTER_SIGNER_TIMEOUT_MS}ms deadline`,
+ );
+ const result = (await executeWithDeadline(
+ {
+ function: '_createClient',
+ params: [
+ privateKey,
+ params.chainId,
+ params.accountIndex,
+ params.nonce,
+ params.apiKeyIndex,
+ ],
+ },
+ deadline,
+ owner,
+ retired,
+ )) as LighterCreateClientResult;
+ assertLighterRegistrationMessage(params, result);
+ return result;
+ });
+
+// Results are validated against the pending call's operation before the
+// WebView executor resolves; this assertion restores the package's generic
+// operation/result relationship at the transport boundary.
+const execute = (async (call: LighterWasmCall) => {
+ if (call.function === '_createClient') {
+ const [chainId, accountIndex, nonce, apiKeyIndex] = call.params;
+ return createClient({ chainId, accountIndex, nonce, apiKeyIndex });
+ }
+ return withOwnership((owner, retired) =>
+ executeWithDeadline(
+ call,
+ Date.now() + LIGHTER_SIGNER_TIMEOUT_MS,
+ owner,
+ retired,
+ ),
+ );
+}) as LighterSignerBridge['execute'];
+
+/** Singleton bridge handed to PerpsController in the Lighter credentials. */
+export const lighterSignerBridge: LighterSignerBridge = {
+ createClient,
+ execute,
+ onReset(listener: () => void): () => void {
+ resetListeners.add(listener);
+ return () => {
+ resetListeners.delete(listener);
+ };
+ },
+ reset: resetLighterBridge,
+};
diff --git a/app/components/UI/Perps/Lighter/wasm-provenance.md b/app/components/UI/Perps/Lighter/wasm-provenance.md
new file mode 100644
index 000000000000..28dc87cedccc
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/wasm-provenance.md
@@ -0,0 +1,33 @@
+# Embedded signer provenance
+
+The embedded WASM was updated for authorized local testnet validation from
+Lighter's deployed testnet frontend. The Go runtime and Mobile message bridge enforce local-only execution and
+private-key response redaction. Registration output is validated before it
+can reach the controller's headless personal-sign path.
+
+- Artifact: https://testnet.app.lighter.xyz/assets/target-Cp9SgPiX.wasm
+- SHA-256: `6874fd2b8174db7f21683b4d21c29861e32999f6d05cc53d5f1b91bd89db85d3`
+- Size: 7,708,261 bytes
+- Embedded build identity: Go 1.26.8, `github.com/elliottech/zklighter-sdk`, revision `ddc1b9821f14e4194d91e38812d9ea2df69fcd60`
+- Referenced by the official frontend's `useAgentChat-DVz6UTij.js`.
+
+The previous signer was built locally. Its current-market regression was
+reproduced offline: order signing rejects testnet ETH4095 and BTC4096. The
+replacement signs those IDs using a disposable key with network access
+disabled. The public lighter-go source does not contain the replacement's
+revision; this artifact must not be described as a reproducible local build
+of that revision. Mainnet financial compatibility has not been validated.
+
+The pinned signer remains trusted for cryptography and public-key derivation.
+Mobile accepts only the exact `Register Lighter Account` plaintext binding the
+returned 40-byte public key, requested nonce, account index, and API-key index.
+Arbitrary text or mismatched fields are rejected before the controller receives
+the result. Supported chain IDs are checked and key storage is chain-scoped.
+The protocol's EIP-191 plaintext contains no chain ID; its L2 transaction
+signature carries the chain binding. This is not a claim of chain-domain
+separation in the L1 plaintext itself.
+
+The HTML content security policy permits only the four embedded scripts by
+SHA-256 hash and allows WASM compilation. It does not permit arbitrary inline
+scripts. `scripts/lighter-wasm-wrapper.test.ts` checks these hashes against the
+actual script contents; update the hashes whenever an embedded script changes.
diff --git a/app/components/UI/Perps/Lighter/wasm-wrapper.standalone.html b/app/components/UI/Perps/Lighter/wasm-wrapper.standalone.html
new file mode 100644
index 000000000000..c50dded2a95f
--- /dev/null
+++ b/app/components/UI/Perps/Lighter/wasm-wrapper.standalone.html
@@ -0,0 +1,699 @@
+
+
+
+
+
+
+ Lighter WASM signer
+
+
+
+
+
+
+