Repository navigation
feat(perps-controller): add Lighter perps venue (initial implementation, flag-gated) - #9889
Conversation
Adds a flag-gated LighterProvider to @metamask/perps-controller following the MYX optional-provider pattern: live REST reads, real order write path through the Lighter Go/WASM signer behind a transport-agnostic LighterSignerBridge seam, deterministic venue-key derivation from an EIP-191 personal_sign signature (hardware-wallet compatible), and controller wiring with a client bridge-injection point on PerpsPlatformDependencies. Provider files are excluded from the published artifact; Lighter is testnet-only and disabled by default.
…rading surface Adds a shared-socket stream manager to LighterProvider (market_stats, user_stats, account_all_positions, account_all_orders, account_all_trades, order_book and candle channels) with keepalive, reconnect, and an injectable structural WebSocket seam. Implements closePosition (reduce-only IOC market orders with a protection price), editOrder, withdraw signing, and historical candles. Converts Lighter private methods to arrow-function fields to survive stale global tslib helpers under Hermes, and replaces the price replay cache with a merged per-symbol snapshot so late subscribers receive their symbol immediately.
…eads - updatePositionTPSL via OCO grouped orders (tx 28, grouping 2) with STOP_LOSS/TAKE_PROFIT trigger orders; trigger orders use the 28-day expiry sentinel, replace semantics cancels prior reduce-only triggers - updateMargin (tx 29, direction 1=add/0=remove) and updateLeverage (tx 20, cross/isolated); leverage change verified via position initial-margin-fraction readback in e2e - getOrderFills via /trades and getFunding via /positionFundings with fill adaptation - e2e driver phases: history-reads, tpsl, margin-leverage (all green against live testnet)
…rResult type OrderResult has no txHash field; drop it from editOrder and updatePositionTPSL returns and coerce EditOrderParams.orderId to string
LIGHTER_TESTNET_ONLY off: Lighter follows the global network toggle like HyperLiquid, exposing the full mainnet market catalog and streams. The signer/venue-key flow is network-agnostic (chain id 300/304).
…nd connection state - getOrders: full historical order lifecycle via accountInactiveOrders merged with open orders - getUserHistory: deposit/withdraw history endpoints mapped to UserHistoryItem with venue status mapping - getUserNonFundingLedgerUpdates: deposits + withdrawals + transfers merged into signed RawLedgerUpdate flows, newest first - getDepositRoutes/getWithdrawalRoutes: USDC bridge routes per network, contract addresses sourced from the venue's layer1BasicInfo - subscribeToConnectionState/reconnect/getWebSocketConnectionState wired to the shared WebSocket manager with real state transitions - e2e phases parity-history (4/4) and connection-state (4/4); recipe now 66 nodes, all green against live testnet
- ensureAccountIndex: fail with a clear error when the L1 address has no Lighter account instead of TypeError on empty reduce - disconnect(): clear fill/order-book/candle subscriber sets too - WS keepalive: replace the timer unconditionally on open; ??= kept a timer bound to a dead socket when a new one opened before onclose - validateClosePosition/validateWithdrawal: validate for the implemented operations instead of rejecting with not-supported - drop always-failing cancelOrders/closePositions stubs so the controller falls back to per-item operations (optional members) - trading-op catch blocks log with per-operation error context - move the Lighter signer bridge off PerpsPlatformDependencies onto LighterCredentials.signerBridge so the shared platform surface stays venue-agnostic
- Bind the venue session (account index, signer, auth token, streams) to the selected wallet address; switching accounts resets it atomically so reads/writes can never target the previous account - Serialize all nonce-consuming venue writes through a per-provider queue; concurrent controller batch fallbacks no longer race fetch-nonce/submit pairs - placeOrder honesty: reject attached TP/SL (directing to updatePositionTPSL), post-only TIF, non-positive sizes, and below-minimum sizes for position-increasing orders (reduce-only and full closes keep the venue-minimum bump since execution clamps to the position); honor IOC for limit orders; apply requested leverage via UpdateLeverage (tx 20) when the market has no position/resting order - editOrder now refuses with a clear error: the venue accepts but does not apply ModifyOrder (raised with Lighter); cancel + re-place instead - Invalidate the cached signer session when the bridge reports the WASM client is gone (WebView reload) so the next call re-runs setup - Re-mint the auth token when re-subscribing authenticated WS channels on reconnect instead of replaying a possibly-expired token - Ship Lighter in the published package (remove files exclusions) and update the changelog accordingly - Pin the WASM signer build to an exact lighter-go commit (LIGHTER_GO_REF) instead of the moving web-wasm HEAD - Make LighterCreateClientResult.prv optional: the mobile WebView now redacts it before results cross the bridge
- Session binding hardened: a generation counter invalidates in-flight account/auth resolutions started under the previous wallet account, and an account switch now rebuilds the stream channels implied by surviving subscribers (market stats, order books, candles, account channels for the new account) instead of leaving a fresh socket subscribed to nothing; both proven by adversarial unit tests - placeOrder honors the full sizing contract: usdAmount as source of truth, maxSlippageBps/slippage-driven protection price, and a priceAtCalculation drift check; below-minimum sizes are bumped only for full closes (incl. dust detected against the live position) and rejected for partial reduce-only orders, which a bump would over-close - Requested leverage is never silently dropped: already-in-effect leverage no-ops, otherwise UpdateLeverage is attempted and a venue rejection fails the placement - validateOrder mirrors every placeOrder rejection - Honest calculations: liquidation price and maintenance margin use the standard cross approximation, fees come from the venue's per-market metadata, and getHistoricalPortfolio reconstructs the 1d-ago account value from the venue pnl flows - Remote-flag enablement now requires the client to have wired the venue signer bridge, so a remote flag cannot register a provider whose signer the client never mounted - Signer-session invalidation covers reload/timeout/not-ready errors
… venue data - Writes are bound to the wallet account they were INITIATED under: a generation captured at method entry aborts a queued write, signer setup, or account-channel request that outlives an account switch; candle series state is recreated on rebind so live candles keep flowing; proofs: queued-write cancellation test, exact new-account channel test (user_stats/900 and never user_stats/28) - Market orders always resolve a fresh venue price as the sizing reference (the caller's snapshot can no longer satisfy its own drift check), usdAmount converts at the reference price rather than the protection price, and the protection offset applies only to the signed execution price; leverage update and order placement share one write-lock acquisition so no concurrent write can interleave - isFullClose is a hint, never trusted: below-minimum bumps require the live position to verify a full close; a false claim is rejected - Fills carry the venue's per-side realized pnl and the capitalized Buy/Sell direction vocabulary client transforms recognize - Max leverage, maintenance margin, and market maxLeverage come from the venue's per-market margin fractions (orderBookDetails) instead of the 50x constant - Signer bridge exposes onReset; the provider invalidates its session the moment the bridge resets instead of on the next failed call
…emantics, unified fills - Signer setup is generation-fenced at every await (a stale _createClient can no longer clobber the current account's WASM client) and only the exact promise that failed is cleared; bridge reset advances the generation so pre-reset work aborts - The write critical section re-fences at each nonce fetch and immediately before submission, not only at lock entry - Frames from a replaced WebSocket are dropped before the router - Composite writes carry ONE intent generation end to end: closePosition reads and places under the same identity, updatePositionTPSL's nested cancels inherit it and the whole operation aborts on a switch - closePosition preserves caller semantics: limit closes with the requested price (rejected without one), usdAmount sizing, slippage tolerance and price-drift protection ride through placement; only market/limit accepted - One fill adapter serves REST history and the live account_all_trades stream: per-side realized pnl, Buy/Sell vocabulary, side-appropriate maker/taker fee when the venue includes it; real captured payload is the test fixture - resolveLeverageIntent reads venue state only; invalid usdAmount is rejected instead of falling back; a missing live market price fails closed; market data and position adapters use per-market margin fractions; liquidation preview clearly marked single-position only Adversarial regression tests for each reviewer scenario: deferred account-A signer setup cannot overwrite B, a paused write never signs after B initializes, stale socket frames are ignored, closePosition and TPSL abort mid-sequence switches, limit/market close params verified.
…st venue data - Signer creation AND venue-key registration run inside the venue write lock: a stale previous-account _createClient aborts at the lock's fence before touching the bridge singleton, no other account's setup or write can interleave, and registration signs/submits through the fenced nonce+submit helpers with generation checks at every await - #assertSession also notices a wallet switch nothing has rebound yet (live address comparison, not only the lazily-advanced generation); nextNonce re-fences after the fetch resolves - Fill lifecycle derived from the venue's position-before context: Open Long/Short, Close Long/Short, and flips (Long > Short) from absolute size-before + sign-changed + trade side, with pnl disambiguating partial reduces; side-only Buy/Sell only when context is absent; real captured payload proves the taker sell is Close Long - Integer venue fees are treated as unavailable (0) until a captured nonzero payload proves their unit — the official model gives no scale - calculateLiquidationPrice is capability-gated for Lighter: cross liquidation needs account-level inputs, so the preview reports unavailable instead of a plausible wrong number - Explicit non-positive leverage rejected at placement and validation; validateOrder rejects invalid usdAmount like placeOrder; margin cache warmed before REST and WS position adaptation Race proofs per reviewer spec: stalled A _createClient with B pending behind the lock (B ends as the actual signer), warmed-signer paused write that never signs after B initializes, plus the existing switch fences — all bounded and deterministic.
…st fees, unique client ids - Session identity is atomic and fail-closed: assertSession cancels on a null binding (a configured account index alone can never act), on a live-address mismatch (rebinding to the new account before cancelling), and on full deselection; disconnect invalidates the whole session so a paused write cannot submit after teardown; ensureAccountIndex, getAuthToken and account-channel setup are address-aware after every await, and a failed/no-account channel setup clears its promise so the next bind retries - Every account-bound read fences its captured identity after its final await (account state, open orders, order merge incl. the open leg, positions before the empty early-return, fills, funding, history, ledger); WS onopen and its deferred auth re-mint are fenced by socket+generation+still-wanted channel - Client order ids come from a synchronous monotonic allocator (venue requires uniqueness across ALL markets): no same-millisecond collisions under Promise.all, no modulo wrap; grouped TP/SL reserves its pair atomically - Fee honesty completed: only Standard (type 0) accounts are supported — resolution fails closed on Premium or unverifiable types, calculateFees gates the tier before quoting zero, and the fill adapter refuses OUR side's nonzero fee (unverified unit) while keeping fills whose Premium counterparty paid the fee - Historical portfolio capability-gated: PnLEntry carries pool/spot/ staking flows whose semantics are unverified; a partial reconstruction would show false daily history - Fill lifecycle: break-even partials fall back to side-only vocabulary (never asserted Open without evidence); flips carry SIGNED startPosition for post-flip sizing - Validate/execute parity shared: close-shape and live full-close checks are the same code in validateOrder, validateClosePosition, closePosition and placement; full-close verification is exact (float epsilon), so a deliberate 99% dust partial is rejected instead of bumped to 100% 23 new adversarial regressions, all bounded/deterministic.
geositta
left a comment
There was a problem hiding this comment.
Thank you for addressing the prior review. I re-verified the updated branch and confirmed that the deposit route, transaction-status mapping, key-registration behavior, signer-key ownership, account-switch reset, order-book continuity, documented order statuses, and e2e credential handling are now corrected.
I am keeping the review at Request changes for five remaining items:
- Lighter's maker-only minimums are still applied to market/IOC orders, which rejects valid small taker orders and partial closes.
- Fill-history time and symbol filters operate on only the newest page, so valid older results can be reported as absent.
- Newly strict order decoding is not applied transactionally: REST errors become empty state, and a malformed WebSocket snapshot can clear valid cached orders before failing.
- The public WASM bridge still lets callers independently assert any result type for any function and argument list.
- The new REST schemas verify that financial decimals are strings but do not verify their numeric grammar or domain before adapters consume them.
These are concrete correctness and contract-boundary issues rather than requests to broaden the feature. The package test suite passes locally and the latest CI is green; additional tests are needed for these failure cases. No React or React Native component code changed in this revision, but the bridge typing directly affects the safety of React Native WebView implementations.
geositta
left a comment
There was a problem hiding this comment.
Thank you for the follow-up work. I verified that the maker-only minimum handling, cursor-based fill history, transactional REST/WebSocket order updates, and function-keyed signer bridge are now correctly addressed. The disconnect and cache fences added in the subsequent commits are also well covered.
I cannot approve this revision yet. The remaining issues share one correctness requirement: when authoritative Lighter financial data is missing, malformed, or incomplete, the provider must surface that state rather than substitute plausible values or empty histories. Specifically:
- Financial decimal schemas validate syntax but not field domains, and documented trade context can still be omitted and converted into zero/neutral history.
- Liquidation and leverage APIs fabricate 1%/50x risk parameters when market metadata cannot be established.
- Funding, ledger, and user-history reads convert integrity failures into empty histories.
- Account-channel setup converts integrity failures into empty account, position, and order state.
- TP/SL recovery repeatedly stops at the same 1,000 inactive orders without reporting that authoritative history was truncated.
These paths can produce materially incorrect Pro trading state while presenting a successful result, so they need to fail closed before merge. The exact-head package tests and all current CI checks pass; additional tests should exercise malformed/missing financial fields, metadata failure, integrity failures in every history/stream path, and recovery targets beyond the traversal bound. All comments are on code introduced by this PR relative to origin/main.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f8a378e. Configure here.
| ) { | ||
| throw new Error( | ||
| `${LIGHTER_DATA_INTEGRITY_PREFIX} missing authoritative leverage for ${market.symbol}`, | ||
| ); |
There was a problem hiding this comment.
Market list fails on cache skew
Medium Severity
getMarkets now throws an integrity error for the entire catalog when any perp from a fresh getOrderBooks result lacks minInitial in #marginBySymbol. #ensureMarketMargins can still return a TTL-fresh cache that predates a newly listed market, so one missing symbol takes down every Lighter market until the cache expires.
Reviewed by Cursor Bugbot for commit f8a378e. Configure here.
## Description Reject malformed Lighter WebSocket fill fields and invalid margin fractions before they reach perps state. Adds coverage for inactive-history cursor nonprogress found during the final MetaMask#9889 review. ## Changelog The existing unreleased Lighter entry already covers this pre-release correction. ## Related issues Follow-up to MetaMask#9889. ## Testing - Focused Lighter suites: 368 passed - Full perps package: 84 suites, 3,675 passed, 40 skipped - TypeScript, ESLint, Prettier, changelog validation, and diff checks - Root build <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes leverage derivation and fill ingestion boundaries for Lighter; overly strict validation could reject otherwise-usable venue rows or block market/position reads until metadata is fixed. > > **Overview** > Tightens **Lighter** venue data handling so bad margin metadata and trade payloads fail before they affect markets, leverage, or fill streams. > > **Max leverage** is now derived through a shared `deriveLighterMaxLeverage` helper that requires `minInitialMarginFraction` to be a safe integer in **1–10,000** and rejects leverage below **1x**. `getMarkets`, margin cache refresh, `getMaxLeverage`, and internal leverage lookups use this path; refresh also validates **maintenance** margin fractions in the same range. REST parsing swaps generic positive numbers for a **`MarginFractionStruct`** on initial/default/maintenance margin fields. > > **Fill adaptation** (`adaptFillFromLighterTrade`) adds checks on trade identifiers, requires **`isMakerAsk` to be a boolean**, and parses **maker/taker** fees via `parseLighterTradeFee` so malformed counterparty fee fields invalidate the row while still allowing valid nonzero counterparty fees. Matching WebSocket fill drops and unit tests cover these cases. > > Tests add rejection paths for out-of-range/non-integer margin fractions, malformed fill fields, and **inactive-order history** paging that advances the cursor with zero rows (TP/SL recovery fails closed). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6f8126d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
## Explanation Releases `@metamask/perps-controller` **14.0.0 → 15.0.0**. The monorepo version goes **1217.0.0 → 1218.0.0**. No other package is being published. Perps-controller has no in-monorepo dependents that need a workspace range bump. The bump is **major**. Adding a new member to the public `PerpsProviderType` union is a breaking type change for exhaustive switches: - **Added (breaking):** Lighter as a perps venue (initial implementation, disabled by default) ([MetaMask#9889](MetaMask#9889), [MetaMask#10015](MetaMask#10015)). `PerpsProviderType` gains `'lighter'`. Enablement requires client opt-in (`providerCredentials.lighter.enabled`, or the `perpsLighterProviderEnabled` remote feature flag plus a client-supplied `providerCredentials.lighter.signerBridge`). Without a bridge the provider is read-only. Lighter is off by default, so clients that do not opt in can upgrade without runtime behavior change; exhaustive `PerpsProviderType` switches still need a new case to typecheck. - **Fixed:** Preserve accepted HyperLiquid Scale orders when part of a batch is rejected ([MetaMask#9989](MetaMask#9989)). ## Changelog Moved Unreleased entries in `packages/perps-controller/CHANGELOG.md` under `[15.0.0]`. Folded the pre-release Lighter validation follow-up (MetaMask#10015) into the Lighter Added entry (as that PR intended). Breaking entry is prefixed with `**BREAKING:**` and includes adaptation notes. ## References - Source PRs: [MetaMask#9889](MetaMask#9889), [MetaMask#10015](MetaMask#10015), [MetaMask#9989](MetaMask#9989) - No draft Mobile/Extension adoption PRs required to keep current clients compiling: Lighter stays disabled until clients opt in. Client work to enable Lighter (credentials, signer bridge, UI) is separate. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
## Explanation Lighter retains inactive markets for historical identity and can zero their order minimums and margin fractions. The strict response decoder rejected those retired rows, which prevented otherwise valid active markets from loading. This change accepts zero constraints only for inactive rows, omits retired markets whose risk metadata cannot produce canonical market data, and preserves inactive rows with valid metadata as delisted. Positions left on retired markets remain readable using their current position leverage as a non-tradable cap. Active markets remain strict and fail closed on zero or malformed constraints. Validated with: - 322 focused Lighter service/provider tests - Full `@metamask/perps-controller` test suite and Core build - Targeted ESLint, formatting, and changelog validation - Mobile yalc integration using package `16.1.0` (`yalc.sig` `ee73aeec8330f58ce0a6e9c6b4cf42a3`) - A 36/36-node iOS recipe covering Lighter and Hyperliquid on mainnet and testnet - Revalidated Lighter mainnet on iOS `mm-1`: live markets loaded and the invalid inactive-market metadata error was absent ## References - Related to https://consensyssoftware.atlassian.net/browse/TAT-3863 - Related to https://consensyssoftware.atlassian.net/browse/TAT-3766 - Unblocks MetaMask/metamask-mobile#34865 - Follow-up to MetaMask#9889 and MetaMask#10015 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them — not applicable; this change is non-breaking <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scoped to Lighter public market-metadata parsing and listing; active markets remain fail-closed on bad constraints, with no changes to signing, deposits, or position operations. > > **Overview** > Fixes Lighter market loading when the venue returns **inactive** perp rows with zeroed minimum order sizes or margin fractions—metadata Lighter keeps for history but that previously made the strict HTTP decoder reject the whole response and block active markets. > > **`LighterClientService`** now allows zero minimums and margin fractions only when `status === 'inactive'`, with `refine` validators still requiring positive values for **active** markets. **`LighterProvider`** adds `isInactiveMarketWithoutUsableRiskMetadata` to drop those retired rows from `getMarketDataWithPrices` and margin caching, skips them in `getMarkets` when leverage metadata is omitted (instead of throwing), and still surfaces inactive markets with valid risk data as **delisted**. Tests cover both omission and preservation paths. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 445456c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->


Explanation
TAT-3766: add Lighter as a second perps venue. Started as a spike, now a full
LighterProviderin@metamask/perps-controller, hardened through 23 rounds of adversarial cross-review and validated end-to-end against live Lighter testnet.What's in it
PerpsProviderinterface: place/cancel/close orders, venue-linked OCO TP/SL (grouped trigger orders), isolated margin add/remove, leverage updates, signed L2 withdraw, historical orders/fills/funding/ledger, and live streams (prices, account, positions, orders, fills, order book, candles) over a shared WebSocket with REST-polling fallback.personal_sign(raw EVM key never leaves the keyring — hardware-wallet compatible). The Go/WASM signer is injected through aLighterSignerBridgeseam so mobile's WebView bridge and the headless Node adapter are interchangeable.getRecoveredDispatches/acknowledgeRecoveredDispatch); a TP/SL settlement journal that survives crashes and restores nothing it cannot prove, parking unprovable protection as durable manual recovery (getPendingManualRecoveries); process-wide serialization of venue writes, the ledger, and the bridge's singleton WASM client; session fences on every account switch.PerpsProviderTypegains'lighter'; enablement viaproviderCredentials.lighter.enabledor theperpsLighterProviderEnabledremote flag. New controller actions expose the safety states to clients.How it was validated
Open items
editOrderdeliberately returns an error: Lighter acceptsModifyOrdersubmissions without executing the reprice — raised with the venue; cancel + re-place instead.References
Checklist
Note
High Risk
Introduces a new trading venue, client signer bridge, and durable dispatch/recovery flows for ambiguous L2 submissions—areas that affect funds, leverage, and order settlement even when feature-gated.
Overview
Adds Lighter as an opt-in perps venue (
PerpsProviderTypenow includes'lighter', a breaking change for exhaustive switches). Registration mirrors MYX: dynamic import ofLighterProvider, gated byproviderCredentials.lighter.enabledor remoteperpsLighterProviderEnabledonly when the client suppliesLighterSignerBridge(venue keys stay client-side; no deposit ads until bridge deposit flow exists).PROVIDER_CONFIG.LIGHTER_TESTNET_ONLYand cache keys follow the same pattern as MYX.Exposes durable settlement on the controller and aggregator:
getPendingManualRecoveries,getRecoveredDispatches, andacknowledgeRecoveredDispatch, with exportedPerpsPendingManualRecovery/PerpsRecoveredDispatchtypes. Aggregated mode fans these out across providers that implement the contract.Multi-provider routing for margin math: optional
providerIdon liquidation/maintenance params and ongetMaxLeverage(asset, providerId?), withAggregatedPerpsProviderdelegating to the owning venue instead of always using the default.Ships Lighter constants/types exports (
lighterConfig, signer bridge types) and changelog notes for the initial (disabled-by-default) implementation.Reviewed by Cursor Bugbot for commit f8a378e. Bugbot is set up for automated code reviews on this repo. Configure here.