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 + + + +
+ + + + + + + diff --git a/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.testIds.ts b/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.testIds.ts new file mode 100644 index 000000000000..335c0a8851e9 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.testIds.ts @@ -0,0 +1,5 @@ +export const PerpsBalanceOrderViewSelectorsIDs = { + CONTAINER: 'perps-balance-order', + BACK: 'perps-balance-order-back', + ERROR: 'perps-balance-order-error', +} as const; diff --git a/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.tsx b/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.tsx new file mode 100644 index 000000000000..0714e490ad8f --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useRef } from 'react'; +import { ScrollView } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + useIsFocused, + useNavigation, + useRoute, + type RouteProp, +} from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { + Box, + HeaderStandard, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../locales/i18n'; +import type { PerpsNavigationParamList } from '../../types/navigation'; +import { + PerpsOrderProvider, + usePerpsOrderContext, +} from '../../contexts/PerpsOrderContext'; +import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; +import { selectPerpsProvider } from '../../selectors/perpsController'; +import { PROVIDER_CONFIG } from '../../constants/perpsConfig'; +import PerpsLoader from '../../components/PerpsLoader'; +import PerpsProOrderFormPanel from '../PerpsProMarketView/components/PerpsProOrderFormPanel'; +import { PerpsBalanceOrderViewSelectorsIDs } from './PerpsBalanceOrderView.testIds'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller/constants'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream'; +import { TraceName } from '../../../../../util/trace'; + +/** Applies an order-book limit selection once, without overwriting later edits. */ +const InitialLimitPrice = ({ price }: { price?: string }) => { + const { commitLimitPrice } = usePerpsOrderContext(); + const applied = useRef(false); + useEffect(() => { + if (!applied.current) { + applied.current = true; + if (price !== undefined) commitLimitPrice(price); + } + }, [price, commitLimitPrice]); + return null; +}; + +/** Direct venue-balance trading, independent of deposit confirmations and UI mode. */ +const PerpsBalanceOrderView = () => { + const { params } = + useRoute>(); + const navigation = useNavigation(); + const isFocused = useIsFocused(); + const tw = useTailwind(); + const insets = useSafeAreaInsets(); + const scrollViewRef = useRef(null); + const activeProvider = useSelector(selectPerpsProvider); + const { markets, isLoading } = usePerpsMarkets(); + const orderProvider = params.providerId ?? activeProvider; + const isLighterOrder = orderProvider === 'lighter'; + const defaultMarketProvider = + activeProvider !== undefined && + activeProvider !== PROVIDER_CONFIG.AggregatedProvider + ? activeProvider + : PROVIDER_CONFIG.DefaultProvider; + const market = isLighterOrder + ? markets.find( + ({ symbol, providerId }) => + symbol === params.asset && + (providerId ?? defaultMarketProvider) === orderProvider, + ) + : undefined; + const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); + const prices = usePerpsLivePrices({ + symbols: market ? [market.symbol] : [], + throttleMs: 1000, + }); + const isReady = + Boolean(market) && + !isLoading && + !isLoadingAccount && + Boolean(account) && + Number(prices[params.asset]?.price) > 0; + const screenKey = `${orderProvider}:${params.asset}`; + usePerpsMeasurement({ + traceName: TraceName.PerpsOrderView, + resetKey: screenKey, + ownerActive: isFocused, + startConditions: [isFocused, isLighterOrder], + endConditions: [isReady], + debugContext: { asset: params.asset, provider: orderProvider }, + }); + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + resetKey: screenKey, + conditions: [isFocused, isReady], + properties: { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: PERPS_EVENT_VALUE.SCREEN_TYPE.TRADING, + [PERPS_EVENT_PROPERTY.ASSET]: params.asset, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.direction, + [PERPS_EVENT_PROPERTY.SOURCE]: + params.source ?? PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + [PERPS_EVENT_PROPERTY.ASSET_TYPE]: PERPS_EVENT_VALUE.ASSET_TYPE.PERP, + [PERPS_EVENT_PROPERTY.HAS_PERP_BALANCE]: + Number(account?.totalBalance) > 0, + }, + }); + + return ( + + + {!isLighterOrder || (!isLoading && !market) ? ( + + {strings('perps.market.details.error_message')} + + ) : !market ? ( + + ) : ( + + + + + + + )} + + ); +}; + +export default PerpsBalanceOrderView; diff --git a/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.view.test.tsx b/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.view.test.tsx new file mode 100644 index 000000000000..88fa3818516e --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.view.test.tsx @@ -0,0 +1,299 @@ +import '../../../../../../tests/component-view/mocks'; + +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { renderPerpsView } from '../../../../../../tests/component-view/renderers/perpsViewRenderer'; +import { createFundedAccountForViews } from '../../../../../../tests/component-view/fixtures/perpsViewFixtures'; +import Engine from '../../../../../core/Engine'; +import Routes from '../../../../../constants/navigation/Routes'; +import { analytics } from '../../../../../util/analytics/analytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller/constants'; +import { + PerpsMarketDetailsViewSelectorsIDs, + PerpsProOrderFormSelectorsIDs, +} from '../../Perps.testIds'; +import PerpsMarketDetailsView from '../PerpsMarketDetailsView'; +import PerpsBalanceOrderView from './PerpsBalanceOrderView'; +import { PerpsBalanceOrderViewSelectorsIDs } from './PerpsBalanceOrderView.testIds'; + +const market = { + symbol: 'ETH', + name: 'Ethereum', + price: '$2,500.00', + change24h: '+$50.00', + change24hPercent: '+2%', + volume: '$1.5B', + openInterest: '$500M', + maxLeverage: '50x', + marketType: 'crypto', + providerId: 'lighter' as const, + szDecimals: 2, +}; + +const options = { + mode: 'lite' as const, + overrides: { + engine: { + backgroundState: { + PerpsController: { activeProvider: 'lighter' as const }, + }, + }, + }, + streamOverrides: { + marketData: [market], + account: createFundedAccountForViews('1000'), + prices: { + ETH: { + symbol: 'ETH', + price: '2500', + markPrice: '2500', + timestamp: 1, + isTradable: true, + }, + }, + positions: [], + }, +}; + +describe('PerpsBalanceOrderView', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(Engine.context.PerpsController.validateOrder) + .mockResolvedValue({ isValid: true }); + jest + .mocked(Engine.context.PerpsController.placeOrder) + .mockResolvedValue({ success: true, orderId: 'balance-order-cv' }); + }); + + it.each([ + PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON, + PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON, + ])( + 'opens a balance order from %s and returns to Lite without a deposit', + async (button) => { + const { store } = renderPerpsView( + PerpsMarketDetailsView, + Routes.PERPS.MARKET_DETAILS, + { + ...options, + initialParams: { market }, + extraRoutes: [ + { + name: Routes.PERPS.BALANCE_ORDER, + Component: PerpsBalanceOrderView, + }, + ], + }, + ); + + fireEvent.press(await screen.findByTestId(button)); + const input = await screen.findByTestId( + PerpsProOrderFormSelectorsIDs.SIZE_INPUT, + ); + fireEvent.changeText(input, '10'); + expect(input).toHaveProp('value', '10'); + const submit = screen.getByTestId( + PerpsProOrderFormSelectorsIDs.PLACE_ORDER_BUTTON, + ); + await waitFor(() => expect(submit).toBeEnabled()); + fireEvent.press(submit); + await waitFor(() => + expect(Engine.context.PerpsController.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'ETH', + providerId: 'lighter', + orderType: 'market', + isBuy: button === PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON, + }), + ), + ); + // Controller acceptance precedes the stream-confirmation/toast promise. + // Keep the screen mounted until the full submission finishes. + await waitFor( + () => + expect( + screen.getByTestId(PerpsProOrderFormSelectorsIDs.SIZE_INPUT), + ).toHaveProp('value', ''), + { timeout: 5000 }, + ); + expect( + Engine.context.PerpsController.depositWithOrder, + ).not.toHaveBeenCalled(); + expect(store.getState().engine.backgroundState.PerpsController.mode).toBe( + 'lite', + ); + fireEvent.press( + screen.getByTestId(PerpsBalanceOrderViewSelectorsIDs.BACK), + ); + + expect(await screen.findByTestId(button)).toBeOnTheScreen(); + expect( + screen.queryByTestId(PerpsBalanceOrderViewSelectorsIDs.CONTAINER), + ).not.toBeOnTheScreen(); + }, + ); + + it('tracks one ready trading-screen entry with the incoming source', async () => { + const tracked = jest.spyOn(analytics, 'trackEvent'); + try { + renderPerpsView(PerpsBalanceOrderView, Routes.PERPS.BALANCE_ORDER, { + ...options, + initialParams: { + asset: 'ETH', + direction: 'long', + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + }, + }); + await waitFor(() => + expect(tracked).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.PERPS_SCREEN_VIEWED.category, + properties: expect.objectContaining({ + screen_type: 'trading', + asset: 'ETH', + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + }), + }), + ), + ); + + fireEvent.changeText( + screen.getByTestId(PerpsProOrderFormSelectorsIDs.SIZE_INPUT), + '10', + ); + fireEvent.changeText( + screen.getByTestId(PerpsProOrderFormSelectorsIDs.SIZE_INPUT), + '20', + ); + + expect( + tracked.mock.calls.filter( + ([event]) => + event.name === MetaMetricsEvents.PERPS_SCREEN_VIEWED.category && + event.properties?.screen_type === 'trading', + ), + ).toHaveLength(1); + } finally { + tracked.mockRestore(); + } + }); + + it('accepts a providerless market while Lighter is the active provider', async () => { + renderPerpsView(PerpsBalanceOrderView, Routes.PERPS.BALANCE_ORDER, { + ...options, + streamOverrides: { + ...options.streamOverrides, + marketData: [{ ...market, providerId: undefined }], + }, + initialParams: { + asset: 'ETH', + direction: 'long', + }, + }); + + expect( + await screen.findByTestId(PerpsProOrderFormSelectorsIDs.SIZE_INPUT), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId(PerpsBalanceOrderViewSelectorsIDs.ERROR), + ).not.toBeOnTheScreen(); + }); + + it('preserves an order-book limit price and allows editing it', async () => { + renderPerpsView(PerpsBalanceOrderView, Routes.PERPS.BALANCE_ORDER, { + ...options, + initialParams: { + asset: 'ETH', + direction: 'short', + orderType: 'limit', + price: '2600', + }, + }); + + const input = await screen.findByTestId( + PerpsProOrderFormSelectorsIDs.LIMIT_PRICE_INPUT, + ); + expect(input).toHaveProp('value', '2600'); + fireEvent.changeText(input, '2700'); + + expect(input).toHaveProp('value', '2700'); + expect( + Engine.context.PerpsController.depositWithOrder, + ).not.toHaveBeenCalled(); + }); + + it('selects the explicit Lighter market while aggregated', async () => { + const hyperliquidMarket = { + ...market, + name: 'Ethereum HyperLiquid', + providerId: 'hyperliquid' as const, + }; + renderPerpsView(PerpsBalanceOrderView, Routes.PERPS.BALANCE_ORDER, { + ...options, + overrides: { + engine: { + backgroundState: { + PerpsController: { activeProvider: 'aggregated' as const }, + }, + }, + }, + streamOverrides: { + ...options.streamOverrides, + marketData: [hyperliquidMarket, market], + }, + initialParams: { + asset: 'ETH', + direction: 'long', + providerId: 'lighter', + }, + }); + + const input = await screen.findByTestId( + PerpsProOrderFormSelectorsIDs.SIZE_INPUT, + ); + fireEvent.changeText(input, '10'); + const submit = screen.getByTestId( + PerpsProOrderFormSelectorsIDs.PLACE_ORDER_BUTTON, + ); + await waitFor(() => expect(submit).toBeEnabled()); + fireEvent.press(submit); + + await waitFor(() => + expect(Engine.context.PerpsController.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'ETH', + providerId: 'lighter', + }), + ), + ); + await waitFor( + () => + expect( + screen.getByTestId(PerpsProOrderFormSelectorsIDs.SIZE_INPUT), + ).toHaveProp('value', ''), + { timeout: 5000 }, + ); + expect( + Engine.context.PerpsController.depositWithOrder, + ).not.toHaveBeenCalled(); + }); + + it('rejects an explicit non-Lighter provider', async () => { + renderPerpsView(PerpsBalanceOrderView, Routes.PERPS.BALANCE_ORDER, { + ...options, + initialParams: { + asset: 'ETH', + direction: 'long', + providerId: 'hyperliquid', + }, + }); + + expect( + await screen.findByTestId(PerpsBalanceOrderViewSelectorsIDs.ERROR), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId(PerpsProOrderFormSelectorsIDs.SIZE_INPUT), + ).not.toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsBalanceOrderView/index.ts b/app/components/UI/Perps/Views/PerpsBalanceOrderView/index.ts new file mode 100644 index 000000000000..2d692bed8f62 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsBalanceOrderView/index.ts @@ -0,0 +1 @@ +export { default } from './PerpsBalanceOrderView'; diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.test.tsx index 49d9a7efc4ae..c46bef851a21 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.test.tsx @@ -1,15 +1,25 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import PerpsMarketDetailsRouter from './PerpsMarketDetailsRouter'; import { usePerpsProModeEnabled } from './usePerpsProModeEnabled'; jest.mock('./usePerpsProModeEnabled'); +const mockSwitchProvider = jest.fn(); +let mockActiveProvider = 'hyperliquid'; +jest.mock('../../hooks/usePerpsProvider', () => ({ + usePerpsProvider: () => ({ + activeProvider: mockActiveProvider, + switchProvider: mockSwitchProvider, + }), +})); + const mockUseRoute = jest.fn(); const mockSetParams = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ setParams: mockSetParams }), useRoute: () => mockUseRoute(), + useIsFocused: () => true, })); const mockSafeAreaMount = jest.fn(); @@ -73,6 +83,7 @@ const mockUsePerpsProModeEnabled = jest.mocked(usePerpsProModeEnabled); describe('PerpsMarketDetailsRouter', () => { beforeEach(() => { + mockActiveProvider = 'hyperliquid'; mockOutreachCampaign = null; mockUseRoute.mockReturnValue({ params: { market: { symbol: 'ETH' } }, @@ -186,4 +197,34 @@ describe('PerpsMarketDetailsRouter', () => { expect.objectContaining({ generationTrigger: 'mode_switch' }), ); }); + it('preserves market-switch attribution while selecting the venue', async () => { + mockUsePerpsProModeEnabled.mockReturnValue(true); + mockUseRoute.mockReturnValue({ + params: { + market: { symbol: 'BTC', providerId: 'lighter' }, + detailGenerationTrigger: 'market_switch', + }, + }); + let finish!: () => void; + mockSwitchProvider.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve({ success: true, providerId: 'lighter' }); + }), + ); + const { rerender } = render(); + mockActiveProvider = 'lighter'; + rerender(); + expect(mockSetParams).not.toHaveBeenCalled(); + expect(mockProProps).not.toHaveBeenCalled(); + + await act(async () => finish()); + + expect(mockProProps).toHaveBeenCalledWith( + expect.objectContaining({ generationTrigger: 'market_switch' }), + ); + expect(mockSetParams).toHaveBeenCalledWith({ + detailGenerationTrigger: undefined, + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.tsx index f241a758aa73..a578c7a8e08c 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.tsx @@ -1,6 +1,13 @@ -import React, { useLayoutEffect, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { useNavigation, + useIsFocused, useRoute, type NavigationProp, type RouteProp, @@ -13,6 +20,9 @@ import PerpsOutreachBanner from '../../components/PerpsOutreachBanner'; import { usePerpsOutreachCampaign } from '../../hooks/usePerpsOutreachCampaign'; import { usePerpsProModeEnabled } from './usePerpsProModeEnabled'; import type { PerpsStackParamList } from '../../types/navigation'; +import { usePerpsProvider } from '../../hooks/usePerpsProvider'; +import PerpsLoader from '../../components/PerpsLoader'; +import PerpsConnectionErrorView from '../../components/PerpsConnectionErrorView'; const SAFE_AREA_EDGES: Edge[] = ['top', 'bottom', 'left', 'right']; // The outreach banner sits above the market header and paints its background up @@ -54,12 +64,56 @@ function resolveGenerationTrigger( const PerpsMarketDetailsRouter: React.FC = () => { const tw = useTailwind(); const isProModeEnabled = usePerpsProModeEnabled(); + const isFocused = useIsFocused(); + const { activeProvider, switchProvider } = usePerpsProvider(); const { campaign: outreachCampaign } = usePerpsOutreachCampaign(); const route = useRoute>(); const navigation = useNavigation>(); const symbol = route.params?.market?.symbol; + const providerId = route.params?.market?.providerId; + const requiresVenueSwitch = + isProModeEnabled && + providerId !== undefined && + activeProvider !== providerId; + const [venueSwitch, setVenueSwitch] = useState< + 'idle' | 'pending' | 'ready' | 'failed' + >('idle'); + const isVenueReady = + !isProModeEnabled || + (!requiresVenueSwitch && + venueSwitch !== 'pending' && + venueSwitch !== 'failed'); + const requestedProviderRef = useRef(undefined); + const selectVenue = useCallback(() => { + if (!providerId) return; + requestedProviderRef.current = providerId; + setVenueSwitch('pending'); + switchProvider(providerId).then( + (result) => setVenueSwitch(result.success ? 'ready' : 'failed'), + () => setVenueSwitch('failed'), + ); + }, [providerId, switchProvider]); + useEffect(() => { + if ( + (!isProModeEnabled && venueSwitch === 'failed') || + (venueSwitch === 'ready' && !requiresVenueSwitch) || + ((venueSwitch === 'ready' || venueSwitch === 'failed') && + requestedProviderRef.current !== providerId) + ) { + setVenueSwitch('idle'); + } else if (requiresVenueSwitch && isFocused && venueSwitch === 'idle') { + selectVenue(); + } + }, [ + isFocused, + isProModeEnabled, + providerId, + requiresVenueSwitch, + selectVenue, + venueSwitch, + ]); const mode = isProModeEnabled ? 'pro' : 'lite'; const previousIdentityRef = useRef< { symbol?: string; mode: string } | undefined @@ -77,12 +131,13 @@ const PerpsMarketDetailsRouter: React.FC = () => { ); useLayoutEffect(() => { + if (!isVenueReady) return; previousIdentityRef.current = { symbol, mode }; consumedExplicitTriggerRef.current = true; if (explicitGenerationTrigger) { navigation.setParams({ detailGenerationTrigger: undefined }); } - }, [explicitGenerationTrigger, mode, navigation, symbol]); + }, [explicitGenerationTrigger, isVenueReady, mode, navigation, symbol]); return ( <> @@ -93,7 +148,15 @@ const PerpsMarketDetailsRouter: React.FC = () => { outreachCampaign ? SAFE_AREA_EDGES_UNDER_BANNER : SAFE_AREA_EDGES } > - {isProModeEnabled ? ( + {isProModeEnabled && venueSwitch === 'failed' ? ( + + ) : !isVenueReady ? ( + + ) : isProModeEnabled ? ( ) : ( diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.view.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.view.test.tsx index 0fb86ec380f0..bf04a5c24221 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsRouter/PerpsMarketDetailsRouter.view.test.tsx @@ -6,7 +6,15 @@ */ import '../../../../../../tests/component-view/mocks'; -import { cleanup, screen } from '@testing-library/react-native'; +import { + PerpsMode, + type SwitchProviderResult, +} from '@metamask/perps-controller'; + +import { act, cleanup, screen, waitFor } from '@testing-library/react-native'; +import Engine from '../../../../../core/Engine'; +import { updateBgState } from '../../../../../core/redux/slices/engine'; +import { strings } from '../../../../../../locales/i18n'; import { renderPerpsView } from '../../../../../../tests/component-view/renderers/perpsViewRenderer'; import { PerpsMarketDetailsViewSelectorsIDs, @@ -35,6 +43,10 @@ const defaultMarket = { describe('PerpsMarketDetailsRouter', () => { beforeEach(() => { jest.clearAllMocks(); + jest + .mocked(Engine.context.PerpsController.switchProvider) + .mockReset() + .mockResolvedValue({ success: true, providerId: 'hyperliquid' }); setupPerpsOutreachApiMock(); }); @@ -101,4 +113,155 @@ describe('PerpsMarketDetailsRouter', () => { await screen.findByTestId(PerpsOutreachBannerSelectorsIDs.BANNER), ).toBeOnTheScreen(); }); + const aggregatedLighterOptions = { + mode: 'pro' as const, + initialParams: { + market: { + ...defaultMarket, + providerId: 'lighter' as const, + maxLeverage: '20x', + szDecimals: 3, + }, + }, + overrides: { + engine: { + backgroundState: { + PerpsController: { activeProvider: 'aggregated' as const }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + perpsProModeEnabled: { enabled: true, minimumVersion: '0.0.0' }, + }, + }, + }, + }, + }, + }; + + it('keeps the inline Pro form unmounted until the venue switch completes', async () => { + let finishSwitch!: (result: SwitchProviderResult) => void; + jest + .mocked(Engine.context.PerpsController.switchProvider) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSwitch = resolve; + }), + ); + const { store } = renderPerpsView( + PerpsMarketDetailsRouter, + Routes.PERPS.MARKET_DETAILS, + aggregatedLighterOptions, + ); + await waitFor(() => + expect( + Engine.context.PerpsController.switchProvider, + ).toHaveBeenCalledWith('lighter'), + ); + expect( + screen.queryByTestId(PerpsProMarketViewSelectorsIDs.CONTAINER), + ).not.toBeOnTheScreen(); + + act(() => { + const engineWithState = Engine as unknown as { + state: Record; + }; + engineWithState.state = { + ...engineWithState.state, + PerpsController: { + ...store.getState().engine.backgroundState.PerpsController, + activeProvider: 'lighter', + }, + }; + store.dispatch(updateBgState({ key: 'PerpsController' })); + }); + expect( + screen.queryByTestId(PerpsProMarketViewSelectorsIDs.CONTAINER), + ).not.toBeOnTheScreen(); + await act(async () => { + finishSwitch({ success: true, providerId: 'lighter' }); + }); + + expect( + await screen.findByTestId(PerpsProMarketViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(Engine.context.PerpsController.switchProvider).toHaveBeenCalledTimes( + 1, + ); + }); + + it.each(['failure', 'rejection'])( + 'keeps the Pro form closed after a switch %s', + async (outcome) => { + if (outcome === 'failure') { + jest + .mocked(Engine.context.PerpsController.switchProvider) + .mockResolvedValueOnce({ + success: false, + providerId: 'lighter', + error: 'Provider unavailable', + }); + } else { + jest + .mocked(Engine.context.PerpsController.switchProvider) + .mockRejectedValueOnce(new Error('Provider unavailable')); + } + + renderPerpsView( + PerpsMarketDetailsRouter, + Routes.PERPS.MARKET_DETAILS, + aggregatedLighterOptions, + ); + + expect( + await screen.findByText(strings('perps.errors.connectionFailed.retry')), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId(PerpsProMarketViewSelectorsIDs.CONTAINER), + ).not.toBeOnTheScreen(); + expect( + Engine.context.PerpsController.switchProvider, + ).toHaveBeenCalledTimes(1); + }, + ); + it('renders Lite after a failed Pro venue switch on the same route', async () => { + jest + .mocked(Engine.context.PerpsController.switchProvider) + .mockResolvedValueOnce({ + success: false, + providerId: 'lighter', + error: 'Provider unavailable', + }); + const { store } = renderPerpsView( + PerpsMarketDetailsRouter, + Routes.PERPS.MARKET_DETAILS, + aggregatedLighterOptions, + ); + expect( + await screen.findByText(strings('perps.errors.connectionFailed.retry')), + ).toBeOnTheScreen(); + + act(() => { + const engineWithState = Engine as unknown as { + state: Record; + }; + engineWithState.state = { + ...engineWithState.state, + PerpsController: { + ...store.getState().engine.backgroundState.PerpsController, + mode: PerpsMode.Lite, + }, + }; + store.dispatch(updateBgState({ key: 'PerpsController' })); + }); + + expect( + await screen.findByTestId(PerpsMarketDetailsViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.queryByText(strings('perps.errors.connectionFailed.retry')), + ).not.toBeOnTheScreen(); + expect(Engine.context.PerpsController.switchProvider).toHaveBeenCalledTimes( + 1, + ); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index d3fa866a081b..40350520c960 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -30,6 +30,7 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, TimeDuration, + type PerpsActiveProviderMode, type PerpsMarketData, } from '@metamask/perps-controller'; import { @@ -45,6 +46,7 @@ const mockTradingViewRender = jest.fn(); let mockMarketContextKey = 'testnet|hyperliquid|1'; let mockMarketContextReady = true; let mockConnectionInitialized = true; +let mockActiveProvider: PerpsActiveProviderMode | undefined; jest.mock('../../../../../util/haptics'); @@ -337,6 +339,7 @@ jest.mock('../../hooks/stream/usePerpsLiveAccount', () => ({ // Mock the selector module first jest.mock('../../selectors/perpsController', () => ({ selectPerpsEligibility: jest.fn(), + selectPerpsProvider: jest.fn(), createSelectIsWatchlistMarket: jest.fn(() => jest.fn(() => false)), })); @@ -1008,6 +1011,9 @@ describe('PerpsMarketDetailsView', () => { const mockSelectPerpsEligibility = jest.requireMock( '../../selectors/perpsController', ).selectPerpsEligibility; + const mockSelectPerpsProvider = jest.requireMock( + '../../selectors/perpsController', + ).selectPerpsProvider; const mockSelectPerpsChartPreferredCandlePeriod = jest.requireMock( '../../selectors/chartPreferences', ).selectPerpsChartPreferredCandlePeriod; @@ -1015,6 +1021,9 @@ describe('PerpsMarketDetailsView', () => { if (selector === mockSelectPerpsEligibility) { return true; } + if (selector === mockSelectPerpsProvider) { + return mockActiveProvider; + } if (selector === selectPerpsRelatedMarketsEnabledFlag) { return false; } @@ -1026,6 +1035,7 @@ describe('PerpsMarketDetailsView', () => { } return undefined; }); + mockActiveProvider = undefined; // Reset notification feature flag to default mockIsNotificationsFeatureEnabled.mockReturnValue(true); @@ -2985,6 +2995,10 @@ describe('PerpsMarketDetailsView', () => { } return undefined; }); + mockRouteParams.market = { + ...mockRouteParams.market, + providerId: 'lighter', + } as PerpsMarketData; const { getByTestId } = renderWithProvider( @@ -3007,6 +3021,7 @@ describe('PerpsMarketDetailsView', () => { expect.objectContaining({ direction: 'long', asset: 'BTC', + providerId: 'lighter', source: 'perp_asset_screen', }), ); @@ -5281,6 +5296,122 @@ describe('PerpsMarketDetailsView', () => { expect(getAllByText('ETH-USD perp').length).toBeGreaterThanOrEqual(1); }); + it.each([ + { + name: 'explicit route provider', + activeProvider: 'hyperliquid' as const, + routeProvider: 'lighter' as const, + expectedProvider: 'lighter' as const, + }, + { + name: 'concrete active provider', + activeProvider: 'lighter' as const, + routeProvider: undefined, + expectedProvider: 'lighter' as const, + }, + { + name: 'default provider in aggregated mode', + activeProvider: 'aggregated' as const, + routeProvider: undefined, + expectedProvider: 'hyperliquid' as const, + }, + ])( + 'selects the $name from duplicate symbols', + async ({ activeProvider, routeProvider, expectedProvider }) => { + mockActiveProvider = activeProvider; + mockRouteParams.market = { + symbol: 'BTC', + name: 'Bitcoin', + price: '$45,000.00', + change24h: '+$1,125.00', + change24hPercent: '+2.50%', + volume: '$1.23B', + maxLeverage: '50', + ...(routeProvider ? { providerId: routeProvider } : {}), + }; + const hyperliquidMarket = { + ...mockRouteParams.market, + providerId: 'hyperliquid' as const, + maxLeverage: '50x', + volumeNumber: 100, + }; + const lighterMarket = { + ...mockRouteParams.market, + providerId: 'lighter' as const, + maxLeverage: '25x', + volumeNumber: 200, + }; + mockUsePerpsMarketsImpl.mockReturnValue({ + markets: + expectedProvider === 'lighter' + ? [hyperliquidMarket, lighterMarket] + : [lighterMarket, hyperliquidMarket], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + await act(async () => { + fireEvent.press( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON), + ); + }); + + expect(mockNavigateToOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerId: expectedProvider }), + ); + }, + ); + + it('uses a providerless candidate as the concrete active provider', async () => { + mockActiveProvider = 'lighter'; + mockRouteParams.market = { + symbol: 'BTC', + name: 'Bitcoin', + price: '$45,000.00', + change24h: '+$1,125.00', + change24hPercent: '+2.50%', + volume: '$1.23B', + maxLeverage: '50', + }; + mockUsePerpsMarketsImpl.mockReturnValue({ + markets: [ + { + ...mockRouteParams.market, + providerId: 'hyperliquid', + maxLeverage: '50x', + volumeNumber: 100, + }, + { + ...mockRouteParams.market, + maxLeverage: '25x', + volumeNumber: 200, + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + const { getByText } = renderWithProvider( + + + , + { state: initialState }, + ); + + await waitFor(() => expect(getByText('25x')).toBeOnTheScreen()); + }); + it('enriches market data when route maxLeverage is unformatted', async () => { mockRouteParams.market = { symbol: 'xyz:SPCX', @@ -5296,6 +5427,7 @@ describe('PerpsMarketDetailsView', () => { markets: [ { symbol: 'xyz:SPCX', + providerId: 'hyperliquid', name: 'SPCX', price: '$0.00', change24h: '+$0.00', @@ -5342,6 +5474,7 @@ describe('PerpsMarketDetailsView', () => { markets: [ { symbol: 'xyz:SPCX', + providerId: 'hyperliquid', name: 'SPCX', price: '$0.00', change24h: '+$0.00', @@ -5413,6 +5546,7 @@ describe('PerpsMarketDetailsView', () => { markets: [ { symbol: 'SPCX', + providerId: 'hyperliquid', name: 'SPCX', price: '$0.00', change24h: '+$0.00', @@ -5455,6 +5589,7 @@ describe('PerpsMarketDetailsView', () => { markets: [ { symbol: 'BTC', + providerId: 'hyperliquid', name: 'Bitcoin', price: '$45,000.00', change24h: '+$1,125.00', diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 295e606e1557..6ffacc211fe3 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -111,7 +111,10 @@ import { selectPerpsServiceInterruptionBannerEnabledFlag, } from '../../selectors/featureFlags'; import { PERPS_CHART_CONFIG } from '../../constants/chartConfig'; -import { PERPS_MIN_BALANCE_THRESHOLD } from '../../constants/perpsConfig'; +import { + PERPS_MIN_BALANCE_THRESHOLD, + PROVIDER_CONFIG, +} from '../../constants/perpsConfig'; import { usePerpsConnection, usePerpsNavigation, @@ -186,7 +189,10 @@ import { } from '../../../MarketInsights'; import { MarketInsightsSelectorsIDs } from '../../../MarketInsights/MarketInsights.testIds'; import { selectMarketInsightsPerpsEnabled } from '../../../../../selectors/featureFlagController/marketInsights'; -import { selectPerpsEligibility } from '../../selectors/perpsController'; +import { + selectPerpsEligibility, + selectPerpsProvider, +} from '../../selectors/perpsController'; import { useComplianceGate } from '../../../Compliance'; import { selectSelectedInternalAccountAddress } from '../../../../../selectors/accountsController'; import { useABTest } from '../../../../../hooks/useABTest'; @@ -325,6 +331,7 @@ const PerpsMarketDetailsView: React.FC = ({ analyticsContext, } = route.params || {}; const { track } = usePerpsEventTracking(); + const activeProvider = useSelector(selectPerpsProvider); const isRelatedMarketsEnabled = useSelector( selectPerpsRelatedMarketsEnabledFlag, ); @@ -346,10 +353,22 @@ const PerpsMarketDetailsView: React.FC = ({ // If route market already has all required fields, use it directly if (!needsEnrichment) return routeMarket; - const fullMarket = markets.find((m) => m.symbol === routeMarket?.symbol); + const defaultCandidateProvider = + activeProvider !== undefined && + activeProvider !== PROVIDER_CONFIG.AggregatedProvider + ? activeProvider + : PROVIDER_CONFIG.DefaultProvider; + const preferredProvider = + routeMarket?.providerId ?? defaultCandidateProvider; + const fullMarket = markets.find( + (candidate) => + candidate.symbol === routeMarket?.symbol && + (candidate.providerId ?? defaultCandidateProvider) === + preferredProvider, + ); return fullMarket || routeMarket; - }, [markets, routeMarket, needsEnrichment]); + }, [activeProvider, markets, routeMarket, needsEnrichment]); // About section: fires displayed (on render) + viewed (on scroll into view) // analytics and gates the section's visibility on an available description. @@ -1196,6 +1215,7 @@ const PerpsMarketDetailsView: React.FC = ({ navigateToOrder({ direction, asset: market.symbol, + ...(market.providerId ? { providerId: market.providerId } : {}), source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, ...(source_section ? { source_section } : {}), chartLibrary, @@ -1217,6 +1237,7 @@ const PerpsMarketDetailsView: React.FC = ({ source_section, transactionActiveAbTests, market?.symbol, + market?.providerId, marketData, isButtonColorTestEnabled, chartLibrary, diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx index dfb351156a9a..f04eb8b6000a 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx @@ -5,7 +5,11 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { PerpsOrderBookViewSelectorsIDs } from '../../Perps.testIds'; import type { OrderBookData } from '../../hooks/stream/usePerpsLiveOrderBook'; -import type { PriceUpdate, PerpsMarketData } from '@metamask/perps-controller'; +import type { + PerpsActiveProviderMode, + PerpsMarketData, + PriceUpdate, +} from '@metamask/perps-controller'; import { mockTheme } from '../../../../../util/theme'; import type { OrderBookRouteParams } from './PerpsOrderBookView.types'; @@ -13,6 +17,7 @@ import type { OrderBookRouteParams } from './PerpsOrderBookView.types'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); const mockCanGoBack = jest.fn(); +let mockActiveProvider: PerpsActiveProviderMode | undefined; const mockUseRoute = jest.fn<{ params: OrderBookRouteParams }, []>(() => ({ params: { symbol: 'BTC', @@ -171,6 +176,7 @@ jest.mock('../../hooks', () => ({ markets: [ { symbol: 'BTC', + providerId: 'lighter', price: '$50,000.00', leverage: 50, }, @@ -369,6 +375,7 @@ jest.mock( // Mock perpsController selectors - return eligible by default for action button tests jest.mock('../../selectors/perpsController', () => ({ selectPerpsEligibility: jest.fn(() => true), + selectPerpsProvider: jest.fn(() => mockActiveProvider), })); const mockComplianceGate = jest.fn((action: () => Promise) => @@ -393,6 +400,7 @@ describe('PerpsOrderBookView', () => { beforeEach(() => { jest.clearAllMocks(); + mockActiveProvider = undefined; mockBottomSheetRefState.refAvailable = true; mockUseRoute.mockReturnValue({ params: { @@ -404,6 +412,7 @@ describe('PerpsOrderBookView', () => { markets: [ { symbol: 'BTC', + providerId: 'hyperliquid', price: '$50,000.00', leverage: 50, }, @@ -761,6 +770,103 @@ describe('PerpsOrderBookView', () => { }); describe('action buttons', () => { + it.each([ + { + name: 'explicit route provider', + activeProvider: 'hyperliquid' as const, + routeProvider: 'lighter' as const, + expectedProvider: 'lighter' as const, + }, + { + name: 'concrete active provider', + activeProvider: 'lighter' as const, + routeProvider: undefined, + expectedProvider: 'lighter' as const, + }, + { + name: 'default provider in aggregated mode', + activeProvider: 'aggregated' as const, + routeProvider: undefined, + expectedProvider: 'hyperliquid' as const, + }, + ])( + 'selects the $name from duplicate symbols', + ({ activeProvider, routeProvider, expectedProvider }) => { + mockActiveProvider = activeProvider; + mockUseRoute.mockReturnValue({ + params: { + symbol: 'BTC', + ...(routeProvider + ? { + marketData: { + ...mockRouteMarketData, + providerId: routeProvider, + }, + } + : {}), + }, + }); + const { usePerpsMarkets } = jest.requireMock('../../hooks'); + const hyperliquidMarket = { + ...mockRouteMarketData, + providerId: 'hyperliquid' as const, + }; + const lighterMarket = { + ...mockRouteMarketData, + providerId: 'lighter' as const, + }; + usePerpsMarkets.mockReturnValue({ + markets: + expectedProvider === 'lighter' + ? [hyperliquidMarket, lighterMarket] + : [lighterMarket, hyperliquidMarket], + isLoading: false, + error: null, + }); + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + fireEvent.press( + getByTestId(PerpsOrderBookViewSelectorsIDs.LONG_BUTTON), + ); + + expect(mockNavigateToOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerId: expectedProvider }), + ); + }, + ); + + it('uses a providerless candidate as the concrete active provider', () => { + mockActiveProvider = 'lighter'; + const { usePerpsMarkets } = jest.requireMock('../../hooks'); + usePerpsMarkets.mockReturnValue({ + markets: [ + { + ...mockRouteMarketData, + providerId: 'hyperliquid', + }, + { + ...mockRouteMarketData, + name: 'Legacy Bitcoin', + }, + ], + isLoading: false, + error: null, + }); + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + fireEvent.press(getByTestId(PerpsOrderBookViewSelectorsIDs.LONG_BUTTON)); + + expect(mockNavigateToOrder).toHaveBeenCalledWith({ + direction: 'long', + asset: 'BTC', + source: 'order_book_long_button', + }); + }); + it('navigates to long order when Long button is pressed', () => { const { getByTestId } = renderWithProvider(, { state: initialState, @@ -775,6 +881,7 @@ describe('PerpsOrderBookView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'BTC', + providerId: 'hyperliquid', source: 'order_book_long_button', }); expect(mockTrack).toHaveBeenCalled(); @@ -794,6 +901,7 @@ describe('PerpsOrderBookView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'short', asset: 'BTC', + providerId: 'hyperliquid', source: 'order_book_short_button', }); expect(mockTrack).toHaveBeenCalled(); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx index 95cef8224f8b..23bcc749feff 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -73,7 +73,10 @@ import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsOrderBookGrouping } from '../../hooks/usePerpsOrderBookGrouping'; import { usePerpsScreenVsBottomSheetAbTest } from '../../hooks/usePerpsScreenVsBottomSheetAbTest'; -import { selectPerpsEligibility } from '../../selectors/perpsController'; +import { + selectPerpsEligibility, + selectPerpsProvider, +} from '../../selectors/perpsController'; import { useComplianceGate } from '../../../Compliance'; import { selectSelectedInternalAccountAddress } from '../../../../../selectors/accountsController'; import { useABTest } from '../../../../../hooks/useABTest'; @@ -85,6 +88,7 @@ import { formatPerpsFiat, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; +import { PROVIDER_CONFIG } from '../../constants/perpsConfig'; import { calculateAggregationParams, calculateGroupingOptions, @@ -113,6 +117,7 @@ const PerpsOrderBookView: React.FC = ({ const { useBottomSheet } = usePerpsScreenVsBottomSheetAbTest(); const { track } = usePerpsEventTracking(); const insets = useSafeAreaInsets(); + const activeProvider = useSelector(selectPerpsProvider); // A/B Testing: Button color test (TAT-1937) const { variantName: buttonColorVariant } = useABTest( @@ -140,9 +145,21 @@ const PerpsOrderBookView: React.FC = ({ return undefined; } - const marketFromList = markets.find((m) => m.symbol === symbol); + const defaultCandidateProvider = + activeProvider !== undefined && + activeProvider !== PROVIDER_CONFIG.AggregatedProvider + ? activeProvider + : PROVIDER_CONFIG.DefaultProvider; + const preferredProvider = + routeMarketData?.providerId ?? defaultCandidateProvider; + const marketFromList = markets.find( + (candidate) => + candidate.symbol === symbol && + (candidate.providerId ?? defaultCandidateProvider) === + preferredProvider, + ); return marketFromList ?? routeMarketData; - }, [markets, symbol, routeMarketData]); + }, [activeProvider, markets, symbol, routeMarketData]); // Check if user has an existing position for this market const { existingPosition } = useHasExistingPosition({ @@ -521,11 +538,20 @@ const PerpsOrderBookView: React.FC = ({ navigateToOrder({ direction: 'long', asset: symbol || '', + ...(market?.providerId ? { providerId: market.providerId } : {}), source: PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK_LONG_BUTTON, ...(useBottomSheet ? { useBottomSheet: true } : {}), }); }), - [gate, isEligible, symbol, navigateToOrder, track, useBottomSheet], + [ + gate, + isEligible, + symbol, + market?.providerId, + navigateToOrder, + track, + useBottomSheet, + ], ); // Handle Short button press @@ -556,11 +582,20 @@ const PerpsOrderBookView: React.FC = ({ navigateToOrder({ direction: 'short', asset: symbol || '', + ...(market?.providerId ? { providerId: market.providerId } : {}), source: PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK_SHORT_BUTTON, ...(useBottomSheet ? { useBottomSheet: true } : {}), }); }), - [gate, isEligible, symbol, navigateToOrder, track, useBottomSheet], + [ + gate, + isEligible, + symbol, + market?.providerId, + navigateToOrder, + track, + useBottomSheet, + ], ); // Handle Close position button press diff --git a/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx b/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx index 7f905747863c..0d44caad3cf5 100644 --- a/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx @@ -46,6 +46,8 @@ import PerpsSelectOrderTypeView from './PerpsSelectOrderTypeView/PerpsSelectOrde const TIMEOUT_MS = 3000; describe('Order Lifecycle & Funds Flow', () => { + const originalEnvironment = process.env.METAMASK_ENVIRONMENT; + const originalLighterOverride = process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED; let ORDER_TYPE_TITLE: string; let ORDER_TYPE_MARKET: string; let ORDER_TYPE_LIMIT: string; @@ -62,6 +64,21 @@ describe('Order Lifecycle & Funds Flow', () => { beforeEach(() => { jest.clearAllMocks(); + process.env.METAMASK_ENVIRONMENT = 'dev'; + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = 'true'; + }); + + afterEach(() => { + if (originalEnvironment === undefined) { + delete process.env.METAMASK_ENVIRONMENT; + } else { + process.env.METAMASK_ENVIRONMENT = originalEnvironment; + } + if (originalLighterOverride === undefined) { + delete process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED; + } else { + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = originalLighterOverride; + } }); it('trader closes position, reviews order book, checks order details, views PnL, withdraws, and opens provider selection', async () => { diff --git a/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx b/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx index 60a33b676adc..35b95924f5e8 100644 --- a/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx @@ -19,6 +19,12 @@ import { } from '../utils/perpsTradeSheetInteractiveTrace'; import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +// Legacy deposit lifecycle cases retain the Hyperliquid route. Lighter routing +// is covered with real Redux state in PerpsOrderRedirect.view.test.tsx. +jest.mock('react-redux', () => ({ + useSelector: () => 'hyperliquid', +})); + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: jest.fn(), diff --git a/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx b/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx index 162777b53bd5..b1000ee40183 100644 --- a/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; import { useNavigation, useRoute, @@ -15,6 +16,7 @@ import { import Routes from '../../../../constants/navigation/Routes'; import { usePerpsConnection } from '../hooks/usePerpsConnection'; import { usePerpsTrading } from '../hooks/usePerpsTrading'; +import { selectPerpsProvider } from '../selectors/perpsController'; import usePerpsToasts from '../hooks/usePerpsToasts'; import PerpsLoader from '../components/PerpsLoader'; import Logger from '../../../../util/Logger'; @@ -52,6 +54,7 @@ const PerpsOrderRedirect: React.FC = () => { const { isConnected, isInitialized } = usePerpsConnection(); const { depositWithOrder } = usePerpsTrading(); + const activeProvider = useSelector(selectPerpsProvider); const { showToast, PerpsToastOptions } = usePerpsToasts(); const { useBottomSheet } = usePerpsScreenVsBottomSheetAbTest(); @@ -63,6 +66,19 @@ const PerpsOrderRedirect: React.FC = () => { if (hasStartedRef.current) return; hasStartedRef.current = true; + if (activeProvider === 'lighter') { + navigation.dispatch( + StackActions.replace(Routes.PERPS.BALANCE_ORDER, { + direction, + asset, + fromTokenDetails, + source: PERPS_EVENT_VALUE.SOURCE.ASSET_DETAIL_SCREEN, + transactionActiveAbTests, + }), + ); + return; + } + Logger.log('[PerpsOrderRedirect] Starting depositWithOrder', { direction, asset, @@ -139,6 +155,7 @@ const PerpsOrderRedirect: React.FC = () => { transactionActiveAbTests, useBottomSheet, depositWithOrder, + activeProvider, navigation, showToast, PerpsToastOptions, diff --git a/app/components/UI/Perps/Views/PerpsOrderRedirect.view.test.tsx b/app/components/UI/Perps/Views/PerpsOrderRedirect.view.test.tsx index 076d9908bc06..7d65d986111b 100644 --- a/app/components/UI/Perps/Views/PerpsOrderRedirect.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderRedirect.view.test.tsx @@ -97,4 +97,23 @@ describe('PerpsOrderRedirect', () => { expect(depositWithOrder).toHaveBeenCalledTimes(1); }); }); + + it('replaces the redirect with balance order for Lighter without a deposit', async () => { + renderPerpsView(PerpsOrderRedirect, Routes.PERPS.ORDER_REDIRECT, { + initialParams: defaultParams, + overrides: { + engine: { + backgroundState: { PerpsController: { activeProvider: 'lighter' } }, + }, + }, + extraRoutes: [{ name: Routes.PERPS.BALANCE_ORDER }], + }); + + expect( + await screen.findByTestId(`route-${Routes.PERPS.BALANCE_ORDER}`), + ).toBeOnTheScreen(); + expect( + Engine.context.PerpsController.depositWithOrder, + ).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.test.ts b/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.test.ts index 12e8ac9fbb69..6a84f5981ffb 100644 --- a/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.test.ts +++ b/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.test.ts @@ -9,6 +9,7 @@ import { computeScalePriceLadder, formatHyperLiquidPrice, type PerpsMarketData, + type OrderResult, type PerpsProviderType, type PositionModifyPreviewResult, } from '@metamask/perps-controller'; @@ -80,7 +81,7 @@ let mockComplianceActionDuringRender: (() => void) | undefined; let mockIsEligible = true; let mockExecutionOptions: { - onSuccess?: (position?: unknown) => void; + onSuccess?: (position?: unknown, result?: OrderResult) => void; onError?: (error: unknown) => void; } = {}; @@ -3561,6 +3562,19 @@ describe('usePerpsProOrderForm', () => { }); describe('execution toasts', () => { + it('shows the accepted size when the provider rounds the requested size', () => { + renderProForm(); + + act(() => { + mockExecutionOptions.onSuccess?.(undefined, { + success: true, + orderId: 'rounded-lighter-order', + submittedSize: '0.00013', + }); + }); + + expect(confirmed).toHaveBeenCalledWith('long', '0.00013', 'BTC'); + }); it('shows the confirmed toast on success', () => { // Arrange renderProForm(); diff --git a/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.ts b/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.ts index 1220066485d9..465ecc23bf57 100644 --- a/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.ts +++ b/app/components/UI/Perps/Views/PerpsProMarketView/components/PerpsProOrderForm/usePerpsProOrderForm.ts @@ -1888,13 +1888,13 @@ export const usePerpsProOrderForm = ({ const isChaseExecutionRef = useRef(false); const { placeOrder: executeOrder, isPlacing } = usePerpsOrderExecution({ - onSuccess: () => { + onSuccess: (_position, result) => { if (isScaleOrder) { return; } const confirmationPositionSize = isChaseExecutionRef.current ? chaseConfirmationPositionSizeRef.current - : submissionPositionSize; + : (result?.submittedSize ?? submissionPositionSize); const toast = isTwapOrder ? PerpsToastOptions.orderManagement.twap.confirmed( orderForm.direction, diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx index 711a49b68a83..e0ac914f6ef2 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx @@ -109,6 +109,7 @@ jest.mock( describe('PerpsSelectModifyActionView', () => { const mockLongPosition: Position = { symbol: 'ETH', + providerId: 'lighter', size: '2.5', marginUsed: '500', entryPrice: '2000', @@ -182,6 +183,7 @@ describe('PerpsSelectModifyActionView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'ETH', + providerId: 'lighter', existingPosition: mockLongPosition, hideTPSL: true, source: 'position_screen', @@ -211,6 +213,7 @@ describe('PerpsSelectModifyActionView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'short', asset: 'ETH', + providerId: 'lighter', existingPosition: mockShortPosition, hideTPSL: true, source: 'position_screen', @@ -254,6 +257,7 @@ describe('PerpsSelectModifyActionView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'short', asset: 'ETH', + providerId: 'lighter', amount: '2.5', leverage: 10, source: 'position_screen', @@ -268,6 +272,7 @@ describe('PerpsSelectModifyActionView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'ETH', + providerId: 'lighter', amount: '2.5', leverage: 10, source: 'position_screen', @@ -348,6 +353,7 @@ describe('PerpsSelectModifyActionView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'ETH', + providerId: 'lighter', existingPosition: mockLongPosition, hideTPSL: true, source: 'position_screen', diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx index 2632247d8eed..c70a683ad77f 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx @@ -105,6 +105,9 @@ const PerpsSelectModifyActionView: React.FC< navigateToOrder({ direction, asset: position.symbol, + ...(position.providerId + ? { providerId: position.providerId } + : {}), existingPosition: position, // Pass position to maintain leverage consistency hideTPSL: true, // Hide TP/SL when adding to existing position source: PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, @@ -139,6 +142,9 @@ const PerpsSelectModifyActionView: React.FC< navigateToOrder({ direction: oppositeDirection, asset: position.symbol, + ...(position.providerId + ? { providerId: position.providerId } + : {}), amount: positionSize.toString(), leverage: positionLeverage, source: PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, diff --git a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx index af0652ca36e7..1489f406ca15 100644 --- a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx @@ -4,6 +4,7 @@ import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { usePerpsProvider } from '../../hooks/usePerpsProvider'; import { usePerpsNetworkConfig } from '../../hooks/usePerpsNetworkConfig'; +import { PerpsConnectionManager } from '../../services/PerpsConnectionManager'; import PerpsSelectProviderView from './PerpsSelectProviderView'; jest.mock('@react-navigation/native', () => ({ @@ -22,6 +23,12 @@ jest.mock('../../hooks/usePerpsNetworkConfig', () => ({ usePerpsNetworkConfig: jest.fn(), })); +jest.mock('../../services/PerpsConnectionManager', () => ({ + PerpsConnectionManager: { + waitForConnection: jest.fn(), + }, +})); + jest.mock('../../../../../util/Logger', () => ({ error: jest.fn(), })); @@ -64,6 +71,8 @@ jest.mock( const mockGoBack = jest.fn(); const mockSwitchProvider = jest.fn(); const mockToggleTestnet = jest.fn(); +const mockWaitForConnection = + PerpsConnectionManager.waitForConnection as jest.Mock; const mockUseSelector = useSelector as jest.Mock; const mockUsePerpsProvider = usePerpsProvider as jest.Mock; const mockUsePerpsNetworkConfig = usePerpsNetworkConfig as jest.Mock; @@ -75,12 +84,14 @@ beforeEach(() => { mockUsePerpsProvider.mockReturnValue({ activeProvider: 'hyperliquid', switchProvider: mockSwitchProvider, + isProviderSelectorEnabled: true, }); mockUsePerpsNetworkConfig.mockReturnValue({ toggleTestnet: mockToggleTestnet, }); mockSwitchProvider.mockResolvedValue({ success: true }); mockToggleTestnet.mockResolvedValue({ success: true }); + mockWaitForConnection.mockResolvedValue(undefined); // Default: select Lighter (provider changes, no network change) mockSheetOption = { id: 'lighter-mainnet', @@ -93,10 +104,22 @@ beforeEach(() => { }); describe('PerpsSelectProviderView', () => { - it('renders the sheet with isVisible=true', () => { + it('renders the sheet when the developer selector is enabled', () => { const { getByTestId } = render(); - expect(getByTestId('perps-select-provider-sheet')).toBeTruthy(); + expect(getByTestId('perps-select-provider-sheet')).toBeOnTheScreen(); + }); + + it('hides the sheet when the developer selector is disabled', () => { + mockUsePerpsProvider.mockReturnValue({ + activeProvider: 'hyperliquid', + switchProvider: mockSwitchProvider, + isProviderSelectorEnabled: false, + }); + + const { queryByTestId } = render(); + + expect(queryByTestId('perps-select-provider-sheet')).not.toBeOnTheScreen(); }); it('shows selectedOptionId as hyperliquid-mainnet by default', () => { @@ -179,6 +202,30 @@ describe('PerpsSelectProviderView', () => { expect(mockToggleTestnet).toHaveBeenCalled(); }); + it('waits for the provider connection before also changing network', async () => { + mockSheetOption = { + id: 'lighter-testnet', + providerId: 'lighter', + isTestnet: true, + name: 'Lighter', + network: 'Testnet', + description: '', + }; + + const { getByTestId } = render(); + + await act(async () => { + fireEvent.press(getByTestId('btn-select-option')); + }); + + expect(mockSwitchProvider).toHaveBeenCalledWith('lighter'); + expect(mockWaitForConnection).toHaveBeenCalledTimes(1); + expect(mockToggleTestnet).toHaveBeenCalledTimes(1); + expect(mockWaitForConnection.mock.invocationCallOrder[0]).toBeLessThan( + mockToggleTestnet.mock.invocationCallOrder[0], + ); + }); + it('logs error when switchProvider fails', async () => { const Logger = jest.requireMock('../../../../../util/Logger'); mockSwitchProvider.mockResolvedValue({ diff --git a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx index d423bcd9df85..04c5e67fbb02 100644 --- a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx @@ -10,6 +10,7 @@ import { selectPerpsNetwork } from '../../selectors/perpsController'; import PerpsProviderSelectorSheet from '../../components/PerpsProviderSelector/PerpsProviderSelectorSheet'; import type { ProviderNetworkOption } from '../../components/PerpsProviderSelector/PerpsProviderSelector.types'; import { PERPS_CONSTANTS } from '@metamask/perps-controller'; +import { PerpsConnectionManager } from '../../services/PerpsConnectionManager'; /** * PerpsSelectProviderView @@ -19,7 +20,8 @@ import { PERPS_CONSTANTS } from '@metamask/perps-controller'; */ const PerpsSelectProviderView: React.FC = () => { const navigation = useNavigation(); - const { activeProvider, switchProvider } = usePerpsProvider(); + const { activeProvider, switchProvider, isProviderSelectorEnabled } = + usePerpsProvider(); const { toggleTestnet } = usePerpsNetworkConfig(); const network = useSelector(selectPerpsNetwork); const isTestnet = network === 'testnet'; @@ -70,6 +72,9 @@ const PerpsSelectProviderView: React.FC = () => { // Then toggle network if needed if (networkChanged) { + if (providerChanged) { + await PerpsConnectionManager.waitForConnection(); + } const result = await toggleTestnet(); if (!result.success) { Logger.error(new Error(`Failed to toggle perps testnet`), { @@ -92,6 +97,10 @@ const PerpsSelectProviderView: React.FC = () => { [activeProvider, isTestnet, switchProvider, toggleTestnet], ); + if (!isProviderSelectorEnabled) { + return null; + } + return ( { + const originalEnvironment = process.env.METAMASK_ENVIRONMENT; + const originalLighterOverride = process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED; + beforeEach(() => { jest.clearAllMocks(); + process.env.METAMASK_ENVIRONMENT = 'dev'; + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = 'true'; + }); + + afterEach(() => { + if (originalEnvironment === undefined) { + delete process.env.METAMASK_ENVIRONMENT; + } else { + process.env.METAMASK_ENVIRONMENT = originalEnvironment; + } + if (originalLighterOverride === undefined) { + delete process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED; + } else { + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = originalLighterOverride; + } }); it('renders the provider selector title', async () => { @@ -39,4 +57,31 @@ describe('PerpsSelectProviderView', () => { ), ).toBeOnTheScreen(); }); + + it.each(['production', 'dev'])( + 'does not expose the selector in %s without the build opt-in', + async (environment) => { + process.env.METAMASK_ENVIRONMENT = environment; + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = 'false'; + + renderPerpsSelectProviderView(); + + await waitFor(() => { + expect( + screen.queryByTestId(PerpsSelectProviderViewSelectorsIDs.SHEET), + ).not.toBeOnTheScreen(); + }); + }, + ); + + it('exposes the selector in an explicit Lighter production build', async () => { + process.env.METAMASK_ENVIRONMENT = 'production'; + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = 'true'; + + renderPerpsSelectProviderView(); + + expect( + await screen.findByTestId(PerpsSelectProviderViewSelectorsIDs.SHEET), + ).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts index 3d7311382fb5..8f8cb4d41748 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts @@ -143,6 +143,11 @@ jest.mock('../../../../util/intl', () => ({ })), })); +const mockIsLighterProviderEnabled = jest.fn(); +jest.mock('../utils/lighterFeatureFlags', () => ({ + isLighterProviderEnabled: () => mockIsLighterProviderEnabled(), +})); + describe('createMobileInfrastructure', () => { beforeEach(() => { jest.clearAllMocks(); @@ -474,21 +479,44 @@ describe('createMobileInfrastructure', () => { }); describe('createMobileClientConfig', () => { - it('returns default config with empty strings and arrays when no env vars are set', () => { - // Arrange — ensure relevant env vars are absent - const envVars = [ - 'MM_PERPS_BLOCKED_REGIONS', - 'MM_PERPS_HIP3_ENABLED', - 'MM_PERPS_HIP3_ALLOWLIST_MARKETS', - 'MM_PERPS_HIP3_BLOCKLIST_MARKETS', - 'MM_PERPS_HL_BUILDER_ADDRESS_TESTNET', - 'MM_PERPS_HL_BUILDER_ADDRESS_MAINNET', - ]; - const saved: Record = {}; + const envVars = [ + 'METAMASK_ENVIRONMENT', + 'MM_PERPS_BLOCKED_REGIONS', + 'MM_PERPS_HIP3_ENABLED', + 'MM_PERPS_HIP3_ALLOWLIST_MARKETS', + 'MM_PERPS_HIP3_BLOCKLIST_MARKETS', + 'MM_PERPS_HL_BUILDER_ADDRESS_TESTNET', + 'MM_PERPS_HL_BUILDER_ADDRESS_MAINNET', + 'MM_PERPS_LIGHTER_PROVIDER_ENABLED', + 'MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET', + 'MM_PERPS_LIGHTER_API_KEY_INDEX', + ] as const; + const saved: Partial> = {}; + + beforeEach(() => { + mockIsLighterProviderEnabled.mockReturnValue(false); for (const key of envVars) { - saved[key] = process.env[key]; + if (process.env[key] !== undefined) { + saved[key] = process.env[key]; + } delete process.env[key]; } + }); + + afterEach(() => { + for (const key of envVars) { + const savedValue = saved[key]; + if (savedValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedValue; + } + delete saved[key]; + } + }); + + it('returns default config with empty strings and arrays when no env vars are set', () => { + // Arrange is provided by beforeEach. // Act const config = createMobileClientConfig(); @@ -504,15 +532,87 @@ describe('createMobileClientConfig', () => { builderAddressTestnet: '', builderAddressMainnet: '', }, + lighter: { + enabled: false, + accountIndexTestnet: undefined, + apiKeyIndex: undefined, + }, }, }); + }); - // Restore - for (const key of envVars) { - if (saved[key] !== undefined) { - process.env[key] = saved[key]; - } - } + it('enables Lighter with its signer bridge when explicitly enabled', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + + const config = createMobileClientConfig(); + + expect(config.providerCredentials?.lighter).toEqual( + expect.objectContaining({ + enabled: true, + signerBridge: expect.any(Object), + }), + ); + }); + + it('enables Lighter with its signer bridge through a production override', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + + const config = createMobileClientConfig(); + + expect(config.providerCredentials?.lighter).toEqual( + expect.objectContaining({ + enabled: true, + signerBridge: expect.any(Object), + }), + ); + }); + + it.each(['abc', 'NaN', 'Infinity', '-1', '1.5', '0x08', ' ', '255'])( + 'rejects malformed signer key slot %s instead of selecting another slot', + (value) => { + mockIsLighterProviderEnabled.mockReturnValue(true); + process.env.MM_PERPS_LIGHTER_API_KEY_INDEX = value; + + expect(() => createMobileClientConfig()).toThrow( + 'MM_PERPS_LIGHTER_API_KEY_INDEX', + ); + }, + ); + + it.each(['invalid', '-1', '1.5', '9007199254740992'])( + 'rejects malformed Lighter account index %s', + (value) => { + mockIsLighterProviderEnabled.mockReturnValue(true); + process.env.MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET = value; + + expect(() => createMobileClientConfig()).toThrow( + 'MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET', + ); + }, + ); + + it('supplies validated explicit signer indices', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + process.env.MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET = '59'; + process.env.MM_PERPS_LIGHTER_API_KEY_INDEX = '254'; + + const config = createMobileClientConfig(); + + expect(config.providerCredentials?.lighter).toEqual( + expect.objectContaining({ accountIndexTestnet: 59, apiKeyIndex: 254 }), + ); + }); + + it('keeps Lighter disabled in production without an override', () => { + mockIsLighterProviderEnabled.mockReturnValue(false); + + const config = createMobileClientConfig(); + + expect(config.providerCredentials?.lighter).toEqual({ + enabled: false, + accountIndexTestnet: undefined, + apiKeyIndex: undefined, + }); }); }); diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index ffd1279639f3..5862fe864f23 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -7,6 +7,8 @@ import Logger from '../../../../util/Logger'; import StorageWrapper from '../../../../store/storage-wrapper'; +import { lighterSignerBridge } from '../Lighter/lighterSignerBridge'; +import { isLighterProviderEnabled } from '../utils/lighterFeatureFlags'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; @@ -208,11 +210,30 @@ function createCacheInvalidatorAdapter() { }; } +/** Parse optional decimal signer indices without selecting a fallback on malformed input. */ +function parseLighterIndex( + value: string | undefined, + name: string, + maximum: number, +): number | undefined { + if (value === undefined || value === '') return undefined; + if ( + !/^\d+$/u.test(value) || + !Number.isSafeInteger(Number(value)) || + Number(value) > maximum + ) { + throw new Error(`${name} must be an integer between 0 and ${maximum}`); + } + return Number(value); +} + /** * Creates mobile-specific client config from environment variables. * Centralizes all process.env reads so the Engine init file stays pure wiring. */ export function createMobileClientConfig(): PerpsControllerConfig { + const lighterProviderEnabled = isLighterProviderEnabled(); + return { fallbackBlockedRegions: parseCommaSeparatedString( process.env.MM_PERPS_BLOCKED_REGIONS ?? '', @@ -231,6 +252,33 @@ export function createMobileClientConfig(): PerpsControllerConfig { builderAddressMainnet: process.env.MM_PERPS_HL_BUILDER_ADDRESS_MAINNET ?? '', }, + lighter: { + enabled: lighterProviderEnabled, + // Lighter Go/WASM signer transport (hidden WebView). Handed to the + // controller before the WebView mounts; calls queue behind the + // bridge's readiness promise (see lighterSignerBridge.ts). Only + // supplied under the same enablement rule that mounts the WebView, so + // the controller's enablement gate and the client's signer mount + // can never disagree (remote flag alone cannot register a trading + // provider whose signer was never mounted). + ...(lighterProviderEnabled + ? { signerBridge: lighterSignerBridge } + : {}), + accountIndexTestnet: lighterProviderEnabled + ? parseLighterIndex( + process.env.MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET, + 'MM_PERPS_LIGHTER_ACCOUNT_INDEX_TESTNET', + Number.MAX_SAFE_INTEGER, + ) + : undefined, + apiKeyIndex: lighterProviderEnabled + ? parseLighterIndex( + process.env.MM_PERPS_LIGHTER_API_KEY_INDEX, + 'MM_PERPS_LIGHTER_API_KEY_INDEX', + 254, + ) + : undefined, + }, }, }; } diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts index 7e0e06a0b886..d8c125d399e8 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts @@ -64,4 +64,20 @@ export const PROVIDER_NETWORK_OPTIONS: ProviderNetworkOption[] = [ network: 'Testnet', description: 'High-performance L1 perps', }, + { + id: 'lighter-mainnet', + providerId: 'lighter', + isTestnet: false, + name: 'Lighter', + network: 'Mainnet', + description: 'zkLighter perps', + }, + { + id: 'lighter-testnet', + providerId: 'lighter', + isTestnet: true, + name: 'Lighter', + network: 'Testnet', + description: 'zkLighter perps', + }, ]; diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx index 38ed4ca6b903..bed961d2512a 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx @@ -58,14 +58,45 @@ describe('PerpsProviderSelectorSheet', () => { expect( queryByTestId('provider-sheet-option-hyperliquid-testnet'), ).not.toBeOnTheScreen(); + expect( + queryByTestId('provider-sheet-option-lighter-mainnet'), + ).not.toBeOnTheScreen(); + expect( + queryByTestId('provider-sheet-option-lighter-testnet'), + ).not.toBeOnTheScreen(); + }); + + it('renders all matching options when all providers available', () => { + mockUsePerpsProvider.mockReturnValue({ + availableProviders: ['hyperliquid', 'lighter'], + }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId('provider-sheet-option-hyperliquid-mainnet'), + ).toBeOnTheScreen(); + expect( + getByTestId('provider-sheet-option-hyperliquid-testnet'), + ).toBeOnTheScreen(); + expect( + getByTestId('provider-sheet-option-lighter-mainnet'), + ).toBeOnTheScreen(); + expect( + getByTestId('provider-sheet-option-lighter-testnet'), + ).toBeOnTheScreen(); }); - it('calls onOptionSelect when an option is pressed', async () => { + it('selects the option and closes its navigation route when pressed', async () => { const onOptionSelect = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); const { getByTestId } = render( , ); @@ -80,6 +111,7 @@ describe('PerpsProviderSelectorSheet', () => { providerId: 'hyperliquid', }), ); + expect(onClose).toHaveBeenCalledTimes(1); }); it('marks the selected option as selected', () => { diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx index aa076163de0e..f81fb0017f79 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx @@ -44,9 +44,9 @@ const PerpsProviderSelectorSheet: React.FC = ({ const handleOptionPress = useCallback( async (option: ProviderNetworkOption) => { await onOptionSelect(option); - bottomSheetRef.current?.onCloseBottomSheet(); + onClose(); }, - [onOptionSelect], + [onClose, onOptionSelect], ); return ( diff --git a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts index 9f8fcda4f478..a6efadb56290 100644 --- a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts +++ b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts @@ -90,6 +90,27 @@ describe('useDefaultPayWithTokenWhenNoPerpsBalance', () => { expect(result.current).toBeNull(); }); + it('does not offer pay-with-token funding for Lighter without a deposit route', () => { + mockUsePerpsPaymentTokens.mockReturnValue([ + { + address: '0xusdc', + chainId: '0xa4b1', + symbol: 'USDC', + balanceFiat: 'US$500', + decimals: 6, + }, + ] as PerpsToken[]); + + const { result } = runHook( + getState({ + activeProvider: 'lighter', + allowlistAssets: ['0xa4b1.0xusdc'], + }), + ); + + expect(result.current).toBeNull(); + }); + it('returns null when feature flag is disabled', () => { mockUsePerpsPaymentTokens.mockReturnValue([ { diff --git a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts index ff98b88a066c..f11a4fc46e40 100644 --- a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts +++ b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts @@ -59,13 +59,11 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment effectiveProvider, currentNetwork, ); + // An unmapped provider has no supported pay-with-token deposit route. + if (perpsProviderChainId === undefined) return null; const allowlistTokens = paymentTokens.filter((token) => { - if ( - perpsProviderChainId !== undefined && - token.chainId === perpsProviderChainId - ) - return false; + if (token.chainId === perpsProviderChainId) return false; const key = `${token.chainId}.${(token.address ?? '').toLowerCase()}`; return allowSet.has(key); }); diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts index 47b6eb64ddc0..0d824c06057e 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts @@ -4,17 +4,22 @@ import { StackActions, useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { PerpsMode, + type PerpsActiveProviderMode, PERPS_EVENT_VALUE, type Position, } from '@metamask/perps-controller'; import { usePerpsNavigation } from './usePerpsNavigation'; import { usePerpsTrading } from './usePerpsTrading'; +import { usePerpsProvider } from './usePerpsProvider'; import usePerpsToasts from './usePerpsToasts'; import { usePerpsEventTracking } from './usePerpsEventTracking'; import Routes from '../../../../constants/navigation/Routes'; import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; import { selectPerpsProModeEnabledFlag } from '../selectors/featureFlags'; -import { selectPerpsMode } from '../selectors/perpsController'; +import { + selectPerpsMode, + selectPerpsProvider, +} from '../selectors/perpsController'; import { failPerpsTradeSheetInteractiveTrace, startPerpsTradeSheetInteractiveTrace, @@ -30,6 +35,7 @@ jest.mock('react-redux', () => ({ })); const mockDepositWithOrder = jest.fn(); +const mockSwitchProvider = jest.fn(); const mockShowToast = jest.fn(); const mockTrack = jest.fn(); const mockWithPendingTransactionActiveAbTests = jest.fn( @@ -40,6 +46,10 @@ jest.mock('./usePerpsTrading', () => ({ usePerpsTrading: jest.fn(), })); +jest.mock('./usePerpsProvider', () => ({ + usePerpsProvider: jest.fn(), +})); + jest.mock('./usePerpsToasts', () => ({ __esModule: true, default: jest.fn(), @@ -71,12 +81,16 @@ describe('usePerpsNavigation', () => { const mockGoBack = jest.fn(); const mockDispatch = jest.fn(); const mockGetState = jest.fn(); + let mockActiveProvider: PerpsActiveProviderMode | undefined; const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation >; const mockUsePerpsTrading = usePerpsTrading as jest.MockedFunction< typeof usePerpsTrading >; + const mockUsePerpsProvider = usePerpsProvider as jest.MockedFunction< + typeof usePerpsProvider + >; const mockUsePerpsToasts = usePerpsToasts as jest.MockedFunction< typeof usePerpsToasts >; @@ -87,20 +101,31 @@ describe('usePerpsNavigation', () => { >; beforeEach(() => { jest.clearAllMocks(); + mockActiveProvider = undefined; mockCanGoBack.mockReturnValue(true); // Default to Pro mode inactive, matching the existing navigateToHome // assertions below which expect the Perps Home screen target. mockUseSelector.mockImplementation((selector: unknown) => { if (selector === selectPerpsProModeEnabledFlag) return false; if (selector === selectPerpsMode) return PerpsMode.Lite; + if (selector === selectPerpsProvider) return mockActiveProvider; return undefined; }); mockDepositWithOrder.mockResolvedValue({ result: Promise.resolve('') }); + mockSwitchProvider.mockResolvedValue({ + success: true, + providerId: 'hyperliquid', + }); mockUsePerpsTrading.mockReturnValue({ depositWithOrder: mockDepositWithOrder, } as Partial> as ReturnType< typeof usePerpsTrading >); + mockUsePerpsProvider.mockReturnValue({ + switchProvider: mockSwitchProvider, + } as Partial> as ReturnType< + typeof usePerpsProvider + >); mockUsePerpsToasts.mockReturnValue({ showToast: mockShowToast, PerpsToastOptions: { @@ -390,6 +415,194 @@ describe('usePerpsNavigation', () => { }); }); + it('switches to Lighter before routing an explicit order while aggregated', async () => { + mockActiveProvider = 'aggregated'; + const { result } = renderHook(() => usePerpsNavigation()); + const params = { + direction: 'long' as const, + asset: 'ETH', + providerId: 'lighter' as const, + useBottomSheet: true, + }; + + result.current.navigateToOrder(params); + + expect(mockSwitchProvider).toHaveBeenCalledWith('lighter'); + expect(mockNavigate).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.BALANCE_ORDER, + params, + ); + }); + expect(mockDepositWithOrder).not.toHaveBeenCalled(); + expect(startPerpsTradeSheetInteractiveTrace).not.toHaveBeenCalled(); + }); + + it('switches a concrete provider before routing an explicit Lighter order', async () => { + mockActiveProvider = 'hyperliquid'; + mockSwitchProvider.mockResolvedValueOnce({ + success: true, + providerId: 'lighter', + }); + const { result } = renderHook(() => usePerpsNavigation()); + const params = { + direction: 'long' as const, + asset: 'ETH', + providerId: 'lighter' as const, + }; + + result.current.navigateToOrder(params); + + expect(mockNavigate).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockSwitchProvider).toHaveBeenCalledWith('lighter'); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.BALANCE_ORDER, + params, + ); + }); + expect(mockSwitchProvider.mock.invocationCallOrder[0]).toBeLessThan( + mockNavigate.mock.invocationCallOrder[0], + ); + expect(mockDepositWithOrder).not.toHaveBeenCalled(); + }); + + it('does not route an explicit Lighter order when provider switching fails', async () => { + mockActiveProvider = 'aggregated'; + mockSwitchProvider.mockResolvedValueOnce({ + success: false, + error: 'Provider switch failed', + }); + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToOrder({ + direction: 'long', + asset: 'ETH', + providerId: 'lighter', + }); + + await waitFor(() => { + expect(mockSwitchProvider).toHaveBeenCalledWith('lighter'); + expect(mockShowToast).toHaveBeenCalledWith({}); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockDepositWithOrder).not.toHaveBeenCalled(); + }); + + it('preserves deposit routing for an aggregated order without providerId', async () => { + mockActiveProvider = 'aggregated'; + const { result } = renderHook(() => usePerpsNavigation()); + const params = { direction: 'long' as const, asset: 'ETH' }; + + result.current.navigateToOrder(params); + + await waitFor(() => { + expect(mockDepositWithOrder).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + expect.objectContaining(params), + ); + }); + expect(mockSwitchProvider).not.toHaveBeenCalled(); + }); + + it('uses the default provider without switching while aggregated', async () => { + mockActiveProvider = 'aggregated'; + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToOrder({ + direction: 'long', + asset: 'ETH', + providerId: 'hyperliquid', + }); + + await waitFor(() => + expect(mockDepositWithOrder).toHaveBeenCalledTimes(1), + ); + expect(mockSwitchProvider).not.toHaveBeenCalled(); + }); + + it('uses the explicit provider over a concrete active provider', async () => { + mockActiveProvider = 'lighter'; + const { result } = renderHook(() => usePerpsNavigation()); + const params = { + direction: 'short' as const, + asset: 'ETH', + providerId: 'hyperliquid' as const, + }; + + result.current.navigateToOrder(params); + + await waitFor(() => { + expect(mockSwitchProvider).toHaveBeenCalledWith('hyperliquid'); + expect(mockDepositWithOrder).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + expect.objectContaining(params), + ); + }); + expect(mockSwitchProvider.mock.invocationCallOrder[0]).toBeLessThan( + mockDepositWithOrder.mock.invocationCallOrder[0], + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.PERPS.BALANCE_ORDER, + expect.anything(), + ); + }); + + it('does not deposit or navigate when an explicit provider switch fails', async () => { + mockActiveProvider = 'lighter'; + mockSwitchProvider.mockResolvedValueOnce({ + success: false, + error: 'Provider switch failed', + }); + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToOrder({ + direction: 'long', + asset: 'ETH', + providerId: 'hyperliquid', + }); + + await waitFor(() => { + expect(mockSwitchProvider).toHaveBeenCalledWith('hyperliquid'); + expect(mockShowToast).toHaveBeenCalledWith({}); + }); + expect(mockDepositWithOrder).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not switch when the explicit provider matches the active provider', async () => { + mockActiveProvider = 'hyperliquid'; + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToOrder({ + direction: 'long', + asset: 'ETH', + providerId: 'hyperliquid', + }); + + await waitFor(() => + expect(mockDepositWithOrder).toHaveBeenCalledTimes(1), + ); + expect(mockSwitchProvider).not.toHaveBeenCalled(); + }); + + it('preserves direct routing for an active Lighter provider without providerId', () => { + mockActiveProvider = 'lighter'; + const { result } = renderHook(() => usePerpsNavigation()); + const params = { direction: 'short' as const, asset: 'ETH' }; + + result.current.navigateToOrder(params); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.BALANCE_ORDER, + params, + ); + expect(mockDepositWithOrder).not.toHaveBeenCalled(); + }); + it('opens order confirmation as a headerless bottom sheet for treatment', async () => { const { result } = renderHook(() => usePerpsNavigation()); const params = { @@ -489,7 +702,11 @@ describe('usePerpsNavigation', () => { mockDepositWithOrder.mockRejectedValue(depositError); const { result } = renderHook(() => usePerpsNavigation()); - const params = { direction: 'long' as const, asset: 'BTC' }; + const params = { + direction: 'long' as const, + asset: 'BTC', + useBottomSheet: true, + }; result.current.navigateToOrder(params); @@ -499,6 +716,9 @@ describe('usePerpsNavigation', () => { }); expect(mockNavigate).not.toHaveBeenCalled(); + expect(failPerpsTradeSheetInteractiveTrace).toHaveBeenCalledWith( + 'transaction_creation_failed', + ); }); it('navigates to tutorial without params', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index c2c567f07d49..751030b5010c 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; import { StackActions, useNavigation } from '@react-navigation/native'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; @@ -13,6 +14,7 @@ import { type Order, } from '@metamask/perps-controller'; import { usePerpsTrading } from './usePerpsTrading'; +import { selectPerpsProvider } from '../selectors/perpsController'; import usePerpsToasts from './usePerpsToasts'; import { usePerpsEventTracking } from './usePerpsEventTracking'; import { MetaMetricsEvents } from '../../../../core/Analytics'; @@ -22,7 +24,11 @@ import { withPendingTransactionActiveAbTests, type TransactionActiveAbTestEntry, } from '../../../../util/transactions/transaction-active-ab-test-attribution-registry'; -import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; +import { + CONFIRMATION_HEADER_CONFIG, + PROVIDER_CONFIG, +} from '../constants/perpsConfig'; +import { usePerpsProvider } from './usePerpsProvider'; import { failPerpsTradeSheetInteractiveTrace, startPerpsTradeSheetInteractiveTrace, @@ -237,12 +243,76 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { ); const { depositWithOrder } = usePerpsTrading(); + const { switchProvider } = usePerpsProvider(); + const activeProvider = useSelector(selectPerpsProvider); const { showToast, PerpsToastOptions } = usePerpsToasts(); const { track } = usePerpsEventTracking(); const navigateToOrder = useCallback( (params: PerpsNavigationParamList['PerpsOrder']) => { const useBottomSheet = Boolean(params.useBottomSheet); + const orderProvider = params.providerId ?? activeProvider; + const handleOrderError = (error: unknown) => { + const err = ensureError(error, 'usePerpsNavigation.navigateToOrder'); + Logger.error(err, { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { name: 'usePerpsNavigation.navigateToOrder', data: {} }, + }); + + track(MetaMetricsEvents.PERPS_ERROR, { + [PERPS_EVENT_PROPERTY.ERROR_TYPE]: + PERPS_EVENT_VALUE.ERROR_TYPE.BACKEND, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: err.message, + [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.TRADE_ACTION, + }); + + showToast( + PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed, + ); + }; + const switchToOrderProvider = async () => { + if (params.providerId === undefined) { + return; + } + const result = await switchProvider(params.providerId); + if (!result.success) { + throw new Error( + result.error ?? + `Failed to switch perps provider to ${params.providerId}`, + ); + } + }; + // Lighter has no deposit-with-order route. Switch first so the form uses + // Lighter's balance and market metadata, including from aggregated mode. + if (orderProvider === 'lighter') { + if ( + params.providerId === 'lighter' && + activeProvider !== undefined && + activeProvider !== params.providerId + ) { + switchToOrderProvider() + .then(() => navigation.navigate(Routes.PERPS.BALANCE_ORDER, params)) + .catch(handleOrderError); + return; + } + navigation.navigate(Routes.PERPS.BALANCE_ORDER, params); + return; + } + const depositProvider = + activeProvider === undefined || + activeProvider === PROVIDER_CONFIG.AggregatedProvider + ? PROVIDER_CONFIG.DefaultProvider + : activeProvider; + let createOrder = depositWithOrder; + if ( + params.providerId !== undefined && + params.providerId !== depositProvider + ) { + createOrder = async () => { + await switchToOrderProvider(); + return depositWithOrder(); + }; + } if (useBottomSheet) { startPerpsTradeSheetInteractiveTrace( params.source ?? PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, @@ -250,7 +320,7 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { } withPendingTransactionActiveAbTests( params.transactionActiveAbTests, - depositWithOrder, + createOrder, ) .then(() => { navigation.navigate( @@ -268,28 +338,14 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { if (useBottomSheet) { failPerpsTradeSheetInteractiveTrace('transaction_creation_failed'); } - const err = ensureError(error, 'usePerpsNavigation.navigateToOrder'); - Logger.error(err, { - tags: { feature: PERPS_CONSTANTS.FeatureName }, - context: { name: 'usePerpsNavigation.navigateToOrder', data: {} }, - }); - - track(MetaMetricsEvents.PERPS_ERROR, { - [PERPS_EVENT_PROPERTY.ERROR_TYPE]: - PERPS_EVENT_VALUE.ERROR_TYPE.BACKEND, - [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: err.message, - [PERPS_EVENT_PROPERTY.SOURCE]: - PERPS_EVENT_VALUE.SOURCE.TRADE_ACTION, - }); - - showToast( - PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed, - ); + handleOrderError(error); }); }, [ navigation, depositWithOrder, + switchProvider, + activeProvider, showToast, PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed, track, diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts index b961f042963a..cee984120783 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts @@ -154,7 +154,10 @@ describe('usePerpsOrderExecution', () => { }); }); - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); expect(mockTrace).not.toHaveBeenCalled(); expect(mockEndTrace).not.toHaveBeenCalled(); }); @@ -174,7 +177,10 @@ describe('usePerpsOrderExecution', () => { }); }); - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); expect(mockTrace).not.toHaveBeenCalled(); expect(mockEndTrace).not.toHaveBeenCalled(); }); @@ -290,7 +296,10 @@ describe('usePerpsOrderExecution', () => { // Limit orders capture a pre-submit position baseline so marketable // limits that fill before watcher registration are still detected. - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); expect(mockGetPositionsSnapshot).toHaveBeenCalledTimes(1); // The already-rested order ends at its stream delivery instant, not now. expect(mockEndTrace).toHaveBeenCalledWith( @@ -495,7 +504,10 @@ describe('usePerpsOrderExecution', () => { }); }); - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); expect(mockEndTrace).not.toHaveBeenCalledWith( expect.objectContaining({ id: expect.stringContaining( @@ -802,7 +814,10 @@ describe('usePerpsOrderExecution', () => { }); }); - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); expect(mockGetPositionsSnapshot).toHaveBeenCalledTimes(1); expect(mockEndTrace).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -850,7 +865,10 @@ describe('usePerpsOrderExecution', () => { }); expect(mockPlaceOrder).toHaveBeenCalledWith(mockOrderParams); - expect(onSuccess).toHaveBeenCalledWith(mockPosition); + expect(onSuccess).toHaveBeenCalledWith( + mockPosition, + result.current.lastResult, + ); expect(onError).not.toHaveBeenCalled(); expect(result.current.lastResult).toEqual({ success: true, @@ -987,7 +1005,10 @@ describe('usePerpsOrderExecution', () => { await act(async () => { await placeOrderPromise; }); - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); mockEndTrace.mockClear(); act(() => { @@ -1037,7 +1058,10 @@ describe('usePerpsOrderExecution', () => { expect(result.current.isPlacing).toBe(false); }); - expect(onSuccess).toHaveBeenCalledWith(); + expect(onSuccess).toHaveBeenCalledWith( + undefined, + result.current.lastResult, + ); // Deliver the late render so the pending stream waiter (and its // timeout) resolves instead of leaking past the test. @@ -1074,7 +1098,10 @@ describe('usePerpsOrderExecution', () => { expect(result.current.isPlacing).toBe(false); }); - expect(onSuccess).toHaveBeenCalledWith(mockPosition); + expect(onSuccess).toHaveBeenCalledWith( + mockPosition, + result.current.lastResult, + ); expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts index 350c5f75371c..8b7e5bd04267 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -40,7 +40,7 @@ interface UsePerpsOrderExecutionParams { /** Called when the order has been successfully submitted to the exchange. */ onSubmitted?: () => void; /** Called when the position has rendered via the stream (or, on stream timeout, without it). */ - onSuccess?: (position?: Position) => void; + onSuccess?: (position?: Position, result?: OrderResult) => void; onError?: (error: string) => void; } @@ -211,7 +211,7 @@ export function usePerpsOrderExecution( return executeControllerPlacement(orderParams, { // Strategy acceptance starts a schedule; it does not imply that a // position or resting child order has rendered yet. - onSuccess: () => onSuccess?.(), + onSuccess: (result) => onSuccess?.(undefined, result), }); } @@ -312,7 +312,7 @@ export function usePerpsOrderExecution( // therefore have no resting IDs; that is still a valid acceptance, // so it stays open for a position stream boundary or timeout rather // than being marked as a request failure. - onSuccess?.(); + onSuccess?.(undefined, result); const orderIds = ( orderParams.orderType === 'scale' ? (result.childOrderIds ?? []) @@ -398,14 +398,14 @@ export function usePerpsOrderExecution( 'usePerpsOrderExecution: Position rendered by stream', rendered.position, ); - onSuccess?.(position); + onSuccess?.(position, result); } else { // Stream quiet: unblock the toast now, end the span when the // position finally renders (or record the miss on timeout). DevLogger.log( 'usePerpsOrderExecution: Position not rendered yet, toasting without it', ); - onSuccess?.(); + onSuccess?.(undefined, result); // Deliberately not awaited: the caller must not block on the span. waitForPerpsPlaceOrderPositionRendered( PERPS_CUF_STREAM_TIMEOUT_MS, diff --git a/app/components/UI/Perps/hooks/usePerpsProvider.test.ts b/app/components/UI/Perps/hooks/usePerpsProvider.test.ts index 90cc0ee20c25..20b90ab04cff 100644 --- a/app/components/UI/Perps/hooks/usePerpsProvider.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsProvider.test.ts @@ -9,6 +9,7 @@ import { } from '../selectors/perpsController'; import { usePerpsProvider } from './usePerpsProvider'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; +import { selectPerpsLighterProviderEnabledFlag } from '../selectors/featureFlags'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -31,11 +32,16 @@ jest.mock('../services/PerpsConnectionManager', () => ({ }, })); +const mockIsLighterProviderEnabled = jest.fn(); +jest.mock('../utils/lighterFeatureFlags', () => ({ + isLighterProviderEnabled: () => mockIsLighterProviderEnabled(), +})); +const mockLighterRemoteFlagEnabled = jest.fn(); + const mockUseSelector = useSelector as jest.Mock; const mockGetOrderCapabilities = jest.mocked( Engine.context.PerpsController.getOrderCapabilities, ); - type Capabilities = Awaited< ReturnType >; @@ -69,6 +75,8 @@ const mockAggregatedProviderSelectors = ( beforeEach(() => { jest.clearAllMocks(); + mockIsLighterProviderEnabled.mockReturnValue(false); + mockLighterRemoteFlagEnabled.mockReturnValue(false); mockGetOrderCapabilities.mockResolvedValue({ status: 'ready', providerId: 'hyperliquid', @@ -76,6 +84,9 @@ beforeEach(() => { }); // Default: Hyperliquid active on mainnet. mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectPerpsLighterProviderEnabledFlag) { + return mockLighterRemoteFlagEnabled(); + } if (selector === selectPerpsProvider) { return 'hyperliquid'; } @@ -96,6 +107,19 @@ describe('usePerpsProvider', () => { expect(result.current.availableProviders).toEqual(['hyperliquid']); }); + + it('includes Lighter and aggregated providers in development', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + mockLighterRemoteFlagEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.availableProviders).toEqual([ + 'hyperliquid', + 'lighter', + 'aggregated', + ]); + }); }); describe('activeProvider', () => { @@ -666,5 +690,42 @@ describe('usePerpsProvider', () => { expect(result.current.isMultiProviderEnabled).toBe(false); }); + + it('enables the provider selector in development', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + mockLighterRemoteFlagEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isProviderSelectorEnabled).toBe(true); + }); + + it('enables the multi-provider badge when both gates are enabled', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + mockLighterRemoteFlagEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isMultiProviderEnabled).toBe(true); + }); + + it('keeps Lighter unavailable when the runtime flag is enabled without the build gate', () => { + mockLighterRemoteFlagEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isProviderSelectorEnabled).toBe(false); + expect(result.current.availableProviders).toEqual(['hyperliquid']); + }); + + it('keeps Lighter unavailable when the build gate is enabled without the runtime flag', () => { + mockIsLighterProviderEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isMultiProviderEnabled).toBe(false); + expect(result.current.isProviderSelectorEnabled).toBe(false); + expect(result.current.availableProviders).toEqual(['hyperliquid']); + }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsProvider.ts b/app/components/UI/Perps/hooks/usePerpsProvider.ts index 3e17cc68f5af..3cd95fff22bb 100644 --- a/app/components/UI/Perps/hooks/usePerpsProvider.ts +++ b/app/components/UI/Perps/hooks/usePerpsProvider.ts @@ -19,6 +19,8 @@ import { PROVIDER_CONFIG, } from '../constants/perpsConfig'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; +import { isLighterProviderEnabled } from '../utils/lighterFeatureFlags'; +import { selectPerpsLighterProviderEnabledFlag } from '../selectors/featureFlags'; interface OrderCapabilitiesState { requestKey?: string; @@ -38,8 +40,6 @@ const EMPTY_ORDER_CAPABILITIES_STATE: OrderCapabilitiesState = { capabilities: null, isLoading: false, }; -const AVAILABLE_PROVIDERS: PerpsActiveProviderMode[] = ['hyperliquid']; - /** * Hook for managing perps provider selection * @@ -54,8 +54,23 @@ export function usePerpsProvider( const activeProvider = useSelector(selectPerpsProvider); const perpsNetwork = useSelector(selectPerpsNetwork); const initializationState = useSelector(selectPerpsInitializationState); + const lighterEnabled = useSelector(selectPerpsLighterProviderEnabledFlag); + const isLighterBuildEnabled = isLighterProviderEnabled(); - const availableProviders = AVAILABLE_PROVIDERS; + /** + * Get list of available providers based on feature flags + */ + const availableProviders = useMemo((): PerpsActiveProviderMode[] => { + const providers: PerpsActiveProviderMode[] = ['hyperliquid']; + + if (isLighterBuildEnabled && lighterEnabled) { + providers.push('lighter'); + providers.push('aggregated'); + } + + return providers; + }, [isLighterBuildEnabled, lighterEnabled]); + const isProviderSelectorEnabled = isLighterBuildEnabled && lighterEnabled; /** * Switch to a different provider @@ -249,8 +264,8 @@ export function usePerpsProvider( * Check if multi-provider mode is enabled (more than one provider available) */ const isMultiProviderEnabled = useMemo( - () => availableProviders.length > 1, - [availableProviders], + () => isProviderSelectorEnabled && availableProviders.length > 1, + [availableProviders, isProviderSelectorEnabled], ); return { @@ -270,6 +285,7 @@ export function usePerpsProvider( supportsScaleOrders, supportsChaseOrders, checkOrderCapability, + isProviderSelectorEnabled, isMultiProviderEnabled, }; } diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index c592cdeebacf..89bdc21e69b7 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -20,6 +20,7 @@ import { PerpsGlobalErrorGate } from '../components/PerpsGlobalErrorGate'; import { PerpsStreamProvider } from '../providers/PerpsStreamManager'; import PerpsHomeView from '../Views/PerpsHomeView/PerpsHomeView'; import PerpsMarketDetailsRouter from '../Views/PerpsMarketDetailsRouter'; +import PerpsBalanceOrderView from '../Views/PerpsBalanceOrderView'; import PerpsMarketListView from '../Views/PerpsMarketListView'; import PerpsRedirect from '../Views/PerpsRedirect'; import PerpsOrderRedirect from '../Views/PerpsOrderRedirect'; @@ -427,6 +428,11 @@ const PerpsScreenStack = () => { /> {/* Withdrawal flow screens */} + ({ + isLighterProviderEnabled: jest.fn().mockReturnValue(false), +})); jest.mock('react-native-device-info', () => ({ getVersion: jest.fn().mockReturnValue('1.0.0'), @@ -687,6 +693,115 @@ describe('Perps Feature Flag Selectors', () => { }); }); + describe('selectPerpsLighterProviderEnabledFlag', () => { + beforeEach(() => { + jest.mocked(isLighterProviderEnabled).mockReturnValue(false); + }); + + const createEmptyFlagsState = () => + ({ + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }) as unknown as Parameters< + typeof selectPerpsLighterProviderEnabledFlag + >[0]; + + it('returns false without a remote flag or local enablement', () => { + expect( + selectPerpsLighterProviderEnabledFlag(createEmptyFlagsState()), + ).toBe(false); + }); + + it('falls back to local enablement when the remote flag is absent', () => { + jest.mocked(isLighterProviderEnabled).mockReturnValue(true); + + expect( + selectPerpsLighterProviderEnabledFlag(createEmptyFlagsState()), + ).toBe(true); + }); + + it('lets a valid remote flag win over local enablement', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + jest.mocked(isLighterProviderEnabled).mockReturnValue(true); + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + perpsLighterProviderEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + } as unknown as Parameters< + typeof selectPerpsLighterProviderEnabledFlag + >[0]; + + expect(selectPerpsLighterProviderEnabledFlag(state)).toBe(false); + }); + + it('enables the provider when the remote flag is enabled and the version gate passes', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = 'false'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + perpsLighterProviderEnabled: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + } as unknown as Parameters< + typeof selectPerpsLighterProviderEnabledFlag + >[0]; + + expect(selectPerpsLighterProviderEnabledFlag(state)).toBe(true); + }); + + it('stays disabled when the remote flag is enabled but the version gate fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED = 'false'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + perpsLighterProviderEnabled: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + } as unknown as Parameters< + typeof selectPerpsLighterProviderEnabledFlag + >[0]; + + expect(selectPerpsLighterProviderEnabledFlag(state)).toBe(false); + }); + }); + describe('selectPerpsOrderBookEnabledFlag', () => { // Helper to create fresh state objects to avoid reselect caching issues const createEmptyFlagsState = () => ({ diff --git a/app/components/UI/Perps/selectors/featureFlags/index.ts b/app/components/UI/Perps/selectors/featureFlags/index.ts index 7eed7c51f738..bfc855d8fd15 100644 --- a/app/components/UI/Perps/selectors/featureFlags/index.ts +++ b/app/components/UI/Perps/selectors/featureFlags/index.ts @@ -7,6 +7,7 @@ import { import type { RootState } from '../../../../../reducers'; import { hasProperty } from '@metamask/utils'; import { parseAllowlistAssets } from '../../utils/parseAllowlistAssets'; +import { isLighterProviderEnabled } from '../../utils/lighterFeatureFlags'; export const selectPerpsEnabledFlag = createSelector( selectRemoteFeatureFlags, @@ -542,3 +543,26 @@ export const selectPerpsCrossMarginEnabledFlag = createSelector( return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; }, ); + +/** + * Selector for the Lighter venue provider (TAT-3766 POC). + * Controls whether Lighter appears in the provider/network selector. + * + * Remote flag wins when valid; falls back to the local env gate so the POC + * stays switchable on a dev machine without a LaunchDarkly entry. + * + * LaunchDarkly key (kebab-case): `perps-lighter-provider-enabled`. + * + * @returns boolean - true if the Lighter provider should be selectable + */ +export const selectPerpsLighterProviderEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + // Default to false if no flag is set (disabled by default) + const localFlag = isLighterProviderEnabled(); + const remoteFlag = + remoteFeatureFlags?.perpsLighterProviderEnabled as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + }, +); diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 16aab3c665f9..1c5ffce2168c 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -9,6 +9,7 @@ import { type SortDirection, type SortOptionId, type MarketTypeFilter, + type PerpsProviderType, } from '@metamask/perps-controller'; import { PerpsTransaction } from './transactionHistory'; import type { DataMonitorParams } from '../hooks/usePerpsDataMonitor'; @@ -59,6 +60,7 @@ export type PerpsClosePositionModalsNavigationParamList = { export type PerpsOrderRouteParams = { direction: 'long' | 'short'; asset: string; + providerId?: PerpsProviderType; defaultSzDecimals?: number; defaultMaxLeverage?: number; leverage?: number; @@ -86,6 +88,7 @@ export type PerpsOrderRouteParams = { export type PerpsStackParamList = { // Order flow routes PerpsOrder: PerpsOrderRouteParams; + PerpsBalanceOrder: PerpsOrderRouteParams; PerpsOrderSuccess: { orderId: string; diff --git a/app/components/UI/Perps/utils/lighterFeatureFlags.test.ts b/app/components/UI/Perps/utils/lighterFeatureFlags.test.ts new file mode 100644 index 000000000000..23cb63f4a783 --- /dev/null +++ b/app/components/UI/Perps/utils/lighterFeatureFlags.test.ts @@ -0,0 +1,46 @@ +import { isLighterProviderEnabled } from './lighterFeatureFlags'; + +describe('lighterFeatureFlags', () => { + describe('isLighterProviderEnabled', () => { + it('keeps Lighter disabled in development without an opt-in', () => { + const environment = { + METAMASK_ENVIRONMENT: 'dev', + MM_PERPS_LIGHTER_PROVIDER_ENABLED: 'false', + }; + + const result = isLighterProviderEnabled(environment); + + expect(result).toBe(false); + }); + + it('enables Lighter through the explicit override outside development', () => { + const environment = { + METAMASK_ENVIRONMENT: 'production', + MM_PERPS_LIGHTER_PROVIDER_ENABLED: 'true', + }; + + const result = isLighterProviderEnabled(environment); + + expect(result).toBe(true); + }); + + it('keeps Lighter disabled in production without an override', () => { + const environment = { METAMASK_ENVIRONMENT: 'production' }; + + const result = isLighterProviderEnabled(environment); + + expect(result).toBe(false); + }); + + it('treats non-true override values as disabled', () => { + const environment = { + METAMASK_ENVIRONMENT: 'production', + MM_PERPS_LIGHTER_PROVIDER_ENABLED: 'false', + }; + + const result = isLighterProviderEnabled(environment); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Perps/utils/lighterFeatureFlags.ts b/app/components/UI/Perps/utils/lighterFeatureFlags.ts new file mode 100644 index 000000000000..7f10984087d9 --- /dev/null +++ b/app/components/UI/Perps/utils/lighterFeatureFlags.ts @@ -0,0 +1,18 @@ +interface LighterFeatureEnvironment { + METAMASK_ENVIRONMENT?: string; + MM_PERPS_LIGHTER_PROVIDER_ENABLED?: string; +} + +/** + * Enables the experimental Lighter infrastructure only with an explicit + * build-time opt-in, matching the Metro fence around the embedded signer. + */ +export function isLighterProviderEnabled( + environment: LighterFeatureEnvironment = { + METAMASK_ENVIRONMENT: process.env.METAMASK_ENVIRONMENT, + MM_PERPS_LIGHTER_PROVIDER_ENABLED: + process.env.MM_PERPS_LIGHTER_PROVIDER_ENABLED, + }, +): boolean { + return environment.MM_PERPS_LIGHTER_PROVIDER_ENABLED === 'true'; +} diff --git a/app/components/Views/Root/index.tsx b/app/components/Views/Root/index.tsx index 8095b22a3d95..128d13a5aee3 100644 --- a/app/components/Views/Root/index.tsx +++ b/app/components/Views/Root/index.tsx @@ -24,6 +24,10 @@ import { ScreenOrientationService } from '../../../core/ScreenOrientation'; ///: BEGIN:ONLY_INCLUDE_IF(snaps) import { SnapsExecutionWebView } from '../../../lib/snaps'; ///: END:ONLY_INCLUDE_IF +///: BEGIN:ONLY_INCLUDE_IF(lighter) +import { LighterSignerWebView } from '../../UI/Perps/Lighter/LighterSignerWebView'; +import { isLighterProviderEnabled } from '../../UI/Perps/utils/lighterFeatureFlags'; +///: END:ONLY_INCLUDE_IF import { ReducedMotionConfig, ReduceMotion } from 'react-native-reanimated'; import { QueryClientProvider } from '@tanstack/react-query'; import reactQueryService from '../../../core/ReactQueryService'; @@ -114,6 +118,14 @@ const Root = ({ foxCode }: RootProps) => { + { + ///: BEGIN:ONLY_INCLUDE_IF(lighter) + // The signer observes the initialized keyring's lock lifecycle. + isLighterProviderEnabled() && ( + + ) + ///: END:ONLY_INCLUDE_IF + } diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index cb42dfe004e5..338e0543ebb1 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -395,6 +395,7 @@ const Routes = { ROOT: 'Perps', PERPS_TAB: 'PerpsTradingView', // Redirect to wallet home and select perps tab ORDER_REDIRECT: 'PerpsOrderRedirect', // Redirect for one-click trade from token details + BALANCE_ORDER: 'PerpsBalanceOrder', WITHDRAW: 'PerpsWithdraw', POSITIONS: 'PerpsPositions', PERPS_HOME: 'PerpsMarketListView', // Home screen (positions, orders, watchlist, markets) diff --git a/app/core/Engine/messengers/perps-controller-messenger/index.ts b/app/core/Engine/messengers/perps-controller-messenger/index.ts index 86cc1211b02d..dff6852e1110 100644 --- a/app/core/Engine/messengers/perps-controller-messenger/index.ts +++ b/app/core/Engine/messengers/perps-controller-messenger/index.ts @@ -47,6 +47,7 @@ export function getPerpsControllerMessenger( 'NetworkController:findNetworkClientIdByChainId', 'KeyringController:getState', 'KeyringController:signTypedMessage', + 'KeyringController:signPersonalMessage', 'TransactionController:addTransaction', 'RemoteFeatureFlagController:getState', 'AccountsController:getSelectedAccount', diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 419f0026fa8c..74a286b2447a 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -933,6 +933,7 @@ export type RootStackParamList = { Perps: NavigatorScreenParams | undefined; PerpsTradingView: PerpsNavigationParamList['PerpsTradingView']; PerpsOrderRedirect: PerpsNavigationParamList['PerpsOrderRedirect']; + PerpsBalanceOrder: PerpsNavigationParamList['PerpsBalanceOrder']; PerpsWithdraw: PerpsNavigationParamList['PerpsWithdraw']; PerpsPositions: PerpsNavigationParamList['PerpsPositions']; PerpsMarketListView: PerpsNavigationParamList['PerpsMarketListView']; diff --git a/babel.config.js b/babel.config.js index 805922ff7ade..049b7c018371 100644 --- a/babel.config.js +++ b/babel.config.js @@ -207,6 +207,14 @@ module.exports = { test: pathIncludes('/app/lib/snaps/SnapsExecutionWebView.tsx'), plugins: [['babel-plugin-inline-import', { extensions: ['.html'] }]], }, + { + // Lighter Go/WASM signer page (base64-embedded WASM, ~10 MB) inlined as + // a string for the hidden signer WebView. + test: pathIncludes( + '/app/components/UI/Perps/Lighter/LighterSignerWebView.tsx', + ), + plugins: [['babel-plugin-inline-import', { extensions: ['.html'] }]], + }, { test: pathIncludes('/app/core/redux/ReduxService.ts'), plugins: privateMethodsLoose, diff --git a/babel.config.tests.js b/babel.config.tests.js index 19edfc3ac8b2..c9f29b077078 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -13,7 +13,24 @@ const newPlugins = baseConfig.plugins.filter((plugin) => { }); const newOverrides = [ - ...baseConfig.overrides, + ...baseConfig.overrides.map((override) => { + if ( + !override.test( + '/app/components/UI/Perps/Lighter/LighterSignerWebView.tsx', + ) + ) { + return override; + } + // Keep the 10 MB native signer asset out of each Jest VM and its coverage + // data. Jest maps the import to a stub; HTML behavior has separate tests. + return { + ...override, + plugins: override.plugins.filter((plugin) => { + const name = Array.isArray(plugin) ? plugin[0] : plugin; + return name !== 'babel-plugin-inline-import'; + }), + }; + }), // Don't transform environment variables for files that depend on them. { exclude: [ @@ -23,6 +40,8 @@ const newOverrides = [ 'app/lib/Money/feature-flags.ts', 'app/lib/Money/feature-flags.test.ts', 'app/components/UI/Perps/selectors/featureFlags/index.ts', + 'app/components/UI/Perps/utils/lighterFeatureFlags.ts', + 'app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.view.test.tsx', 'app/core/Engine/controllers/network-controller/utils.ts', 'app/core/Engine/controllers/network-controller/utils.test.ts', 'app/util/test/utils.js', diff --git a/docs/perps/lighter-capabilities-and-fees.html b/docs/perps/lighter-capabilities-and-fees.html new file mode 100644 index 000000000000..8e3ef7da95e5 --- /dev/null +++ b/docs/perps/lighter-capabilities-and-fees.html @@ -0,0 +1,829 @@ + + + + + + Lighter: fees, capabilities and integration status + + + +
+

MetaMask Perps · Status checked September 19, 2026

+

Lighter: fees, capabilities and integration status

+

+ Reviewer companion for + Mobile #34865. This compares the MetaMask integrations, not everything each venue + offers. +

+ +
+ +
+

How MetaMask can earn with low user fees

+

+ Lighter supports fee capture natively. MetaMask has not wired it into + this integration yet. + Lighter calls it Partner Attribution: the user approves + MetaMask, each order identifies MetaMask's Lighter account and fee, and + Lighter credits MetaMask in USDC when perpetual trades generate + integrator fees. Our current provider instead quotes + metamaskFeeRate: 0 and supports Standard accounts only. + [L1, L2] + [C1] +

+ +

The PM explanation

+
    +
  1. + Get permission. The user approves MetaMask as an + integrator, with a maximum fee and optional expiry. Different L1 + owners normally require L1 approval and L2 confirmation. Approval can + be revoked. +
  2. +
  3. + Tag the order. The signed order carries + integrator_account_index, + integrator_maker_fee and + integrator_taker_fee. This is Lighter's equivalent of a + builder code. The protocol does not infer MetaMask from the wallet + brand or API key. +
  4. +
  5. + Collect on trades. The protocol credits perpetual + integrator fees in USDC to the specified integrator account. This is + an additional user-paid fee, not an automatic share of Lighter's + existing fee. Treasury withdrawal and reconciliation still need + implementation and proof. +
  6. +
+

+ The published perpetual integrator ceiling is 10 bps, + with 0.01 bp increments. That is a limit, not a + recommended price. Confirm live limits with + systemConfig before approving or charging fees. One basis + point is 0.01%. The SDK's integer fee value divided by 1,000,000 is the + fee fraction: 100 = 1 bp, 1000 = 10 bps. + [L1, L2, L5] +

+ +

Low-fee option to evaluate: Plus + a small MetaMask fee

+

+ Lighter lists Plus maker and taker fees at + 0.5 bp / 0.005%. A hypothetical + 1 bp / 0.01% MetaMask integrator fee would make the + combined trading charge 1.5 bps / 0.015% per filled + notional. This is a pricing proposal for discussion, not an approved + MetaMask rate or a tested quote. [L3] +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Illustration: $1,000 filled notional, Plus fee 0.5 bp, hypothetical + MetaMask fee 1 bp +
RecipientRateOne $1,000 fill
Lighter0.005%$0.05
MetaMask0.01%$0.10
Total trading fee0.015%$0.15
+
+

+ An opening fill and a closing fill of $1,000 each would total $0.30 + under those assumptions. Fees apply to + traded notional, not posted margin. Spreads, slippage, + funding, liquidation and transfer/withdrawal costs are separate. “Low + fee” must not be presented as “free,” and a small notional rate can + still be material at high leverage. +

+

+ Maker and taker integrator fees are configured independently, allowing a + different maker price to be evaluated. Do not default to the maximum fee + merely because the protocol permits it. +

+ +

Could MetaMask earn without adding a user surcharge?

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Different business models; none establishes permanent, guaranteed + zero-cost revenue capture +
ModelWho funds MetaMask revenue?User experience and limits
Native integrator feeThe trader pays the approved additional fee. + Predictable configurable collection, subject to protocol + eligibility. Not zero-fee trading. +
Referral share + Lighter distributes 10–30% of eligible gross fees from referred + users, depending on the referrer's tier. + + No extra MetaMask surcharge, but the user still pays eligible + venue fees. Rewards/tier upgrades are discretionary. Ordinary + zero-fee Standard trades provide no positive fee base. Plus + referral eligibility is not explicitly established by the + program text. +
Referral trial rebates + The program describes both referrer kickbacks and user fee + rebates. + + Eligible new referees can activate a one-week Premium trial; + fees are paid first and rebated weekly, capped at the first $10M + of trial volume. This can be zero net trading fees temporarily, + not zero fees at execution. Confirm concurrent kickback + eligibility before forecasting revenue. +
Negotiated subsidy / revenue shareWould require Lighter or another agreed budget to pay. + No MetaMask-specific agreement verified. A MetaMask-funded + rebate reduces its own net revenue; it does not create free + protocol income. +
+
+

+ Referral payouts are documented as weekly L2 transfers with a $1 + minimum. Trades carrying + nonzero integrator fees are excluded from referral + kickbacks and rebates: do not count both revenues on the same trade. The + commercial program says referral attribution is fixed at signup and + cannot be added later; API wording elsewhere disagrees. Confirm + onboarding eligibility before promising revenue from existing MetaMask + users. [L4, L6] +

+ +

Important eligibility change and documentation conflicts

+

+ The official Partner Attribution notice says that from + September 14, 2026, Standard-account partner trades and + new integrator approvals are rejected. Plan nonzero integrator fees + around Plus or Premium, with explicit user consent to + the tier's venue fees. Our Standard-only integration cannot simply add + an order tag and start collecting. [L1] +

+
    +
  • + The API guide still says “Both standard and premium accounts are + accepted” in Definitions, but its Requirements mandate Plus/Premium + for nonzero fees. The newer notice is broader. Treat Standard zero-fee + attribution as unresolved, not supported revenue capture. +
  • +
  • + The API guide allows L2-only approval for zero fees, whereas the + overview describes signature requirements by L1 ownership without that + exception. Confirm the zero-fee case; it does not establish nonzero + revenue. +
  • +
  • + Referral API descriptions suggest attribution can change later, + conflicting with the commercial program's signup-only rule. The + program also labels July 28, 2026 a Monday, although that date is a + Tuesday. Confirm the exact eligibility cutoff and rule with Lighter + before using it in forecasts. +
  • +
  • + These are verified documentation claims as of September 15, not live + fee-credit tests. No account tier, approval or order was changed for + this research. +
  • +
+ +

What must be built and proven

+
    +
  1. + Select a business model and user-facing price. Confirm applicable + Lighter Core network/tier rules and commercial eligibility; do not + assume Robinhood Chain has identical terms. +
  2. +
  3. + Support eligible non-Standard account fees safely in Core, replacing + the current refusal only after fee units, quotes and history are + covered by tests. Add explicit tier selection/consent rather than + silently upgrading users. +
  4. +
  5. + For native fees: configure MetaMask's receiving account per network, + add integrator approval/revocation/expiry and signed order fields + through the WASM bridge, and show venue + MetaMask + total fees before + submission. Verify the pinned signer supports these operations; + generic signing support is insufficient proof. +
  6. +
  7. + Reconcile partial/full fills against both the user's charges and the + integrator's credited USDC, then verify treasury withdrawal. Cover + cancellation, failed orders and approval limits. Mainnet financial + testing requires separate authorization. +
  8. +
+

+ Hyperliquid comparison: its existing integration + already sends an approved builder: { b, f } payload. The + configured standard maximum is 10 bps, with discounts/subscription + exceptions; native TWAP has no builder field. Lighter has a comparable + protocol mechanism, but uses an account index, its own fee units and + different eligibility rules. This document does not compare all-in + prices for every market or certify Hyperliquid settlement. + [C2, C3] +

+
+ +
+

Capability matrix versus Hyperliquid

+

+ Implemented means present in inspected source, not + necessarily enabled in every build. Validated refers + only to the dated Lighter evidence below. + Unavailable means this integration explicitly refuses + or does not advertise the capability. Not validated is + not a claim that the venue cannot do it. +

+

+ Hyperliquid is the source-based comparison baseline; its flows were not + re-proved in this Lighter run. The historical Core references below use + #10187 at 2617b5880. Core #10187 has since merged, and the + Mobile PR now consumes the published + @metamask/perps-controller@17.2.0. Advanced capability + declarations are not a promise of end-to-end validation in Mobile. + [C1–C4] +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Integration support and the actual scope of Lighter proof +
CapabilityHyperliquid integrationLighter integrationLighter validation / limit
Markets, prices, candles, order bookImplemented. + Implemented; WebSocket subscriptions and price polling fallback. + + Loaded testnet UI; earlier changing mainnet prices/candles + observed read-only. Not every stream re-proved. +
Provider/network selectorSelectable in development UI. + Selectable in development UI; mainnet/testnet. Not a production + rollout control. + + Earlier UI switches passed. Non-dev hiding has static test + coverage. [M1] +
Lite / Pro navigationExisting routes. + Both modes; funded Lite order screen reuses the order form + without changing mode. + + Lite entry, Back and restored Lite controls validated; Pro limit + flow validated. [M1] +
Market and limit ordersImplemented. + Implemented for supported Standard accounts and authorized + signer. + + Testnet BTC long market/open/close and Pro limit/cancel + validated. Short entry exists; no separate short cycle claimed. +
Post-onlyALO order handling implemented.Unavailable: placement and validation reject ALO.No post-only proof; excluded from basic acceptance.
Positions and open ordersImplemented reads/subscriptions.Implemented reads/subscriptions. + Visible position, resting order and independent empty final + state validated. +
Close / reduce positionImplemented.Close path implemented, including size-based reduction. + Full UI close validated. Partial reduction not revalidated. +
Cancel / edit resting orderBoth implemented. + Cancel implemented. Edit explicitly refused because the + documented ModifyOrder path accepted but did not apply changes; + cancel and re-place is the stated alternative. + + Individual cancel validated. Cancel/re-place is not atomic and + was not separately validated as an edit workflow. +
Leverage / margin mode + Leverage and explicit cross/isolated handling, subject to market + and position/order guards. + + Leverage handling implemented. Explicit order + marginMode is rejected; do not advertise selectable + cross/isolated parity. + 2x order cycle validated; no margin-mode switching proof.
Add/remove marginImplemented with eligibility checks. + updateMargin implemented; + previewPositionModify returns unsupported. + No UI parity or device proof claimed.
TP/SL at placementImplemented. + Unavailable: rejects attached TP/SL; directs callers to + post-entry update. + Excluded from this initial acceptance.
Existing-position TP/SLImplemented. + updatePositionTPSL implemented for full-position + protection; partial TP/SL sizes refused. + Not revalidated; advanced protection deferred.
TWAP / scale / chase + Declared by the inspected provider's strategy capability + contract, subject to market readiness and client gates. + + No getOrderCapabilities implementation; controller + returns not_implemented. Basic placement accepts + only market/limit. + + Not part of initial integration acceptance. Do not infer support + from signing helpers. +
DepositDeposit routes and shared funding integration exist. + Unavailable through shared deposit routes: + getDepositRoutes() returns an empty array. Lighter + requires approval plus bridge-proxy deposit, not the shared + ERC-20 transfer builder. + + Existing funded testnet account used. Funded Lite order entry is + not a deposit implementation. +
WithdrawalImplemented route/execution. + Signed withdrawal implemented; mainnet route advertised, testnet + route withheld because devnet L1 is unreachable per source. + No withdrawal or mainnet financial proof.
Trade/order historyImplemented. + Implemented, with opening-PnL and timestamp corrections in Core + #10187. + + Revalidated on September 21 with published 17.2.0: exact new + opening, closing and canceled-order records appear under Today. + A dependency patch uses Lighter's explicit filled amount so an + unfilled cancellation is not reported as fully filled. +
MetaMask fee capture + Builder approval/payload path, with strategy and waiver + exceptions. + + Protocol supports Partner Attribution. Not yet wired into + MetaMask; current quote is zero and eligible Plus/Premium + support is missing. See the fee proposal above. + No revenue collection or settlement proof.
Account tiers / signerAccount-bound signing and builder authorization paths. + Standard-only gate; embedded Go/WASM signer with + SecureKeychain-backed key. + + Pinned official testnet artifact, not a reproducible local + build. Mainnet signing remains unvalidated. + [M2] +
+
+
+ +
+

Current delivery status

+
    +
  • + Mobile #34865 + is open and feature-gated. Source inspected at + 1876414683. It includes the hidden signer WebView, strict + request/result validation, SecureKeychain-backed venue keys, + external-navigation blocking, Android new-window containment, funded + Lite order entry and provider-aware aggregated routing. +
  • +
  • + Core #10187 + merged on September 16. Mobile consumes the resulting published + @metamask/perps-controller@17.2.0. +
  • +
  • + The dependency migration is complete: package.json and + yarn.lock resolve published 17.2.0 with a narrow Yarn + patch for Lighter filled-size normalization. Remove that patch when + the correction is available upstream. No .yalc package or + yalc.lock is part of the PR. +
  • +
  • + The September 19 CI snapshot is not green. TypeScript, lint, format, + unit, integration, security, bundle-size and Android smoke checks + pass. Component-view shards and the intentional PR max-lines policy + remain red; the branch is labeled size-XL and requires + the normal review/policy approvals. +
  • +
  • + An automated Bugbot finding about Android new-window navigation was + addressed with explicit navigation filtering and + setSupportMultipleWindows={false}. Automated review is + not an independent security certification. +
  • +
+

+ Before rollout: resolve CI, size and review gates; agree the signer + trust model; validate mainnet financial compatibility; and decide + whether zero MetaMask fee is acceptable for the initial release. Fee + monetization is separate unfinished work, not an already-proven + capability. +

+
+ +
+

Retained validation evidence

+

+ September 21 revalidation on commit 9f31932280, iOS + mm-1, Runway 35154767219, and published + controller 17.2.0 passed 32 Lite market/open/close steps, 28 Pro + limit/cancel steps, and 19 history steps. Every operation used Lighter + testnet account 59. Raw Lighter API records independently matched the + client receipts to filled or canceled venue order IDs; final assertions + found no BTC position or open order. These financial runs preceded the + filled-size dependency patch. Current screenshots and full run packages + are retained under + artifacts/trading-revalidation-9f31932280/ in the original + recipe task. +

+

+ These are September 11 iOS mm-1 development-build proofs, not September + 15 replays. They used the existing authorized funded testnet fixture. No + mainnet trades or new funding. Android and advanced orders/TP-SL were + excluded. No wallet screenshots or secrets are embedded in this + document. +

+
    +
  • + final-ui-cycle-01: 29 nodes passed. Lite UI market entry, + submitted position, Back, full UI close and no task-owned residue. +
  • +
  • + lighter-pro-limit-cycle-run-01: 27 nodes passed. Pro UI + limit placement and individual cancellation; separately retained venue + evidence maps the client receipt. +
  • +
  • + history-selected-perps-screen-01: inspected screenshot of + opening/closing trades. history-date-proof-run-01: 8 + nodes passed, exact canceled-order identity, correct date and no BTC + residue, using the earlier compatible local controller build. +
  • +
  • + The financial cycles preceded the history-only Core patch. History + validation did not repeat trading. Failed injected Activity filter + selections were rejected as proof; native selection established the + settled screen used for the final history assertion. +
  • +
  • + Original runs, graphs, traces, summaries and manifests remain in local + tmp/lighter-rebase-20260910-ercHNq/; selected package is + temp/tasks/fix/34865-0904-123534/closeout-evidence/pr-package/. These are local evidence locators, not public download links. PR + description records the validation summary. Public wallet-media upload + awaits separate consent. +
  • +
  • + Current migration checks include focused signer, provider, navigation, + order-execution and controller-integration suites, component-view + coverage for funded and aggregated Lighter orders, and passing CI + TypeScript, lint, format, unit and integration jobs. A post-upgrade + iOS provider-selector recipe passed 36/36 nodes. The earlier funded + trading cycles remain the financial evidence. +
  • +
+
+ +
+

Source references and reading scope

+

Official protocol and program documentation

+

+ Read directly on September 15, 2026. These pages can change; conflicting + language is recorded above rather than silently reconciled. +

+ +

+ Core snapshot: 2617b5880a1420c4f802f439d768834601db819f. + Links pin the inspected revision; later changes require a new audit. +

+
    +
  • + C1 · LighterProvider: account tier guard, place/cancel/edit/close, TP/SL, margin, fee + quote, subscriptions and asset routes. +
  • +
  • + C2 · HyperLiquidProvider: builder approval, builder payload, strategy exceptions and fee + calculation. +
  • +
  • + C3 · Hyperliquid configuration: builder fee units, maximum and strategy declaration. Defaults are + not proof of the recipient configured in a deployed app. +
  • +
  • + C4 · PerpsController: unavailable capability result when a provider lacks the optional + strategy API. +
  • +
+

+ Mobile snapshot: 18764146836007e1a789dbd7fc164f5d93c05403. +

+
    +
  • + M1 · Mobile Perps integration: PerpsBalanceOrderView, usePerpsNavigation, PerpsOrderRedirect and + lighterFeatureFlags. +
  • +
  • + M2 · Signer provenance and containment: artifact origin/hash, non-reproducibility, response redaction and + navigation/new-window boundaries. Presence of these controls is not a + new device security certification. +
  • +
+

+ Open this self-contained HTML file locally in a browser. GitHub's file + page displays source; it does not host this page as an interactive site. + No scripts, external styles, fonts, analytics or remote images are + loaded. +

+
+ + diff --git a/docs/perps/perps-feature-flags.md b/docs/perps/perps-feature-flags.md index 91a00daf88cd..17bdfd35f65b 100644 --- a/docs/perps/perps-feature-flags.md +++ b/docs/perps/perps-feature-flags.md @@ -175,6 +175,7 @@ Follow existing test patterns covering: | `perpsPerpTradingServiceInterruptionBannerEnabled` | `perps-perp-trading-service-interruption-banner-enabled` | `MM_PERPS_SERVICE_INTERRUPTION_BANNER_ENABLED` | false | Service disruption banner | | `perpsPerpGtmOnboardingModalEnabled` | `perps-perp-gtm-onboarding-modal-enabled` | `MM_PERPS_GTM_MODAL_ENABLED` | false | GTM onboarding modal | | `perpsOrderBookEnabled` | `perps-order-book-enabled` | `MM_PERPS_ORDER_BOOK_ENABLED` | false | Order Book feature | +| `perpsLighterProviderEnabled` | `perps-lighter-provider-enabled` | `MM_PERPS_LIGHTER_PROVIDER_ENABLED` | false | Lighter provider POC; explicit build-time opt-in in every environment, including dev. Restart Metro with a cleared cache after changing it | | `perpsProModeEnabled` | `perps-pro-mode-enabled` | — | false | Lite/Pro mode toggle and Pro-mode entry points (Pro market view) | | `perpsMobileScale` | `perps-mobile-scale` | — | false | Version-gated Scale ladder placement in the Pro order form, minimum production version 8.10.0 | | `perpsMobileTwap` | `perps-mobile-twap` | — | false | Version-gated Hyperliquid TWAP placement in the Pro order form, minimum production version 8.10.0 | diff --git a/docs/perps/perps-screens.md b/docs/perps/perps-screens.md index fb010eb98a93..928805dce75c 100644 --- a/docs/perps/perps-screens.md +++ b/docs/perps/perps-screens.md @@ -1,6 +1,6 @@ # Perps Screens & Views Documentation -Complete architectural reference for all 17 Perps screens in MetaMask Mobile. +Complete architectural reference for all 18 Perps screens in MetaMask Mobile. ## Table of Contents @@ -9,18 +9,19 @@ Complete architectural reference for all 17 Perps screens in MetaMask Mobile. 3. [PerpsMarketListView](#perpsmarketlistview) - Market browser 4. [PerpsMarketDetailsView](#perpsmarketdetailsview) - Market detail 5. [PerpsOrderView](#perpsorderview) - Order entry -6. [PerpsPositionsView](#perpspositionsview) - Positions list -7. [PerpsClosePositionView](#perpsclosepositio nview) - Close position -8. [PerpsAdjustMarginView](#perpsadjustmarginview) - Adjust margin -9. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all -10. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all -11. [PerpsTPSLView](#perpstpslview) - TP/SL management -12. [PerpsTransactionsView](#perpstransactionsview) - Transaction history -13. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal -14. [PerpsHeroCardView](#perpsherocardview) - Hero cards -15. [PerpsEmptyState](#perpsemptystate) - Empty states -16. [PerpsRedirect](#perpsredirect) - Routing logic -17. [HIP3DebugView](#hip3debugview) - Debug tools +6. [PerpsBalanceOrderView](#perpsbalanceorderview) - Funded Lighter order entry +7. [PerpsPositionsView](#perpspositionsview) - Positions list +8. [PerpsClosePositionView](#perpsclosepositio nview) - Close position +9. [PerpsAdjustMarginView](#perpsadjustmarginview) - Adjust margin +10. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all +11. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all +12. [PerpsTPSLView](#perpstpslview) - TP/SL management +13. [PerpsTransactionsView](#perpstransactionsview) - Transaction history +14. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal +15. [PerpsHeroCardView](#perpsherocardview) - Hero cards +16. [PerpsEmptyState](#perpsemptystate) - Empty states +17. [PerpsRedirect](#perpsredirect) - Routing logic +18. [HIP3DebugView](#hip3debugview) - Debug tools --- @@ -290,6 +291,31 @@ User action: --- +## PerpsBalanceOrderView + +**Location:** `app/components/UI/Perps/Views/PerpsBalanceOrderView/PerpsBalanceOrderView.tsx` + +### Purpose & User Journey + +Feature-gated Lighter order entry for users with an existing venue balance. It reuses the Pro order form without creating a deposit transaction or changing the user's Lite/Pro preference. + +### Navigation + +- **Route:** `Routes.PERPS.BALANCE_ORDER` +- **From:** Lite market Long/Short actions and the token-details order redirect when Lighter is active +- **Params:** Standard `PerpsOrderRouteParams`, including direction, asset, optional provider ID, source, and optional limit price +- **Back:** Returns to the previous Lite or token-details screen + +### testIDs + +| Constant | testID | +| --------------------------------------------- | --------------------------- | +| `PerpsBalanceOrderViewSelectorsIDs.CONTAINER` | `perps-balance-order` | +| `PerpsBalanceOrderViewSelectorsIDs.BACK` | `perps-balance-order-back` | +| `PerpsBalanceOrderViewSelectorsIDs.ERROR` | `perps-balance-order-error` | + +--- + ## Pro mode — open order edit (TAT-3642) **Location:** `app/components/UI/Perps/Views/PerpsProMarketView/` (orders tab within Pro market view) diff --git a/jest.config.js b/jest.config.js index 11c10ac754df..36de1f8bbd8c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -176,6 +176,8 @@ const config = { '\\.(mp4)$': '/app/__mocks__/mp4Mock.js', '^react-native-video$': '/app/__mocks__/react-native-video.tsx', '\\webview/index.html': '/app/__mocks__/htmlMock.ts', + 'wasm-wrapper\\.standalone\\.html$': + '/app/__mocks__/lighterSignerHtml.ts', '^@expo/vector-icons@expo/vector-icons$': 'react-native-vector-icons', '^@expo/vector-icons/(.*)': 'react-native-vector-icons/$1', '^@metamask/native-utils$': diff --git a/jest.config.view.js b/jest.config.view.js index 770467ba24a7..ed8883d2a330 100644 --- a/jest.config.view.js +++ b/jest.config.view.js @@ -28,6 +28,9 @@ process.env.MM_PREDICT_API_URL = module.exports = { ...baseConfig, + // Unit coverage owns the repo-wide file list. Report the production files + // exercised by view tests without rescanning the entire app in every shard. + collectCoverageFrom: undefined, setupFilesAfterEnv: ['/app/util/test/testSetupView.js'], testPathIgnorePatterns: (baseConfig.testPathIgnorePatterns || []).filter( (pattern) => !pattern.includes('view'), diff --git a/metro.transform.js b/metro.transform.js index 183fff75e943..f79502e43e69 100644 --- a/metro.transform.js +++ b/metro.transform.js @@ -25,6 +25,7 @@ const availableFeatures = new Set([ 'sample-feature', 'tron', 'experimental', + 'lighter', ]); // Legacy (main) hardcoded feature sets — used when CODE_FENCING_FEATURES is not set (e.g. local dev) @@ -62,9 +63,9 @@ const experimentalFeatureSet = new Set([...mainFeatureSet, 'experimental']); * * @returns {Set} The set of features to be included in the build. */ -function getBuildTypeFeaturesFromEnv() { - const buildType = process.env.METAMASK_BUILD_TYPE ?? 'main'; - const envType = process.env.METAMASK_ENVIRONMENT ?? 'production'; +function getBuildTypeFeaturesFromEnv(environment = process.env) { + const buildType = environment.METAMASK_BUILD_TYPE ?? 'main'; + const envType = environment.METAMASK_ENVIRONMENT ?? 'production'; let features; switch (buildType) { @@ -100,22 +101,27 @@ function getBuildTypeFeaturesFromEnv() { * * @returns {Set} The set of features to be included in the build. */ -function getBuildTypeFeatures() { +function getBuildTypeFeatures(environment = process.env) { let featureSet; // Prefer GH Actions path: single source of truth from builds.yml - if (process.env.CODE_FENCING_FEATURES) { - const features = JSON.parse(process.env.CODE_FENCING_FEATURES); + if (environment.CODE_FENCING_FEATURES) { + const features = JSON.parse(environment.CODE_FENCING_FEATURES); featureSet = new Set(features); } else { // Fallback for local dev builds - featureSet = getBuildTypeFeaturesFromEnv(); + featureSet = getBuildTypeFeaturesFromEnv(environment); } - if (process.env.INCLUDE_SAMPLE_FEATURE === 'true') { + if (environment.INCLUDE_SAMPLE_FEATURE === 'true') { featureSet.add('sample-feature'); } + // Keep the embedded signer out of ordinary bundles, including local dev. + if (environment.MM_PERPS_LIGHTER_PROVIDER_ENABLED === 'true') { + featureSet.add('lighter'); + } + return featureSet; } @@ -219,3 +225,6 @@ function getESLintInstance() { } return eslintInstance; } + +// Exposed for focused code-fencing tests only. +module.exports.getBuildTypeFeatures = getBuildTypeFeatures; diff --git a/package.json b/package.json index 47129d4cc9fa..dfda9a968e9c 100644 --- a/package.json +++ b/package.json @@ -368,7 +368,7 @@ "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A5.4.1#~/.yarn/patches/@metamask-network-enablement-controller-npm-5.4.1-020a82a308.patch", "@metamask/notification-services-controller": "^29.0.0", "@metamask/permission-controller": "^13.1.1", - "@metamask/perps-controller": "^17.1.0", + "@metamask/perps-controller": "^17.3.0", "@metamask/phishing-controller": "^17.4.1", "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^23.0.0", diff --git a/scripts/babel-config-paths.test.ts b/scripts/babel-config-paths.test.ts index 1fce58b79a06..a8726950b83d 100644 --- a/scripts/babel-config-paths.test.ts +++ b/scripts/babel-config-paths.test.ts @@ -6,6 +6,8 @@ * only matches forward slashes silently never fires on Windows (see the * posixPath/pathIncludes comments in babel.config.js). */ +import path from 'node:path'; +import { transformSync } from '@babel/core'; // eslint-disable-next-line @typescript-eslint/no-require-imports, import-x/no-commonjs const babelConfig = require('../babel.config'); @@ -30,6 +32,24 @@ const toBothStyles = (posixRelative: string) => ({ }); describe('babel.config.js path matching', () => { + it('keeps the native signer asset out of Jest transforms', () => { + const result = transformSync( + "import html from './wasm-wrapper.standalone.html'; export default html;", + { + filename: path.resolve( + __dirname, + '../app/components/UI/Perps/Lighter/LighterSignerWebView.tsx', + ), + configFile: path.resolve(__dirname, '../babel.config.tests.js'), + babelrc: false, + sourceMaps: false, + }, + ); + + expect(result?.code?.length).toBeLessThan(1024); + expect(result?.code).toContain('./wasm-wrapper.standalone.html'); + }); + describe('ignore', () => { it.each([ '/node_modules/ses/dist/ses.cjs', @@ -71,6 +91,7 @@ describe('babel.config.js path matching', () => { '/node_modules/@noble/secp256k1/index.js', '/node_modules/@metamask/rpc-errors/dist/index.js', '/app/lib/snaps/SnapsExecutionWebView.tsx', + '/app/components/UI/Perps/Lighter/LighterSignerWebView.tsx', '/app/core/redux/ReduxService.ts', '/app/core/Engine/Engine.ts', '/app/core/NavigationService/NavigationService.ts', diff --git a/scripts/lighter-wasm-wrapper.test.ts b/scripts/lighter-wasm-wrapper.test.ts new file mode 100644 index 000000000000..c5dee798e3c3 --- /dev/null +++ b/scripts/lighter-wasm-wrapper.test.ts @@ -0,0 +1,263 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createHash } from 'node:crypto'; + +const html = fs.readFileSync( + path.join(__dirname, '../app/components/UI/Perps/Lighter/wasm-wrapper.standalone.html'), + 'utf8', +); +const scripts = [...html.matchAll(/