Skip to content

feat(perps-controller): add Lighter perps venue (initial implementation, flag-gated) - #9889

Open
abretonc7s wants to merge 43 commits into
mainfrom
TAT-3766-feat-spike-lighter-perps-integratio
Open

feat(perps-controller): add Lighter perps venue (initial implementation, flag-gated)#9889
abretonc7s wants to merge 43 commits into
mainfrom
TAT-3766-feat-spike-lighter-perps-integratio

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Explanation

TAT-3766: add Lighter as a second perps venue. Started as a spike, now a full LighterProvider in @metamask/perps-controller, hardened through 23 rounds of adversarial cross-review and validated end-to-end against live Lighter testnet.

What's in it

  • Full trading surface behind the PerpsProvider interface: 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.
  • Signer model: deterministic venue-key derivation via personal_sign (raw EVM key never leaves the keyring — hardware-wallet compatible). The Go/WASM signer is injected through a LighterSignerBridge seam so mobile's WebView bridge and the headless Node adapter are interchangeable.
  • Financial-safety architecture (the bulk of the review rounds): a durable nonce-dispatch ledger with exact tx-hash reconciliation — an ambiguous dispatch is never blindly retried; proven-committed outcomes are quarantined until explicitly acknowledged per-outcome (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.
  • Controller wiring behind a flag: PerpsProviderType gains 'lighter'; enablement via providerCredentials.lighter.enabled or the perpsLighterProviderEnabled remote flag. New controller actions expose the safety states to clients.

How it was validated

  • Unit: 213 provider tests (3,031 package-wide), including deterministic fault-injection proofs for response loss, masked commits, restarts, account switches mid-flight, concurrent providers, and storage failures. Every review-round fix was proven RED against the prior commit before landing.
  • Live testnet e2e (real venue, real WASM signer built from source): sign-only 9/9 (pinned WASM identity contract), key registration, order-lifecycle 7/7, TP/SL 12/12 (including a real venue-linked OCO pair), close-position 5/5 with both fills observed on the trades stream, margin/leverage 7/7 on a fresh faucet account.
  • Mainnet read path: 227 active perp markets, full-catalog WS snapshot, candles.
  • On-device (iOS simulator): live prices/candles/positions over WS-only transport; WASM signer mount→ready ~2.1 s, round-trip 32–78 ms.

Open items

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them — not applicable: no breaking changes

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 of LighterProvider so clients that omit the module skip registration quietly. Enablement is off by default: providerCredentials.lighter.enabled, or remote perpsLighterProviderEnabled only when the client also supplies providerCredentials.lighter.signerBridge (no bridge → no registration). PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY and buildProviderCacheKey treat Lighter’s effective network like MYX; direct active-provider selection falls back to Hyperliquid if Lighter isn’t registered.

Introduces lighterConfig.ts and public Lighter types/constants (endpoints, chain IDs, key-derivation message, integer wire helpers, bridge facts) exported from the package entry.

Exposes durable settlement on PerpsController and AggregatedPerpsProvider: getPendingManualRecoveries, getRecoveredDispatches, and acknowledgeRecoveredDispatch, with PerpsPendingManualRecovery / PerpsRecoveredDispatch types 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.

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.
Comment thread packages/perps-controller/src/constants/perpsConfig.ts
Comment thread packages/perps-controller/src/PerpsController.ts
@abretonc7s abretonc7s changed the title feat(perps): spike — investigate Lighter as second perps venue feat(perps-controller): add Lighter perps venue (initial implementation, flag-gated) Aug 17, 2026
…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.
Comment thread packages/perps-controller/src/providers/LighterProvider.ts
…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.
Comment thread packages/perps-controller/src/providers/LighterProvider.ts
…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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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.
Comment thread packages/perps-controller/src/providers/LighterProvider.ts
…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).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40fd9d0. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant