feat(perps-controller): add Lighter perps venue (initial implementation, flag-gated) - #9889
feat(perps-controller): add Lighter perps venue (initial implementation, flag-gated)#9889abretonc7s wants to merge 59 commits into
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.
Pin GOTOOLCHAIN=go1.26.0 and add -trimpath so two clean builds of the pinned lighter-go commit produce the identical sha256 anywhere; the script now enforces self-reproducibility with a forced full recompile. The upstream committed blob remains unmatched by construction — it was built without -trimpath and embeds the author's machine paths (raised as an upstream ask); the compare stays informational.
…ose, unreachable testnet routes, deposit-route guard
Three defects found only by driving the real mobile app against live
Lighter testnet:
- The mobile close sheet sends a FULL close as an EMPTY size string;
HyperLiquid and TradingService treat falsy size as full-close, but
Lighter validation rejected it ('Order size must be positive').
Close params now normalize empty/whitespace size and usdAmount to
absent.
- Lighter testnet settles on a venue devnet L1 (chain 123456) the
wallet cannot reach; advertising it as a deposit/withdrawal route made
the mobile pay-with flow build a transaction on an unknown chain
('Invalid chain ID 0x1e240'). Testnet now advertises no routes.
- DepositService fails closed with a clear error when a provider has no
deposit route instead of dereferencing undefined.
…arket minimum
- getDepositRoutes/getWithdrawalRoutes honor the params.isTestnet
OVERRIDE (the route contract HyperLiquid implements): effective
testnet returns no routes (venue devnet L1 unreachable), while
DepositService's { isTestnet: false } scaffold request receives the
Ethereum L1 bridge so the deposit-and-trade confirmation mounts and
venue-balance trading works on testnet.
- getMarkets reports the BINDING USD minimum per market —
max(quote minimum, base minimum x last trade price) rounded up to
cents — instead of the raw quote minimum; at current prices the base
minimum can bind (ETH: 0.0053 ETH > $10) and the UI's $10 default
landed one tick under the venue floor. Max leverage was already
venue-derived (margin fractions).
…l API when a non-HyperLiquid provider is active
…e venue size grid
…loads at the WS/REST boundary
…nitial rollout gate)
bc7d2f5 to
4c097f8
Compare
geositta
left a comment
There was a problem hiding this comment.
Thank you for the extensive failure-injection coverage and the work to make ambiguous financial dispatches fail closed. I am requesting changes because two remaining boundary defects can present incorrect venue state or allow malformed venue data into signing and trading paths.
Required before merge:
- Enforce Lighter order-book
begin_nonce/noncecontinuity and obtain a fresh snapshot after a gap. Without this, a dropped delta leaves pro traders looking at a silently corrupted depth book, spread, and mid-price. - Decode REST responses from
unknownwith endpoint-specific runtime validators before assigning TypeScript response types. The current generic assertion makes untrusted JSON appear valid and permits missing or malformed nonce, account, market, and transaction fields to reach financial logic. - Stop accepting private keys through command-line arguments in the e2e driver; process arguments and shell history expose them.
I also left a non-blocking design comment on the 8,149-line provider. Its signer, persistence, recovery, REST, WebSocket, validation, and trading state machines should be separated behind explicit typed interfaces so future venue changes can be reviewed and tested in isolation.
yarn workspace @metamask/perps-controller run test passes locally. Current CI checks are green. This PR does not change React or React Native components, so there are no component-level React findings. All findings are in files introduced by this branch relative to origin/main.
geositta
left a comment
There was a problem hiding this comment.
Follow-up review identified additional correctness blockers after the initial submission. The highest-risk items are the Lighter mainnet deposit transaction construction, transaction-status decoding, and automatic venue-key replacement after a failed key lookup. These need resolution before the earlier change request can be cleared. The comments below are limited to behavior introduced by this branch and verified against the current Lighter documentation.
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 a95b9b5. Configure here.
| default: | ||
| throw new Error( | ||
| `${LIGHTER_DATA_INTEGRITY_PREFIX} unknown order status '${status}'`, | ||
| ); |
There was a problem hiding this comment.
Unknown order status empties orders
Medium Severity
Unknown venue order statuses now throw a data-integrity error, but getOpenOrders and getOrders still swallow that into [], and the orders stream clears #wsOrders before adapting. One unrecognized status can hide every open order or emit a partial book after the dropped frame.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit a95b9b5. Configure here.


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 with signer-bridge auth, L2 dispatch recovery, and aggregated routing changes that affect leverage and margin calculations across providers.
Overview
Adds Lighter as an opt-in perps venue (
'lighter'onPerpsProviderType), wired like MYX via dynamicLighterProviderimport,#isLighterProviderEnabled()(localproviderCredentials.lighter.enabledor remoteperpsLighterProviderEnabledonly when a clientsignerBridgeis mounted), registration, and direct/aggregated routing. ShipslighterConfig.tsand public Lighter constants/types exports;PROVIDER_CONFIG.LIGHTER_TESTNET_ONLYandbuildProviderCacheKeytreat Lighter network pinning like MYX.Exposes durable-settlement flows on the controller and aggregated provider:
getPendingManualRecoveries,getRecoveredDispatches, andacknowledgeRecoveredDispatch, withPerpsPendingManualRecovery/PerpsRecoveredDispatchexported for clients (providers without durable state return empty lists or throw on acknowledge).For multi-venue math,
getMaxLeveragegains an optionalproviderId, andAggregatedPerpsProviderroutescalculateLiquidationPrice,calculateMaintenanceMargin, andgetMaxLeverageto the owning provider instead of always using the default.Reviewed by Cursor Bugbot for commit a95b9b5. Bugbot is set up for automated code reviews on this repo. Configure here.