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 43 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.
…stent capability surfacing - Configured account indexes must be OWNED by the bound wallet address (verified against the venue's l1Address, fail-closed on missing data) and every account-bound fetch/quote — including the cached fast path and calculateFees — asserts a live bound session before touching the venue - WS onopen/onmessage re-run the live session binding so an EXTERNAL account switch nothing else observed tears the socket down before any account frame routes into current UI; aborted account-channel setups can no longer blank the new session's subscribers with stale empty emissions - validateClosePosition gains live sizing parity with execution: no open position, below-minimum partials, and exact dust full closes all agree between validator and closePosition - Capability gates surface consistently: Premium/unverified-tier, cross-owner configuration, and unverified nonzero fees are prefixed errors that read catches rethrow instead of degrading into plausible empty state, while the WS trades handler drops (never renders, never crashes on) an unsupported fill - Fills carry providerId and fall back to neutral side-only vocabulary when isMakerAsk is absent (never deriving lifecycle from the wrong side's position context) - Client order ids are lane-separated across provider instances (Date.now()*100 + per-instance lane, stepping 100, uint48-bounded to ~2059) — defense in depth beyond the controller's singleton scoping
…, WS capability honesty, close parity - Remove a patch artifact that injected a 6th argument into _signUpdateLeverage (shifting the nonce and mis-signing every leverage-changing placement); contract pinned by an exact arity/nonce regression and the live margin-leverage phase re-proven 7/7 - WS capability signaling is consistent with REST: channel-setup failures from capability gates (Premium/unverified tier, cross-owner config) preserve subscriber state instead of emitting false empties; the inner auth catch is generation-fenced so an aborted previous- account setup cannot blank the new session's orders; a fills snapshot containing an unsupported (nonzero-fee) fill is withheld rather than emitted as a partial that would overwrite valid history - validateClosePosition prices per order type exactly like execution: limit closes validate the caller's finite positive price (0/NaN rejected, never silently replaced by a live price), market closes size at the FRESH venue price regardless of caller snapshots - Client-order-id lane comment narrowed to the truth: the random lane reduces residual cross-instance same-ms collisions to 1-in-100 per simultaneous pair and does not eliminate them; the controller's one-provider-per-session scoping remains the primary guarantee
…s, close validation parity, finite price guards
…tion at fresh price, numeric intent fail-closed
…-integer parity across validation and execution
…idated TP/SL replacement, positive wire integers, market leverage bounds
…ore-cancel, single-trigger venue contract, strict intent parsing, fail-closed leverage metadata
…ialized TP/SL transitions, uint32 price bounds, TTL margin cache
…bookkeeping, dedup margin refresh, lock-safe auth
…l, phase-barrier activation, terminal reconciliation, complete validator boundary
… resolution, durable transition state machine, startup recovery - LighterClientService.getTx (/api/v1/tx?by=hash): venue-confirmed 21500 -> null, every other error rethrown as ambiguity; strict identity (hash+account+apiKey slot+nonce) - Journal v2: signed txHash + ExpiredAt per attempt (fail-closed extraction before submit), phase (creating/cancelling/restoring), snapshotted priorTriggers wire intents, attempt roles, restore priorOrderId linkage; v1 fails closed as unsupported schema - Durable journal index (INDEX-FIRST, 64-cap throw, no eviction) + startup/read-path recovery kicks (deduped, kick-preserving, per-entry error logging) - Recovery state machine mirrors the live rollback/restore logic; clears only on fully-settled visibility and keeps replacement ids proven while old cancels settle - Section-local nonce floor advancing only on observed acceptance; apiKeyIndex in settlement identity; strict terminal whitelist (filled/executed + zero remaining); market-scoped cached inactive pagination - 5 restart/crash proofs (all red on d0ac021): mid-rollback, post-cancel restore, replacement dying during recovery cancels, terminal-rejected restore retry, crash mid-restore with two priors
…-gated faithful restores, one settlement machine for foreground and recovery - Journal v2 adds durable intent (replace/remove), position lifecycle fingerprint (sign+size+entry), and EXACT prior wire intents (order type incl. limit variants, time-in-force, absolute expiry); loader fails closed when any is missing/invalid; writer enforces loader capacity caps pre-submit - updatePositionTPSL routes pending journals through the same #settleTpslObligation state machine as startup recovery (no foreground bypass, no duplicated logic); removal intent never restores; degraded OCO pairs after old cancels roll the survivor back and restore the WHOLE prior set (recovery and live final) - Every restore re-verifies the live position against the persisted fingerprint and fails closed without attaching stale triggers when the lifecycle changed - Reconcile: unknown attempts always resolve by exact tx identity (books satisfy only observed-accepted attempts); venue tx statuses 4/5 classify deterministically as landed-failed; proven never-landed releases the nonce reservation - Nonce reservation is session-global per accountIndex:apiKeyIndex, advancing at dispatch, so queued/next lock sections never reuse a lagging REST nonce after response loss - Recovery-complete marker invalidated whenever a journal is persisted, so later read kicks retry same-session - Venue fake: tx-status registry copied across restarts, failed-execution seam, limit trigger staging (wire types 3/5)
…urable dispatch nonce ledger, lifecycle-proven restores
- Journal v2: immutable operationId + createdAt; in-lock reload for recovery; persist/clear are compare-and-swap on operationId so a stale resolver can never erase a newer operation's journal
- Durable per-account:apiKey dispatch ledger: every nonce-consuming submission records {nonce, txHash?, expiresAt?} before sendTx; write sections resolve it first (REST-advance/exact-hash → consumed, venue-confirmed absent after signed expiry → released, ambiguous → writes blocked) — restart can never reuse a consumed nonce and a never-landed dispatch cannot brick writes
- Prior wire intents strictly validated + integerization-preflighted before any mutation; missing trigger price fails closed (never substituted)
- Restores lifecycle-proven via fingerprint AND venue fill evidence since createdAt (catches identical close/reopen); active restore legs gated and rolled back on mismatch; live post-cancel restore re-verifies freshly
- Attempts identified by unique attemptId (nonce uniqueness dropped — proven-never-landed retries reuse nonces legitimately); proven-resolved failed restores compact under the attempt cap
- Restore legs are independent obligations in visibility (no OCO any-success masking); explicitly elapsed prior expiries are never revived
…ble nonce watermark, op-scoped journal storage, grouped OCO restores - Dispatch identity (result txHash + txInfo ExpiredAt) passed explicitly from every bridge signing result into submit and the durable ledger; the pinned WASM contract (hash in result, Nonce/ExpiredAt in txInfo, never the hash) is asserted live in the e2e sign-only phase and mirrored exactly by the unit fake (stages by wire Nonce) - Ledger: durable append before any memory floor advance (storage failure = no dispatch); no release on ANY error path (coded/5xx can mask commits); consumption verified by full identity (hash+account+apiKey+nonce+status); hashless entries resolve only by REST-advance; durable consumedFloor watermark makes never-landed releases generation-aware - Journal storage: operation-scoped payload keys + pointer CAS at the base key, serialized by a process-wide storage mutex across provider instances; collision-resistant operationIds; clear is boolean CAS that can only remove its own payload - Lifecycle: boundary captured before the position read; fill evidence cursor-paged to the boundary (exhaustion fails closed); coverage rechecked on a fresh book post-verification; replaces with cancellable priors refuse pre-mutation without a provable fingerprint - Prior OCO pairs restore as one grouped transaction (grouping 2) preserving auto-cancel linkage; restore attempts carry index-aligned priorOrderIds - Compaction covers proven-resolved cancel attempts; >40 mixed failures stay recoverable
…e dispatch identity, real OCO linkage, venue-clock lifecycle proof - Process-wide mutexes (module-level, shared across provider instances): the whole venue write critical section per network:account:apiKey; the whole per-settlement journal state machine (with in-mutex journal reload + operation-id abort); journal-index RMW with re-verified removal - submit refuses any nonce-consuming dispatch without a complete signing identity (hash+expiry) from the bridge result — every op passes it explicitly; all sign mocks carry the pinned contract shape - Ledger v2 (v1 migrated in place); journal v3 (v1/v2 fail closed with explicit per-version unsupported-schema errors) with durable nextAttemptId and venueCheckpoint - OCO grouping decided only by the venue's own linkage fields (toCancelOrderId0 mutual references, official SDK Order model); grouped invariants preflighted pre-cancel; partial genuine-OCO surfaced for manual recovery, never lone-restored; visibility aggregates per create-attempt group (grouped fill+auto-cancel = success; independents each land) - Lifecycle proof via venue-derived checkpoint (venue clocks only, cursor-paged to the boundary, exhaustion fails closed) captured before the fingerprint-producing read; restores re-verified POST-submit — a mutation in the window withdraws the restored legs and surfaces manual recovery - Compaction covers accepted-then-terminal-failed cancels via durably tagged venue status; orphan payload cleanup, mutex tail eviction
…bridge signer ownership, auto-restore removed for durable manual recovery - Ledger v3: dispatches record kind+intent; ambiguous dispatches later proven consumed quarantine into durable recovered outcomes that BLOCK all writes until acknowledgeRecoveredDispatches() — no blind retry can double a withdrawal/order/margin change - Bridge-wide signer ownership: module registry + bridge-scoped mutex inside the venue write mutex; write sections re-create the correct venue client when another account/network overwrote the WASM singleton - AUTO-RESTORE REMOVED (simplification over compensation): a replacement failing after old cancels parks the journal in durable phase 'manual', surfaced via getPendingManualRecoveries(), resolved only by an explicit new TP/SL intent; surviving OCO legs left deliberately; no lifecycle fingerprint/venue checkpoint machinery remains - Disk absence authoritative: memory resurrection removed; functional default test disk store - Linkage fail-closed with the LIVE-probed '0' absent-sentinel contract; dangling/one-sided parent/toCancel/toTrigger linkage refuses pre-mutation; mutual pairs validate side+size+expiry - Journal v4 (v3 migrated; v1/v2 convert to durable manual remediation), attemptId<nextAttemptId enforced, entropy-strengthened op ids, dangling-pointer claimability; index-clear read failures propagate; leverage-then-order failures report explicit PARTIAL STATE
…acknowledgment, owned dispatches, durable manual docs, bridge lease, public safety-state contract - Quarantine check runs before the empty-entries return: unacknowledged recovered outcomes block every retry until acknowledged per-outcome. - Read-only getRecoveredDispatches() + selective acknowledgeRecoveredDispatch(recoveryId) under the ledger mutex with session re-assertion; destructive read-all removed; strict bounded recovered-schema validation. - Outcome enum succeeded|failed|unknown with evidence: exact-hash status decides; status 4/5 retry-safe; hashless+advance is UNKNOWN (never 'completed'); absent-hash+advance is proven never-landed (retry-safe). - Ledger entries carry owner (TP/SL operationId): journal-owned dispatches resolve through the settlement machine, never the generic quarantine (no deadlock). - Manual recovery moved to its own durable doc + index (lighterTpslManual:*): parking releases the journal slot; the warning clears ONLY after a successor protection intent succeeds; discovery is identity-filtered, propagates storage errors, returns reason/prior intent/survivors/action. - Bridge lease keyed on the raw bridge object; covers _createAuthToken; seed re-derived under the lease (never retained); ownership material cleared on reset/rebind. - Public contract: PerpsProvider optional methods, PerpsController methods + messenger actions, PerpsPendingManualRecovery / PerpsRecoveredDispatch exports, OrderResult.partialState.leverageUpdated. - Nits: ChangePubKey result txHash typed; ids draw from WebCrypto. - Live: margin-leverage 7/7 on a fresh faucet account (block cleared); sign-only 9/9, tpsl 12/12, order-lifecycle 7/7, close-position 5/5.
…st-dispatch session-cancel quarantine - ALL nonce-ledger read-modify-writes (submit append, resolve pass, consumed-resolution, post-dispatch quarantine) serialize with the selective acknowledgment on ONE process-wide mutex per account+slot document; lock order venueWrite -> bridge -> ledger, ack takes only the ledger mutex — an ack RMW can no longer land a stale doc that erases a concurrent unresolved dispatch entry. - A session fence cancelling AFTER sendTx acceptance now durably quarantines outcome=succeeded (evidence post-dispatch-session- cancelled) into the ORIGINAL account/slot ledger before the failure surfaces, so a switch-back retry of the committed financial intent is refused until per-outcome acknowledgment. TP/SL-journal-owned dispatches keep reconciling through their machine. - Proofs (both RED on 574a102): gated-ack interleave preserves the concurrent response-lost entry; switch-during-send then switch-back retry blocked with the quarantined outcome, unblocked by ack.
…ion decided by the session fence - The entry is never consumed before the post-send fence: after sendTx/onAccepted the fence is evaluated first, then ONE write under the ledger lock commits either consumed/removed (fence pass) or recovered=succeeded (fence fail). If that write fails, the ORIGINAL unresolved entry remains the durable record and retries stay blocked — the only durable proof of the accepted mutation is never consumed first and quarantined second. - Proof (RED on 233be5a): accepted withdrawal + account switch + one-shot quarantine disk failure keeps the unresolved entry; the switch-back retry stays blocked, reconciles the exact hash into a succeeded quarantine, and unblocks only after per-id acknowledgment.
…ighter-perps-integratio # Conflicts: # packages/perps-controller/CHANGELOG.md
… types, Node 18 WebCrypto polyfill in tests - PerpsController-method-action-types.ts regenerated via messenger-action-types:generate (hand-added entries reformatted to the generator's canonical output; the three durable-settlement actions are derived from the controller methods' jsdoc). - The crypto-spy tests obtain WebCrypto via ensureWebCrypto(), which installs node:crypto's webcrypto as the global on the Node 18 CI floor (Node 20+ already exposes it) so the spies intercept the same object the provider reads.
…ing, concise changelog - Every nonce-consuming Lighter venue write (including signer-key registration) is refused on mainnet at the venue write lock until mainnet trading is validated end-to-end; mainnet stays read-only and the enablement flags alone cannot unlock unvalidated trading. - AggregatedPerpsProvider forwards the durable-settlement contract: getPendingManualRecoveries / getRecoveredDispatches aggregate across sub-providers (storage errors propagate), acknowledgeRecoveredDispatch routes to the provider owning the id. - Changelog Unreleased collapsed to a single initial-implementation entry.
…ack filter and test formatting - typeof guard for the optional acknowledgeRecoveredDispatch reference (unbound-method), lowercase test title, oxfmt formatting.
…eads; refuse dispatch at submit Signer setup may enter the venue write lock on mainnet (client creation is bridge-local, the nonce fetch is read-only) so the auth token can be minted and authenticated reads work with an already-registered key. The mainnet rollout gate moves to a dispatch backstop inside submit, so any nonce-consuming write — including key registration — is still refused before the durable append or anything reaches the venue.
…nature prompt Registration can never succeed under the mainnet rollout gate, so it is refused before the L1 personal_sign and the ChangePubKey signing — a hardware wallet or keyring must never be prompted for a signature the dispatch backstop is guaranteed to refuse.
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.
| # the identical hash. | ||
| (cd "$REPO_DIR/web-wasm" && GOOS=js GOARCH=wasm GOTOOLCHAIN=go1.26.0 go build -trimpath -ldflags="-s -w" -o main.wasm) | ||
| FIRST_SHA="$(shasum -a 256 "$REPO_DIR/web-wasm/main.wasm" | awk '{print $1}')" | ||
| (cd "$REPO_DIR/web-wasm" && GOOS=js GOARCH=wasm GOTOOLCHAIN=go1.26.0 go build -a -trimpath -ldflags="-s -w" -o main.wasm) |
There was a problem hiding this comment.
WASM runtime version mismatch
Medium Severity
GOTOOLCHAIN=go1.26.0 pins only the go build invocations, while go env GOROOT and go version still use the host toolchain. That stages a wasm_exec.js that may not match the compiler that produced main.wasm, which Go does not support and can break the Node signer bridge at instantiate/run time. The same mismatch also misreports goVersion in manifest.json.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 3034be0. Configure here.
…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).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ 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 40fd9d0. Configure here.
| } | ||
| } | ||
| // Wire-format parity: placement integerizes size and the | ||
| // slippage-adjusted EXECUTION |
There was a problem hiding this comment.
Testnet deposits hit mainnet bridge
High Severity
getDepositRoutes now honors params.isTestnet, and DepositService always calls it with isTestnet: false. A testnet-bound Lighter provider therefore returns the Ethereum mainnet bridge, so a deposit credits the mainnet venue account while trading stays on testnet. Previously this path returned no routes and failed closed.
Reviewed by Cursor Bugbot for commit 40fd9d0. Configure here.
| const bindingUsd = Math.max(minQuoteUsd, minBaseUsd); | ||
| if (Number.isFinite(bindingUsd) && bindingUsd > 0) { | ||
| adapted.minimumOrderSize = Math.ceil(bindingUsd * 100) / 100; | ||
| } |
There was a problem hiding this comment.
Minimum size skips size step
Medium Severity
getMarkets sets minimumOrderSize from max(minQuote, minBase × lastTradePrice) rounded to cents, but order validation uses computeLighterMinOrderSize, which first rounds the binding base size up to the market size step. The displayed USD floor can sit below the size the venue actually accepts after that rounding.
Reviewed by Cursor Bugbot for commit 40fd9d0. 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 signing, nonce/dispatch settlement, and client-controlled enablement—errors could affect order safety or funds if mis-gated.
Overview
Adds Lighter as a new
PerpsProviderType('lighter'), wired like MYX via dynamic import ofLighterProviderso clients that omit the module skip registration quietly. Enablement is off by default:providerCredentials.lighter.enabled, or remoteperpsLighterProviderEnabledonly when the client also suppliesproviderCredentials.lighter.signerBridge(no bridge → no registration).PROVIDER_CONFIG.LIGHTER_TESTNET_ONLYandbuildProviderCacheKeytreat Lighter’s effective network like MYX; direct active-provider selection falls back to Hyperliquid if Lighter isn’t registered.Introduces
lighterConfig.tsand public Lighter types/constants (endpoints, chain IDs, key-derivation message, integer wire helpers, bridge facts) exported from the package entry.Exposes durable settlement on
PerpsControllerandAggregatedPerpsProvider:getPendingManualRecoveries,getRecoveredDispatches, andacknowledgeRecoveredDispatch, withPerpsPendingManualRecovery/PerpsRecoveredDispatchtypes and messenger action types. Providers without durable state return empty lists; aggregated mode merges across capable providers.Reviewed by Cursor Bugbot for commit 9d3128e. Bugbot is set up for automated code reviews on this repo. Configure here.