diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 75c648d587..8650c4d1d6 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add Lighter as a perps venue (initial implementation, disabled by default) ([#9889](https://github.com/MetaMask/core/pull/9889)) + - `PerpsProviderType` gains `'lighter'`. Enablement requires client opt-in: `providerCredentials.lighter.enabled`, or the `perpsLighterProviderEnabled` remote feature flag combined with a client-supplied `providerCredentials.lighter.signerBridge` (without a bridge the provider is read-only). Lighter follows the global network toggle on both testnet and mainnet (reads and writes). + - New Lighter types/constants exports, `KeyringController:signPersonalMessage` in the allowed messenger actions (type-only), and durable-settlement surfacing on the controller: `getPendingManualRecoveries`, `getRecoveredDispatches`, `acknowledgeRecoveredDispatch` actions with `PerpsPendingManualRecovery` / `PerpsRecoveredDispatch` exported types and `OrderResult.partialState`. - Add `PERPS_EVENT_PROPERTY.PREVIOUS_LEVERAGE` (`previous_leverage`) for Perp UI Interaction `leverage_changed` events so clients can import the Segment property key from `@metamask/perps-controller` instead of a local interim constant ([#9881](https://github.com/MetaMask/core/pull/9881)) ## [12.0.0] diff --git a/packages/perps-controller/recipes/README.md b/packages/perps-controller/recipes/README.md new file mode 100644 index 0000000000..64f4eb065d --- /dev/null +++ b/packages/perps-controller/recipes/README.md @@ -0,0 +1,17 @@ +# Lighter validation recipes (temporary — do not merge) + +Recipe v1 definitions proving the Lighter integration. This folder is a +temporary holding area so the recipes survive the POC branch review; they +graduate into the harness recipe library before this PR can merge. **The PR +carrying this folder stays DO-NOT-MERGE.** Not published to npm (`files` +only ships `dist/`). + +- `lighter-e2e.recipe.json` — headless core proof against live Lighter + testnet: signer build, sign-only, venue key registration, order + lifecycle, and the controller abstraction path (51 nodes, includes a + revert-check). Runner: `mm-harness run` from the core repo root with the + e2e env described in `tests/e2e/lighter/`. + +The on-device mobile counterparts (composable `lighter.*` units + the +composed capability suite that drives the real UI) live in the mobile PR +under `recipes/` — see MetaMask/metamask-mobile#34865. diff --git a/packages/perps-controller/recipes/lighter-e2e.recipe.json b/packages/perps-controller/recipes/lighter-e2e.recipe.json new file mode 100644 index 0000000000..467c35564f --- /dev/null +++ b/packages/perps-controller/recipes/lighter-e2e.recipe.json @@ -0,0 +1,553 @@ +{ + "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", + "title": "Lighter POC full testnet lifecycle (TAT-3766)", + "description": "Proves the LighterProvider POC end-to-end on Lighter testnet: the Go/WASM signer is built from source and runs headless in Node, the venue key derives deterministically from an EIP-191 personal_sign signature and registers on-chain via ChangePubKey (signature injection, no raw EVM key), a REAL resting limit order is placed and canceled through the provider's full signing path, and a real PerpsController surfaces Lighter markets through the AggregatedPerpsProvider abstraction alongside HyperLiquid. Extended with per-channel WebSocket stream proofs: prices (market_stats/all), account (user_stats), positions (account_all_positions, REST-cross-checked), and authenticated orders (account_all_orders, real order in/out of the stream). Further extended with closePosition+fills-stream, order-book and candle WebSocket channels, the withdraw signing path, and mainnet read-path validation. Final additions: authenticated history reads (fills + funding), OCO TP/SL via grouped orders, and the isolated margin/leverage lifecycle.", + "workflow": { + "entry": "build-wasm", + "nodes": { + "build-wasm": { + "action": "command", + "cmd": "bash packages/perps-controller/tests/e2e/lighter/build-wasm.sh --out temp/lighter-wasm", + "timeout_ms": 600000, + "next": "assert-build-exit", + "intent": "Build the Lighter Go/WASM signer from source (elliottech/lighter-go@web-wasm) and stage wasm_exec.js" + }, + "assert-build-exit": { + "action": "assert_exit_code", + "source": "build-wasm", + "expected": 0, + "next": "assert-build-output", + "intent": "Verify the WASM build completed" + }, + "assert-build-output": { + "action": "assert_output", + "source": "build-wasm", + "stream": "stdout", + "contains": "BUILD_WASM_OK", + "next": "assert-build-manifest", + "intent": "Verify the build script reached its success marker" + }, + "assert-build-manifest": { + "action": "assert_file", + "path": "temp/lighter-wasm/manifest.json", + "contains": "builtSha256", + "next": "sign-only", + "intent": "Verify the reproducibility manifest (built vs upstream sha256) was written" + }, + "sign-only": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=sign-only --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-sign-only-exit", + "intent": "Run the offline signer phase: WASM in Node, deterministic key derivation, ChangePubKey plaintext, auth token, order signature" + }, + "assert-sign-only-exit": { + "action": "assert_exit_code", + "source": "sign-only", + "expected": 0, + "next": "assert-sign-only-checks", + "intent": "Verify the sign-only phase exited green" + }, + "assert-sign-only-checks": { + "action": "assert_output", + "source": "sign-only", + "stream": "stdout", + "contains": "PHASE_PASS: sign-only (7/7 checks)", + "next": "assert-sign-only-json", + "intent": "Verify all 7 sign-only checks passed" + }, + "assert-sign-only-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/sign-only.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "register", + "intent": "Verify the sign-only phase artifact records success" + }, + "register": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=register --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-register-exit", + "intent": "Register the derived venue key on Lighter testnet via a REAL ChangePubKey transaction (EIP-191 signature injection)" + }, + "assert-register-exit": { + "action": "assert_exit_code", + "source": "register", + "expected": 0, + "next": "assert-register-json", + "intent": "Verify the register phase exited green" + }, + "assert-register-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/register.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "assert-register-slot", + "intent": "Verify the register phase artifact records success (strict pubkey equality at the key slot)" + }, + "assert-register-slot": { + "action": "assert_json", + "path": "temp/lighter-e2e/register.json", + "assert": { + "path": "$.apiKeyIndex", + "operator": "eq", + "value": 7 + }, + "next": "order-lifecycle", + "intent": "Verify the venue key landed at the dedicated MetaMask API key slot" + }, + "order-lifecycle": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=order-lifecycle --market=SOL --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-order-exit", + "intent": "Place a REAL resting SOL limit order on Lighter testnet through the provider's full WASM signing path, verify visibility, cancel, verify gone" + }, + "assert-order-exit": { + "action": "assert_exit_code", + "source": "order-lifecycle", + "expected": 0, + "next": "assert-order-checks", + "intent": "Verify the order lifecycle exited green" + }, + "assert-order-checks": { + "action": "assert_output", + "source": "order-lifecycle", + "stream": "stdout", + "contains": "PHASE_PASS: order-lifecycle (7/7 checks)", + "next": "assert-order-json", + "intent": "Verify all 7 order-lifecycle checks passed (place, visible, cancel, gone)" + }, + "assert-order-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/order-lifecycle.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "controller", + "intent": "Verify the order-lifecycle phase artifact records success" + }, + "controller": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=controller --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-controller-exit", + "intent": "Prove the abstraction path: a real PerpsController with lighter enabled surfaces lighter-stamped markets through AggregatedPerpsProvider alongside HyperLiquid" + }, + "assert-controller-exit": { + "action": "assert_exit_code", + "source": "controller", + "expected": 0, + "next": "assert-controller-checks", + "intent": "Verify the controller phase exited green" + }, + "assert-controller-checks": { + "action": "assert_output", + "source": "controller", + "stream": "stdout", + "contains": "PASS: aggregated getMarkets returns lighter-stamped markets", + "next": "assert-controller-json", + "intent": "Verify aggregated reads carry providerId 'lighter'" + }, + "assert-controller-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/controller.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "price-stream", + "intent": "Verify the controller phase artifact records success" + }, + "done": { + "action": "end", + "status": "pass" + }, + "price-stream": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=price-stream --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-price-stream-exit", + "intent": "Prove the live price subscription: market_stats/all WebSocket delivers a snapshot plus repeated update cycles with numeric BTC prices" + }, + "assert-price-stream-exit": { + "action": "assert_exit_code", + "next": "assert-price-stream-json", + "intent": "The price-stream phase driver must exit cleanly", + "source": "price-stream", + "expected": 0 + }, + "assert-price-stream-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/price-stream.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "account-stream", + "intent": "Verify the price-stream phase artifact records success on every check" + }, + "account-stream": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=account-stream --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-account-stream-exit", + "intent": "Prove the live account subscription: user_stats WebSocket delivers positive collateral and balances for the funded testnet account" + }, + "assert-account-stream-exit": { + "action": "assert_exit_code", + "next": "assert-account-stream-json", + "intent": "The account-stream phase driver must exit cleanly", + "source": "account-stream", + "expected": 0 + }, + "assert-account-stream-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/account-stream.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "positions-stream", + "intent": "Verify the account-stream phase artifact records success on every check" + }, + "positions-stream": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=positions-stream --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-positions-stream-exit", + "intent": "Prove the live positions subscription: account_all_positions WebSocket snapshot matches the REST getPositions read" + }, + "assert-positions-stream-exit": { + "action": "assert_exit_code", + "next": "assert-positions-stream-json", + "intent": "The positions-stream phase driver must exit cleanly", + "source": "positions-stream", + "expected": 0 + }, + "assert-positions-stream-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/positions-stream.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "orders-stream", + "intent": "Verify the positions-stream phase artifact records success on every check" + }, + "orders-stream": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=orders-stream --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-orders-stream-exit", + "intent": "Prove the authenticated orders subscription end-to-end: a real resting order placed through the WASM signer arrives on the account_all_orders stream and leaves it after cancel" + }, + "assert-orders-stream-exit": { + "action": "assert_exit_code", + "next": "assert-orders-stream-json", + "intent": "The orders-stream phase driver must exit cleanly", + "source": "orders-stream", + "expected": 0 + }, + "assert-orders-stream-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/orders-stream.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "candles", + "intent": "Verify the orders-stream phase artifact records success on every check" + }, + "candles": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=candles --out=../../temp/lighter-e2e", + "timeout_ms": 300000, + "next": "assert-candles-exit", + "intent": "Prove the candles endpoint: live OHLCV history with sane ascending values plus the subscription seed used by the mobile chart" + }, + "assert-candles-exit": { + "action": "assert_exit_code", + "source": "candles", + "expected": 0, + "next": "assert-candles-json", + "intent": "The candles phase driver must exit cleanly" + }, + "assert-candles-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/candles.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "close-position", + "intent": "Verify the candles phase artifact records success on every check" + }, + "close-position": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=close-position --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-close-position-exit", + "intent": "Prove closePosition end-to-end: a real market order opens a tiny position and closePosition flattens it, with both fills delivered live on the account_all_trades WebSocket stream" + }, + "assert-close-position-exit": { + "action": "assert_exit_code", + "source": "close-position", + "expected": 0, + "next": "assert-close-position-json", + "intent": "The close-position phase driver must exit cleanly" + }, + "assert-close-position-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/close-position.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "order-book-stream", + "intent": "Verify the close-position phase artifact records success on every check" + }, + "order-book-stream": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=order-book-stream --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-order-book-stream-exit", + "intent": "Prove the order_book WebSocket channel: live sorted bid/ask levels with a sane spread and repeated delta updates" + }, + "assert-order-book-stream-exit": { + "action": "assert_exit_code", + "source": "order-book-stream", + "expected": 0, + "next": "assert-order-book-stream-json", + "intent": "The order-book-stream phase driver must exit cleanly" + }, + "assert-order-book-stream-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/order-book-stream.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "candles-stream", + "intent": "Verify the order-book-stream phase artifact records success on every check" + }, + "candles-stream": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=candles-stream --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-candles-stream-exit", + "intent": "Prove the live candle WebSocket channel: a seeded history window plus at least one live update merged into the series" + }, + "assert-candles-stream-exit": { + "action": "assert_exit_code", + "source": "candles-stream", + "expected": 0, + "next": "assert-candles-stream-json", + "intent": "The candles-stream phase driver must exit cleanly" + }, + "assert-candles-stream-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/candles-stream.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "withdraw-sign", + "intent": "Verify the candles-stream phase artifact records success on every check" + }, + "withdraw-sign": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=withdraw-sign --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-withdraw-sign-exit", + "intent": "Prove the withdraw signing path: the WASM signer produces a valid signed L2 withdraw for 1 USDC without submitting it" + }, + "assert-withdraw-sign-exit": { + "action": "assert_exit_code", + "source": "withdraw-sign", + "expected": 0, + "next": "assert-withdraw-sign-json", + "intent": "The withdraw-sign phase driver must exit cleanly" + }, + "assert-withdraw-sign-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/withdraw-sign.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "mainnet-reads", + "intent": "Verify the withdraw-sign phase artifact records success on every check" + }, + "mainnet-reads": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=mainnet-reads --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-mainnet-reads-exit", + "intent": "Prove Lighter mainnet read paths: the full 200+ active perp catalog, live WebSocket prices covering it, and BTC candle history \u2014 read-only, no account" + }, + "assert-mainnet-reads-exit": { + "action": "assert_exit_code", + "source": "mainnet-reads", + "expected": 0, + "next": "assert-mainnet-reads-json", + "intent": "The mainnet-reads phase driver must exit cleanly" + }, + "assert-mainnet-reads-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/mainnet-reads.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "history-reads", + "intent": "Verify the mainnet-reads phase artifact records success on every check" + }, + "history-reads": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=history-reads --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-history-reads-exit", + "intent": "Prove authenticated history reads: real trade fills and user funding payments for the funded testnet account" + }, + "assert-history-reads-exit": { + "action": "assert_exit_code", + "source": "history-reads", + "expected": 0, + "next": "assert-history-reads-json", + "intent": "The history-reads phase driver must exit cleanly" + }, + "assert-history-reads-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/history-reads.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "tpsl", + "intent": "Verify the history-reads phase artifact records success on every check" + }, + "tpsl": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=tpsl --out=../../temp/lighter-e2e", + "timeout_ms": 600000, + "next": "assert-tpsl-exit", + "intent": "Prove position TP/SL end-to-end: open a real position, attach an OCO take-profit/stop-loss pair via grouped orders, verify both trigger orders, remove them, and close" + }, + "assert-tpsl-exit": { + "action": "assert_exit_code", + "source": "tpsl", + "expected": 0, + "next": "assert-tpsl-json", + "intent": "The tpsl phase driver must exit cleanly" + }, + "assert-tpsl-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/tpsl.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "margin-leverage", + "intent": "Verify the tpsl phase artifact records success on every check" + }, + "margin-leverage": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=margin-leverage --out=../../temp/lighter-e2e", + "timeout_ms": 600000, + "next": "assert-margin-leverage-exit", + "intent": "Prove margin and leverage at protocol level: switch the market to isolated 10x, verify the position margin fraction, add and remove isolated margin, close, and restore cross 20x" + }, + "assert-margin-leverage-exit": { + "action": "assert_exit_code", + "source": "margin-leverage", + "expected": 0, + "next": "assert-margin-leverage-json", + "intent": "The margin-leverage phase driver must exit cleanly" + }, + "assert-margin-leverage-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/margin-leverage.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "parity-history", + "intent": "Verify the margin-leverage phase artifact records success on every check" + }, + "parity-history": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=parity-history --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-parity-history-exit", + "intent": "Prove the parity history surface: full historical order lifecycle, deposit/withdrawal user history, the merged non-funding ledger, and bridge routes cross-checked against the venue-reported L1 contracts" + }, + "assert-parity-history-exit": { + "action": "assert_exit_code", + "source": "parity-history", + "expected": 0, + "next": "assert-parity-history-json", + "intent": "The parity-history phase driver must exit cleanly" + }, + "assert-parity-history-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/parity-history.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "connection-state", + "intent": "Verify the parity-history phase artifact records success on every check" + }, + "connection-state": { + "action": "command", + "cmd": "yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts --phase=connection-state --out=../../temp/lighter-e2e", + "timeout_ms": 420000, + "next": "assert-connection-state-exit", + "intent": "Prove connection-state management over the real venue WebSocket: connecting/connected transitions on subscribe, live state reads, a manual reconnect cycle that resumes price flow, and clean teardown" + }, + "assert-connection-state-exit": { + "action": "assert_exit_code", + "source": "connection-state", + "expected": 0, + "next": "assert-connection-state-json", + "intent": "The connection-state phase driver must exit cleanly" + }, + "assert-connection-state-json": { + "action": "assert_json", + "path": "temp/lighter-e2e/connection-state.json", + "assert": { + "path": "$.ok", + "operator": "eq", + "value": true + }, + "next": "done", + "intent": "Verify the connection-state phase artifact records success on every check" + } + } + } +} diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index dcba771ad5..7ee6dde89f 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -328,6 +328,43 @@ export type PerpsControllerGetOrderFillsAction = { handler: PerpsController['getOrderFills']; }; +/** + * List TP/SL protection changes the active provider parked for + * explicit manual re-establishment. Providers without durable + * settlement state return an empty list. + * + * @returns Pending manual-recovery entries. + */ +export type PerpsControllerGetPendingManualRecoveriesAction = { + type: `PerpsController:getPendingManualRecoveries`; + handler: PerpsController['getPendingManualRecoveries']; +}; + +/** + * READ-ONLY list of the active provider's recovered-dispatch outcomes + * (previously ambiguous submissions later resolved). Providers without + * durable dispatch state return an empty list. + * + * @returns Pending recovered-dispatch outcomes. + */ +export type PerpsControllerGetRecoveredDispatchesAction = { + type: `PerpsController:getRecoveredDispatches`; + handler: PerpsController['getRecoveredDispatches']; +}; + +/** + * Acknowledge ONE recovered-dispatch outcome by its stable id, after + * refreshing venue state. Throws when the active provider has no + * durable dispatch state or the id no longer matches. + * + * @param recoveryId - Stable id from {@link getRecoveredDispatches}. + * @returns Resolves when the outcome is acknowledged. + */ +export type PerpsControllerAcknowledgeRecoveredDispatchAction = { + type: `PerpsController:acknowledgeRecoveredDispatch`; + handler: PerpsController['acknowledgeRecoveredDispatch']; +}; + /** * Get historical user orders (order lifecycle) * Thin delegation to MarketDataService @@ -1185,6 +1222,9 @@ export type PerpsControllerMethodActions = | PerpsControllerWithdrawAction | PerpsControllerGetPositionsAction | PerpsControllerGetOrderFillsAction + | PerpsControllerGetPendingManualRecoveriesAction + | PerpsControllerGetRecoveredDispatchesAction + | PerpsControllerAcknowledgeRecoveredDispatchAction | PerpsControllerGetOrdersAction | PerpsControllerGetOpenOrdersAction | PerpsControllerGetFundingAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index e6391f735c..467246ecfc 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -101,6 +101,8 @@ import type { OrderResult, PerpsControllerConfig, PerpsMarketData, + PerpsPendingManualRecovery, + PerpsRecoveredDispatch, Position, SubscribeAccountParams, SubscribeCandlesParams, @@ -134,6 +136,10 @@ import type { MYXCredentials, } from './types/index.js'; import type { SortDirection } from './types/index.js'; +import type { + LighterAuthConfig, + LighterSignerBridge, +} from './types/lighter-types.js'; import type { PerpsControllerAllowedActions, PerpsControllerAllowedEvents, @@ -900,8 +906,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOrderBookGrouping', 'getOrderFills', 'getOrders', + 'getPendingManualRecoveries', 'getPendingTradeConfiguration', 'getPositions', + 'getRecoveredDispatches', + 'acknowledgeRecoveredDispatch', 'getTradeConfiguration', 'getRecentlyViewedMarkets', 'getWatchlistMarkets', @@ -983,6 +992,8 @@ export class PerpsController extends BaseController< /** Tracks the async MYX dynamic import so performInitialization can await it. */ #myxRegistrationPromise: Promise | null = null; + #lighterRegistrationPromise: Promise | null = null; + protected blockedRegionList: BlockedRegionList = { list: [], source: 'fallback', @@ -1066,6 +1077,48 @@ export class PerpsController extends BaseController< } } + /** + * Check if the Lighter provider is enabled. + * + * Local override (`providerCredentials.lighter.enabled`) wins; otherwise + * the remote `perpsLighterProviderEnabled` feature flag decides — but only + * for clients that wired the venue signer bridge. A remote flag must not + * be able to register a trading provider the client never mounted a + * signer for (the gates would otherwise split between core and client). + * + * @returns True if the condition is met. + */ + #isLighterProviderEnabled(): boolean { + const lighter = this.#options.clientConfig?.providerCredentials?.lighter; + + if (lighter?.enabled) { + return true; + } + if (!lighter?.signerBridge) { + return false; + } + + try { + const remoteState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const remoteFlag = + remoteState.remoteFeatureFlags?.perpsLighterProviderEnabled; + + if (isVersionGatedFeatureFlag(remoteFlag)) { + const validated = + this.#options.infrastructure.featureFlags.validateVersionGated( + remoteFlag, + ); + return validated ?? false; + } + + return false; + } catch { + return false; + } + } + /** * Active provider instance for routing operations. * When activeProvider is 'hyperliquid' or 'myx': points to specific provider directly @@ -1309,6 +1362,7 @@ export class PerpsController extends BaseController< if ( providerId === 'hyperliquid' || (providerId === 'myx' && this.#isMYXProviderEnabled()) || + (providerId === 'lighter' && this.#isLighterProviderEnabled()) || this.providers.has(providerId as PerpsProviderType) ) { providerIds.add(providerId); @@ -2121,8 +2175,10 @@ export class PerpsController extends BaseController< await Promise.all([ wait(PERPS_CONSTANTS.ReconnectionCleanupDelayMs), this.#myxRegistrationPromise, + this.#lighterRegistrationPromise, ]); this.#myxRegistrationPromise = null; + this.#lighterRegistrationPromise = null; this.#assignActiveProvider(); @@ -2260,6 +2316,24 @@ export class PerpsController extends BaseController< }) .catch((error: unknown) => this.handleMYXImportError(error)); } + + // Register Lighter provider if enabled (POC). Same dynamic-import pattern + // as MYX so clients that do not ship the Lighter files skip registration + // silently. + const isLighterEnabled = this.#isLighterProviderEnabled(); + if (isLighterEnabled) { + // NOTE: Keep the path in a variable so ts-bridge does not rewrite the + // import argument and strip the webpackIgnore magic comment in core dist. + const lighterModulePath = './providers/LighterProvider'; + this.#lighterRegistrationPromise = import( + /* webpackIgnore: true */ lighterModulePath + ) + .then(({ LighterProvider }) => { + this.registerLighterProvider(LighterProvider); + return undefined; + }) + .catch((error: unknown) => this.handleLighterImportError(error)); + } } /** @@ -2317,6 +2391,69 @@ export class PerpsController extends BaseController< } } + /** + * Registers the Lighter provider after dynamic import resolves. + * + * Extracted from the import().then() callback so it can be tested directly + * (Jest cannot resolve dynamic imports without --experimental-vm-modules). + * + * @param LighterProviderClass - Constructor class for the Lighter provider. + */ + protected registerLighterProvider( + LighterProviderClass: new (opts: { + isTestnet: boolean; + platformDependencies: PerpsPlatformDependencies; + messenger: PerpsControllerMessenger; + lighterAuthConfig: LighterAuthConfig; + signerBridge?: LighterSignerBridge; + }) => PerpsProvider, + ): void { + const lighterIsTestnet = + PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY || this.state.isTestnet; + const lighter = + this.#options.clientConfig?.providerCredentials?.lighter ?? {}; + const lighterProvider = new LighterProviderClass({ + isTestnet: lighterIsTestnet, + platformDependencies: this.#options.infrastructure, + messenger: this.messenger, + signerBridge: lighter.signerBridge, + lighterAuthConfig: { + enabled: lighter.enabled, + accountIndex: lighterIsTestnet + ? lighter.accountIndexTestnet + : lighter.accountIndexMainnet, + apiKeyIndex: lighter.apiKeyIndex, + }, + }); + this.providers.set('lighter', lighterProvider); + this.#debugLog('PerpsController: Lighter provider registered', { + isTestnet: lighterIsTestnet, + }); + } + + /** + * Handles errors from the Lighter dynamic import. + * + * Module-not-found errors are expected (clients may not ship Lighter) → + * debug log. Other errors indicate constructor/config problems → Sentry. + * + * @param error - The caught error from the dynamic import or constructor. + */ + protected handleLighterImportError(error: unknown): void { + const isModuleError = + (error as Record)?.code === 'MODULE_NOT_FOUND'; + if (isModuleError) { + this.#debugLog( + 'PerpsController: Lighter provider module not available, skipping registration', + ); + } else { + this.#logError( + error instanceof Error ? error : new Error(String(error)), + this.#getErrorContext('createProviders.lighter'), + ); + } + } + /** * Assigns the active provider instance based on the current activeProvider state. * Separated from #createProviders so it runs after async MYX registration settles. @@ -2346,13 +2483,13 @@ export class PerpsController extends BaseController< this.#debugLog( `PerpsController: Using direct provider (${activeProvider})`, ); - } else if (activeProvider === 'myx') { - const myxProvider = this.providers.get('myx'); - if (myxProvider) { - this.activeProviderInstance = myxProvider; + } else if (activeProvider === 'myx' || activeProvider === 'lighter') { + const directProvider = this.providers.get(activeProvider); + if (directProvider) { + this.activeProviderInstance = directProvider; } else { this.#debugLog( - 'PerpsController: MYX provider not available, falling back to hyperliquid', + `PerpsController: ${activeProvider} provider not available, falling back to hyperliquid`, ); this.activeProviderInstance = hyperLiquidProvider; this.update((state) => { @@ -2364,7 +2501,7 @@ export class PerpsController extends BaseController< ); } else { throw new Error( - `Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', and 'aggregated' are supported.`, + `Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', 'lighter', and 'aggregated' are supported.`, ); } } @@ -3347,6 +3484,54 @@ export class PerpsController extends BaseController< }); } + /** + * List TP/SL protection changes the active provider parked for + * explicit manual re-establishment. Providers without durable + * settlement state return an empty list. + * + * @returns Pending manual-recovery entries. + */ + async getPendingManualRecoveries(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + if (!provider.getPendingManualRecoveries) { + return []; + } + return provider.getPendingManualRecoveries(); + } + + /** + * READ-ONLY list of the active provider's recovered-dispatch outcomes + * (previously ambiguous submissions later resolved). Providers without + * durable dispatch state return an empty list. + * + * @returns Pending recovered-dispatch outcomes. + */ + async getRecoveredDispatches(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + if (!provider.getRecoveredDispatches) { + return []; + } + return provider.getRecoveredDispatches(); + } + + /** + * Acknowledge ONE recovered-dispatch outcome by its stable id, after + * refreshing venue state. Throws when the active provider has no + * durable dispatch state or the id no longer matches. + * + * @param recoveryId - Stable id from {@link getRecoveredDispatches}. + * @returns Resolves when the outcome is acknowledged. + */ + async acknowledgeRecoveredDispatch(recoveryId: string): Promise { + const provider = await this.#getActiveProviderWhenReady(); + if (!provider.acknowledgeRecoveredDispatch) { + throw new Error( + 'The active perps provider has no recovered dispatches to acknowledge', + ); + } + return provider.acknowledgeRecoveredDispatch(recoveryId); + } + /** * Get historical user orders (order lifecycle) * Thin delegation to MarketDataService diff --git a/packages/perps-controller/src/constants/index.ts b/packages/perps-controller/src/constants/index.ts index cd510d8579..4d3636e6ca 100644 --- a/packages/perps-controller/src/constants/index.ts +++ b/packages/perps-controller/src/constants/index.ts @@ -9,3 +9,4 @@ export * from './perpsConfig.js'; export * from './transactionsHistoryConfig.js'; export * from './performanceMetrics.js'; export * from './myxConfig.js'; +export * from './lighterConfig.js'; diff --git a/packages/perps-controller/src/constants/lighterConfig.ts b/packages/perps-controller/src/constants/lighterConfig.ts new file mode 100644 index 0000000000..c5a2b1c63d --- /dev/null +++ b/packages/perps-controller/src/constants/lighterConfig.ts @@ -0,0 +1,369 @@ +/** + * Lighter Protocol Configuration Constants + * + * Endpoints, chain ids, transaction type codes and signer defaults for the + * zkLighter integration. Values verified against the public API + * (https://apidocs.lighter.xyz) and lighter-python `endpoint_profiles.py`. + */ + +import type { + LighterEndpoints, + LighterNetwork, + LighterOrderBookMeta, +} from '../types/lighter-types.js'; + +// ============================================================================ +// Network Constants +// ============================================================================ + +/** + * zkLighter L2 chain ids (protocol-level, not EVM chain ids). + */ +export const LIGHTER_MAINNET_CHAIN_ID = 304; +export const LIGHTER_TESTNET_CHAIN_ID = 300; + +/** + * Get the zkLighter chain id for a network. + * + * @param network - The Lighter network environment (mainnet or testnet). + * @returns The zkLighter chain id for the specified network. + */ +export function getLighterChainId(network: LighterNetwork): number { + return network === 'testnet' + ? LIGHTER_TESTNET_CHAIN_ID + : LIGHTER_MAINNET_CHAIN_ID; +} + +// ============================================================================ +// API Endpoints +// ============================================================================ + +/** + * Lighter REST and WebSocket endpoints + */ +export const LIGHTER_ENDPOINTS: LighterEndpoints = { + mainnet: { + http: 'https://mainnet.zklighter.elliot.ai', + ws: 'wss://mainnet.zklighter.elliot.ai/stream', + }, + testnet: { + http: 'https://testnet.zklighter.elliot.ai', + ws: 'wss://testnet.zklighter.elliot.ai/stream', + }, +}; + +/** + * Get HTTP endpoint for a network. + * + * @param network - The Lighter network environment (mainnet or testnet). + * @returns The HTTP API base URL for the specified network. + */ +export function getLighterHttpEndpoint(network: LighterNetwork): string { + return LIGHTER_ENDPOINTS[network].http; +} + +/** + * Get the WebSocket stream endpoint for a network. + * + * @param network - The Lighter network environment (mainnet or testnet). + * @returns The WebSocket stream URL for the specified network. + */ +export function getLighterWsEndpoint(network: LighterNetwork): string { + return LIGHTER_ENDPOINTS[network].ws; +} + +/** L2 transaction type: Withdraw (funds exit to L1). */ +export const LIGHTER_TX_TYPE_WITHDRAW = 13; + +/** L2 transaction type: ModifyOrder (reprice/resize a resting order). */ +export const LIGHTER_TX_TYPE_MODIFY_ORDER = 17; + +/** USDC collateral asset index on zkLighter (asset indexing starts at 1). */ +export const LIGHTER_USDC_ASSET_INDEX = 1; + +/** + * Candle resolutions Lighter serves natively (subset of CandlePeriod values). + */ +export const LIGHTER_SUPPORTED_RESOLUTIONS: ReadonlySet = new Set([ + '1m', + '5m', + '15m', + '30m', + '1h', + '4h', + '12h', + '1d', +]); + +/** + * Millisecond span per supported resolution (range computation for candles). + */ +export const LIGHTER_RESOLUTION_MS: Record = { + '1m': 60_000, + '5m': 300_000, + '15m': 900_000, + '30m': 1_800_000, + '1h': 3_600_000, + '4h': 14_400_000, + '12h': 43_200_000, + '1d': 86_400_000, +}; + +// ============================================================================ +// L2 Transaction Types (types/txtypes/constants.go) +// ============================================================================ + +export const LIGHTER_TX_TYPE_CHANGE_PUB_KEY = 8; +export const LIGHTER_TX_TYPE_CREATE_ORDER = 14; +export const LIGHTER_TX_TYPE_CANCEL_ORDER = 15; +export const LIGHTER_TX_TYPE_CANCEL_ALL_ORDERS = 16; + +// ============================================================================ +// Order enums (wire values expected by `_signCreateOrder`) +// ============================================================================ + +export const LIGHTER_ORDER_TYPE_LIMIT = 0; +export const LIGHTER_ORDER_TYPE_MARKET = 1; +export const LIGHTER_ORDER_TYPE_STOP_LOSS = 2; +export const LIGHTER_ORDER_TYPE_TAKE_PROFIT = 4; + +/** Grouped-orders grouping type: one-cancels-the-other (OCO). */ +export const LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER = 2; + +/** L2 transaction type: CreateGroupedOrders (e.g. OCO TP/SL pairs). */ +export const LIGHTER_TX_TYPE_CREATE_GROUPED_ORDERS = 28; + +/** L2 transaction type: UpdateMargin (isolated margin add/remove). */ +export const LIGHTER_TX_TYPE_UPDATE_MARGIN = 29; + +/** L2 transaction type: UpdateLeverage (per-market IMF + margin mode). */ +export const LIGHTER_TX_TYPE_UPDATE_LEVERAGE = 20; + +export const LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = 0; +export const LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME = 1; +export const LIGHTER_TIME_IN_FORCE_POST_ONLY = 2; + +/** Sentinel for "no expiry" on GTT orders (per lighter SDKs). */ +export const LIGHTER_ORDER_EXPIRY_NONE = -1; +/** Sentinel for "no trigger price". */ +export const LIGHTER_NO_TRIGGER_PRICE = 0; + +// ============================================================================ +// Signer / key derivation +// ============================================================================ + +/** + * Fixed EIP-191 message signed by the user's L1 account to derive the + * Lighter venue key seed. The signature (deterministic per RFC 6979) is + * hashed into the seed, so the same wallet always derives the same venue + * key — recoverable across devices and compatible with hardware wallets. + * + * `{address}`, `{chainId}` and `{apiKeyIndex}` are substituted before + * signing so a seed is bound to one account, network and key slot. + */ +export const LIGHTER_KEY_DERIVATION_MESSAGE_TEMPLATE = + 'MetaMask Perps: derive Lighter API key\n' + + 'Address: {address}\n' + + 'Chain ID: {chainId}\n' + + 'API key index: {apiKeyIndex}\n' + + 'Only sign this message for a trusted client!'; + +/** + * Build the key-derivation message for an account/network/key-slot triple. + * + * @param params - Substitution values. + * @param params.address - L1 address owning the Lighter account. + * @param params.chainId - zkLighter chain id (binds testnet/mainnet). + * @param params.apiKeyIndex - API key slot being derived. + * @returns The message to personal_sign. + */ +export function buildLighterKeyDerivationMessage(params: { + address: string; + chainId: number; + apiKeyIndex: number; +}): string { + return LIGHTER_KEY_DERIVATION_MESSAGE_TEMPLATE.replace( + '{address}', + params.address.toLowerCase(), + ) + .replace('{chainId}', String(params.chainId)) + .replace('{apiKeyIndex}', String(params.apiKeyIndex)); +} + +/** + * Default API key slot used by the MetaMask integration. + * Slots 0-2 are commonly used by Lighter's own frontends; a dedicated + * slot avoids clobbering keys registered by other clients. + */ +export const LIGHTER_DEFAULT_API_KEY_INDEX = 7; + +// ============================================================================ +// REST API Configuration +// ============================================================================ + +/** + * HTTP request timeout in milliseconds + */ +export const LIGHTER_HTTP_TIMEOUT_MS = 10000; + +/** + * Interval for polling REST prices (no WS subscription in the POC) + */ +export const LIGHTER_PRICE_POLLING_INTERVAL_MS = 5000; + +/** + * Maximum leverage placeholder until per-market margin fractions are wired. + */ +export const LIGHTER_MAX_LEVERAGE = 50; + +/** + * TTL for the authoritative per-market margin-metadata cache used by + * explicit leverage validation. Without expiry, metadata fetched once + * (e.g. an older, higher max) would keep validating later-overlimit + * leverage for the whole session; the venue cap remains the final + * enforcement either way. + */ +export const LIGHTER_MARGIN_METADATA_TTL_MS = 60_000; + +/** + * Prefix marking venue-data integrity failures (malformed numeric fields + * in venue payloads). These must fail closed and surface — never degrade + * into silently-coerced values or empty reads. + */ +export const LIGHTER_DATA_INTEGRITY_PREFIX = 'Invalid Lighter venue data:'; + +/** Full-string decimal/scientific literal (optional sign and exponent). */ +const LIGHTER_STRICT_DECIMAL_PATTERN = + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; + +/** + * Parse a numeric string STRICTLY: the entire trimmed string must be a + * decimal/scientific literal. parseFloat prefix-parses, so '0.1oops' + * would silently become 0.1. + * + * Accepts unknown because venue REST payloads are type-cast without + * runtime validation: a missing/null/numeric field must yield null (for + * the caller's explicit error path), never a TypeError that generic + * catches misclassify as an ordinary read failure. + * + * Note: '1e999' matches the literal pattern and parses to Infinity — + * callers own the finiteness check. + * + * @param value - Raw value from params or a venue payload. + * @returns The parsed number, or null when the value is not a string + * containing a pure numeric literal. + */ +export function parseLighterStrictDecimal(value: unknown): number | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return LIGHTER_STRICT_DECIMAL_PATTERN.test(trimmed) + ? parseFloat(trimmed) + : null; +} + +// ============================================================================ +// Size / price integerization +// ============================================================================ + +/** + * Convert a human-readable amount to the integer representation expected by + * the Lighter signer for a given number of supported decimals. + * + * @param value - Human-readable amount (e.g. 0.05 SOL, 187.25 USDC). + * @param decimals - `supportedSizeDecimals` / `supportedPriceDecimals` + * from the market metadata. + * @returns Integer wire value (e.g. 0.05 @ 5 decimals -> 5000). + */ +export function toLighterInteger(value: number, decimals: number): number { + const scaled = Math.round(value * 10 ** decimals); + // Fail closed on wire-format overflow: a huge-but-finite value scales to + // an unsafe integer (or Infinity) and would stringify as '1e+305' inside + // signer params. + if (!Number.isSafeInteger(scaled)) { + throw new Error( + `Value ${value} is outside Lighter's integer range at ${decimals} decimals`, + ); + } + // NOTE: this is a generic converter — zero and negative results are + // valid here. Positive-intent policy for signer-bound values lives in + // the provider's internal wire wrapper. + return scaled; +} + +/** + * Convert an integer wire value back to a human-readable amount. + * + * @param value - Integer wire value. + * @param decimals - Supported decimals from the market metadata. + * @returns Human-readable amount. + */ +export function fromLighterInteger(value: number, decimals: number): number { + return value / 10 ** decimals; +} + +/** + * Compute the minimum order base size for a market that satisfies both + * `minBaseAmount` and `minQuoteAmount` at a given price. + * + * @param market - Market metadata from `orderBooks`. + * @param price - Order price (human units). + * @returns Base size in human units, rounded up to the market's size step. + */ +export function computeLighterMinOrderSize( + market: Pick< + LighterOrderBookMeta, + 'minBaseAmount' | 'minQuoteAmount' | 'supportedSizeDecimals' + >, + price: number, +): number { + const minBase = parseFloat(market.minBaseAmount); + const minQuote = parseFloat(market.minQuoteAmount); + const step = 10 ** -market.supportedSizeDecimals; + const byQuote = price > 0 ? minQuote / price : minBase; + const raw = Math.max(minBase, byQuote); + // Small epsilon guards against float artifacts (0.1 / 1e-5 = 10000.0000002) + // pushing the ceil one step too high. + const units = Math.ceil(raw / step - 1e-9); + return Number((units * step).toFixed(market.supportedSizeDecimals)); +} + +/** + * L1 bridge facts per network, as reported live by + * `GET /api/v1/layer1BasicInfo` (contract addresses) and the venue docs + * (minimums). Mainnet settles against Ethereum L1; testnet runs on a + * venue-hosted devnet L1 (chain id 123456), so its route is informational. + */ +export const LIGHTER_BRIDGE_CONFIG = { + mainnet: { + /** CAIP-2 chain the bridge contract lives on (Ethereum mainnet). */ + chainId: 'eip155:1', + /** ZkLighter L1 contract (deposits via `deposit`, selector 0x8a857083). */ + bridgeContract: '0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7', + /** Canonical Ethereum USDC. */ + usdcContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + /** Venue-documented USDC minimums. */ + minDepositUsdc: '1', + minWithdrawUsdc: '1', + }, + testnet: { + chainId: 'eip155:123456', + bridgeContract: '0xe034801BC49cCDC79FB683022dA0591C86077261', + usdcContract: '0x57382a12EC72eBb1e717b7BB76c78CdDAfE3A396', + minDepositUsdc: '1', + minWithdrawUsdc: '1', + }, +} as const; + +/** UpdateLeverage margin-mode codes (types/txtypes constants). */ +export const LIGHTER_MARGIN_MODE_CROSS = 0; +export const LIGHTER_MARGIN_MODE_ISOLATED = 1; + +/** + * Marker prefix for capability-gate errors (unsupported account tier / + * unverified fee semantics). Callers use it to surface these explicitly + * instead of degrading them into empty state. + */ +export const LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX = + 'Unsupported Lighter capability:'; diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index f81dac8896..f4f76aea07 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -608,6 +608,14 @@ export const PROVIDER_CONFIG = { DefaultProvider: 'hyperliquid' as const, /** Force MYX to testnet only (mainnet credentials not yet available) */ MYX_TESTNET_ONLY: false, + /** + * Force Lighter to testnet only. Off: Lighter follows the global network + * toggle — mainnet reads AND writes are enabled (the initial rollout + * write gate was removed once the write path was validated end-to-end + * on testnet). Flip on to pin Lighter to testnet regardless of the + * global network toggle. + */ + LIGHTER_TESTNET_ONLY: false, } as const; // Disk-backed cold-start cache keys and throttle interval. @@ -656,9 +664,11 @@ export function buildProviderCacheKey( providerId: string, isTestnet: boolean, ): string { - const effectiveTestnet = - providerId === 'myx' - ? PROVIDER_CONFIG.MYX_TESTNET_ONLY || isTestnet - : isTestnet; + let effectiveTestnet = isTestnet; + if (providerId === 'myx') { + effectiveTestnet = PROVIDER_CONFIG.MYX_TESTNET_ONLY || isTestnet; + } else if (providerId === 'lighter') { + effectiveTestnet = PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY || isTestnet; + } return `${providerId}:${effectiveTestnet ? 'testnet' : 'mainnet'}`; } diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 578b60b352..c5b1aa5c85 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -93,8 +93,11 @@ export type { PerpsControllerGetOrderBookGroupingAction, PerpsControllerGetOrderFillsAction, PerpsControllerGetOrdersAction, + PerpsControllerGetPendingManualRecoveriesAction, PerpsControllerGetPendingTradeConfigurationAction, PerpsControllerGetPositionsAction, + PerpsControllerGetRecoveredDispatchesAction, + PerpsControllerAcknowledgeRecoveredDispatchAction, PerpsControllerGetTradeConfigurationAction, PerpsControllerGetRecentlyViewedMarketsAction, PerpsControllerGetWatchlistMarketsAction, @@ -181,6 +184,8 @@ export type { TPSLTrackingData, OrderParams, OrderResult, + PerpsPendingManualRecovery, + PerpsRecoveredDispatch, Position, AccountState, ClosePositionParams, @@ -452,6 +457,31 @@ export { MYX_MINIMUM_ORDER_SIZE_USD, MYX_EXECUTION_FEE_TOKEN, } from './constants/index.js'; +export { + LIGHTER_MAINNET_CHAIN_ID, + LIGHTER_TESTNET_CHAIN_ID, + getLighterChainId, + LIGHTER_ENDPOINTS, + getLighterHttpEndpoint, + LIGHTER_DEFAULT_API_KEY_INDEX, + LIGHTER_KEY_DERIVATION_MESSAGE_TEMPLATE, + buildLighterKeyDerivationMessage, + LIGHTER_HTTP_TIMEOUT_MS, + LIGHTER_PRICE_POLLING_INTERVAL_MS, + LIGHTER_MAX_LEVERAGE, + toLighterInteger, + fromLighterInteger, + computeLighterMinOrderSize, +} from './constants/index.js'; +export type { + LighterNetwork, + LighterSignerBridge, + LighterWebSocketCtor, + LighterWebSocketLike, + LighterWasmCall, + LighterAuthConfig, + LighterPersonalSigner, +} from './types/lighter-types.js'; export { PERPS_CONSTANTS, WITHDRAWAL_CONSTANTS, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 22a84842ee..8084d1d878 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -61,6 +61,8 @@ import type { OrderParams, OrderResult, PerpsMarketData, + PerpsPendingManualRecovery, + PerpsRecoveredDispatch, PerpsProviderType, Position, ReadyToTradeResult, @@ -525,6 +527,70 @@ export class AggregatedPerpsProvider implements PerpsProvider { return provider.withdraw(params); } + /** + * Aggregate parked manual TP/SL recoveries from every underlying + * provider implementing the durable-settlement contract. Storage + * errors PROPAGATE — a corrupt store degrading to "nothing pending" + * would hide an under-protected position. + * + * @returns Pending manual-recovery entries across providers. + */ + async getPendingManualRecoveries(): Promise { + const results = await Promise.all( + this.#getActiveProviders().map(async ([, provider]) => + provider.getPendingManualRecoveries + ? provider.getPendingManualRecoveries() + : [], + ), + ); + return results.flat(); + } + + /** + * Aggregate recovered-dispatch outcomes from every underlying provider + * implementing the durable-settlement contract. + * + * @returns Pending recovered-dispatch outcomes across providers. + */ + async getRecoveredDispatches(): Promise { + const results = await Promise.all( + this.#getActiveProviders().map(async ([, provider]) => + provider.getRecoveredDispatches + ? provider.getRecoveredDispatches() + : [], + ), + ); + return results.flat(); + } + + /** + * Acknowledge ONE recovered-dispatch outcome by its stable id on + * whichever underlying provider owns it. + * + * @param recoveryId - Stable id from {@link getRecoveredDispatches}. + */ + async acknowledgeRecoveredDispatch(recoveryId: string): Promise { + const capable = this.#getActiveProviders().filter( + ([, provider]) => + typeof provider.acknowledgeRecoveredDispatch === 'function', + ); + if (capable.length === 0) { + throw new Error( + 'No perps provider has recovered dispatches to acknowledge', + ); + } + let lastError: Error | null = null; + for (const [, provider] of capable) { + try { + await provider.acknowledgeRecoveredDispatch?.(recoveryId); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + throw lastError as Error; + } + // ============================================================================ // Validation (Route to specific provider) // ============================================================================ diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts new file mode 100644 index 0000000000..f3c8099ee4 --- /dev/null +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -0,0 +1,8031 @@ +/** + * LighterProvider + * + * Provider implementation for the zkLighter protocol (POC). + * Implements the PerpsProvider interface with live REST reads and a real + * write path (place/cancel limit orders) driven through the Lighter Go/WASM + * signer behind the transport-agnostic {@link LighterSignerBridge} seam. + * + * Key differences from HyperLiquid: + * - Venue-specific key (Schnorr over ECgFp5) registered per API-key slot via + * a ChangePubKey L2 transaction carrying an EIP-191 personal_sign L1Sig. + * - Order prices/sizes are integers scaled by per-market decimals. + * - REST + polling in the POC; WebSocket streams deferred. + */ + +import type { CaipAccountId } from '@metamask/utils'; + +import type { CandlePeriod } from '../constants/chartConfig.js'; +import { + computeLighterMinOrderSize, + fromLighterInteger, + getLighterChainId, + LIGHTER_RESOLUTION_MS, + LIGHTER_SUPPORTED_RESOLUTIONS, + LIGHTER_DEFAULT_API_KEY_INDEX, + LIGHTER_MAX_LEVERAGE, + LIGHTER_NO_TRIGGER_PRICE, + LIGHTER_ORDER_EXPIRY_NONE, + LIGHTER_ORDER_TYPE_LIMIT, + LIGHTER_ORDER_TYPE_MARKET, + LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME, + getLighterWsEndpoint, + LIGHTER_PRICE_POLLING_INTERVAL_MS, + LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + LIGHTER_BRIDGE_CONFIG, + LIGHTER_TX_TYPE_CANCEL_ORDER, + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER, + LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_ORDER_TYPE_TAKE_PROFIT, + LIGHTER_TX_TYPE_CREATE_GROUPED_ORDERS, + LIGHTER_TX_TYPE_CREATE_ORDER, + LIGHTER_TX_TYPE_UPDATE_LEVERAGE, + LIGHTER_TX_TYPE_UPDATE_MARGIN, + LIGHTER_TX_TYPE_WITHDRAW, + LIGHTER_MARGIN_MODE_CROSS, + LIGHTER_MARGIN_MODE_ISOLATED, + LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX, + LIGHTER_USDC_ASSET_INDEX, + LIGHTER_DATA_INTEGRITY_PREFIX, + LIGHTER_MARGIN_METADATA_TTL_MS, + parseLighterStrictDecimal, + toLighterInteger, +} from '../constants/lighterConfig.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { PerpsControllerMessenger } from '../PerpsController.js'; +import { + convertKeysToCamelCase, + LighterClientService, +} from '../services/LighterClientService.js'; +import { LighterWalletService } from '../services/LighterWalletService.js'; +import { WebSocketConnectionState } from '../types/index.js'; +import type { + AccountState, + AssetRoute, + CandleData, + CandleStick, + CancelOrderParams, + CancelOrderResult, + ClosePositionParams, + DepositParams, + DisconnectResult, + EditOrderParams, + FeeCalculationParams, + FeeCalculationResult, + Funding, + GetAccountStateParams, + GetFundingParams, + GetHistoricalPortfolioParams, + GetMarketsParams, + GetOrderFillsParams, + GetOrdersParams, + GetOrFetchFillsParams, + GetPositionsParams, + GetSupportedPathsParams, + HistoricalPortfolioResult, + InitializeResult, + LiquidationPriceParams, + LiveDataConfig, + MaintenanceMarginParams, + MarginResult, + MarketInfo, + Order, + OrderFill, + OrderParams, + OrderResult, + PerpsMarketData, + PerpsPlatformDependencies, + PerpsProvider, + PerpsReadOptions, + Position, + RawLedgerUpdate, + ReadyToTradeResult, + SubscribeAccountParams, + SubscribeCandlesParams, + SubscribeOICapsParams, + OrderBookData, + OrderBookLevel, + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + PriceUpdate, + SubscribePositionsParams, + SubscribePricesParams, + ToggleTestnetResult, + UpdateMarginParams, + UpdatePositionTPSLParams, + UserHistoryItem, + WithdrawParams, + WithdrawResult, +} from '../types/index.js'; +import type { + LighterApiOrder, + LighterAuthConfig, + LighterTxLookupResponse, + LighterCreateAuthTokenResult, + LighterCreateClientResult, + LighterOrderBookMeta, + LighterSignChangePubKeyResult, + LighterSendTxResponse, + LighterSignerBridge, + LighterWasmCall, + LighterTxResult, + LighterWebSocketCtor, + LighterWebSocketLike, + LighterWsAccountMessage, + LighterCandle, + LighterWsCandleMessage, + LighterWsOrderBookMessage, + LighterWsTradesMessage, + LighterWsMarketStat, + LighterWsMarketStatsMessage, +} from '../types/lighter-types.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { + adaptAccountStateFromLighter, + adaptAccountStateFromLighterUserStats, + adaptFillFromLighterTrade, + adaptMarketDataFromLighter, + adaptMarketFromLighter, + adaptOrderFromLighter, + adaptPositionFromLighter, + adaptPriceUpdateFromLighter, + adaptPriceUpdateFromLighterWsStat, +} from '../utils/lighterAdapter.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Full-string decimal/scientific literal (optional sign and exponent). */ +/** + * Strict full-string numeric parsing shared with the adaptation boundary + * (see lighterConfig.parseLighterStrictDecimal): '10USD' or '0.001BTC' + * would prefix-parse into signed intent under bare parseFloat. + */ +const parseStrictDecimal = parseLighterStrictDecimal; + +/** + * Parse caller-supplied numeric intent, accepting only finite positive + * values from a strictly numeric string. + * + * @param value - Raw numeric string from params. + * @returns The parsed number, or null when malformed, non-finite or + * non-positive. + */ +const parseFinitePositive = (value: string): number | null => { + const parsed = parseStrictDecimal(value); + return parsed !== null && Number.isFinite(parsed) && parsed > 0 + ? parsed + : null; +}; + +/** + * Integerize a SIGNER-BOUND value: the scaled result must be a positive + * safe wire integer. The positive-intent policy lives here, not in the + * generic public converter. + * + * @param value - Human-units value. + * @param decimals - Market/asset decimals. + * @returns The positive wire integer. + */ +const toSignerWireInteger = (value: number, decimals: number): number => { + const scaled = toLighterInteger(value, decimals); + if (scaled < 1) { + throw new Error(`Value ${value} rounds to zero at ${decimals} decimals`); + } + return scaled; +}; + +/** + * Snap a base size onto the market's size grid exactly as wire + * integerization will (round to nearest step). Minimum-size checks must + * judge the SNAPPED size: a raw USD/price quotient one hair under the + * minimum still reaches the venue as the valid minimum step, and + * rejecting the raw quotient refuses orders the venue accepts. + * + * @param size - Raw base size (human units). + * @param supportedSizeDecimals - Market size decimals. + * @returns The grid-snapped size, or the input unchanged when it cannot + * be integerized (range overflow) — later wire conversion fails closed. + */ +const snapToLighterSizeGrid = ( + size: number, + supportedSizeDecimals: number, +): number => { + try { + return fromLighterInteger( + toLighterInteger(size, supportedSizeDecimals), + supportedSizeDecimals, + ); + } catch { + return size; + } +}; + +/** + * Map one venue candle onto the CandleStick contract, or null when any + * field is non-finite. WS/REST candle payloads are cast at the boundary, + * not validated — a malformed candle stringified blind reaches the chart + * as "undefined"/NaN, the same native-SVG crash class the bare + * order-book levels produced. + * + * @param candle - Raw venue candle. + * @returns The contract candle, or null when unmappable. + */ +const toFiniteCandle = (candle: LighterCandle): CandleStick | null => { + const time = Number(candle?.t); + const fields = [candle?.o, candle?.h, candle?.l, candle?.c, candle?.v].map( + Number, + ); + if ( + !Number.isFinite(time) || + fields.some((value) => !Number.isFinite(value)) + ) { + return null; + } + return { + time, + open: String(candle.o), + high: String(candle.h), + low: String(candle.l), + close: String(candle.c), + volume: String(candle.v), + }; +}; + +/** The pinned signer casts price fields to uint32. */ +const LIGHTER_MAX_WIRE_PRICE = 4_294_967_295; + +/** + * One recorded TP/SL venue mutation attempt. Each attempt carries its own + * nonce and outcome: a single flat flag cannot represent "create accepted, + * cancel #1 accepted, cancel #2 response-lost". + */ +type TpslCreateAttempt = { + kind: 'create'; + /** + * Unique per-journal attempt identity. Nonces CANNOT identify + * attempts: a proven-never-landed submission releases its nonce and a + * retry legitimately reuses it. + */ + attemptId: number; + /** See TpslCancelAttempt.terminalStatus. */ + terminalStatus?: number; + /** The venue nonce this submission attempted to consume. */ + nonce: number; + /** 'accepted' only after the venue's 200 was OBSERVED. */ + outcome: 'unknown' | 'accepted'; + /** Created client ids (nonempty). */ + clientIds: number[]; + /** The signed transaction hash (known BEFORE submission). */ + txHash: string; + /** + * Signed payload expiry (ms). After this instant (+ clock slack) the + * sequencer can no longer accept the payload, so a not-found hash is + * authoritatively never-landed. + */ + expiresAt: number; + /** What this create IS: the replacement, or a restore of the old set. */ + role: 'replacement' | 'restore'; + /** + * For role 'restore' only: the prior triggers (by original orderId in + * `priorTriggers`) this attempt restores, INDEX-ALIGNED with + * `clientIds` (a grouped OCO restore carries two legs). With multiple + * prior triggers and a crash mid-restore, recovery uses this to + * restore exactly the remaining intents — never duplicating or + * omitting one. + */ + priorOrderIds?: string[]; +}; + +type TpslCancelAttempt = { + kind: 'cancel'; + /** Unique per-journal attempt identity (see TpslCreateAttempt). */ + attemptId: number; + /** + * Venue-reported terminal status (4 failed / 5 rejected) recorded by + * reconciliation for an attempt that LANDED but did not mutate the + * books — makes it compactable. + */ + terminalStatus?: number; + nonce: number; + outcome: 'unknown' | 'accepted'; + /** The cancelled order id. */ + orderId: string; + txHash: string; + expiresAt: number; + /** Whether this cancels OLD protection or rolls back a failed leg. */ + role: 'stale' | 'rollback'; +}; + +/** A recorded TP/SL venue mutation attempt (discriminated by kind). */ +type TpslAttempt = TpslCreateAttempt | TpslCancelAttempt; + +/** + * The wire intent of a PRIOR trigger, persisted before it is cancelled so + * a crash-then-terminal-failure can still RESTORE the old protection. + */ +type TpslPriorTrigger = { + orderId: string; + side: 'buy' | 'sell'; + /** + * EXACT signer wire order type (2 stop-loss, 3 stop-loss-limit, + * 4 take-profit, 5 take-profit-limit). A restore must rebuild the + * prior order faithfully — never coerce a limit trigger to market. + */ + wireOrderType: 2 | 3 | 4 | 5; + /** Exact signer wire time-in-force (0 IOC, 1 GTT, 2 post-only). */ + wireTimeInForce: 0 | 1 | 2; + /** + * Venue-reported absolute order expiry (ms). Restores reuse it while + * still in the future; otherwise the signer's default sentinel. + */ + orderExpiry: number; + /** Execution price (market triggers) or exact limit price. */ + price: string; + /** User-facing trigger level. */ + triggerPrice: string; + remainingSize: string; +}; + +/** + * Identity of the position the journalled protection belonged to. A + * delayed restore must never attach old triggers to a DIFFERENT + * lifecycle (original closed, new same-symbol position opened). + */ +/** + * Durable nonce dispatch ledger document. Entries carry the OPERATION + * kind (tx type) and a human-readable intent so a dispatch whose + * response was lost but which is later PROVEN consumed can be surfaced + * as a recovered outcome — blocking blind retries of financial + * operations until explicitly acknowledged. + */ +type LighterRecoveredDispatch = { + /** Stable identity for selective acknowledgment. */ + recoveryId: string; + kind: number; + intent: string; + txHash: string | null; + /** + * Authoritative outcome: 'succeeded' (exact-hash lookup, venue status + * executed), 'failed' (exact-hash lookup, venue status failed/rejected + * — retry-safe, non-blocking), 'unknown' (only the nonce advance is + * proven, e.g. another device moved the nonce; the intent's own fate + * is NOT known and must never be reported as completed). + */ + outcome: 'succeeded' | 'failed' | 'unknown'; + /** What proved the outcome (e.g. 'tx-status:3', 'rest-advance'). */ + evidence: string; +}; + +type LighterNonceLedgerDoc = { + consumedFloor: number; + entries: { + nonce: number; + txHash: string | null; + expiresAt: number | null; + kind: number; + intent: string; + /** + * Operation that owns reconciliation of this dispatch (a TP/SL + * journal's operationId). Owned dispatches resolve through their + * own state machine and are NEVER quarantined into the generic + * recovered list — that would deadlock the machine behind an + * acknowledgment it cannot give. + */ + owner: string | null; + }[]; + recovered: LighterRecoveredDispatch[]; +}; + +/** + * Durable transition state: 'creating' means the old protection is still + * untouched (a failed replacement needs at most a rollback of surviving + * legs); 'cancelling' means old cancels are underway/done; 'manual' + * parks the obligation for explicit user re-establishment. + */ +type TpslJournalState = { + attempts: TpslAttempt[]; + recordedAt: number; + /** + * IMMUTABLE identity of the operation this journal records. Clears and + * updates are compare-and-swap on this id so a recovery pass holding a + * STALE snapshot can never erase a newer operation's journal. + */ + operationId: string; + /** When the OPERATION began (immutable; `recordedAt` moves per write). */ + createdAt: number; + /** + * DURABLE monotonic attempt-id allocator: compaction removes attempts, + * so deriving the next id from the surviving maximum could recycle an + * identity a removed attempt already used. + */ + nextAttemptId: number; + /** + * The durable OPERATION intent: a 'remove' journals only cancels and + * must NEVER be "recovered" by restoring the cancelled protection — + * that would silently undo an intentional removal. + */ + intent: 'replace' | 'remove'; + /** + * 'creating': old protection untouched (failure needs at most a + * rollback of surviving replacement legs). 'cancelling': old cancels + * underway/done. 'manual': the venue has NO atomic primitive that + * could prove a restore attaches to the same position lifecycle, so a + * fully-failed replacement after old cancels is NEVER auto-restored — + * the journal parks durably in this state, is surfaced to callers via + * `getPendingManualRecoveries`, and only an explicit NEW protection + * intent from the user resolves it. + */ + phase: 'creating' | 'cancelling' | 'manual'; + /** + * Whether the prior set was a venue-linked auto-cancel TP+SL pair + * (decided ONLY by the venue's own linkage fields). + */ + priorGrouping: 'oco' | 'independent'; + priorTriggers: TpslPriorTrigger[]; +}; + +/** + * DURABLE manual-recovery record, SEPARATE from the settlement journal: + * parking releases the journal slot (so a successor protection intent + * can run), while this warning survives until a successor intent + * SUCCEEDS — a failed successor must never erase the warning. + */ +type TpslManualRecovery = { + settlementKey: string; + symbol: string; + /** Human-readable cause of the parked state. */ + reason: string; + priorIntent: 'replace' | 'remove'; + /** Exact wire intents of the protection that was in place before. */ + priorTriggers: TpslPriorTrigger[]; + /** Venue order ids still on the books when the state was parked. */ + survivingOrderIds: string[]; + operationId: string; + recordedAt: number; +}; + +/** + * Clock slack added to a signed payload's ExpiredAt before a not-found + * transaction hash is declared never-landed. + */ +const LIGHTER_TX_EXPIRY_SLACK_MS = 30_000; + +/** + * Extract the signed txHash and ExpiredAt from a bridge signing result, + * failing CLOSED: without them the settlement journal cannot resolve a + * lost response authoritatively, so the mutation must not be submitted. + * + * @param signed - Bridge signing result. + * @param signed.txHash - Signed transaction hash (hex). + * @param signed.txInfo - Signed wire payload JSON (carries ExpiredAt). + * @returns The transaction hash and expiry (ms). + */ +const requireSignedTxIdentity = (signed: { + txHash?: string; + txInfo?: string; +}): { txHash: string; expiresAt: number } => { + const { txHash } = signed; + if ( + typeof txHash !== 'string' || + !/^(0x)?[0-9a-fA-F]{8,128}$/u.test(txHash) + ) { + throw new Error( + 'Lighter signing result carries no usable txHash; refusing to submit an unreconcilable mutation', + ); + } + let expiresAt: unknown; + try { + // eslint-disable-next-line @typescript-eslint/naming-convention + expiresAt = (JSON.parse(signed.txInfo ?? '') as { ExpiredAt?: unknown }) + .ExpiredAt; + } catch { + expiresAt = undefined; + } + if ( + typeof expiresAt !== 'number' || + !Number.isSafeInteger(expiresAt) || + expiresAt <= 0 + ) { + throw new Error( + 'Lighter signing result carries no usable ExpiredAt; refusing to submit an unreconcilable mutation', + ); + } + return { txHash, expiresAt }; +}; + +/** + * PROCESS-WIDE mutexes: venue write sections, the per-settlement journal + * state machine, and journal/index read-modify-writes are serialized + * across ALL provider instances in this runtime. Instance-local write + * chains cannot protect two live providers sharing one venue account or + * one disk cache. Completed tails are evicted to keep the map bounded. + */ +const processMutexTails = new Map>(); + +/** + * Run an operation atomically w.r.t. every other holder of the same key + * in this process. + * + * @param key - Key to serialize on. + * @param operation - The critical operation. + * @returns The operation's result. + */ +const withProcessMutex = async ( + key: string, + operation: () => Promise, +): Promise => { + const tail = processMutexTails.get(key) ?? Promise.resolve(); + const run = tail.then(operation, operation); + const settled = run.then( + () => undefined, + () => undefined, + ); + processMutexTails.set(key, settled); + settled + .then(() => { + // Evict when no newer holder queued behind us. + if (processMutexTails.get(key) === settled) { + processMutexTails.delete(key); + } + return undefined; + }) + .catch(() => undefined); + return await run; +}; + +/** + * Storage-scoped alias of the process mutex (kept for call-site clarity). + * + * @param key - Storage key to serialize on. + * @param operation - The read-modify-write. + * @returns The operation's result. + */ +const withStorageMutex = withProcessMutex; + +/** + * The WASM signer hosts ONE global client per bridge. These module maps + * track which venue identity (`network:account:apiKey`) currently owns + * each bridge's client, and give every bridge a process-unique mutex key + * so all sign-and-dispatch sections across ALL provider instances + * sharing a bridge are serialized and re-establish the correct client + * before signing. + */ +/** + * Cryptographic randomness with a bounded Math.random fallback for hosts + * without WebCrypto. Collision-resistant ids matter here: a recycled + * operation id could let a stale journal resolver clear a live journal. + * + * @param byteCount - Number of random bytes. + * @returns The random bytes. + */ +const randomBytes = (byteCount: number): Uint8Array => { + const bytes = new Uint8Array(byteCount); + const cryptoObj = (globalThis as { crypto?: Crypto }).crypto; + if (cryptoObj?.getRandomValues) { + cryptoObj.getRandomValues(bytes); + return bytes; + } + for (let index = 0; index < byteCount; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + return bytes; +}; + +/** + * Two independent 24-bit random values (client order id halves). + * + * @returns The [high, low] pair. + */ +const randomUint24Pair = (): [number, number] => { + const bytes = randomBytes(6); + return [ + bytes[0] * 65_536 + bytes[1] * 256 + bytes[2], + bytes[3] * 65_536 + bytes[4] * 256 + bytes[5], + ]; +}; + +/** + * Collision-resistant id suffix (80 bits, hex). + * + * @returns The suffix string. + */ +const randomIdSuffix = (): string => + Array.from(randomBytes(10), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + +const bridgeClientOwners = new WeakMap(); +const bridgeIds = new WeakMap(); +let nextBridgeId = 1; + +/** + * Process-unique mutex key for a bridge instance. + * + * @param bridge - The signer bridge. + * @returns The mutex key. + */ +const bridgeMutexKey = (bridge: object): string => { + let id = bridgeIds.get(bridge); + if (id === undefined) { + id = nextBridgeId; + nextBridgeId += 1; + bridgeIds.set(bridge, id); + } + return `lighterBridge:${id}`; +}; + +/** + * Parse a journal-pointer document, or null when the content is not a + * pointer (legacy inline journal or corrupt data — both handled by the + * caller's payload validation path). + * + * @param raw - Raw base-key content. + * @returns The pointer, or null. + */ +const parseTpslJournalPointer = ( + raw: string, +): { operationId: string } | null => { + try { + const parsed = JSON.parse(raw) as { + pointerVersion?: unknown; + operationId?: unknown; + }; + if ( + parsed.pointerVersion === 1 && + typeof parsed.operationId === 'string' && + parsed.operationId.length >= 1 && + parsed.operationId.length <= 64 + ) { + return { operationId: parsed.operationId }; + } + } catch { + // Not JSON: not a pointer. + } + return null; +}; + +/** + * Best-effort dispatch identity from a bridge signing result. The pinned + * WASM contract (web-wasm light_client.go) returns `{txHash, txInfo}` + * where txInfo is the marshaled wire payload — it carries Nonce and + * ExpiredAt but NEVER the hash. Non-throwing: ops whose signers omit a + * field dispatch with a partial identity (resolvable only by REST + * advance, never by expiry). + * + * @param signed - Bridge signing result. + * @param signed.txHash - Signed transaction hash from the RESULT. + * @param signed.txInfo - Marshaled wire payload. + * @returns The dispatch identity (null fields when unavailable). + */ +const extractDispatchIdentity = (signed: { + txHash?: unknown; + txInfo?: string; +}): { txHash: string | null; expiresAt: number | null } => { + const txHash = + typeof signed.txHash === 'string' && + /^(0x)?[0-9a-fA-F]{8,128}$/u.test(signed.txHash) + ? signed.txHash + : null; + let expiresAt: number | null = null; + try { + const wire = JSON.parse(signed.txInfo ?? '') as { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExpiredAt?: unknown; + }; + expiresAt = + typeof wire.ExpiredAt === 'number' && + Number.isSafeInteger(wire.ExpiredAt) && + wire.ExpiredAt > 0 + ? wire.ExpiredAt + : null; + } catch { + expiresAt = null; + } + return { txHash, expiresAt }; +}; + +/** + * Allocate the next unique attempt identity from the journal's DURABLE + * monotonic counter (compaction can therefore never recycle an id). + * + * @param journal - The journal being appended to. + * @returns The allocated attempt id. + */ +const nextAttemptIdFor = (journal: TpslJournalState): number => { + const allocated = journal.nextAttemptId; + journal.nextAttemptId += 1; + return allocated; +}; + +/** Delay between TP/SL settlement visibility polls. */ +const LIGHTER_TPSL_SETTLE_POLL_MS = 150; + +/** Bounded attempts for TP/SL settlement visibility. */ +const LIGHTER_TPSL_SETTLE_ATTEMPTS = 10; + +/** + * Integerize a signer-bound PRICE (order price / trigger price): the + * pinned lighter-go signer casts these to uint32 (web-wasm/main.go), so a + * safe-integer above 2^32-1 silently WRAPS (e.g. 429496729.7 at 1 decimal + * scales to 4,294,967,297 and wires as 1). + * + * @param value - Human-units price. + * @param decimals - Market price decimals. + * @returns The positive uint32 wire integer. + */ +const toSignerWirePriceInteger = (value: number, decimals: number): number => { + const scaled = toSignerWireInteger(value, decimals); + if (scaled > LIGHTER_MAX_WIRE_PRICE) { + throw new Error( + `Price ${value} exceeds Lighter's uint32 wire range at ${decimals} decimals`, + ); + } + return scaled; +}; + +/** + * Map a RAW venue trigger row to its durable prior wire intent, or null + * when it cannot be faithfully restored: unknown type/TIF/expiry, a + * MISSING trigger price (never substituted — that would change the + * user's protection semantics), a malformed/non-positive decimal, or a + * value that cannot be integerized onto the wire (range/sub-tick). The + * writer must never persist state the loader (or the signer) would + * later reject. A mutation that would cancel such a row must fail + * closed BEFORE any cancel. + * + * @param raw - Raw venue order row. + * @param market - Market integerization parameters. + * @param market.supportedSizeDecimals - Size integerization decimals. + * @param market.supportedPriceDecimals - Price integerization decimals. + * @returns The exact prior wire intent, or null when unmappable. + */ +const mapRawTriggerToPriorIntent = ( + raw: LighterApiOrder, + market: { + supportedSizeDecimals: number; + supportedPriceDecimals: number; + }, +): TpslPriorTrigger | null => { + const wireOrderTypeByVenueType: Record = { + 'stop-loss': 2, + 'stop-loss-limit': 3, + 'take-profit': 4, + 'take-profit-limit': 5, + }; + const wireTimeInForceByVenueTif: Record = { + 'immediate-or-cancel': 0, + 'good-till-time': 1, + 'post-only': 2, + }; + const wireOrderType = wireOrderTypeByVenueType[raw.type]; + const wireTimeInForce = wireTimeInForceByVenueTif[raw.timeInForce]; + if ( + wireOrderType === undefined || + wireTimeInForce === undefined || + !Number.isSafeInteger(raw.orderExpiry) || + raw.orderExpiry < -1 || + !/^\d{1,20}$/u.test(String(raw.orderIndex)) || + // A trigger's trigger price is REQUIRED verbatim. + typeof raw.triggerPrice !== 'string' + ) { + return null; + } + const price = parseStrictDecimal(raw.price); + const triggerPrice = parseStrictDecimal(raw.triggerPrice); + const remainingSize = parseStrictDecimal(raw.remainingBaseAmount); + if ( + price === null || + !Number.isFinite(price) || + price <= 0 || + triggerPrice === null || + !Number.isFinite(triggerPrice) || + triggerPrice <= 0 || + remainingSize === null || + !Number.isFinite(remainingSize) || + remainingSize <= 0 + ) { + return null; + } + // Wire PREFLIGHT: integerize exactly what a restore would sign. A + // range/sub-tick failure here refuses the whole mutation up front. + try { + toSignerWireInteger(remainingSize, market.supportedSizeDecimals); + toSignerWirePriceInteger(price, market.supportedPriceDecimals); + toSignerWirePriceInteger(triggerPrice, market.supportedPriceDecimals); + } catch { + return null; + } + return { + orderId: String(raw.orderIndex), + side: raw.isAsk ? 'sell' : 'buy', + wireOrderType, + wireTimeInForce, + orderExpiry: raw.orderExpiry, + price: raw.price, + triggerPrice: raw.triggerPrice, + remainingSize: raw.remainingBaseAmount, + }; +}; + +/** + * Validate caller leverage intent against what Lighter can represent. + * + * @param leverage - Requested leverage, if any. + * @returns The exact rejection message, or null when acceptable. + */ +const lighterLeverageError = (leverage: number | undefined): string | null => { + if (leverage === undefined) { + return null; + } + if (!Number.isFinite(leverage) || !(leverage > 0)) { + return `Invalid leverage ${leverage}: must be a positive number`; + } + // UpdateLeverage signs an initial margin fraction in hundredths of a + // percent. The derived IMF must itself be a positive safe integer within + // the venue's fraction range: huge finite leverage rounds it to zero, + // tiny finite leverage (Number.MIN_VALUE) overflows the division to + // Infinity, and sub-1x leverage exceeds a 100% margin fraction. + const imfHundredths = Math.round(10_000 / leverage); + if ( + !Number.isSafeInteger(imfHundredths) || + imfHundredths < 1 || + imfHundredths > 10_000 + ) { + return `Invalid leverage ${leverage}: outside Lighter's representable leverage range`; + } + return null; +}; + +/** + * Derive the protection/execution price a market order signs from its + * reference price — shared by placement and both validators so wire-range + * checks always inspect the exact value the signer receives. + * + * @param referencePrice - Fresh venue reference price. + * @param isBuy - Order side; buys protect above, sells below. + * @param slippageFraction - Slippage tolerance (validated < 1). + * @returns The slippage-adjusted execution price. + */ +const deriveLighterExecutionPrice = ( + referencePrice: number, + isBuy: boolean, + slippageFraction: number, +): number => + isBuy + ? referencePrice * (1 + slippageFraction) + : referencePrice * (1 - slippageFraction); + +const LIGHTER_NOT_SUPPORTED_ERROR = 'Lighter operation not yet supported'; +const LIGHTER_SIGNER_UNAVAILABLE_ERROR = 'Lighter signer bridge not configured'; +const LIGHTER_MAINNET_EXPLORER_URL = 'https://scan.lighter.xyz'; +const LIGHTER_TESTNET_EXPLORER_URL = 'https://testnet.zklighter.elliot.ai'; + +/** + * Empty account state returned when reads fail or no account exists. + */ +const EMPTY_ACCOUNT_STATE: AccountState = { + totalBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + providerId: 'lighter', +}; + +// ============================================================================ +// LighterProvider +// ============================================================================ + +/** + * Lighter provider implementation (POC). + */ +export class LighterProvider implements PerpsProvider { + readonly protocolId = 'lighter'; + + readonly #deps: PerpsPlatformDependencies; + + readonly #clientService: LighterClientService; + + readonly #walletService: LighterWalletService; + + readonly #messenger: PerpsControllerMessenger | null; + + readonly #signerBridge: LighterSignerBridge | null; + + readonly #isTestnet: boolean; + + readonly #apiKeyIndex: number; + + readonly #configuredAccountIndex: number | undefined; + + /** Markets cache keyed by symbol (freshness delegated to client service). */ + #marketsBySymbol: Map = new Map(); + + #marketsById: Map = new Map(); + + /** Resolved Lighter account index (after ensureAccount()). */ + #accountIndex: number | null = null; + + /** L1 address the current venue session (index/signer/auth) is bound to. */ + #boundAddress: string | null = null; + + /** + * Monotonic counter bumped on every session rebind. Async resolutions + * capture it before awaiting and refuse to cache results from a stale + * generation (an account-A lookup resolving after the switch to B). + */ + #sessionGeneration = 0; + + /** Active price-stream subscribers (REST polling fan-out). */ + readonly #priceSubscribers: Set = new Set(); + + #pricePollTimer: ReturnType | null = null; + + #priceWs: LighterWebSocketLike | null = null; + + /** Monotonic poll counter — surfaced in debug logs so e2e can assert liveness. */ + #pricePollCycle = 0; + + /** Injectable WebSocket constructor (null → REST polling fallback). */ + readonly #webSocketCtor: LighterWebSocketCtor | null; + + /** Channels the shared socket should be subscribed to (subscribe payloads). */ + readonly #wsWantedChannels: Map = new Map(); + + #wsKeepaliveTimer: ReturnType | null = null; + + /** Live WS connection state, mirrored to subscribed listeners. */ + #connectionState: WebSocketConnectionState = + WebSocketConnectionState.Disconnected; + + /** Consecutive reconnect attempts since the last successful open. */ + #wsReconnectAttempts = 0; + + readonly #connectionListeners = new Set< + (state: WebSocketConnectionState, reconnectionAttempt: number) => void + >(); + + readonly #setConnectionState = (state: WebSocketConnectionState): void => { + if (this.#connectionState === state) { + return; + } + this.#connectionState = state; + for (const listener of this.#connectionListeners) { + try { + listener(state, this.#wsReconnectAttempts); + } catch (error) { + this.#logSubscriberError('connection-state', error); + } + } + }; + + #wsReconnectTimer: ReturnType | null = null; + + /** Merged latest price per symbol, replayed to late price subscribers. */ + readonly #lastPriceBySymbol: Map = new Map(); + + /** Merged live position state from account_all_positions (keyed marketId). */ + readonly #wsPositions: Map = new Map(); + + /** Merged live open orders from account_all_orders (keyed orderId). */ + readonly #wsOrders: Map = new Map(); + + readonly #oiCapSubscribers: Set = new Set(); + + readonly #accountSubscribers: Set = new Set(); + + readonly #positionSubscribers: Set = new Set(); + + readonly #orderSubscribers: Set = new Set(); + + readonly #fillSubscribers: Set = new Set(); + + /** Order-book subscribers keyed by market id. */ + readonly #orderBookSubscribers: Map> = + new Map(); + + /** Live order-book level state per market (price → size). */ + readonly #orderBookState: Map< + number, + { bids: Map; asks: Map } + > = new Map(); + + /** Candle subscribers keyed by `marketId:resolution`. */ + readonly #candleSubscribers: Map> = + new Map(); + + /** Cached candle series per `marketId:resolution` (keyed by open time). */ + readonly #candleSeries: Map> = new Map(); + + /** Dedup for the async account-channel setup. */ + #accountChannelsPromise: Promise | null = null; + + /** Derived venue public key hex, set after the signer client is created. */ + #venuePublicKey: string | null = null; + + /** Signer session dedup. */ + #signerReadyPromise: Promise | null = null; + + /** Cached auth token (deadline-managed). */ + #authToken: { token: string; deadline: number } | null = null; + + constructor(options: { + isTestnet?: boolean; + platformDependencies: PerpsPlatformDependencies; + messenger?: PerpsControllerMessenger; + lighterAuthConfig?: LighterAuthConfig; + signerBridge?: LighterSignerBridge; + webSocketCtor?: LighterWebSocketCtor | null; + }) { + this.#deps = options.platformDependencies; + this.#isTestnet = options.isTestnet ?? true; + this.#messenger = options.messenger ?? null; + this.#signerBridge = options.signerBridge ?? null; + // Learn about bridge resets proactively (e.g. the mobile WebView + // reloading) instead of from the next failed trading call. + this.#signerBridge?.onReset?.(() => this.#invalidateSignerSession()); + const globalWebSocket = Reflect.get(globalThis, 'WebSocket') as + | LighterWebSocketCtor + | undefined; + const defaultWebSocketCtor = + typeof globalWebSocket === 'function' ? globalWebSocket : null; + this.#webSocketCtor = + options.webSocketCtor === undefined + ? defaultWebSocketCtor + : options.webSocketCtor; + this.#apiKeyIndex = + options.lighterAuthConfig?.apiKeyIndex ?? LIGHTER_DEFAULT_API_KEY_INDEX; + this.#configuredAccountIndex = options.lighterAuthConfig?.accountIndex; + + this.#clientService = new LighterClientService(this.#deps, { + isTestnet: this.#isTestnet, + }); + this.#walletService = new LighterWalletService(this.#deps, { + isTestnet: this.#isTestnet, + messenger: options.messenger, + personalSigner: options.lighterAuthConfig?.personalSigner, + l1Address: options.lighterAuthConfig?.l1Address, + }); + + this.#deps.debugLogger.log('[LighterProvider] Constructor complete', { + protocolId: this.protocolId, + isTestnet: this.#isTestnet, + hasMessenger: Boolean(this.#messenger), + hasSignerBridge: Boolean(this.#signerBridge), + apiKeyIndex: this.#apiKeyIndex, + }); + } + + // ============================================================================ + // Error Context Helper + // ============================================================================ + + readonly #getErrorContext = ( + method: string, + extra?: Record, + ): { + tags?: Record; + context?: { name: string; data: Record }; + } => { + return { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'LighterProvider', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: `LighterProvider.${method}`, + data: { + isTestnet: this.#isTestnet, + ...extra, + }, + }, + }; + }; + + // ============================================================================ + // Initialization & Lifecycle + // ============================================================================ + + async initialize(): Promise { + try { + const markets = await this.#clientService.getOrderBooks(true); + this.#marketsBySymbol = new Map( + markets.map((market) => [market.symbol, market]), + ); + this.#marketsById = new Map( + markets.map((market) => [market.marketId, market]), + ); + this.#deps.debugLogger.log('[LighterProvider] Initialized', { + markets: markets.length, + }); + return { success: true }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.initialize', + ); + this.#deps.debugLogger.log('[LighterProvider] initialize failed', { + error: String(wrappedError), + ...this.#getErrorContext('initialize'), + }); + return { success: false, error: wrappedError.message }; + } + } + + async disconnect(): Promise { + // A disconnect (provider switch, shutdown) invalidates the whole + // session: an in-flight write paused inside the lock must fail its + // fences instead of submitting after the provider was torn down. + this.#invalidateSessionState(); + this.#teardownStream(); + this.#priceSubscribers.clear(); + this.#oiCapSubscribers.clear(); + this.#accountSubscribers.clear(); + this.#positionSubscribers.clear(); + this.#orderSubscribers.clear(); + this.#fillSubscribers.clear(); + this.#orderBookSubscribers.clear(); + this.#candleSubscribers.clear(); + return { success: true }; + } + + async ping(_timeoutMs?: number): Promise { + await this.#clientService.getOrderBooks(); + } + + async toggleTestnet(): Promise { + // Network is fixed at construction, mirroring MYXProvider. + return { + success: false, + isTestnet: this.#isTestnet, + error: 'Lighter network is fixed at construction', + }; + } + + async isReadyToTrade(): Promise { + try { + if (!this.#signerBridge) { + return { + ready: false, + error: LIGHTER_SIGNER_UNAVAILABLE_ERROR, + walletConnected: false, + networkSupported: true, + }; + } + await this.#ensureSignerReady(); + return { + ready: true, + walletConnected: true, + networkSupported: true, + authenticatedAddress: this.#walletService.getUserAddress(), + }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.isReadyToTrade', + ); + return { + ready: false, + error: wrappedError.message, + walletConnected: false, + networkSupported: true, + }; + } + } + + // ============================================================================ + // Signer session + // ============================================================================ + + /** + * The RAW bridge instance — the STABLE identity object for the + * process-wide ownership map and mutex key. The `#getSignerBridge` + * wrapper below is a fresh object per call and must NEVER key either. + * + * @returns The raw signer bridge. + */ + readonly #rawSignerBridge = (): LighterSignerBridge => { + if (!this.#signerBridge) { + throw new Error(LIGHTER_SIGNER_UNAVAILABLE_ERROR); + } + return this.#signerBridge; + }; + + readonly #getSignerBridge = (): LighterSignerBridge => { + if (!this.#signerBridge) { + throw new Error(LIGHTER_SIGNER_UNAVAILABLE_ERROR); + } + const bridge = this.#signerBridge; + // The WASM client lives inside the bridge host (mobile: a WebView that + // can reload and lose it). When the venue signer reports a missing + // client, drop the cached session so the next call re-runs setup + // instead of failing forever against a resolved-but-dead session. + return { + execute: async (call: LighterWasmCall): Promise => { + const lostClientPattern = + /client is not created|WebView reloaded|signer not ready|executor not connected|timed out/iu; + try { + const result = await bridge.execute(call); + const error = (result as { error?: string } | null)?.error; + if (error && lostClientPattern.test(error)) { + this.#invalidateSignerSession(); + } + return result; + } catch (error) { + if (lostClientPattern.test(String(error))) { + this.#invalidateSignerSession(); + } + throw error; + } + }, + }; + }; + + readonly #invalidateSignerSession = (): void => { + // Advancing the generation aborts any in-flight setup/write that was + // started against the now-dead WASM client. + this.#sessionGeneration += 1; + this.#signerReadyPromise = null; + this.#authToken = null; + this.#clearBridgeOwnership(); + this.#deps.debugLogger.log( + '[LighterProvider] signer session invalidated (client lost); will re-setup on next call', + ); + }; + + /** + * Drop ALL bridge-client ownership material for this provider: the + * identity, the recreate params, and — when WE are the recorded owner + * — the process-wide ownership entry, so a dead/rebound session can + * never be mistaken for the live owner of the singleton client. + */ + readonly #clearBridgeOwnership = (): void => { + if ( + this.#signerBridge && + this.#signerIdentity !== null && + bridgeClientOwners.get(this.#signerBridge) === this.#signerIdentity + ) { + bridgeClientOwners.delete(this.#signerBridge); + } + this.#signerIdentity = null; + this.#signerRecreateParams = null; + }; + + /** + * Bind the venue session to the currently selected wallet address. + * + * Everything downstream — account index, venue signer, auth token, and + * the account-scoped stream channels — is derived from one L1 address. + * When the wallet switches accounts, all of it must be dropped + * atomically or reads/writes would keep targeting the previous account. + */ + readonly #ensureSessionBinding = (): void => { + let address: string; + try { + address = this.#walletService.getUserAddress().toLowerCase(); + } catch { + if (this.#boundAddress !== null) { + // All accounts deselected while a session existed: invalidate so + // nothing in flight can still act for the old account. + this.#invalidateSessionState(); + this.#teardownStream(); + } + // The caller's own address resolution surfaces the error. + return; + } + if (this.#boundAddress === address) { + return; + } + const hadPreviousBinding = this.#boundAddress !== null; + this.#boundAddress = address; + if (!hadPreviousBinding) { + // First binding (or first after a deselection): surviving + // subscribers may be sitting on an empty channel set. + if (this.#hasAnySubscriber() && this.#wsWantedChannels.size === 0) { + this.#rebuildStreamForSubscribers(); + } + return; + } + // Invalidate in-flight async resolutions started under the previous + // binding: they compare this generation after their awaits and retry + // instead of caching results for the wrong account. + this.#sessionGeneration += 1; + this.#accountIndex = null; + this.#signerReadyPromise = null; + this.#authToken = null; + // #tpslUnsettled is NOT cleared: entries are keyed by + // address+accountIndex+symbol, so B never consumes A's pending ids and + // switching back to A retains its reconciliation obligation. + this.#teardownStream(); + this.#rebuildStreamForSubscribers(); + this.#deps.debugLogger.log( + '[LighterProvider] session rebound to new wallet account', + ); + }; + + /** + * Re-request every channel the current subscriber registries imply. + * + * #teardownStream clears the wanted-channel intents; without this, + * subscribers that outlive an account switch would sit on a fresh socket + * subscribed to nothing. + */ + readonly #rebuildStreamForSubscribers = (): void => { + if (!this.#hasAnySubscriber()) { + return; + } + if (this.#priceSubscribers.size > 0 || this.#oiCapSubscribers.size > 0) { + this.#requestChannel('market_stats/all'); + } + for (const marketId of this.#orderBookSubscribers.keys()) { + this.#requestChannel(`order_book/${marketId}`); + } + for (const seriesKey of this.#candleSubscribers.keys()) { + // Series keys are `${marketId}:${resolution}`; the channel form uses + // slashes. The teardown cleared the series state, and the message + // router drops updates for unknown series — recreate an empty series + // so live candles flow again (history reseeds on the next fetch). + this.#candleSeries.set(seriesKey, new Map()); + this.#requestChannel(`candle/${seriesKey.replace(':', '/')}`); + } + if ( + this.#accountSubscribers.size > 0 || + this.#positionSubscribers.size > 0 || + this.#orderSubscribers.size > 0 || + this.#fillSubscribers.size > 0 + ) { + // The promise was cleared by the teardown, so this re-resolves the + // account channels against the newly bound address. + this.#ensureAccountChannels(); + } + this.#ensureStream(); + }; + + /** + * Resolve the Lighter account index for the current user. + * + * @returns The account index. + */ + readonly #ensureAccountIndex = async (): Promise => { + this.#ensureSessionBinding(); + // Account-bound work requires a bound wallet — including the cached + // fast path and the configured-index path. + this.#assertSession(this.#sessionGeneration); + if (this.#accountIndex !== null) { + return this.#accountIndex; + } + if (this.#configuredAccountIndex !== undefined) { + // A configured index must be a Standard (0-fee) account AND owned by + // the bound wallet address: a signed-in wallet must never read or + // trade another owner's account just because an env var names it. + const generationAtCheck = this.#sessionGeneration; + const configured = await this.#clientService.getAccountByIndex( + this.#configuredAccountIndex, + ); + this.#ensureSessionBinding(); + if (generationAtCheck !== this.#sessionGeneration) { + return await this.#ensureAccountIndex(); + } + const configuredAccount = configured.accounts[0]; + this.#assertStandardAccount(configuredAccount?.accountType); + const ownerAddress = configuredAccount?.l1Address?.toLowerCase(); + if (!ownerAddress || ownerAddress !== this.#boundAddress) { + // Capability-prefixed so read catches SURFACE it instead of + // degrading a cross-owner misconfiguration into empty state. + throw new Error( + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} configured account ${this.#configuredAccountIndex} is not owned by the selected wallet address`, + ); + } + this.#accountIndex = this.#configuredAccountIndex; + return this.#accountIndex; + } + const generation = this.#sessionGeneration; + const address = this.#walletService.getUserAddress(); + const response = await this.#clientService.getAccountsByL1Address(address); + // Re-run the binding so an EXTERNAL switch nothing else observed also + // advances the generation, then compare: caching after any switch + // would poison the new session with the old account. Retry instead. + this.#ensureSessionBinding(); + if (generation !== this.#sessionGeneration) { + return await this.#ensureAccountIndex(); + } + if (!response.subAccounts?.length) { + throw new Error( + `No Lighter account exists for ${address}; fund it via the bridge (or the testnet faucet) first`, + ); + } + const master = response.subAccounts.reduce((min, account) => + account.index < min.index ? account : min, + ); + this.#assertStandardAccount(master.accountType); + this.#accountIndex = master.index; + return this.#accountIndex; + }; + + /** + * Capability gate: only Standard (0-fee) Lighter accounts are supported. + * Premium accounts pay nonzero maker/taker fees whose wire unit is + * unverified — serving their history would show financially false zero + * fees, so the whole account-bound surface refuses instead. + * + * @param accountType - Venue account type code (0 = Standard). + */ + readonly #assertStandardAccount = (accountType: number | undefined): void => { + // Fail closed: only a PROVEN Standard (type 0) account passes. A + // missing account/type is not evidence of Standard. + if (accountType === undefined) { + throw new Error( + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} account type could not be verified (account not found); refusing to assume a Standard account`, + ); + } + if (accountType !== 0) { + throw new Error( + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} Premium accounts are not supported yet: their fee semantics are unverified and history would be financially incorrect`, + ); + } + }; + + /** + * Whether an error is an explicit capability gate (unsupported account + * tier / unverified fee semantics). These must SURFACE to callers — + * swallowing them into empty state would present false data. + * + * @param error - Caught error. + * @returns True for capability-gate errors. + */ + readonly #isUnsupportedCapabilityError = (error: unknown): boolean => + String(error).includes(LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX); + + readonly #isDataIntegrityError = (error: unknown): boolean => + String(error).includes(LIGHTER_DATA_INTEGRITY_PREFIX); + + /** + * TP/SL settlement expectations that timed out before becoming visible + * on the venue's REST book, per symbol. While an entry exists, further + * TP/SL mutations for that symbol must reconcile it first. + */ + readonly #tpslUnsettled = new Map(); + + /** + * Session-global nonce reservation per `accountIndex:apiKeyIndex`. + * Advanced at submission DISPATCH; consulted by every write-lock + * section so a lagging nextNonce endpoint can never reissue a nonce an + * earlier (possibly response-lost) submission may have consumed. A + * reconciliation that PROVES a submission never landed (exact-hash + * not-found after signed expiry) releases the reservation again. + */ + readonly #nonceReservations = new Map(); + + /** Monotonic source for journal operation ids within this session. */ + #tpslOperationCounter = 0; + + /** This provider's bridge-client ownership identity (set at setup). */ + #signerIdentity: string | null = null; + + /** + * Parameters to re-create OUR venue client on the shared bridge. The + * wallet-derived seed is NEVER retained here — it is re-derived under + * the bridge lease each time re-establishment is needed. + */ + #signerRecreateParams: { + chainId: number; + accountIndex: number; + } | null = null; + + /** + * Durable dispatch-ledger key: every nonce-consuming submission is + * recorded here BEFORE dispatch so a restart can never reissue a nonce + * whose outcome is unknown, and a proven never-landed dispatch can + * release its nonce for the venue to consume. + * + * @param accountIndex - Venue account index. + * @returns The disk-cache key. + */ + readonly #nonceLedgerKey = (accountIndex: number): string => + `lighterNonceLedger:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}:${this.#apiKeyIndex}`; + + /** + * Read and strictly validate the durable dispatch ledger. Corruption + * fails CLOSED (writes stay blocked) — guessing at nonce state could + * duplicate or wedge submissions. + * + * @param accountIndex - Venue account index. + * @returns The ledger document (consumed-nonce watermark + unresolved + * dispatch entries). + */ + readonly #readNonceLedger = async ( + accountIndex: number, + ): Promise => { + let raw: string | null; + try { + raw = await this.#deps.diskCache.getItem( + this.#nonceLedgerKey(accountIndex), + ); + } catch (error) { + throw new Error( + `Lighter nonce ledger read failed; refusing writes: ${ensureError(error, 'LighterProvider.#readNonceLedger').message}`, + ); + } + if (raw === null) { + return { consumedFloor: 0, entries: [], recovered: [] }; + } + try { + const parsed = JSON.parse(raw) as { + version?: unknown; + consumedFloor?: unknown; + entries?: unknown; + recovered?: unknown; + }; + // EXPLICIT schema evolution: earlier documents (v1 without the + // consumed watermark, v2 without operation kind/intent) migrate in + // place — calling valid outstanding dispatch state corrupt would + // block writes permanently. + const consumedFloor = + parsed.version === 1 && parsed.consumedFloor === undefined + ? 0 + : parsed.consumedFloor; + if ( + (parsed.version === 1 || + parsed.version === 2 || + parsed.version === 3 || + parsed.version === 4) && + typeof consumedFloor === 'number' && + Number.isSafeInteger(consumedFloor) && + consumedFloor >= 0 && + Array.isArray(parsed.entries) && + parsed.entries.length <= 16 && + parsed.entries.every((entry) => { + if (typeof entry !== 'object' || entry === null) { + return false; + } + const candidate = entry as Record; + return ( + typeof candidate.nonce === 'number' && + Number.isSafeInteger(candidate.nonce) && + candidate.nonce >= 0 && + (candidate.txHash === null || + typeof candidate.txHash === 'string') && + (candidate.expiresAt === null || + (typeof candidate.expiresAt === 'number' && + Number.isSafeInteger(candidate.expiresAt) && + candidate.expiresAt > 0)) + ); + }) + ) { + // STRICT bounded validation of the recovered list; malformed + // rows are dropped (they are observability records, never nonce + // state), and the list is capped. + const recoveredRaw = Array.isArray(parsed.recovered) + ? parsed.recovered + : []; + const recovered = recoveredRaw + .filter((row): row is LighterRecoveredDispatch => { + if (typeof row !== 'object' || row === null) { + return false; + } + const candidate = row as Record; + return ( + typeof candidate.recoveryId === 'string' && + candidate.recoveryId.length >= 1 && + candidate.recoveryId.length <= 160 && + typeof candidate.kind === 'number' && + typeof candidate.intent === 'string' && + candidate.intent.length <= 200 && + (candidate.txHash === null || + typeof candidate.txHash === 'string') && + (candidate.outcome === 'succeeded' || + candidate.outcome === 'failed' || + candidate.outcome === 'unknown') && + typeof candidate.evidence === 'string' + ); + }) + .slice(0, 32); + return { + consumedFloor, + entries: ( + parsed.entries as { + nonce: number; + txHash: string | null; + expiresAt: number | null; + kind?: number; + intent?: string; + owner?: string | null; + }[] + ).map((entry) => ({ + ...entry, + // v1-v3 migration: kind/intent/owner unknown. + kind: typeof entry.kind === 'number' ? entry.kind : -1, + intent: typeof entry.intent === 'string' ? entry.intent : 'unknown', + owner: typeof entry.owner === 'string' ? entry.owner : null, + })), + recovered, + }; + } + } catch { + // fall through to fail closed + } + throw new Error( + 'Lighter nonce dispatch ledger is corrupt; refusing further writes until it is resolved', + ); + }; + + /** + * Persist the dispatch ledger document. + * + * @param accountIndex - Venue account index. + * @param doc - The ledger document. + * @param doc.consumedFloor - Highest proven-consumed nonce + 1. + * @param doc.entries - Unresolved dispatch entries. + */ + readonly #writeNonceLedger = async ( + accountIndex: number, + doc: LighterNonceLedgerDoc, + ): Promise => { + await this.#deps.diskCache.setItem( + this.#nonceLedgerKey(accountIndex), + JSON.stringify({ version: 4, ...doc }), + ); + }; + + /** + * Resolve one dispatch entry as CONSUMED: remove it and advance the + * durable consumed-nonce watermark so no later (stale) reconciliation + * can ever release the nonce back. + * + * @param accountIndex - Venue account index. + * @param entry - The consumed entry. + * @param entry.nonce - The dispatched nonce. + * @param entry.txHash - The dispatched tx hash (or null). + */ + /** + * EVERY ledger read-modify-write (append, resolve, consumed-resolve, + * selective acknowledgment) serializes on this ONE process-wide mutex + * per account+slot document. The venue write mutex alone cannot + * protect the document: `acknowledgeRecoveredDispatch` legitimately + * runs OUTSIDE it, and an unserialized ack RMW could overwrite a + * concurrent append with a stale doc — silently erasing an unresolved + * dispatch entry. Lock order is always venueWrite → bridge → ledger + * (the ack path takes only the ledger mutex), so no cycle exists. + * + * @param accountIndex - Venue account index. + * @param operation - The ledger RMW critical section. + * @returns The operation's result. + */ + readonly #withLedgerLock = async ( + accountIndex: number, + operation: () => Promise, + ): Promise => + await withProcessMutex(this.#nonceLedgerKey(accountIndex), operation); + + /** + * ATOMIC post-dispatch entry transition, decided by the session fence + * BEFORE any ledger mutation: fence passed → the entry is consumed and + * removed (watermark advances); fence failed → the entry converts to a + * durable recovered SUCCEEDED outcome (the venue mutation is committed + * and a later retry under the original account would double the + * financial intent). Both shapes land in ONE write under the ledger + * lock — if that write fails, the ORIGINAL unresolved entry remains + * the durable record and every retry stays blocked. The entry is never + * consumed first and quarantined second. TP/SL-journal-owned entries + * are consumed without quarantine in both cases (their machine + * reconciles the intent by exact hash). + * + * @param accountIndex - Venue account index of the ORIGINAL session. + * @param entry - The dispatched (accepted) ledger entry. + * @param fenceFailed - Whether the post-send session fence rejected. + * @returns Resolves when the transition is durably committed. + */ + readonly #resolveEntryPostDispatch = async ( + accountIndex: number, + entry: LighterNonceLedgerDoc['entries'][number], + fenceFailed: boolean, + ): Promise => + await this.#withLedgerLock(accountIndex, async () => { + const doc = await this.#readNonceLedger(accountIndex); + const at = doc.entries.findIndex( + (candidate) => + candidate.nonce === entry.nonce && candidate.txHash === entry.txHash, + ); + if (at >= 0) { + doc.entries.splice(at, 1); + } + doc.consumedFloor = Math.max(doc.consumedFloor, entry.nonce + 1); + if (fenceFailed && entry.owner === null) { + const recoveryId = `${String(entry.nonce)}:${entry.txHash ?? 'nohash'}`; + if ( + !doc.recovered.some((outcome) => outcome.recoveryId === recoveryId) + ) { + doc.recovered = [ + ...doc.recovered, + { + recoveryId, + kind: entry.kind, + intent: entry.intent, + txHash: entry.txHash, + outcome: 'succeeded' as const, + evidence: 'post-dispatch-session-cancelled', + }, + ].slice(0, 32); + } + } + await this.#writeNonceLedger(accountIndex, doc); + }); + + /** + * Resolve every unresolved dispatch before a write section may issue + * nonces. Consumption is proven by REST-nonce advance or an exact tx + * lookup verifying the FULL identity (hash + account + api-key slot + + * nonce + a numeric venue status); never-landed is proven ONLY by + * venue-confirmed absence of the exact HASH after the signed validity + * elapsed. A hashless dispatch can never be proven absent — it stays + * blocking until the venue advances. Ambiguity blocks the write. + * Runs under the account+slot ledger lock. + * + * @param accountIndex - Venue account index. + * @returns Resolves when every prior dispatch is accounted for. + */ + readonly #resolveNonceLedger = async (accountIndex: number): Promise => + await this.#withLedgerLock(accountIndex, async () => + this.#resolveNonceLedgerLocked(accountIndex), + ); + + /** + * @param accountIndex - Venue account index. + * @returns Resolves when the pass completes. + */ + readonly #resolveNonceLedgerLocked = async ( + accountIndex: number, + ): Promise => { + const doc = await this.#readNonceLedger(accountIndex); + const reservationKey = `${accountIndex}:${this.#apiKeyIndex}`; + // The durable consumed watermark always seeds the memory floor. + if (doc.consumedFloor > 0) { + const floor = this.#nonceReservations.get(reservationKey) ?? 0; + this.#nonceReservations.set( + reservationKey, + Math.max(floor, doc.consumedFloor), + ); + } + // QUARANTINE CHECK FIRST: unacknowledged recovered outcomes block + // EVERY retry, including retries arriving when no unresolved + // entries remain — an early empty-entries return here would let the + // second retry sail past the quarantine. + const throwIfQuarantined = (): void => { + const blocking = doc.recovered.filter( + (outcome) => outcome.outcome !== 'failed', + ); + if (blocking.length > 0) { + throw new Error( + `A previous Lighter submission believed failed actually ${blocking.some((outcome) => outcome.outcome === 'succeeded') ? 'completed' : 'landed with an UNKNOWN outcome'} (${blocking + .map((outcome) => outcome.intent) + .join( + ', ', + )}); refresh state and call acknowledgeRecoveredDispatch before retrying`, + ); + } + }; + throwIfQuarantined(); + if (doc.entries.length === 0) { + return; + } + const quarantine = ( + entry: LighterNonceLedgerDoc['entries'][number], + outcome: LighterRecoveredDispatch['outcome'], + evidence: string, + ): void => { + // TP/SL-journal-OWNED dispatches resolve through their own state + // machine (journal attempts + exact-hash reconciliation) — they + // are never parked behind the generic acknowledgment. + if (entry.owner !== null) { + return; + } + doc.recovered.push({ + recoveryId: `${String(entry.nonce)}:${entry.txHash ?? 'nohash'}`, + kind: entry.kind, + intent: entry.intent, + txHash: entry.txHash, + outcome, + evidence, + }); + }; + const nonceResponse = await this.#clientService.getNextNonce( + accountIndex, + this.#apiKeyIndex, + ); + const remaining: typeof doc.entries = []; + for (const entry of doc.entries) { + if (entry.txHash === null && nonceResponse.nonce > entry.nonce) { + // Only the nonce ADVANCE is proven (possibly by another device): + // the intent's own fate is UNKNOWN — never reported completed. + doc.consumedFloor = Math.max(doc.consumedFloor, entry.nonce + 1); + const floor = this.#nonceReservations.get(reservationKey) ?? 0; + this.#nonceReservations.set( + reservationKey, + Math.max(floor, entry.nonce + 1), + ); + quarantine(entry, 'unknown', 'rest-advance'); + continue; + } + if (entry.txHash !== null) { + let lookedUp: LighterTxLookupResponse | null; + try { + lookedUp = await this.#clientService.getTx(entry.txHash); + } catch { + // Lookup failure is AMBIGUITY, never evidence either way: the + // entry stays and the write remains blocked. + remaining.push(entry); + continue; + } + if (lookedUp !== null) { + const matchesIdentity = + typeof lookedUp.hash === 'string' && + lookedUp.hash.toLowerCase().replace(/^0x/u, '') === + entry.txHash.toLowerCase().replace(/^0x/u, '') && + lookedUp.accountIndex === accountIndex && + lookedUp.apiKeyIndex === this.#apiKeyIndex && + lookedUp.nonce === entry.nonce && + typeof lookedUp.status === 'number'; + if (matchesIdentity) { + doc.consumedFloor = Math.max(doc.consumedFloor, entry.nonce + 1); + const floor = this.#nonceReservations.get(reservationKey) ?? 0; + this.#nonceReservations.set( + reservationKey, + Math.max(floor, entry.nonce + 1), + ); + // The EXACT tx status decides the intent's fate: executed → + // succeeded (blocking until acknowledged); failed/rejected → + // retry-safe FAILURE (recorded, non-blocking); anything else + // still pending → keep blocking as unresolved. + if (lookedUp.status === 4 || lookedUp.status === 5) { + quarantine( + entry, + 'failed', + `tx-status:${String(lookedUp.status)}`, + ); + } else if (lookedUp.status === 3) { + quarantine(entry, 'succeeded', 'tx-status:3'); + } else { + quarantine( + entry, + 'unknown', + `tx-status:${String(lookedUp.status ?? -1)}`, + ); + } + continue; + } + // A DIFFERENT payload under this hash: ambiguity, fail closed. + remaining.push(entry); + continue; + } + if (nonceResponse.nonce > entry.nonce) { + // The venue moved past this nonce while OUR exact hash is + // absent: another dispatch (e.g. a second device) consumed it. + // Our payload can never land now — retry-safe never-landed, + // no quarantine; the floor advances with the venue. + doc.consumedFloor = Math.max(doc.consumedFloor, entry.nonce + 1); + const floor = this.#nonceReservations.get(reservationKey) ?? 0; + this.#nonceReservations.set( + reservationKey, + Math.max(floor, entry.nonce + 1), + ); + continue; + } + if ( + entry.expiresAt !== null && + Date.now() > entry.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + // Venue-confirmed absent after the signed validity: PROVEN + // never landed — the venue still expects this nonce (unless a + // later dispatch already consumed it: consumedFloor guards). + if (entry.nonce >= doc.consumedFloor) { + this.#releaseNonceReservation(accountIndex, entry.nonce); + } + continue; + } + } + // Hashless, or hash present but unexpired-and-absent: ambiguous. + remaining.push(entry); + } + await this.#writeNonceLedger(accountIndex, { + consumedFloor: doc.consumedFloor, + entries: remaining, + recovered: doc.recovered, + }); + if (remaining.length > 0) { + throw new Error( + 'A previous Lighter submission has an unresolved outcome; writes are blocked until it can be proven consumed or never-landed', + ); + } + // RECOVERED-OUTCOME quarantine: succeeded/unknown outcomes block + // every subsequent write until selectively acknowledged (a blind + // retry could double the financial intent). FAILED outcomes are + // retry-safe and never block. + throwIfQuarantined(); + }; + + /** + * List TP/SL obligations parked in DURABLE manual-recovery state: the + * venue removed (or rejected) protection in a way that cannot be + * safely re-established automatically. Surfaced to callers/UI; each + * entry resolves when the user issues a new explicit TP/SL update for + * the symbol. + * + * @returns Parked manual-recovery entries. + */ + async getPendingManualRecoveries(): Promise< + { + symbol: string; + settlementKey: string; + recordedAt: number; + reason: string; + priorIntent: 'replace' | 'remove'; + survivingOrderIds: string[]; + actionNeeded: string; + }[] + > { + this.#ensureSessionBinding(); + const accountIndex = await this.#ensureAccountIndex(); + // ONLY the bound identity's parked warnings: another account's (or + // api key's) protection state must never leak into this session. + const identityPrefix = `${this.#boundAddress ?? 'unbound'}:${accountIndex}:${this.#apiKeyIndex}:`; + const pending: { + symbol: string; + settlementKey: string; + recordedAt: number; + reason: string; + priorIntent: 'replace' | 'remove'; + survivingOrderIds: string[]; + actionNeeded: string; + }[] = []; + const actionNeeded = + 'Review the position and submit a new explicit TP/SL update for this symbol to re-establish protection'; + // Storage errors PROPAGATE — a corrupt index degrading to "nothing + // pending" would hide a naked position. + const manualIndex = await this.#readTpslManualIndex(); + for (const settlementKey of manualIndex) { + if (!settlementKey.startsWith(identityPrefix)) { + continue; + } + const doc = await this.#loadTpslManualRecovery(settlementKey); + if (doc) { + pending.push({ + symbol: doc.symbol, + settlementKey, + recordedAt: doc.recordedAt, + reason: doc.reason, + priorIntent: doc.priorIntent, + survivingOrderIds: doc.survivingOrderIds, + actionNeeded, + }); + } + } + // Legacy: journals parked 'manual' in the journal slot by an earlier + // version (migrated to the doc on the next settle pass). + const journalIndex = await this.#readTpslJournalIndex(); + for (const settlementKey of journalIndex) { + if ( + !settlementKey.startsWith(identityPrefix) || + pending.some((entry) => entry.settlementKey === settlementKey) + ) { + continue; + } + const journal = await this.#loadTpslJournal(settlementKey); + if (journal?.phase === 'manual') { + pending.push({ + symbol: settlementKey.split(':').at(-1) ?? settlementKey, + settlementKey, + recordedAt: journal.recordedAt, + reason: + 'TP/SL protection could not be safely re-established automatically (parked by an earlier session)', + priorIntent: journal.intent, + survivingOrderIds: [], + actionNeeded, + }); + } + } + return pending; + } + + /** + * READ-ONLY view of the durable recovered-dispatch outcomes + * (previously ambiguous submissions later resolved). Never mutates the + * ledger — acknowledgment is a separate, per-outcome call so a crash + * between reading and acting can never silently drop an outcome. + * + * @returns The pending recovered-dispatch outcomes. + */ + async getRecoveredDispatches(): Promise { + this.#ensureSessionBinding(); + const accountIndex = await this.#ensureAccountIndex(); + const doc = await this.#readNonceLedger(accountIndex); + return doc.recovered.map((outcome) => ({ ...outcome })); + } + + /** + * Acknowledge ONE recovered-dispatch outcome by its stable id, after + * the caller has refreshed venue state and decided how to proceed. + * Runs under the ledger mutex and re-verifies the session generation + * inside it so an account switch mid-acknowledge can never clear + * another account's outcome. + * + * @param recoveryId - Stable id from {@link getRecoveredDispatches}. + */ + async acknowledgeRecoveredDispatch(recoveryId: string): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + await withProcessMutex(this.#nonceLedgerKey(accountIndex), async () => { + this.#ensureSessionBinding(); + this.#assertSession(generation); + const doc = await this.#readNonceLedger(accountIndex); + const remaining = doc.recovered.filter( + (outcome) => outcome.recoveryId !== recoveryId, + ); + if (remaining.length === doc.recovered.length) { + throw new Error( + `No pending recovered Lighter dispatch matches id ${recoveryId}; refresh and re-read before acknowledging`, + ); + } + await this.#writeNonceLedger(accountIndex, { + consumedFloor: doc.consumedFloor, + entries: doc.entries, + recovered: remaining, + }); + }); + } + + /** + * Release a nonce reservation for a PROVEN never-landed dispatch — + * refused when the durable consumed watermark shows a later dispatch + * (e.g. a retry) already consumed the nonce. + * + * @param accountIndex - Venue account index. + * @param nonce - The proven-unconsumed nonce. + */ + readonly #releaseNonceReservationIfUnconsumed = async ( + accountIndex: number, + nonce: number, + ): Promise => { + const doc = await this.#readNonceLedger(accountIndex).catch(() => null); + if (doc === null || nonce < doc.consumedFloor) { + return; + } + this.#releaseNonceReservation(accountIndex, nonce); + }; + + /** + * Durable TP/SL journal key (network + address + accountIndex + symbol + * scoped): the in-memory map alone cannot survive app/WebView/provider + * death between venue commit and visibility. + * + * @param settlementKey - address:accountIndex:symbol identity. + * @returns The disk-cache key. + */ + readonly #tpslJournalKey = (settlementKey: string): string => + `lighterTpslJournal:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}`; + + /** + * Operation-scoped journal payload key: each operation's journal lives + * under its OWN key so a stale resolver physically cannot overwrite or + * delete a newer operation's payload — only its own. + * + * @param settlementKey - Settlement identity. + * @param operationId - The operation identity. + * @returns The disk-cache key. + */ + readonly #tpslJournalOpKey = ( + settlementKey: string, + operationId: string, + ): string => + `lighterTpslJournalOp:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}:${operationId}`; + + /** + * Load and strictly validate a persisted journal entry. Malformed or + * unsupported disk data BLOCKS protection changes (fail closed) — it is + * never trusted into signing decisions nor silently dropped. + * + * @param settlementKey - Settlement identity. + * @returns The validated entry, or null. + */ + readonly #loadTpslJournal = async ( + settlementKey: string, + ): Promise => { + const key = this.#tpslJournalKey(settlementKey); + // FAIL CLOSED on read failure and on corruption: turning either into + // "no entry" would erase exactly the uncertainty this journal exists + // to preserve and could duplicate a committed mutation. Malformed + // data is NOT auto-removed — it blocks until inspected/resolved. + let baseRaw: string | null; + try { + baseRaw = await this.#deps.diskCache.getItem(key); + } catch (error) { + throw new Error( + `Lighter TP/SL journal read failed for ${settlementKey}; refusing protection changes: ${ensureError(error, 'LighterProvider.#loadTpslJournal').message}`, + ); + } + if (baseRaw === null) { + return null; + } + // The base key holds either a POINTER to an operation-scoped payload + // (code-written journals: a stale writer physically cannot destroy a + // newer operation's payload) or a legacy inline journal. + let raw = baseRaw; + const pointer = parseTpslJournalPointer(baseRaw); + if (pointer !== null) { + const payloadRaw = await this.#deps.diskCache.getItem( + this.#tpslJournalOpKey(settlementKey, pointer.operationId), + ); + if (payloadRaw === null) { + // Dangling pointer (payload already resolved elsewhere). + return null; + } + raw = payloadRaw; + } + let parsed: { + version?: unknown; + recordedAt?: unknown; + operationId?: unknown; + createdAt?: unknown; + nextAttemptId?: unknown; + apiKeyIndex?: unknown; + intent?: unknown; + phase?: unknown; + priorGrouping?: unknown; + priorTriggers?: unknown; + attempts?: unknown; + }; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch { + throw new Error( + `Lighter TP/SL journal for ${settlementKey} is corrupt; refusing protection changes until it is resolved`, + ); + } + const isWireId = (value: unknown): boolean => + typeof value === 'number' && + Number.isSafeInteger(value) && + value > 0 && + value < 2 ** 48; + const isNonce = (value: unknown): boolean => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + const isOrderIdString = (value: unknown): boolean => + typeof value === 'string' && /^\d{1,20}$/u.test(value); + const isTxHash = (value: unknown): boolean => + typeof value === 'string' && /^(0x)?[0-9a-fA-F]{8,128}$/u.test(value); + const isExpiry = (value: unknown): boolean => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0; + const isAttempt = (value: unknown): value is TpslAttempt => { + if (typeof value !== 'object' || value === null) { + return false; + } + const attempt = value as Record; + if ( + !isNonce(attempt.nonce) || + typeof attempt.attemptId !== 'number' || + !Number.isSafeInteger(attempt.attemptId) || + attempt.attemptId < 1 || + (attempt.terminalStatus !== undefined && + (typeof attempt.terminalStatus !== 'number' || + !Number.isSafeInteger(attempt.terminalStatus))) || + (attempt.outcome !== 'unknown' && attempt.outcome !== 'accepted') || + !isTxHash(attempt.txHash) || + !isExpiry(attempt.expiresAt) + ) { + return false; + } + if (attempt.kind === 'create') { + return ( + attempt.orderId === undefined && + (attempt.role === 'replacement' || attempt.role === 'restore') && + // priorOrderIds durably key WHICH prior intents a restore + // restores, INDEX-ALIGNED with clientIds; REQUIRED on + // restores, forbidden on replacements. + (attempt.role === 'restore' + ? Array.isArray(attempt.priorOrderIds) && + Array.isArray(attempt.clientIds) && + attempt.priorOrderIds.length === attempt.clientIds.length && + attempt.priorOrderIds.every(isOrderIdString) + : attempt.priorOrderIds === undefined) && + Array.isArray(attempt.clientIds) && + attempt.clientIds.length >= 1 && + attempt.clientIds.length <= 2 && + attempt.clientIds.every(isWireId) && + new Set(attempt.clientIds).size === attempt.clientIds.length + ); + } + if (attempt.kind === 'cancel') { + return ( + attempt.clientIds === undefined && + (attempt.role === 'stale' || attempt.role === 'rollback') && + isOrderIdString(attempt.orderId) + ); + } + return false; + }; + // Recovery SIGNS from these values: they must be strict, finite and + // strictly positive before they can reach the wire. + const isPositiveDecimalString = (value: unknown): boolean => { + if (typeof value !== 'string') { + return false; + } + const numeric = parseStrictDecimal(value); + return numeric !== null && Number.isFinite(numeric) && numeric > 0; + }; + const isPriorTrigger = (value: unknown): value is TpslPriorTrigger => { + if (typeof value !== 'object' || value === null) { + return false; + } + const trigger = value as Record; + return ( + isOrderIdString(trigger.orderId) && + (trigger.side === 'buy' || trigger.side === 'sell') && + (trigger.wireOrderType === 2 || + trigger.wireOrderType === 3 || + trigger.wireOrderType === 4 || + trigger.wireOrderType === 5) && + (trigger.wireTimeInForce === 0 || + trigger.wireTimeInForce === 1 || + trigger.wireTimeInForce === 2) && + typeof trigger.orderExpiry === 'number' && + Number.isSafeInteger(trigger.orderExpiry) && + trigger.orderExpiry >= -1 && + isPositiveDecimalString(trigger.price) && + isPositiveDecimalString(trigger.triggerPrice) && + isPositiveDecimalString(trigger.remainingSize) + ); + }; + // EXPLICIT remediation policy for early schemas (v1/v2): their + // transition state cannot be interpreted safely, so instead of a + // permanent opaque block they convert to a DURABLE MANUAL-recovery + // state — surfaced to the user, resolved only by an explicit new + // protection intent. + if (parsed.version === 1 || parsed.version === 2) { + return { + attempts: [], + recordedAt: + typeof parsed.recordedAt === 'number' ? parsed.recordedAt : 0, + operationId: + typeof parsed.operationId === 'string' && + parsed.operationId.length > 0 + ? parsed.operationId + : `legacy-v${String(parsed.version)}`, + createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0, + nextAttemptId: 1, + intent: 'replace', + phase: 'manual', + priorGrouping: 'independent', + priorTriggers: [], + }; + } + if ( + (parsed.version === 3 || parsed.version === 4) && + typeof parsed.recordedAt === 'number' && + Number.isSafeInteger(parsed.recordedAt) && + parsed.recordedAt >= 0 && + // The journal is bound to ONE api-key slot: nonces are per slot. + parsed.apiKeyIndex === this.#apiKeyIndex && + typeof parsed.operationId === 'string' && + parsed.operationId.length >= 1 && + parsed.operationId.length <= 64 && + typeof parsed.createdAt === 'number' && + Number.isSafeInteger(parsed.createdAt) && + parsed.createdAt >= 0 && + typeof parsed.nextAttemptId === 'number' && + Number.isSafeInteger(parsed.nextAttemptId) && + parsed.nextAttemptId >= 1 && + // An explicit durable operation intent is REQUIRED: without it a + // remove could be misread as a failed replacement. + (parsed.intent === 'replace' || parsed.intent === 'remove') && + (parsed.phase === 'creating' || + parsed.phase === 'cancelling' || + // v3's 'restoring' migrates to 'manual' below. + parsed.phase === 'restoring' || + parsed.phase === 'manual') && + // 'oco' grouping structurally requires the linked pair. + (parsed.priorGrouping === 'independent' || + (parsed.priorGrouping === 'oco' && + Array.isArray(parsed.priorTriggers) && + parsed.priorTriggers.length === 2)) && + Array.isArray(parsed.priorTriggers) && + parsed.priorTriggers.length <= 4 && + parsed.priorTriggers.every(isPriorTrigger) && + new Set(parsed.priorTriggers.map((trigger) => trigger.orderId)).size === + parsed.priorTriggers.length && + Array.isArray(parsed.attempts) && + // An EMPTY journal is malformed — empty-but-shape-valid would be + // accepted and silently cleared. + parsed.attempts.length >= 1 && + parsed.attempts.length <= 40 && + parsed.attempts.every(isAttempt) && + // Attempt IDENTITY is the attemptId — nonces may legitimately + // repeat when a proven-never-landed submission is retried. The + // durable allocator must sit strictly ABOVE every recorded id so + // compaction can never recycle one. + new Set(parsed.attempts.map((entry) => entry.attemptId)).size === + parsed.attempts.length && + parsed.attempts.every( + (entry) => entry.attemptId < (parsed.nextAttemptId as number), + ) + ) { + const { attempts } = parsed; + const { priorTriggers } = parsed; + // Every restore leg must link to a persisted prior intent — an + // unlinked restore could sign a duplicate or orphan a prior one. + const restoresLinked = attempts.every( + (attempt) => + attempt.kind !== 'create' || + attempt.role !== 'restore' || + (attempt.priorOrderIds ?? []).every((priorOrderId) => + priorTriggers.some((trigger) => trigger.orderId === priorOrderId), + ), + ); + if (restoresLinked) { + return { + attempts, + recordedAt: parsed.recordedAt, + operationId: parsed.operationId, + createdAt: parsed.createdAt, + nextAttemptId: parsed.nextAttemptId, + intent: parsed.intent, + // v3 MIGRATION: an interrupted 'restoring' operation predates + // the no-auto-restore policy — it parks as MANUAL. + phase: parsed.phase === 'restoring' ? 'manual' : parsed.phase, + priorGrouping: parsed.priorGrouping, + priorTriggers, + }; + } + } + throw new Error( + `Lighter TP/SL journal for ${settlementKey} is malformed; refusing protection changes until it is resolved`, + ); + }; + + /** + * Durable index of settlement keys with pending journals. + * + * @returns The disk-cache key of the index. + */ + readonly #tpslJournalIndexKey = (): string => + `lighterTpslJournalIndex:${this.#isTestnet ? 'testnet' : 'mainnet'}`; + + /** + * Read the durable journal index (strictly validated; failures fail + * closed by throwing). + * + * @returns The list of settlement keys with pending journals. + */ + readonly #readTpslJournalIndex = async (): Promise => { + const raw = await this.#deps.diskCache.getItem(this.#tpslJournalIndexKey()); + if (raw === null) { + return []; + } + try { + const parsed = JSON.parse(raw) as unknown; + if ( + Array.isArray(parsed) && + parsed.length <= 64 && + parsed.every((entry) => typeof entry === 'string') + ) { + return parsed; + } + } catch { + // fall through + } + throw new Error('Lighter TP/SL journal index is corrupt'); + }; + + /** + * Durable manual-recovery doc key (separate from the journal slot). + * + * @param settlementKey - Settlement identity. + * @returns The disk-cache key. + */ + readonly #tpslManualKey = (settlementKey: string): string => + `lighterTpslManual:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}`; + + /** + * Manual-recovery index key. + * + * @returns The disk-cache key. + */ + readonly #tpslManualIndexKey = (): string => + `lighterTpslManualIndex:${this.#isTestnet ? 'testnet' : 'mainnet'}`; + + /** + * Read the manual-recovery index. Corruption THROWS — a parked + * protection warning silently degrading to "nothing pending" would + * hide a naked position. + * + * @returns Settlement keys with pending manual recoveries. + */ + readonly #readTpslManualIndex = async (): Promise => { + const raw = await this.#deps.diskCache.getItem(this.#tpslManualIndexKey()); + if (raw === null) { + return []; + } + try { + const parsed = JSON.parse(raw) as unknown; + if ( + Array.isArray(parsed) && + parsed.length <= 64 && + parsed.every((entry) => typeof entry === 'string') + ) { + return parsed; + } + } catch { + // fall through + } + throw new Error('Lighter TP/SL manual-recovery index is corrupt'); + }; + + /** + * Durably record a manual-recovery warning (doc + index entry). + * + * @param doc - The manual-recovery record. + */ + readonly #writeTpslManualRecovery = async ( + doc: TpslManualRecovery, + ): Promise => { + await this.#deps.diskCache.setItem( + this.#tpslManualKey(doc.settlementKey), + JSON.stringify({ version: 1, ...doc }), + ); + await withStorageMutex(this.#tpslManualIndexKey(), async () => { + const index = await this.#readTpslManualIndex(); + if (!index.includes(doc.settlementKey)) { + await this.#deps.diskCache.setItem( + this.#tpslManualIndexKey(), + JSON.stringify([...index, doc.settlementKey].slice(0, 64)), + ); + } + }); + }; + + /** + * Load a manual-recovery record. Corruption THROWS (never null) so a + * parked warning cannot silently vanish. + * + * @param settlementKey - Settlement identity. + * @returns The record, or null when none is parked. + */ + readonly #loadTpslManualRecovery = async ( + settlementKey: string, + ): Promise => { + const raw = await this.#deps.diskCache.getItem( + this.#tpslManualKey(settlementKey), + ); + if (raw === null) { + return null; + } + try { + const parsed = JSON.parse(raw) as Record; + if ( + parsed.version === 1 && + typeof parsed.settlementKey === 'string' && + typeof parsed.symbol === 'string' && + typeof parsed.reason === 'string' && + parsed.reason.length <= 500 && + (parsed.priorIntent === 'replace' || parsed.priorIntent === 'remove') && + Array.isArray(parsed.priorTriggers) && + Array.isArray(parsed.survivingOrderIds) && + (parsed.survivingOrderIds as unknown[]).every( + (id) => typeof id === 'string', + ) && + typeof parsed.operationId === 'string' && + typeof parsed.recordedAt === 'number' + ) { + return { + settlementKey: parsed.settlementKey, + symbol: parsed.symbol, + reason: parsed.reason, + priorIntent: parsed.priorIntent, + priorTriggers: parsed.priorTriggers as TpslPriorTrigger[], + survivingOrderIds: parsed.survivingOrderIds as string[], + operationId: parsed.operationId, + recordedAt: parsed.recordedAt, + }; + } + } catch { + // fall through to fail closed + } + throw new Error( + `Lighter TP/SL manual-recovery record for ${settlementKey} is corrupt; resolve storage before proceeding`, + ); + }; + + /** + * Clear a manual-recovery record — called ONLY after a successor + * protection intent has authoritatively succeeded. + * + * @param settlementKey - Settlement identity. + */ + readonly #clearTpslManualRecovery = async ( + settlementKey: string, + ): Promise => { + await this.#deps.diskCache.removeItem(this.#tpslManualKey(settlementKey)); + await withStorageMutex(this.#tpslManualIndexKey(), async () => { + const index = await this.#readTpslManualIndex(); + if (index.includes(settlementKey)) { + await this.#deps.diskCache.setItem( + this.#tpslManualIndexKey(), + JSON.stringify(index.filter((entry) => entry !== settlementKey)), + ); + } + }); + }; + + /** + * Persist a journal entry durably and ensure the index lists its key so + * restart recovery can enumerate pending obligations without waiting + * for the next mutation. + * + * @param settlementKey - Settlement identity. + * @param journal - The journal entry. + */ + readonly #persistTpslJournal = async ( + settlementKey: string, + journal: TpslJournalState, + ): Promise => { + // WRITER-SIDE capacity enforcement, mirrored from the loader: a + // journal the loader would reject as malformed must never be written + // in the first place. Throwing here aborts BEFORE the submission the + // entry was journalling, with every older obligation intact. + if (journal.priorTriggers.length > 4) { + throw new Error( + `Lighter TP/SL journal for ${settlementKey} would record too many prior triggers (${journal.priorTriggers.length} > 4); refusing the mutation`, + ); + } + if (journal.attempts.length > 40) { + throw new Error( + `Lighter TP/SL journal for ${settlementKey} would record too many attempts (${journal.attempts.length} > 40); refusing further submissions until pending obligations resolve`, + ); + } + // INDEX-FIRST: a dangling index entry (no journal behind it) is + // safely prunable by recovery, whereas compensating a failed index + // write by removing the journal could erase an EXISTING authoritative + // journal holding already-accepted attempts. Any failure here aborts + // BEFORE the next submission with every older obligation intact. + // Index RMW under its OWN process-wide mutex: concurrent persists + // for different settlement keys must never lose each other's entry. + await withStorageMutex(this.#tpslJournalIndexKey(), async () => { + const index = await this.#readTpslJournalIndex(); + if (!index.includes(settlementKey)) { + if (index.length >= 64) { + // NEVER evict a live obligation: fail the mutation before + // submission instead. + throw new Error( + 'Lighter TP/SL journal index is full; refusing further protection changes until pending obligations resolve', + ); + } + await this.#deps.diskCache.setItem( + this.#tpslJournalIndexKey(), + JSON.stringify([...index, settlementKey]), + ); + } + }); + const baseKey = this.#tpslJournalKey(settlementKey); + // The pointer read-modify-write is serialized PROCESS-WIDE: the + // instance-local write lock cannot protect two live provider + // instances sharing one disk cache. + await withStorageMutex(baseKey, async () => { + // COMPARE-AND-SWAP on the operation identity: a writer holding a + // stale snapshot must never take over a DIFFERENT operation's + // journal. (A missing journal is fine — first write of an op.) + const currentRaw = await this.#deps.diskCache.getItem(baseKey); + const pointerAlreadyOurs = + currentRaw !== null && + parseTpslJournalPointer(currentRaw)?.operationId === + journal.operationId; + // A DANGLING pointer (payload already resolved; only the base + // removal failed) has no live owner — it is claimable, otherwise a + // partial clear would block every future operation forever. + let danglingPointer = false; + if (currentRaw !== null) { + const staleCheck = parseTpslJournalPointer(currentRaw); + if ( + staleCheck !== null && + staleCheck.operationId !== journal.operationId + ) { + danglingPointer = + (await this.#deps.diskCache.getItem( + this.#tpslJournalOpKey(settlementKey, staleCheck.operationId), + )) === null; + } + } + if (currentRaw !== null && !danglingPointer) { + const pointer = parseTpslJournalPointer(currentRaw); + let currentOperationId: unknown = pointer?.operationId ?? null; + if (pointer === null) { + try { + currentOperationId = ( + JSON.parse(currentRaw) as { operationId?: unknown } + ).operationId; + } catch { + // Corrupt current journal: fail closed below via mismatch. + } + } + if (currentOperationId !== journal.operationId) { + throw new Error( + `Lighter TP/SL journal for ${settlementKey} belongs to a different operation; refusing a stale write`, + ); + } + } + // Payload first, under the operation's OWN key — then the pointer. + await this.#deps.diskCache.setItem( + this.#tpslJournalOpKey(settlementKey, journal.operationId), + JSON.stringify({ + version: 4, + recordedAt: journal.recordedAt, + operationId: journal.operationId, + createdAt: journal.createdAt, + nextAttemptId: journal.nextAttemptId, + apiKeyIndex: this.#apiKeyIndex, + intent: journal.intent, + phase: journal.phase, + priorGrouping: journal.priorGrouping, + priorTriggers: journal.priorTriggers, + attempts: journal.attempts, + }), + ); + try { + await this.#deps.diskCache.setItem( + baseKey, + JSON.stringify({ + pointerVersion: 1, + operationId: journal.operationId, + }), + ); + } catch (error) { + // Pointer write failed on the FIRST persist of this operation: + // remove the freshly written payload so no orphan accumulates. + // (When an earlier persist already pointed here, the payload is + // referenced durable state — keep it.) + if (!pointerAlreadyOurs) { + await this.#deps.diskCache + .removeItem( + this.#tpslJournalOpKey(settlementKey, journal.operationId), + ) + .catch(() => undefined); + } + throw error; + } + }); + // A NEW pending obligation invalidates any "recovery complete" + // marker recorded earlier in this session — otherwise later read + // kicks would skip it until a restart or another mutation. + this.#tpslRecoveryGeneration = -1; + }; + + /** + * Resolve a settlement obligation everywhere — compare-and-swap on the + * operation identity: a resolver holding a STALE snapshot must never + * erase a NEWER operation's journal. Disk removal failures PROPAGATE + * and the in-memory entry is retained: silently dropping only the + * memory copy would leave a stale durable obligation to wedge a later + * session. + * + * @param settlementKey - Settlement identity. + * @param expectedOperationId - The operation this resolver settled; + * null prunes only a dangling index entry with NO journal behind it. + * @returns True when the obligation was cleared (or already gone); + * false when a NEWER operation owns the journal (unresolved). + */ + readonly #clearTpslJournal = async ( + settlementKey: string, + expectedOperationId: string | null, + ): Promise => { + const journalKey = this.#tpslJournalKey(settlementKey); + const cleared = await withStorageMutex(journalKey, async () => { + const currentRaw = await this.#deps.diskCache.getItem(journalKey); + if (currentRaw === null) { + // Already resolved (or never journalled): nothing left to clear. + return true; + } + const pointer = parseTpslJournalPointer(currentRaw); + if (pointer !== null) { + if (expectedOperationId === null) { + // Prune mode: only a DANGLING pointer may be pruned. + const payloadRaw = await this.#deps.diskCache.getItem( + this.#tpslJournalOpKey(settlementKey, pointer.operationId), + ); + if (payloadRaw !== null) { + return false; + } + await this.#deps.diskCache.removeItem(journalKey); + return true; + } + if (pointer.operationId !== expectedOperationId) { + // A NEWER operation owns the journal: remove only OUR OWN + // payload (physically incapable of touching theirs) and + // report the clear as unresolved. + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL journal clear refused: different operation', + { settlementKey }, + ); + await this.#deps.diskCache + .removeItem( + this.#tpslJournalOpKey(settlementKey, expectedOperationId), + ) + .catch(() => undefined); + return false; + } + await this.#deps.diskCache.removeItem( + this.#tpslJournalOpKey(settlementKey, expectedOperationId), + ); + await this.#deps.diskCache.removeItem(journalKey); + return true; + } + // Legacy inline journal at the base key. + if (expectedOperationId === null) { + return false; + } + let currentOperationId: unknown = null; + try { + const inline = JSON.parse(currentRaw) as { + operationId?: unknown; + version?: unknown; + }; + currentOperationId = + inline.operationId ?? + // Early schemas carry no operation id: the loader synthesizes + // `legacy-v{n}` for their manual-remediation state — mirror it + // so the explicit new intent can clear them. + (inline.version === 1 || inline.version === 2 + ? `legacy-v${String(inline.version)}` + : null); + } catch { + // Corrupt journal is never silently cleared. + } + if (currentOperationId !== expectedOperationId) { + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL journal clear refused: different operation', + { settlementKey }, + ); + return false; + } + await this.#deps.diskCache.removeItem(journalKey); + return true; + }); + if (!cleared) { + return false; + } + // Index removal under the index mutex, RE-VERIFYING the journal is + // still gone: a newer operation may have persisted (journal + + // index entry) between our clear and this removal — removing the + // entry then would blind restart recovery to a live obligation. + // A storage READ failure here is AMBIGUITY, never absence: it + // propagates (the index entry is retained and the settlement stays + // unresolved) — guessing could orphan a live obligation. + await withStorageMutex(this.#tpslJournalIndexKey(), async () => { + const stillGone = + (await this.#deps.diskCache.getItem(journalKey)) === null; + if (!stillGone) { + return; + } + const index = await this.#readTpslJournalIndex(); + if (index.includes(settlementKey)) { + await this.#deps.diskCache.setItem( + this.#tpslJournalIndexKey(), + JSON.stringify(index.filter((entry) => entry !== settlementKey)), + ); + } + }); + const memoryEntry = this.#tpslUnsettled.get(settlementKey); + if ( + memoryEntry === undefined || + expectedOperationId === null || + memoryEntry.operationId === expectedOperationId + ) { + this.#tpslUnsettled.delete(settlementKey); + } + return true; + }; + + /** + * Targeted, cached, active-first inactive-history reader shared by the + * mutation transition and recovery: terminal rows are immutable so they + * cache across polls; page 1 per call; the deep cursor walk runs at + * most ONCE per reader and stops when every target id is found. + * + * @param accountIndex - Captured account index. + * @param authToken - Captured auth token. + * @param generation - Captured session generation (fenced per read). + * @param marketId - Market to scope inactive-history requests to. + * @returns The reader closure. + */ + readonly #makeInactiveReader = ( + accountIndex: number, + authToken: string, + generation: number, + marketId: number, + ): ((targetClientIds: number[]) => Promise) => { + const terminalCache = new Map(); + let deepTraversalDone = false; + return async (targetClientIds: number[]): Promise => { + this.#assertSession(generation); + const targets = targetClientIds.map(String); + const missing = (): boolean => + targets.some((id) => !terminalCache.has(id)); + const ingest = (orders: LighterApiOrder[]): void => { + for (const order of orders) { + if (order.ownerAccountIndex === accountIndex) { + terminalCache.set(String(order.clientOrderIndex), order); + } + } + }; + const firstPage = await this.#clientService.getInactiveOrders( + accountIndex, + authToken, + 100, + undefined, + marketId, + ); + this.#assertSession(generation); + ingest(firstPage.orders); + if (missing() && !deepTraversalDone) { + deepTraversalDone = true; + let cursor = firstPage.nextCursor; + for (let page = 0; page < 9 && cursor && missing(); page += 1) { + const response = await this.#clientService.getInactiveOrders( + accountIndex, + authToken, + 100, + cursor, + marketId, + ); + this.#assertSession(generation); + ingest(response.orders); + cursor = response.nextCursor; + } + } + return [...terminalCache.values()]; + }; + }; + + /** Session generation whose journal recovery fully resolved. */ + #tpslRecoveryGeneration = -1; + + /** In-flight journal recovery (deduplicates concurrent triggers). */ + #tpslRecoveryInFlight: Promise | null = null; + + /** + * Detached, deduplicated recovery kick. Wired into signer setup AND the + * public read paths: a recovery that returned unresolved (e.g. REST + * visibility lag) must get another chance later in the SAME session, + * not only at the next signer setup. + */ + /** A kick arrived while a (possibly stale) recovery was in flight. */ + #tpslRecoveryKickPending = false; + + readonly #kickTpslRecovery = (): void => { + if (this.#tpslRecoveryGeneration === this.#sessionGeneration) { + return; + } + if (this.#tpslRecoveryInFlight) { + // A stale-generation recovery may be finishing: remember this kick + // so the CURRENT generation's journals are not silently skipped. + this.#tpslRecoveryKickPending = true; + return; + } + setTimeout(() => { + this.#recoverPendingTpslJournals().catch((error) => { + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL journal recovery failed', + { error: String(error) }, + ); + }); + }, 0); + }; + + /** + * Enumerate durable journal-index entries for the CURRENT identity and + * recover each: reconcile, complete an interrupted replacement's stale + * cancels when its created protection is live, then clear. Bounded and + * deduplicated per session generation; unresolved entries stay for the + * next attempt. + */ + readonly #recoverPendingTpslJournals = async (): Promise => { + const generation = this.#sessionGeneration; + if (this.#tpslRecoveryGeneration === generation) { + return; + } + if (this.#tpslRecoveryInFlight) { + await this.#tpslRecoveryInFlight; + return; + } + this.#tpslRecoveryInFlight = (async (): Promise => { + try { + // Index corruption/read failure PROPAGATES (logged by the hook): + // silently treating it as empty would disable recovery entirely. + const index = await this.#readTpslJournalIndex(); + if (index.length === 0) { + this.#tpslRecoveryGeneration = generation; + return; + } + const address = this.#boundAddress; + if (!address) { + return; + } + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const prefix = `${address}:${accountIndex}:${this.#apiKeyIndex}:`; + let allResolved = true; + for (const settlementKey of index) { + if (!settlementKey.startsWith(prefix)) { + continue; + } + const resolved = await this.#recoverTpslSymbol( + settlementKey.slice(prefix.length), + settlementKey, + generation, + accountIndex, + ).catch((error) => { + // Surface the exact cause (corruption, transport, session + // fence) — the entry stays retryable, but never silently. + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL journal entry recovery failed', + { + settlementKey, + error: + error instanceof Error + ? (error.stack ?? error.message) + : String(error), + }, + ); + return false; + }); + if (!resolved) { + allResolved = false; + } + } + // Marked complete ONLY when everything resolved: unresolved or + // errored entries stay retryable within this session. + if (allResolved) { + this.#tpslRecoveryGeneration = generation; + } + } finally { + this.#tpslRecoveryInFlight = null; + if (this.#tpslRecoveryKickPending) { + this.#tpslRecoveryKickPending = false; + this.#kickTpslRecovery(); + } + } + })(); + await this.#tpslRecoveryInFlight; + }; + + /** + * Recover one pending TP/SL journal without any new protection intent. + * + * @param symbol - Market symbol from the settlement key. + * @param settlementKey - Full settlement identity. + * @param generation - Captured session generation. + * @param accountIndex - Captured account index. + * @returns True when the obligation fully resolved (journal cleared); + * false when it remains pending and must be retried. + */ + readonly #recoverTpslSymbol = async ( + symbol: string, + settlementKey: string, + generation: number, + accountIndex: number, + ): Promise => { + const markets = await this.#ensureMarkets(); + const market = markets.get(symbol); + if (!market) { + return false; + } + await this.#ensureSignerReady(); + this.#assertSession(generation); + const authToken = await this.#getAuthToken(); + this.#assertSession(generation); + return await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit): Promise => { + // The journal is loaded INSIDE the lock: a snapshot taken while + // waiting for the lock could be superseded by a foreground + // operation that settles it and journals a NEW one — acting on + // the stale snapshot could erase the newer obligation. + const journalEntry = await this.#loadTpslJournal(settlementKey); + if (!journalEntry) { + // Stale index entry with no journal behind it: prune. + return await this.#clearTpslJournal(settlementKey, null).catch( + () => false, + ); + } + const readActiveRaw = async (): Promise => { + this.#assertSession(generation); + const response = await this.#clientService.getActiveOrders( + accountIndex, + authToken, + ); + this.#assertSession(generation); + return response.orders; + }; + const readInactiveFor = this.#makeInactiveReader( + accountIndex, + authToken, + generation, + market.marketId, + ); + return await this.#settleTpslObligation({ + settlementKey, + symbol, + journalEntry, + market, + accountIndex, + authToken, + generation, + readActiveRaw, + readInactiveFor, + nextNonce, + submit, + }); + }, + generation, + ); + }; + + /** + * THE TP/SL obligation state machine — the single implementation run by + * startup/read-path recovery AND by a direct foreground update that + * finds a pending journal. Reconciles every attempt authoritatively, + * then acts per durable intent and phase, and clears the journal ONLY + * on a fully-settled outcome. + * + * @param context - Captured settlement context. + * @param context.settlementKey - Full settlement identity. + * @param context.symbol - Market symbol. + * @param context.journalEntry - The pending journal. + * @param context.market - Market integerization parameters. + * @param context.market.marketId - Venue market id. + * @param context.market.supportedSizeDecimals - Size integerization decimals. + * @param context.market.supportedPriceDecimals - Price integerization decimals. + * @param context.accountIndex - Captured account index. + * @param context.authToken - Captured venue auth token. + * @param context.generation - Captured session generation. + * @param context.readActiveRaw - Session-fenced raw active reader. + * @param context.readInactiveFor - Targeted inactive reader. + * @param context.nextNonce - Lock-section nonce issuer. + * @param context.submit - Lock-section submitter. + * @returns True when fully resolved (journal cleared); false when the + * obligation remains pending and must be retried. + */ + readonly #settleTpslObligation = async (context: { + settlementKey: string; + symbol: string; + journalEntry: TpslJournalState; + market: { + marketId: number; + supportedSizeDecimals: number; + supportedPriceDecimals: number; + }; + accountIndex: number; + authToken: string; + generation: number; + readActiveRaw: () => Promise; + readInactiveFor: (targetClientIds: number[]) => Promise; + nextNonce: () => Promise; + submit: ( + txType: number, + txInfo: string, + onAccepted?: () => void, + identity?: { + txHash: string | null; + expiresAt: number | null; + intent?: string; + owner?: string | null; + }, + ) => Promise; + }): Promise => { + const { settlementKey } = context; + // The ENTIRE same-settlement state machine is serialized + // PROCESS-WIDE: two live providers resolving the same operation + // could otherwise both choose and submit identical restores/cancels + // and overwrite each other's attempt state. + return await withProcessMutex( + `lighterTpslSettle:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}`, + async () => await this.#settleTpslObligationLocked(context), + ); + }; + + /** + * The settlement machine body — MUST only run under the per-settlement + * process mutex (see #settleTpslObligation). + * + * @param context - See #settleTpslObligation. + * @param context.settlementKey - Full settlement identity. + * @param context.symbol - Market symbol. + * @param context.journalEntry - Caller's journal snapshot (reloaded). + * @param context.market - Market integerization parameters. + * @param context.market.marketId - Venue market id. + * @param context.market.supportedSizeDecimals - Size decimals. + * @param context.market.supportedPriceDecimals - Price decimals. + * @param context.accountIndex - Captured account index. + * @param context.authToken - Captured venue auth token. + * @param context.generation - Captured session generation. + * @param context.readActiveRaw - Session-fenced raw active reader. + * @param context.readInactiveFor - Targeted inactive reader. + * @param context.nextNonce - Lock-section nonce issuer. + * @param context.submit - Lock-section submitter. + * @returns See #settleTpslObligation. + */ + readonly #settleTpslObligationLocked = async (context: { + settlementKey: string; + symbol: string; + journalEntry: TpslJournalState; + market: { + marketId: number; + supportedSizeDecimals: number; + supportedPriceDecimals: number; + }; + accountIndex: number; + authToken: string; + generation: number; + readActiveRaw: () => Promise; + readInactiveFor: (targetClientIds: number[]) => Promise; + nextNonce: () => Promise; + submit: ( + txType: number, + txInfo: string, + onAccepted?: () => void, + identity?: { + txHash: string | null; + expiresAt: number | null; + intent?: string; + owner?: string | null; + }, + ) => Promise; + }): Promise => { + const { + settlementKey, + symbol, + market, + accountIndex, + readActiveRaw, + readInactiveFor, + nextNonce, + submit, + } = context; + // RELOAD inside the settlement mutex: the caller's snapshot may have + // been superseded while waiting for the mutex — decisions must be + // made on the CURRENT journal of the SAME operation only. Disk is + // AUTHORITATIVE: absence means another resolver cleared it, so any + // stale in-memory copy must be dropped, never resurrected. + const journalEntry = await this.#loadTpslJournal(settlementKey); + if (!journalEntry) { + this.#tpslUnsettled.delete(settlementKey); + return await this.#clearTpslJournal(settlementKey, null).catch( + () => false, + ); + } + if (journalEntry.operationId !== context.journalEntry.operationId) { + // A different operation owns the journal now: this resolver's + // obligation no longer exists — report unresolved so the caller + // re-evaluates against the fresh state. + return false; + } + const reconciled = await this.#reconcilePriorTpsl( + readActiveRaw, + readInactiveFor, + accountIndex, + journalEntry, + ); + if (reconciled === 'unresolved') { + return false; + } + const persistEntry = async (): Promise => { + this.#tpslUnsettled.set(settlementKey, journalEntry); + await this.#persistTpslJournal(settlementKey, journalEntry); + }; + // Same journalled cancel discipline as the live transition. + const submitRecoveryCancel = async ( + orderId: string, + role: 'stale' | 'rollback', + ): Promise => { + if (role === 'stale' && journalEntry.intent === 'replace') { + journalEntry.phase = 'cancelling'; + } + const cancelNonce = await nextNonce(); + const signedCancel = + await this.#getSignerBridge().execute({ + function: '_signCancelOrder', + params: [accountIndex, market.marketId, orderId, cancelNonce], + }); + if (signedCancel.error) { + throw new Error( + `Failed to cancel trigger order ${orderId}: ${signedCancel.error}`, + ); + } + const cancelIdentity = requireSignedTxIdentity(signedCancel); + const cancelAttempt: TpslCancelAttempt = { + kind: 'cancel', + attemptId: nextAttemptIdFor(journalEntry), + nonce: cancelNonce, + outcome: 'unknown', + orderId, + txHash: cancelIdentity.txHash, + expiresAt: cancelIdentity.expiresAt, + role, + }; + journalEntry.attempts.push(cancelAttempt); + await persistEntry(); + await submit( + LIGHTER_TX_TYPE_CANCEL_ORDER, + signedCancel.txInfo, + () => { + cancelAttempt.outcome = 'accepted'; + }, + { + txHash: cancelIdentity.txHash, + expiresAt: cancelIdentity.expiresAt, + owner: journalEntry.operationId, + }, + ); + }; + // Classify every journalled create leg on the books (reconcile + // proved each attempt either landed or never can). + const replacementIds = journalEntry.attempts + .filter( + (attempt): attempt is TpslCreateAttempt => + attempt.kind === 'create' && attempt.role === 'replacement', + ) + .flatMap((attempt) => attempt.clientIds); + const restoreAttempts = journalEntry.attempts.filter( + (attempt): attempt is TpslCreateAttempt => + attempt.kind === 'create' && attempt.role === 'restore', + ); + const allCreateIds = [ + ...replacementIds, + ...restoreAttempts.flatMap((attempt) => attempt.clientIds), + ]; + const rawActive = await readActiveRaw(); + const missingFromActive = allCreateIds.filter( + (clientId) => + !rawActive.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ), + ); + const rawInactive = + missingFromActive.length > 0 + ? await readInactiveFor(missingFromActive) + : []; + const stateOf = (clientId: number): 'active' | 'success' | 'failed' => { + if ( + rawActive.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ) + ) { + return 'active'; + } + const terminal = rawInactive.find( + (order) => String(order.clientOrderIndex) === String(clientId), + ); + if (!terminal) { + // Reconcile proved never-landed: same outcome as failed. + return 'failed'; + } + const status = terminal.status.toLowerCase(); + const fullyExecuted = + (status === 'filled' || status === 'executed') && + parseStrictDecimal(terminal.remainingBaseAmount) === 0; + return fullyExecuted ? 'success' : 'failed'; + }; + const replacementStates = replacementIds.map(stateOf); + const anySuccess = replacementStates.includes('success'); + const anyActive = replacementStates.includes('active'); + const anyFailed = replacementStates.includes('failed'); + const priorActive = (prior: TpslPriorTrigger): boolean => + rawActive.some((order) => String(order.orderIndex) === prior.orderId); + const cancelledOrderIds: string[] = []; + const createdClientIds: number[] = []; + // Aggregation groups parallel to createdClientIds: one group per + // create ATTEMPT (grouped OCO semantics within, independence across). + const createdGroups: number[][] = []; + const pushCreatedGroup = (group: number[]): void => { + createdClientIds.push(...group); + createdGroups.push(group); + }; + const cancelPriorLeftovers = async (): Promise => { + // The replacement must STAY proven while the old protection is + // removed: keep its live ids in the final expectation so a leg + // terminal-failing DURING these cancels (the phase race) fails + // this pass instead of clearing the journal naked. Grouped per + // replacement ATTEMPT: an executed OCO leg legitimately + // auto-cancels its sibling. + for (const attempt of journalEntry.attempts) { + if (attempt.kind !== 'create' || attempt.role !== 'replacement') { + continue; + } + const activeLegs = attempt.clientIds.filter( + (clientId) => stateOf(clientId) === 'active', + ); + if (activeLegs.length > 0) { + pushCreatedGroup(attempt.clientIds); + } + } + for (const prior of journalEntry.priorTriggers) { + if (priorActive(prior)) { + await submitRecoveryCancel(prior.orderId, 'stale'); + cancelledOrderIds.push(prior.orderId); + } + } + }; + const rollbackActiveJournalledLegs = async ( + legIds: number[], + ): Promise => { + for (const clientId of legIds) { + if (stateOf(clientId) !== 'active') { + continue; + } + const survivor = rawActive.find( + (order) => String(order.clientOrderIndex) === String(clientId), + ); + if (survivor) { + await submitRecoveryCancel(String(survivor.orderIndex), 'rollback'); + cancelledOrderIds.push(String(survivor.orderIndex)); + } + } + }; + const rollbackActiveReplacements = async (): Promise => + await rollbackActiveJournalledLegs(replacementIds); + // COMPACTION: proven-resolved attempts with no live effect and no + // coverage are dropped so repeated retries can never dead-end at the + // attempt cap: FAILED restore creates (never landed/terminal-failed) + // and resolved cancels (target gone, or proven never-landed). + const compactionNow = Date.now(); + journalEntry.attempts = journalEntry.attempts.filter((attempt) => { + if (attempt.kind === 'create') { + return ( + attempt.role !== 'restore' || + attempt.clientIds.some((clientId) => stateOf(clientId) !== 'failed') + ); + } + const targetGone = !rawActive.some( + (order) => String(order.orderIndex) === attempt.orderId, + ); + const provenNeverLanded = + attempt.outcome === 'unknown' && + compactionNow > attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS; + // Accepted-but-terminal-FAILED cancels (venue status 4/5) landed + // without mutating the books: proven-resolved, compactable. + const landedTerminalFailed = + attempt.terminalStatus === 4 || attempt.terminalStatus === 5; + return !(targetGone || provenNeverLanded || landedTerminalFailed); + }); + // NO AUTOMATIC RESTORE: the venue exposes no atomic primitive that + // could prove a re-created trigger attaches to the SAME position + // lifecycle, so a fully-failed replacement after old cancels parks + // the journal in a DURABLE 'manual' state — surfaced via + // `getPendingManualRecoveries` and resolved only by an explicit NEW + // protection intent from the user. Never restored, never silently + // cleared. + const parkManual = async (reason: string): Promise => { + // Survivors: prior triggers still on the books + replacement legs + // still active — deliberately LEFT (only remaining protection). + const survivingOrderIds = [ + ...new Set([ + ...journalEntry.priorTriggers + .filter((prior) => priorActive(prior)) + .map((prior) => prior.orderId), + ...rawActive + .filter((order) => + replacementIds.some( + (clientId) => + String(order.clientOrderIndex) === String(clientId), + ), + ) + .map((order) => String(order.orderIndex)), + ]), + ]; + // The DURABLE warning lives in its own doc; the journal slot is + // released so a successor protection intent can run. The doc + // clears only after a successor SUCCEEDS. + await this.#writeTpslManualRecovery({ + settlementKey, + symbol, + reason, + priorIntent: journalEntry.intent, + priorTriggers: journalEntry.priorTriggers, + survivingOrderIds, + operationId: journalEntry.operationId, + recordedAt: Date.now(), + }); + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL protection requires MANUAL re-establishment', + { settlementKey, reason }, + ); + await this.#clearTpslJournal(settlementKey, journalEntry.operationId); + return true; + }; + if (journalEntry.phase === 'manual') { + // Journal parked 'manual' by an earlier version: migrate the + // warning into the dedicated durable doc. + return await parkManual( + 'TP/SL protection could not be safely re-established automatically (parked by an earlier session)', + ); + } + if (journalEntry.intent === 'remove') { + // An intentional REMOVAL is never "recovered" by restoring the + // cancelled protection: finish/reconcile the cancels exactly. + for (const prior of journalEntry.priorTriggers) { + if (priorActive(prior)) { + await submitRecoveryCancel(prior.orderId, 'stale'); + cancelledOrderIds.push(prior.orderId); + } + } + } else if (journalEntry.phase === 'creating') { + // Old protection untouched. Nothing landed / everything failed + // → the old set is still the only intent: just clear. + if (replacementIds.length > 0 && (anySuccess || anyActive)) { + if (!anySuccess && anyFailed) { + // Partial OCO before old cancels: roll surviving legs back so + // the OLD protection remains authoritative. + await rollbackActiveReplacements(); + } else { + // Replacement in force (or executed): finish the swap. + await cancelPriorLeftovers(); + } + } + } else if (journalEntry.phase === 'cancelling') { + if (anySuccess || (anyActive && !anyFailed)) { + // Replacement fully won — finish cancelling the old protection. + await cancelPriorLeftovers(); + } else { + // Replacement fully failed (or degraded to a partial set) AFTER + // old cancels began: the position's protection can no longer be + // proven — park durably for MANUAL re-establishment. Any + // surviving leg is deliberately LEFT (it is the only protection + // remaining); nothing is restored. + return await parkManual( + 'Replacement TP/SL orders failed after the previous protection cancels began; the position may be under-protected', + ); + } + } + if (cancelledOrderIds.length > 0 || createdClientIds.length > 0) { + const settled = await this.#awaitTpslVisibility( + readActiveRaw, + readInactiveFor, + { createdClientIds, cancelledOrderIds }, + // PER-ATTEMPT groups: a grouped OCO replacement's executed leg + // legitimately auto-cancels its sibling. + { createdGroups }, + ); + // ONLY a fully-settled pass may clear; a replacement dying DURING + // the old cancels parks for manual re-establishment. + if (settled.outcome === 'timeout') { + return false; + } + if (settled.outcome === 'created-terminal-failed') { + return await parkManual( + 'Replacement TP/SL order was cancelled or rejected by the venue after the previous protection was already removed', + ); + } + } + // A refused clear (superseded by a newer operation) is UNRESOLVED — + // never reported as success. + return await this.#clearTpslJournal( + settlementKey, + journalEntry.operationId, + ); + }; + + /** + * Reconcile a PRIOR transition's expectation before any new mutation. + * Only created ids can cause duplicates from a stale snapshot, so they + * must be accounted for (active or terminal). Cancelled ids are safe in + * either state: still-active targets reappear in the fresh snapshot and + * are re-cancelled. + * + * @param readActiveRaw - Strict raw active-orders reader. + * @param readInactive - Targeted inactive-history reader (cached, bounded). + * @param accountIndex - Captured account index. + * @param entry - The recorded expectation. + * @param entry.attempts - Journalled per-attempt submissions. + * @param entry.recordedAt - When the journal was recorded (ms). + * @returns 'resolved' when safe to proceed; 'unresolved' when an + * ACCEPTED mutation is still not visible. + */ + readonly #reconcilePriorTpsl = async ( + readActiveRaw: () => Promise, + readInactive: (targetClientIds: number[]) => Promise, + accountIndex: number, + entry: { attempts: TpslAttempt[]; recordedAt: number }, + ): Promise<'resolved' | 'unresolved'> => { + // Per-attempt reconciliation, authoritative and never time-guessed: + // 1. Books first — a create is resolved when its ids are all + // active/terminal, a cancel when its target left the active book. + // 2. Otherwise the EXACT signed tx hash is looked up: a strict match + // (hash + account + api key slot + nonce) proves the payload + // reached the sequencer, so absence from the books can only be + // visibility lag (keep blocking). A venue-confirmed not-found is + // only never-landed once the signed ExpiredAt (+ clock slack) has + // passed — the sequencer cannot accept an expired payload. + const satisfiedOnBooks = ( + attempt: TpslAttempt, + rawActive: LighterApiOrder[], + rawInactive: LighterApiOrder[], + ): boolean => + attempt.kind === 'create' + ? attempt.clientIds.every( + (clientId) => + rawActive.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ) || + rawInactive.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ), + ) + : !rawActive.some( + (order) => String(order.orderIndex) === attempt.orderId, + ); + // Books can satisfy only OBSERVED-accepted attempts. An UNKNOWN + // attempt's desired book state may hold for INDEPENDENT reasons (a + // fill, an external cancel) while the signed payload could still + // land later and consume its nonce — every unknown attempt must + // resolve by exact hash identity or proven expiry. + let rawActive: LighterApiOrder[] = []; + let rawInactive: LighterApiOrder[] = []; + for (let poll = 0; poll < LIGHTER_TPSL_SETTLE_ATTEMPTS; poll += 1) { + const activeNow = await readActiveRaw(); + // ACTIVE-FIRST (see #awaitTpslVisibility): inactive history is only + // consulted for create ids not already visible active. + const createIdsMissingFromActive = entry.attempts + .filter( + (attempt): attempt is TpslCreateAttempt => attempt.kind === 'create', + ) + .flatMap((attempt) => attempt.clientIds) + .filter( + (clientId) => + !activeNow.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ), + ); + const inactiveNow = + createIdsMissingFromActive.length > 0 + ? await readInactive(createIdsMissingFromActive) + : []; + rawActive = activeNow; + rawInactive = inactiveNow; + // Poll the books through visibility lag for ALL attempts — book + // convergence resolves accepted attempts directly and lets an + // unknown-but-landed attempt pass its final identity check below. + const anyUnsatisfied = entry.attempts.some( + (attempt) => !satisfiedOnBooks(attempt, activeNow, inactiveNow), + ); + if (!anyUnsatisfied) { + break; + } + await new Promise((resolve) => + setTimeout(resolve, LIGHTER_TPSL_SETTLE_POLL_MS), + ); + } + for (const attempt of entry.attempts) { + if ( + attempt.outcome === 'accepted' && + satisfiedOnBooks(attempt, rawActive, rawInactive) + ) { + continue; + } + let lookedUp: LighterTxLookupResponse | null; + try { + lookedUp = await this.#clientService.getTx(attempt.txHash); + } catch { + // Lookup failure is AMBIGUOUS, never evidence of non-acceptance. + return 'unresolved'; + } + if (lookedUp !== null) { + // The exact signed hash exists at the venue. With a matching + // identity (hash + account + api key slot + nonce): + // - terminal FAILED/REJECTED status (4/5) resolves the attempt + // deterministically — the nonce was consumed but the books + // were never mutated (the machine re-acts on book state); + // - any other status with the books already reflecting the + // attempt resolves it; + // - otherwise it reached the sequencer but is not yet visible — + // keep blocking. A NON-matching payload under this hash fails + // closed identically, and is logged (signer/venue defect). + const matchesIdentity = + typeof lookedUp.hash === 'string' && + lookedUp.hash.toLowerCase().replace(/^0x/u, '') === + attempt.txHash.toLowerCase().replace(/^0x/u, '') && + lookedUp.accountIndex === accountIndex && + lookedUp.apiKeyIndex === this.#apiKeyIndex && + lookedUp.nonce === attempt.nonce; + if (!matchesIdentity) { + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL tx lookup identity mismatch; failing closed', + { txHash: attempt.txHash }, + ); + return 'unresolved'; + } + if (lookedUp.status === 4 || lookedUp.status === 5) { + // Record the terminal venue status durably (next persist): + // compaction can then drop this attempt even though its target + // may still be on the books. + attempt.terminalStatus = lookedUp.status; + continue; + } + if (satisfiedOnBooks(attempt, rawActive, rawInactive)) { + continue; + } + return 'unresolved'; + } + // Venue-confirmed not-found: only never-landed once the signed + // payload can no longer be accepted. + if (Date.now() <= attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS) { + return 'unresolved'; + } + // Expired and venue-confirmed absent: authoritatively never landed — + // its reserved nonce may be released UNLESS a later dispatch (a + // retry) already consumed it (durable consumed watermark guards). + await this.#releaseNonceReservationIfUnconsumed( + accountIndex, + attempt.nonce, + ); + } + return 'resolved'; + }; + + /** + * Release a session-global nonce reservation once a submission is + * PROVEN never-landed. Only the topmost reservation can be safely + * lowered; anything else stays reserved until proven in turn. + * + * @param accountIndex - Venue account index. + * @param nonce - The proven-unconsumed nonce. + */ + readonly #releaseNonceReservation = ( + accountIndex: number, + nonce: number, + ): void => { + const reservationKey = `${accountIndex}:${this.#apiKeyIndex}`; + if (this.#nonceReservations.get(reservationKey) === nonce + 1) { + this.#nonceReservations.set(reservationKey, nonce); + } + }; + + /** + * Bounded poll until the venue reflects a TP/SL transition: every + * created client id accounted for and every cancelled order id absent + * from the active book. + * + * A created trigger can EXECUTE, expire, or be venue-cancelled before + * the first poll (an immediate/crossed TP/SL never rests), so created + * ids reconcile against the active book PLUS the inactive/terminal + * history — otherwise the obligation could never resolve and would + * permanently block the symbol. + * + * @param readActiveRaw - Strict raw active-orders reader (session-fenced). + * @param readInactive - Targeted inactive-history reader (cached, bounded). + * @param expectation - Ids the venue must account for. + * @param expectation.createdClientIds - Client ids that must be active + * or terminal. + * @param expectation.cancelledOrderIds - Order ids that must leave the + * active book. + * @param options - Aggregation options. + * @param options.createdGroups - Per-attempt aggregation groups over + * the created ids (see inline doc). + * @returns Outcome: 'settled' when every id is accounted for and no + * created id failed ('executedCreated' marks created ids that reached a + * SUCCESS terminal state — filled/executed — instead of resting + * active); 'created-terminal-failed' when the venue reports a created + * id cancelled/rejected/expired (the obligation RESOLVES — the caller + * surfaces the failure but no permanent block remains); 'timeout' when + * the bound elapsed unresolved. + */ + readonly #awaitTpslVisibility = async ( + readActiveRaw: () => Promise, + readInactive: (targetClientIds: number[]) => Promise, + expectation: { createdClientIds: number[]; cancelledOrderIds: string[] }, + options: { + /** + * PER-ATTEMPT aggregation groups over `createdClientIds`: within a + * group, grouped-OCO semantics hold (one fully executed leg + * legitimately auto-cancels its sibling — the GROUP succeeded); + * ACROSS groups every group must independently succeed or rest + * active. Omitted: all created ids form one group (legacy grouped + * semantics). + */ + createdGroups?: number[][]; + } = {}, + ): Promise< + | { outcome: 'settled'; executedCreated: boolean } + | { + outcome: 'created-terminal-failed'; + /** New legs still resting ACTIVE despite a failed sibling. */ + survivingActiveClientIds: number[]; + } + | { outcome: 'timeout' } + > => { + for ( + let attempt = 0; + attempt < LIGHTER_TPSL_SETTLE_ATTEMPTS; + attempt += 1 + ) { + const rawActive = await readActiveRaw(); + // ACTIVE-FIRST: only ids not already proven active need the + // high-weight inactive-history lookup; a normal freshly-active + // replacement performs ZERO inactive requests. + const missingFromActive = expectation.createdClientIds.filter( + (clientId) => + !rawActive.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ), + ); + const rawInactive = + missingFromActive.length > 0 + ? await readInactive(missingFromActive) + : []; + // Per-id classification. Success is EXACT-whitelisted + // ('filled'/'executed') AND requires a strictly ZERO remaining size + // (a 'filled' row with remainder is not a proven execution); + // everything else terminal — including unknown statuses — fails + // CLOSED. + const classified = expectation.createdClientIds.map((clientId) => { + if ( + rawActive.some( + (order) => String(order.clientOrderIndex) === String(clientId), + ) + ) { + return { clientId, state: 'active' as const }; + } + const terminal = rawInactive.find( + (order) => String(order.clientOrderIndex) === String(clientId), + ); + if (!terminal) { + return { clientId, state: 'missing' as const }; + } + const status = terminal.status.toLowerCase(); + // STRICT remaining parse: a prefix-parsed '0oops' must never + // count as a proven zero remainder. + const fullyExecuted = + (status === 'filled' || status === 'executed') && + parseStrictDecimal(terminal.remainingBaseAmount) === 0; + return { + clientId, + state: fullyExecuted ? ('success' as const) : ('failed' as const), + }; + }); + const createdAccounted = !classified.some( + (entry) => entry.state === 'missing', + ); + const cancelledGone = expectation.cancelledOrderIds.every( + (orderId) => + !rawActive.some((order) => String(order.orderIndex) === orderId), + ); + if (createdAccounted && cancelledGone) { + // PER-GROUP aggregation: within a group one fully executed leg + // auto-cancels its sibling (grouped OCO — the GROUP succeeded); + // across groups each must independently succeed or rest active. + const groups = + options.createdGroups ?? + (expectation.createdClientIds.length > 0 + ? [expectation.createdClientIds] + : []); + const stateOfId = new Map( + classified.map((entry) => [entry.clientId, entry.state]), + ); + let anyGroupSuccess = false; + const failedGroupActiveIds: number[] = []; + let anyGroupFailed = false; + for (const group of groups) { + const states = group.map( + (clientId) => stateOfId.get(clientId) ?? 'missing', + ); + if (states.includes('success')) { + anyGroupSuccess = true; + continue; + } + if (states.includes('failed')) { + anyGroupFailed = true; + failedGroupActiveIds.push( + ...group.filter( + (clientId) => stateOfId.get(clientId) === 'active', + ), + ); + } + } + if (anyGroupFailed) { + return { + outcome: 'created-terminal-failed', + survivingActiveClientIds: failedGroupActiveIds, + }; + } + return { outcome: 'settled', executedCreated: anyGroupSuccess }; + } + await new Promise((resolve) => + setTimeout(resolve, LIGHTER_TPSL_SETTLE_POLL_MS), + ); + } + return { outcome: 'timeout' }; + }; + + /** + * Throws when the session generation moved past the captured one — used + * after every await in account-bound async work so a delayed account-A + * step can never mutate account-B's session. + * + * @param generation - Generation captured when the work started. + */ + readonly #assertSession = (generation: number): void => { + if (generation !== this.#sessionGeneration) { + throw new Error( + 'Operation cancelled: the wallet switched accounts (or the signer reset) while this operation was in flight', + ); + } + // The generation only advances when some provider call rebinds; also + // notice a wallet switch nothing has observed yet. Account-bound work + // must never run without a binding: every legitimate flow (including + // headless l1Address and configured-index setups) binds first, so a + // null binding here means the wallet was deselected — fail closed even + // when a configured account index could still resolve. + if (this.#boundAddress === null) { + throw new Error( + 'Operation cancelled: no wallet account is bound to the venue session', + ); + } + let address: string | null = null; + try { + address = this.#walletService.getUserAddress().toLowerCase(); + } catch { + address = null; + } + if (address !== this.#boundAddress) { + if (address === null) { + // Deselected: nothing to rebind to yet. + this.#invalidateSessionState(); + this.#teardownStream(); + } else { + // Unobserved switch: rebind properly (invalidates caches and + // rebuilds stream channels for the new account) before cancelling + // the stale operation. + this.#ensureSessionBinding(); + } + throw new Error( + 'Operation cancelled: the wallet switched accounts (or the signer reset) while this operation was in flight', + ); + } + }; + + /** Drop every cache derived from the previously bound account. */ + readonly #invalidateSessionState = (): void => { + this.#sessionGeneration += 1; + this.#boundAddress = null; + this.#accountIndex = null; + this.#signerReadyPromise = null; + this.#authToken = null; + this.#clearBridgeOwnership(); + // #tpslUnsettled survives (address+accountIndex+symbol keyed): a + // reselect of the same account must still reconcile its pending ids. + }; + + /** + * Create the WASM signer client and register the venue key if the + * account's key slot does not hold it yet. Deduplicated. + * + * @returns Resolves when the signer session is ready. + */ + readonly #ensureSignerReady = async (): Promise => { + this.#ensureSessionBinding(); + if (this.#signerReadyPromise) { + return await this.#signerReadyPromise; + } + const generation = this.#sessionGeneration; + const setupPromise = this.#setupSigner(generation); + this.#signerReadyPromise = setupPromise; + try { + return await setupPromise; + } catch (error) { + // Only clear the promise WE installed — a newer session may already + // have replaced it, and an old rejection must not tear that down. + if (this.#signerReadyPromise === setupPromise) { + this.#signerReadyPromise = null; + } + throw error; + } + }; + + readonly #setupSigner = async (generation: number): Promise => { + const bridge = this.#getSignerBridge(); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const chainId = getLighterChainId(this.#clientService.network); + // The WASM client is a singleton inside the bridge host and the venue + // key registration is a nonce-consuming write. Both therefore run + // INSIDE the venue write lock: a stale previous-account setup aborts at + // the lock's fence before it can touch the bridge, and no other + // account's setup or write can interleave with this critical section. + await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + const seed = await this.#walletService.deriveKeySeedPlain( + this.#apiKeyIndex, + ); + this.#assertSession(generation); + const nonce = await nextNonce(); + this.#assertSession(generation); + const created = await bridge.execute({ + function: '_createClient', + params: [seed, chainId, accountIndex, nonce, this.#apiKeyIndex], + }); + if (created.error || !created.success) { + throw new Error( + `Lighter signer client creation failed: ${created.error ?? 'unknown'}`, + ); + } + this.#assertSession(generation); + this.#venuePublicKey = created.pk; + // Record bridge-client OWNERSHIP: the WASM client is a singleton + // per bridge, so every later write section re-establishes it + // when another identity has since overwritten it. + this.#signerIdentity = `${this.#clientService.network}:${accountIndex}:${this.#apiKeyIndex}`; + // The seed is deliberately NOT retained: re-establishment + // re-derives it under the bridge lease. + this.#signerRecreateParams = { chainId, accountIndex }; + bridgeClientOwners.set(this.#rawSignerBridge(), this.#signerIdentity); + + // Register the venue key when the slot does not hold it yet. Only + // the plaintext body leaves this scope — `created.prv` (the venue + // private key) must stay inside the signer bridge boundary and + // never be logged. + const registered = await this.#isVenueKeyRegistered(accountIndex); + this.#assertSession(generation); + if (!registered) { + await this.#registerVenueKey( + accountIndex, + created.body, + generation, + nextNonce, + submit, + ); + this.#assertSession(generation); + } + }, + generation, + ); + // AUTOMATIC bounded recovery: pending TP/SL journals must be + // reconciled at startup/reconnect, not only when the next mutation + // happens to run. Detached so it awaits THIS setup's resolved promise + // instead of deadlocking on it. + this.#kickTpslRecovery(); + }; + + readonly #isVenueKeyRegistered = async ( + accountIndex: number, + ): Promise => { + try { + const response = await this.#clientService.getApiKeys( + accountIndex, + this.#apiKeyIndex, + ); + return response.apiKeys.some( + (key) => + key.apiKeyIndex === this.#apiKeyIndex && + key.publicKey === this.#venuePublicKey, + ); + } catch { + return false; + } + }; + + readonly #registerVenueKey = async ( + accountIndex: number, + changePubKeyBody: string, + generation: number, + nextNonce: () => Promise, + submit: ( + txType: number, + txInfo: string, + onAccepted?: () => void, + identity?: { + txHash: string | null; + expiresAt: number | null; + intent?: string; + owner?: string | null; + }, + ) => Promise, + ): Promise => { + const bridge = this.#getSignerBridge(); + // The ChangePubKey plaintext from _createClient embeds the nonce used at + // client creation; sign it with the user's L1 account (EIP-191). Every + // await is fenced and the submission goes through the lock's fenced + // submit — a stale registration can never reach the venue. + const l1Signature = + await this.#walletService.signPersonalMessage(changePubKeyBody); + this.#assertSession(generation); + const nonce = await nextNonce(); + this.#assertSession(generation); + const signed = await bridge.execute({ + function: '_signChangePubKey', + params: [accountIndex, l1Signature, nonce, this.#apiKeyIndex], + }); + if (signed.error) { + throw new Error(`Lighter ChangePubKey signing failed: ${signed.error}`); + } + this.#assertSession(generation); + const result = await submit( + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + signed.txInfo, + undefined, + extractDispatchIdentity(signed), + ); + this.#deps.debugLogger.log('[LighterProvider] Venue key registered', { + accountIndex, + apiKeyIndex: this.#apiKeyIndex, + txHash: result.txHash, + }); + }; + + /** + * Mint (or reuse) an auth token for authenticated REST reads. + * + * @returns Auth token string. + */ + /** Tail of the serialized venue-write chain (see #withVenueNonce). */ + #writeChain: Promise = Promise.resolve(); + + /** Every client order id this instance has issued (collision set). */ + readonly #issuedClientOrderIds = new Set(); + + /** + * Atomically reserve unique client order indexes. + * + * The venue requires client_order_index to be UNIQUE ACROSS ALL MARKETS + * for the account (official Get Started docs) and does not require + * monotonicity. Ids are uniform random draws over the uint48 space + * (two 24-bit draws, exact in float space) with a per-instance + * collision set and retry: within an instance duplicates are + * impossible; across simultaneous instances/devices a single pair + * collides with probability 1/2^48 (~3.6e-15) and the birthday bound + * over n total ids is ~n(n-1)/2^49 — about 1.8e-7 after ten thousand + * orders, versus the 1% per-pair risk of the previous 100-lane scheme. + * + * @param count - How many ids to reserve. + * @returns The reserved ids. + */ + readonly #allocateClientOrderIndexes = (count: number): number[] => { + const ids: number[] = []; + // Bounded: a degenerate randomness source (or an absurdly full + // collision set) must surface as an error, never a synchronous spin. + // 100 attempts per id makes accidental exhaustion unreachable in + // practice (collision odds per draw stay astronomically small). + let attempts = 0; + const maxAttempts = count * 100; + while (ids.length < count) { + if (attempts >= maxAttempts) { + throw new Error( + `Unable to allocate a unique Lighter client order id after ${maxAttempts} attempts`, + ); + } + attempts += 1; + const [high, low] = randomUint24Pair(); + const candidate = high * 2 ** 24 + low; + if (candidate === 0 || this.#issuedClientOrderIds.has(candidate)) { + continue; + } + this.#issuedClientOrderIds.add(candidate); + ids.push(candidate); + } + return ids; + }; + + /** + * Serialize a nonce-consuming venue write. + * + * Lighter nonces are strictly ordered per key slot; two interleaved + * fetch→submit pairs (e.g. the controller's per-item batch fallbacks + * running concurrently) would sign with the same nonce and get one + * rejection. Every write acquires the chain, fetches a fresh nonce + * inside it, and submits before the next write's fetch runs. A section + * queued under a wallet account that has since been switched away from + * refuses to run — a delayed account-A write must never execute inside + * account-B's session. + * + * @param accountIndex - Account whose key-slot nonce is consumed. + * @param section - Work to run exclusively; fetch nonces via the + * provided helper (each call returns the next fresh nonce). + * @param generationAtIntent - Session generation captured when the + * caller's intent was formed (defaults to now). + * @returns The section's result. + */ + readonly #withVenueWriteLock = async ( + accountIndex: number, + section: ( + nextNonce: () => Promise, + submit: ( + txType: number, + txInfo: string, + onAccepted?: () => void, + identity?: { + txHash: string | null; + expiresAt: number | null; + intent?: string; + owner?: string | null; + }, + ) => Promise, + ) => Promise, + generationAtIntent = this.#sessionGeneration, + ): Promise => { + const criticalSection = async (): Promise => { + this.#assertSession(generationAtIntent); + // Every unresolved prior dispatch (this session OR a previous one — + // the ledger is durable) must resolve before this section may issue + // nonces: a restart would otherwise reuse a consumed-but-lagging + // nonce, and a proven never-landed dispatch must release its nonce. + await this.#resolveNonceLedger(accountIndex); + this.#assertSession(generationAtIntent); + // Monotonic nonce reservation: the venue's nextNonce endpoint can + // LAG accepted submissions. The floor is SESSION-GLOBAL per + // accountIndex:apiKeyIndex — a queued/next lock section (any + // symbol, any operation) must never be handed a nonce an earlier + // submission may have consumed, even when that submission's + // response was lost. Reservation advances at DISPATCH (a signing + // failure never burns a nonce the venue still expects); a proven + // never-landed submission releases it again via reconciliation. + const reservationKey = `${accountIndex}:${this.#apiKeyIndex}`; + let lastIssuedNonce: number | null = null; + const nextNonce = async (): Promise => { + // Re-fenced on every fetch AND after it resolves: the account can + // switch between the section's own await points, not only while it + // sat in the queue. + this.#assertSession(generationAtIntent); + const nonceResponse = await this.#clientService.getNextNonce( + accountIndex, + this.#apiKeyIndex, + ); + this.#assertSession(generationAtIntent); + const reservedFloor = this.#nonceReservations.get(reservationKey); + const issued = + reservedFloor === undefined + ? nonceResponse.nonce + : Math.max(nonceResponse.nonce, reservedFloor); + lastIssuedNonce = issued; + return issued; + }; + // BRIDGE OWNERSHIP: the WASM client is a singleton per bridge — + // another provider (different account/network sharing the bridge) + // may have overwritten it since our setup. Re-establish OUR client + // before any signing in this section. (During initial setup the + // identity is not yet recorded; setup itself creates the client.) + if ( + this.#signerIdentity !== null && + this.#signerRecreateParams !== null && + bridgeClientOwners.get(this.#rawSignerBridge()) !== this.#signerIdentity + ) { + await this.#reestablishSignerClient( + generationAtIntent, + await nextNonce(), + ); + } + const submit = async ( + txType: number, + txInfo: string, + onAccepted?: () => void, + identity?: { + txHash: string | null; + expiresAt: number | null; + intent?: string; + owner?: string | null; + }, + ): Promise => { + // Last fence before anything reaches the venue: a switch that + // happened while SIGNING must abort before submission. + this.#assertSession(generationAtIntent); + // Record the dispatch DURABLY BEFORE anything else: a failed + // ledger read/write means NO dispatch and an UNTOUCHED memory + // floor — the nonce stays safely unissued at the venue. The + // identity comes from the SIGNING RESULT (pinned WASM contract: + // txInfo never carries the hash). + let ledgerEntry: LighterNonceLedgerDoc['entries'][number] | null = null; + if (lastIssuedNonce !== null) { + // COMPLETE identity is REQUIRED before anything reaches the + // wire: a hashless dispatch could never be proven absent, so a + // response loss would wedge writes until the venue advances. + if ( + identity?.txHash === null || + identity?.expiresAt === null || + identity === undefined + ) { + throw new Error( + 'Lighter dispatch refused: the signing result did not provide a complete transaction identity (hash + expiry)', + ); + } + ledgerEntry = { + nonce: lastIssuedNonce, + txHash: identity.txHash, + expiresAt: identity.expiresAt, + kind: txType, + intent: identity.intent ?? `txType:${txType}`, + owner: identity.owner ?? null, + }; + const appendedEntry = ledgerEntry; + await this.#withLedgerLock(accountIndex, async () => { + const doc = await this.#readNonceLedger(accountIndex); + if (doc.entries.length >= 16) { + throw new Error( + 'Too many unresolved Lighter dispatches; refusing further writes until they resolve', + ); + } + await this.#writeNonceLedger(accountIndex, { + consumedFloor: doc.consumedFloor, + entries: [...doc.entries, appendedEntry], + recovered: doc.recovered, + }); + }); + // Only AFTER the durable append: reserve in memory — from this + // point the venue may consume the nonce even if the response + // never arrives. + this.#nonceReservations.set(reservationKey, lastIssuedNonce + 1); + } + // EVERY error path below keeps the durable entry — a coded venue + // or HTTP error can mask a commit, so nothing short of an exact + // authoritative reconciliation may release the nonce. + const response: LighterSendTxResponse = + await this.#clientService.sendTx(txType, txInfo); + // Acceptance bookkeeping runs SYNCHRONOUSLY before anything can + // fail: a switch during network submission must cancel the + // operation, never the record of an accepted venue mutation. + onAccepted?.(); + // POST-SEND ORDER: evaluate the session fence BEFORE the ledger + // entry transitions, then commit the transition ATOMICALLY in + // ONE write under the ledger lock — fence pass → consumed/ + // removed; fence fail → recovered(SUCCEEDED). If that single + // write fails, the ORIGINAL unresolved entry remains the durable + // record and every retry stays blocked; the only durable proof + // of the accepted mutation is never consumed first and + // quarantined second. + let fenceError: unknown = null; + try { + this.#assertSession(generationAtIntent); + } catch (error) { + fenceError = error; + } + if (ledgerEntry !== null) { + await this.#resolveEntryPostDispatch( + accountIndex, + ledgerEntry, + fenceError !== null, + ).catch(() => undefined); + } + if (fenceError !== null) { + throw ensureError(fenceError, 'LighterProvider.submit'); + } + return response; + }; + return await section(nextNonce, submit); + }; + // The ENTIRE nonce resolve→fetch→sign/append→dispatch sequence is + // serialized PROCESS-WIDE per network+account+api-key slot: the + // instance chain alone cannot stop a second live provider from + // issuing the same nonce or interleaving ledger writes. + const guardedSection = async (): Promise => + await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}:${this.#apiKeyIndex}`, + // INNERMOST: the bridge mutex — the WASM client is a singleton + // per bridge, so ensure-correct-client + every sign of a section + // are serialized across ALL providers sharing the bridge. + async () => + await withProcessMutex( + bridgeMutexKey(this.#rawSignerBridge()), + criticalSection, + ), + ); + const run = this.#writeChain.then(guardedSection, guardedSection); + this.#writeChain = run.then( + () => undefined, + () => undefined, + ); + return await run; + }; + + readonly #withVenueNonce = async ( + accountIndex: number, + operation: ( + nonce: number, + submit: ( + txType: number, + txInfo: string, + onAccepted?: () => void, + identity?: { + txHash: string | null; + expiresAt: number | null; + intent?: string; + owner?: string | null; + }, + ) => Promise, + ) => Promise, + generationAtIntent = this.#sessionGeneration, + ): Promise => + await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => operation(await nextNonce(), submit), + generationAtIntent, + ); + + /** + * Re-create OUR venue client on the shared bridge after another + * identity overwrote the singleton. MUST run while holding the bridge + * mutex. The wallet-derived seed is re-derived here — never retained. + * + * @param generation - The caller's captured session generation. + * @param nonce - A fresh venue nonce for the client creation. + */ + readonly #reestablishSignerClient = async ( + generation: number, + nonce: number, + ): Promise => { + const recreateParams = this.#signerRecreateParams; + const identity = this.#signerIdentity; + if (recreateParams === null || identity === null) { + throw new Error( + 'Lighter signer client re-establishment attempted before setup', + ); + } + const seed = await this.#walletService.deriveKeySeedPlain( + this.#apiKeyIndex, + ); + this.#assertSession(generation); + const recreated = await this.#getSignerBridge().execute<{ + success?: boolean; + error?: string; + }>({ + function: '_createClient', + params: [ + seed, + recreateParams.chainId, + recreateParams.accountIndex, + nonce, + this.#apiKeyIndex, + ], + }); + if (recreated.error || !recreated.success) { + throw new Error( + `Lighter signer client re-establishment failed: ${recreated.error ?? 'unknown'}`, + ); + } + this.#assertSession(generation); + bridgeClientOwners.set(this.#rawSignerBridge(), identity); + }; + + readonly #getAuthToken = async (): Promise => { + this.#ensureSessionBinding(); + const nowSeconds = Math.floor(Date.now() / 1000); + if (this.#authToken && this.#authToken.deadline - nowSeconds > 60) { + return this.#authToken.token; + } + const generation = this.#sessionGeneration; + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + // The auth-token mint is a singleton-client call like any other sign: + // it runs under the BRIDGE LEASE, and re-establishes OUR client first + // when another identity has since overwritten it — otherwise the + // token would be minted by the wrong account's venue key. + const token = await withProcessMutex( + bridgeMutexKey(this.#rawSignerBridge()), + async () => { + if ( + this.#signerIdentity !== null && + this.#signerRecreateParams !== null && + bridgeClientOwners.get(this.#rawSignerBridge()) !== + this.#signerIdentity + ) { + // Client creation is bridge-local: the read-only nonce fetch + // seeds its tracking without dispatching anything. + const nonceResponse = await this.#clientService.getNextNonce( + this.#signerRecreateParams.accountIndex, + this.#apiKeyIndex, + ); + this.#assertSession(generation); + await this.#reestablishSignerClient(generation, nonceResponse.nonce); + } + return await this.#getSignerBridge().execute( + { + function: '_createAuthToken', + params: [accountIndex, this.#apiKeyIndex], + }, + ); + }, + ); + if (token.error || !token.token) { + throw new Error( + `Lighter auth token creation failed: ${token.error ?? 'unknown'}`, + ); + } + // Rebind first so an unobserved external switch during the bridge call + // advances the generation, then compare: a token minted under a binding + // that no longer exists must never be cached — re-mint under the new + // captured session instead. + this.#ensureSessionBinding(); + if (generation !== this.#sessionGeneration) { + return await this.#getAuthToken(); + } + this.#authToken = { token: token.token, deadline: token.deadline }; + return token.token; + }; + + readonly #ensureMarkets = async (): Promise< + Map + > => { + if (this.#marketsBySymbol.size === 0) { + await this.initialize(); + } + return this.#marketsBySymbol; + }; + + // ============================================================================ + // Market Data Operations (Public Reads) + // ============================================================================ + + async getMarkets(_params?: GetMarketsParams): Promise { + try { + const markets = await this.#clientService.getOrderBooks(); + // Best effort: per-market max leverage from the venue's margin + // fractions; the adapter's constant only stands in when unknown. + await this.#ensureMarketMargins().catch(() => undefined); + return markets + .filter((market) => market.marketType === 'perp') + .map((market) => { + const adapted = adaptMarketFromLighter(market); + const margins = this.#marginBySymbol.get(market.symbol); + if (margins?.minInitial && margins.minInitial > 0) { + adapted.maxLeverage = Math.floor(10_000 / margins.minInitial); + } + // The venue floor is the SAME base size placement enforces: + // max(minBase, minQuote/price) rounded UP to the size grid — + // grid rounding matters (ETH: $10/price = 0.005222 rounds up + // to 0.0053 ETH ≈ $10.15), so a flat quote-minimum default + // lands one grid tick below the floor. Report that binding + // base size in USD, rounded UP to whole cents. + if (margins?.lastTradePrice && margins.lastTradePrice > 0) { + const minBaseSize = computeLighterMinOrderSize( + market, + margins.lastTradePrice, + ); + const bindingUsd = minBaseSize * margins.lastTradePrice; + if (Number.isFinite(bindingUsd) && bindingUsd > 0) { + adapted.minimumOrderSize = Math.ceil(bindingUsd * 100) / 100; + } + } + return adapted; + }); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getMarkets', + ); + this.#deps.debugLogger.log('[LighterProvider] getMarkets failed', { + error: String(wrappedError), + ...this.#getErrorContext('getMarkets'), + }); + return []; + } + } + + async getMarketDataWithPrices(): Promise { + try { + const response = await this.#clientService.getOrderBookDetails(); + return response.orderBookDetails + .filter((detail) => detail.marketType === 'perp') + .map((detail) => + adaptMarketDataFromLighter(detail, this.#deps.marketDataFormatters), + ); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getMarketDataWithPrices', + ); + this.#deps.debugLogger.log( + '[LighterProvider] getMarketDataWithPrices failed', + { + error: String(wrappedError), + ...this.#getErrorContext('getMarketDataWithPrices'), + }, + ); + return []; + } + } + + // ============================================================================ + // Account Operations + // ============================================================================ + + async getPositions(_params?: GetPositionsParams): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + // Per-market max leverage comes from the margin cache; warm it so + // known markets never fall back to the global constant. + await this.#ensureMarketMargins().catch(() => undefined); + const accountIndex = await this.#ensureAccountIndex(); + const response = + await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generation); + const account = response.accounts[0]; + if (!account?.positions) { + return []; + } + // Adapt BEFORE filtering: the adapter strict-validates raw numeric + // sizes, and a prefix-parsing filter would silently drop (or keep) + // malformed entries like '0oops' before validation could fire. + return account.positions + .map((position) => + adaptPositionFromLighter( + position, + this.#maxLeverageForMarketId(position.marketId), + ), + ) + .filter((position) => parseFloat(position.size) !== 0); + } catch (caughtError) { + if ( + this.#isUnsupportedCapabilityError(caughtError) || + this.#isDataIntegrityError(caughtError) + ) { + // Capability gates and venue-data integrity failures must surface, + // never degrade into empty state that can preserve stale views. + throw caughtError; + } + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getPositions', + ); + this.#deps.debugLogger.log('[LighterProvider] getPositions failed', { + error: String(wrappedError), + ...this.#getErrorContext('getPositions'), + }); + return []; + } + } + + async getAccountState( + _params?: GetAccountStateParams, + ): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const response = + await this.#clientService.getAccountByIndex(accountIndex); + // A delayed response for the previous account must never surface as + // the current account's state. + this.#assertSession(generation); + const account = response.accounts[0]; + if (!account) { + return EMPTY_ACCOUNT_STATE; + } + return adaptAccountStateFromLighter(account); + } catch (caughtError) { + if (this.#isUnsupportedCapabilityError(caughtError)) { + // Capability gates must surface, never degrade into empty state. + throw caughtError; + } + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getAccountState', + ); + this.#deps.debugLogger.log('[LighterProvider] getAccountState failed', { + error: String(wrappedError), + ...this.#getErrorContext('getAccountState'), + }); + return EMPTY_ACCOUNT_STATE; + } + } + + /** + * STRICT active-orders read: any REST/auth failure THROWS. Mutation + * flows (TP/SL replacement/removal) must use this — treating a swallowed + * [] as authoritative would let them "succeed" while cancelling nothing. + * + * @returns Adapted open orders. + */ + readonly #readOpenOrdersStrict = async (): Promise => { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const authToken = await this.#getAuthToken(); + // The index and the token must belong to the SAME session — never + // pair the previous account's index with the new account's token. + this.#assertSession(generation); + const response = await this.#clientService.getActiveOrders( + accountIndex, + authToken, + ); + this.#assertSession(generation); + return response.orders.map((order) => + adaptOrderFromLighter( + order, + this.#marketsById.get(order.marketIndex)?.symbol ?? + String(order.marketIndex), + ), + ); + }; + + async getOpenOrders(_params?: GetOrdersParams): Promise { + // Public reads re-kick pending journal recovery (deduped, detached). + this.#kickTpslRecovery(); + try { + return await this.#readOpenOrdersStrict(); + } catch (caughtError) { + if (this.#isUnsupportedCapabilityError(caughtError)) { + // Capability gates must surface, never degrade into empty state. + throw caughtError; + } + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getOpenOrders', + ); + this.#deps.debugLogger.log('[LighterProvider] getOpenOrders failed', { + error: String(wrappedError), + ...this.#getErrorContext('getOpenOrders'), + }); + return []; + } + } + + async getOrders( + params?: GetOrdersParams, + _options?: PerpsReadOptions, + ): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const authToken = await this.#getAuthToken(); + this.#assertSession(generation); + await this.#ensureMarkets(); + const response = await this.#clientService.getInactiveOrders( + accountIndex, + authToken, + ); + // Both legs (historical + open) must come from one session — a + // switch mid-way would merge account A's history with B's orders. + this.#assertSession(generation); + const historical = (response.orders ?? []).map((order) => + adaptOrderFromLighter( + order, + this.#marketsById.get(order.marketIndex)?.symbol ?? + String(order.marketIndex), + ), + ); + // Full lifecycle: open orders first, then the historical states. + const open = await this.getOpenOrders(params); + // getOpenOrders swallows its own cancellation into []; the merge must + // still refuse to pair A's history with B's session. + this.#assertSession(generation); + return [...open, ...historical]; + } catch (caughtError) { + if (this.#isUnsupportedCapabilityError(caughtError)) { + // Capability gates must surface, never degrade into empty state. + throw caughtError; + } + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getOrders', + ); + this.#deps.debugLogger.log('[LighterProvider] getOrders failed', { + error: String(wrappedError), + ...this.#getErrorContext('getOrders'), + }); + return []; + } + } + + async getCurrentAccountId(): Promise { + const address = this.#walletService.getUserAddress(); + const chainId = getLighterChainId(this.#clientService.network); + return `eip155:${chainId}:${address}` as CaipAccountId; + } + + // ============================================================================ + // Trading Operations (POC: limit/market place + cancel) + // ============================================================================ + + /** + * Apply the leverage the caller requested with the order. + * + * Lighter models leverage as a per-market account setting (UpdateLeverage, + * tx 20; initial margin fraction in hundredths of a percent), not an order + * field. The venue rejects the update while a position or resting order + * exists on the market, so in that case the request is skipped with a log + * (matching the already-set leverage is not an error). + * + * @param accountIndex - Lighter account index. + * @param market - Market metadata for the order being placed. + * @param params - The original order params carrying `leverage`. + */ + /** + * Decide whether the caller's requested leverage needs a venue update. + * + * @param params - The order params carrying `leverage`. + * @returns The UpdateLeverage margin fraction (hundredths of a percent) + * to sign, or null when no change is needed. + */ + readonly #resolveLeverageIntent = async ( + params: OrderParams, + ): Promise => { + const requested = params.leverage; + if (requested === undefined) { + return null; + } + // Venue state decides, never the caller's possibly-stale + // existingPositionLeverage snapshot. + const positions = await this.getPositions(); + const held = positions.find( + (position) => position.symbol === params.symbol, + ); + if ( + held?.leverage?.value !== undefined && + Math.abs(held.leverage.value - requested) < 0.5 + ) { + // Requested leverage already in effect — intent satisfied. + return null; + } + // Otherwise sign the update inside the placement's own write lock. If + // the market has a position or resting order the venue rejects it with + // a clear error, failing the placement instead of silently trading at + // a leverage the caller did not ask for. + return Math.round(10_000 / requested); + }; + + async placeOrder( + params: OrderParams, + inheritedGeneration?: number, + ): Promise { + // Tracks a COMMITTED leverage change so an order failing afterwards + // reports the partial venue state explicitly instead of implying no + // mutation happened. + let leverageCommitted = false; + try { + if (params.orderType !== 'limit' && params.orderType !== 'market') { + return { success: false, error: LIGHTER_NOT_SUPPORTED_ERROR }; + } + // User intent is never silently dropped: fields this venue path does + // not execute are rejected so the caller can adapt, not surprised. + if (params.takeProfitPrice || params.stopLossPrice) { + return { + success: false, + error: + 'Lighter does not support TP/SL attached at placement; place the order, then call updatePositionTPSL', + }; + } + if (params.timeInForce === 'ALO') { + return { + success: false, + error: 'Lighter placement does not support post-only (ALO) yet', + }; + } + const leverageError = lighterLeverageError(params.leverage); + if (leverageError) { + return { success: false, error: leverageError }; + } + // Bind the write to the wallet account it was INITIATED under; if the + // wallet switches before the queued critical section runs, it aborts. + // A composite caller (closePosition) passes ITS generation so the + // whole read-then-write sequence shares one intent identity. + this.#ensureSessionBinding(); + const generationAtIntent = inheritedGeneration ?? this.#sessionGeneration; + this.#assertSession(generationAtIntent); + // All intent validation below uses PUBLIC market data only; signer + // and account setup are deferred until it passes so invalid intent + // causes zero bridge calls (no client creation or key registration + // side effects). + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + if (!market) { + return { + success: false, + error: `Unknown Lighter market: ${params.symbol}`, + }; + } + if (params.orderType === 'limit' && !params.price) { + return { success: false, error: 'Limit order requires a price' }; + } + if (params.leverage !== undefined) { + // Authoritative metadata REQUIRED: the display fallback (global + // 50x) must never approve leverage for a market whose published + // bound is unavailable. + const maxLeverage = await this.#requireMarketMaxLeverage(params.symbol); + if (maxLeverage === null) { + return { + success: false, + error: `Cannot validate leverage for ${params.symbol}: venue margin metadata unavailable`, + }; + } + if (params.leverage > maxLeverage) { + return { + success: false, + error: `Invalid leverage ${params.leverage}: exceeds the ${params.symbol} maximum of ${maxLeverage}x`, + }; + } + } + + // Slippage tolerance: caller basis points win, then the deprecated + // decimal field, then the venue-conventional 5%. + const slippageFraction = + params.maxSlippageBps === undefined + ? (params.slippage ?? 0.05) + : params.maxSlippageBps / 10_000; + // The reference price sizes the order; market orders additionally get + // a protection price offset by the slippage tolerance. They are kept + // separate so usdAmount sizing is never distorted by the protection + // offset. + let referencePrice: number; + if (params.orderType === 'limit') { + // STRICT full-string parse: '90000USD' prefix-parses under + // parseFloat and must never become signed intent. + const parsedLimitPrice = parseFinitePositive(params.price ?? ''); + if (parsedLimitPrice === null) { + return { + success: false, + error: `Invalid limit price ${params.price}: must be a positive number`, + }; + } + referencePrice = parsedLimitPrice; + } else { + referencePrice = parseFloat( + params.price ?? String(params.currentPrice ?? 0), + ); + } + let executionPrice = referencePrice; + if (params.orderType === 'market') { + const resolved = await this.#resolveMarketReferencePrice( + params.symbol, + slippageFraction, + params.priceAtCalculation, + ); + if (resolved.error !== null) { + return { success: false, error: resolved.error }; + } + referencePrice = resolved.referencePrice; + executionPrice = deriveLighterExecutionPrice( + referencePrice, + params.isBuy, + slippageFraction, + ); + } + // Finite AND positive: 'Infinity' passes a bare > 0 check but would + // corrupt integerization/signing downstream. + if ( + !Number.isFinite(referencePrice) || + !(referencePrice > 0) || + !Number.isFinite(executionPrice) || + !(executionPrice > 0) + ) { + return { + success: false, + error: 'Unable to resolve a finite execution price for the order', + }; + } + // USD is the source of truth when provided (hybrid sizing contract), + // converted at the reference price — not the protection price. A + // provided-but-invalid usdAmount is an error, never a silent fallback + // to the size field. + let requestedSize: number; + if (params.usdAmount === undefined) { + const parsedSize = parseFinitePositive(params.size); + if (parsedSize === null) { + return { success: false, error: 'Order size must be positive' }; + } + requestedSize = parsedSize; + } else { + const usdAmount = parseFinitePositive(params.usdAmount); + if (usdAmount === null) { + return { + success: false, + error: `Invalid usdAmount ${params.usdAmount}: must be a positive number`, + }; + } + // A USD amount is approximate by contract (converted at the + // reference price), so it is snapped onto the venue size grid the + // way wire integerization will round it — an explicit size string + // is exact user intent and is never adjusted here. + requestedSize = snapToLighterSizeGrid( + usdAmount / referencePrice, + market.supportedSizeDecimals, + ); + } + if (!(requestedSize > 0)) { + return { success: false, error: 'Order size must be positive' }; + } + const minSize = computeLighterMinOrderSize(market, referencePrice); + if (requestedSize < minSize) { + // Only a LIVE-VERIFIED full close may be bumped to the venue + // minimum: reduce-only execution clamps to the position, so no + // extra exposure results and dust positions stay closable. The + // isFullClose flag is a hint, never trusted — a partial close + // bumped to the minimum would close more than the caller asked. + const verifiedFullClose = params.reduceOnly + ? await this.#isVerifiedFullClose(params.symbol, requestedSize) + : false; + if (!verifiedFullClose) { + return { + success: false, + error: `Order size ${requestedSize} is below the Lighter minimum of ${minSize} ${params.symbol}`, + }; + } + } + const size = Math.max(requestedSize, minSize); + + // Wire-format integerization runs BEFORE signer setup: overflow and + // sub-tick rejections throw here, still with zero bridge calls. + const priceInt = toSignerWirePriceInteger( + executionPrice, + market.supportedPriceDecimals, + ); + const sizeInt = toSignerWireInteger(size, market.supportedSizeDecimals); + + const leverageImfHundredths = await this.#resolveLeverageIntent(params); + // The app manages ISOLATED positions only (there is no cross-margin + // management UI, and small cross positions report no liquidation + // price), so a flat market opens isolated. The venue refuses + // changing the mode of a market with an open position, so an + // existing position keeps whatever mode it already has. + const leverageMarginMode = + leverageImfHundredths === null + ? LIGHTER_MARGIN_MODE_ISOLATED + : await this.#resolveMarginModeForSymbol(params.symbol); + + // Intent validated — only now do signer and account setup run. + // Re-fence FIRST: the preflight awaited public/account reads during + // which the wallet may have switched, and a stale intent must never + // create or register the new account's venue key. + this.#assertSession(generationAtIntent); + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generationAtIntent); + const [clientOrderIndex] = this.#allocateClientOrderIndexes(1); + + // Leverage update and order placement share ONE lock acquisition so a + // concurrent write can never interleave between the caller's leverage + // intent and the order that depends on it. + const result = await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + if (leverageImfHundredths !== null) { + const signedLeverage = + await this.#getSignerBridge().execute({ + function: '_signUpdateLeverage', + // Contract: [accountIndex, marketId, imfHundredths, + // marginMode, nonce] — exactly five params. + params: [ + accountIndex, + market.marketId, + leverageImfHundredths, + leverageMarginMode, + await nextNonce(), + ], + }); + if (signedLeverage.error) { + throw new Error( + `Lighter leverage update failed: ${signedLeverage.error}`, + ); + } + await submit( + LIGHTER_TX_TYPE_UPDATE_LEVERAGE, + signedLeverage.txInfo, + undefined, + { + ...extractDispatchIdentity(signedLeverage), + intent: `updateLeverage:${params.symbol}:${String(params.leverage)}`, + }, + ); + leverageCommitted = true; + } + const signed = await this.#getSignerBridge().execute( + { + function: '_signCreateOrder', + params: [ + accountIndex, + market.marketId, + clientOrderIndex, + String(sizeInt), + String(priceInt), + params.isBuy ? 0 : 1, + params.orderType === 'limit' + ? LIGHTER_ORDER_TYPE_LIMIT + : LIGHTER_ORDER_TYPE_MARKET, + params.orderType === 'limit' && params.timeInForce !== 'IOC' + ? LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME + : LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + params.reduceOnly ? 1 : 0, + String(LIGHTER_NO_TRIGGER_PRICE), + // GTT orders auto-expire in 28 days (signer sentinel -1); + // IOC orders must carry a zero expiry. + params.orderType === 'limit' && params.timeInForce !== 'IOC' + ? LIGHTER_ORDER_EXPIRY_NONE + : 0, + await nextNonce(), + ], + }, + ); + if (signed.error) { + throw new Error(`Lighter order signing failed: ${signed.error}`); + } + return await submit( + LIGHTER_TX_TYPE_CREATE_ORDER, + signed.txInfo, + undefined, + { + ...extractDispatchIdentity(signed), + intent: `placeOrder:${params.symbol}:${clientOrderIndex}`, + }, + ); + }, + generationAtIntent, + ); + + this.#deps.debugLogger.log('[LighterProvider] Order placed', { + symbol: params.symbol, + clientOrderIndex, + txHash: result.txHash, + }); + + return { + success: true, + orderId: String(clientOrderIndex), + submittedSize: String(size), + providerId: 'lighter', + }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.placeOrder', + ); + this.#deps.debugLogger.log('[LighterProvider] placeOrder failed', { + error: String(wrappedError), + ...this.#getErrorContext('placeOrder', { symbol: params.symbol }), + }); + // PARTIAL VENUE STATE is contract-visible: a committed leverage + // change followed by an order failure must never imply "nothing + // happened". + const partialPrefix = leverageCommitted + ? `PARTIAL STATE: leverage for ${params.symbol} was already updated to ${String(params.leverage)}x before the order failed. ` + : ''; + return { + success: false, + error: `${partialPrefix}${wrappedError.message}`, + ...(leverageCommitted + ? { partialState: { leverageUpdated: Number(params.leverage) } } + : {}), + }; + } + } + + async cancelOrder( + params: CancelOrderParams, + inheritedGeneration?: number, + ): Promise { + try { + this.#ensureSessionBinding(); + const generationAtIntent = inheritedGeneration ?? this.#sessionGeneration; + this.#assertSession(generationAtIntent); + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + if (!market) { + return { + success: false, + error: `Unknown Lighter market: ${params.symbol}`, + }; + } + + await this.#withVenueNonce( + accountIndex, + async (nonce, submit) => { + const signed = await this.#getSignerBridge().execute( + { + function: '_signCancelOrder', + params: [accountIndex, market.marketId, params.orderId, nonce], + }, + ); + if (signed.error) { + throw new Error(`Lighter cancel signing failed: ${signed.error}`); + } + return await submit( + LIGHTER_TX_TYPE_CANCEL_ORDER, + signed.txInfo, + undefined, + { + ...extractDispatchIdentity(signed), + intent: `cancelOrder:${params.symbol}:${params.orderId}`, + }, + ); + }, + generationAtIntent, + ); + + return { + success: true, + orderId: params.orderId, + providerId: 'lighter', + }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.cancelOrder', + ); + this.#deps.debugLogger.log('[LighterProvider] cancelOrder failed', { + error: String(wrappedError), + ...this.#getErrorContext('cancelOrder', { symbol: params.symbol }), + }); + return { success: false, error: wrappedError.message }; + } + } + + // ============================================================================ + // Trading Operations (POC: stubbed) + // ============================================================================ + + async editOrder(_params: EditOrderParams): Promise { + // ModifyOrder (tx 17) is accepted by the venue's sendTx but the resting + // order keeps its original price — an execution no-op we have raised + // with Lighter. Reporting success here would misrepresent user intent, + // so the operation refuses until the venue behavior is resolved. + // Callers can cancel + re-place instead. + return { + success: false, + error: + 'Lighter order editing is unavailable: the venue currently accepts but does not apply ModifyOrder. Cancel and re-place the order instead.', + }; + } + + /** + * Resolve the FRESH venue reference price for a market-order sizing, + * with the same fail-closed and drift semantics as execution — shared + * by placement and close validation so they can never disagree. + * + * @param symbol - Market symbol. + * @param slippageFraction - Caller slippage tolerance (fraction). + * @param priceAtCalculation - Caller's sizing snapshot, if any. + * @returns The fresh reference price, or the exact execution error. + */ + readonly #resolveMarketReferencePrice = async ( + symbol: string, + slippageFraction: number, + priceAtCalculation?: number, + ): Promise< + | { referencePrice: number; error: null } + | { referencePrice: null; error: string } + > => { + // Numeric intent validates fail-closed BEFORE any drift math. A + // non-finite/non-positive snapshot makes the drift comparison NaN + // (silently bypassing protection), and a tolerance at or above 100% + // derives a zero-or-negative protection price on sells. + if ( + !Number.isFinite(slippageFraction) || + slippageFraction < 0 || + slippageFraction >= 1 + ) { + return { + referencePrice: null, + error: `Invalid slippage tolerance ${slippageFraction * 10_000} bps: must be at least 0 and below 10000`, + }; + } + if ( + priceAtCalculation !== undefined && + (!Number.isFinite(priceAtCalculation) || !(priceAtCalculation > 0)) + ) { + return { + referencePrice: null, + error: `Invalid price snapshot ${priceAtCalculation}: must be a positive finite number`, + }; + } + // Always a FRESH venue price: the caller's currentPrice is the same + // snapshot as priceAtCalculation, and a drift check that compares a + // snapshot to itself would never fire. + const details = await this.#clientService.getOrderBookDetails(); + const freshPrice = + details.orderBookDetails.find((entry) => entry.symbol === symbol) + ?.lastTradePrice ?? 0; + if (!Number.isFinite(freshPrice) || !(freshPrice > 0)) { + // Fail closed: falling back to the caller's snapshot would let the + // drift check compare that snapshot to itself. + return { + referencePrice: null, + error: `No live venue price available for ${symbol}; refusing to size a market order`, + }; + } + if ( + priceAtCalculation !== undefined && + priceAtCalculation > 0 && + Math.abs(freshPrice - priceAtCalculation) / priceAtCalculation > + slippageFraction + ) { + return { + referencePrice: null, + error: `Price moved beyond the ${(slippageFraction * 100).toFixed(2)}% slippage tolerance since sizing`, + }; + } + return { referencePrice: freshPrice, error: null }; + }; + + /** + * Validate the shape of a close request (shared by validateClosePosition + * and closePosition so validation can never approve a close the + * execution path refuses). + * + * @param params - Close request. + * @returns Error message, or null when the shape is acceptable. + */ + /** + * The mobile close sheet sends a FULL close as an EMPTY size string + * (`size: sizeToClose || ''`); HyperLiquid and TradingService treat a + * falsy size as "no explicit size", so this venue honors the same + * contract — an empty/whitespace size or usdAmount means full close, + * never a validation failure. + * + * @param params - Raw close request. + * @returns The request with empty-string sizing normalized to absent. + */ + readonly #normalizeCloseParams = ( + params: ClosePositionParams, + ): ClosePositionParams => ({ + ...params, + size: params.size?.trim() ? params.size : undefined, + usdAmount: params.usdAmount?.trim() ? params.usdAmount : undefined, + }); + + readonly #validateCloseShape = ( + params: ClosePositionParams, + ): string | null => { + const closeOrderType = params.orderType ?? 'market'; + if (closeOrderType !== 'market' && closeOrderType !== 'limit') { + return `Lighter cannot close with a ${closeOrderType} order; use market or limit`; + } + if (closeOrderType === 'limit' && !params.price) { + return 'Limit close requires a price'; + } + if ( + params.usdAmount !== undefined && + parseFinitePositive(params.usdAmount) === null + ) { + // Finite REQUIRED: a non-finite usdAmount must never fall back to + // held-size validation while execution forwards the infinite USD + // into placement. + return `Invalid usdAmount ${params.usdAmount}: must be a positive number`; + } + // closePosition forwards an explicit size to placement, which rejects + // non-finite or non-positive values; validation must match. + if ( + params.usdAmount === undefined && + params.size !== undefined && + parseFinitePositive(params.size) === null + ) { + return 'Order size must be positive'; + } + return null; + }; + + /** + * Live check whether a below-minimum reduce-only request is actually a + * full close of the held position (shared by placement and validation). + * + * @param symbol - Market symbol. + * @param requestedSize - Requested base size. + * @returns True when the live position verifies a full close. + */ + readonly #isVerifiedFullClose = async ( + symbol: string, + requestedSize: number, + ): Promise => { + const positions = await this.getPositions(); + const held = Math.abs( + parseFloat( + positions.find((entry) => entry.symbol === symbol)?.size ?? '0', + ), + ); + // Exact match (float epsilon only): closePosition forwards the precise + // live size, and anything less is a deliberate partial that a min-size + // bump would silently over-close. + return held > 0 && requestedSize >= held * (1 - 1e-9); + }; + + async closePosition(rawParams: ClosePositionParams): Promise { + const params = this.#normalizeCloseParams(rawParams); + try { + // One intent identity from the position read through the final write: + // an account switch mid-sequence aborts instead of trading the new + // account with sizing derived from the old one. + this.#ensureSessionBinding(); + const generationAtIntent = this.#sessionGeneration; + const closeOrderType = params.orderType ?? 'market'; + const shapeError = this.#validateCloseShape(params); + if (shapeError) { + return { success: false, error: shapeError }; + } + const positions = await this.getPositions(); + this.#assertSession(generationAtIntent); + const position = positions.find( + (entry) => entry.symbol === params.symbol, + ); + if (!position) { + return { + success: false, + error: `No open Lighter position for ${params.symbol}`, + }; + } + const signedSize = parseFloat(position.size); + const explicitSizing = + params.size !== undefined || params.usdAmount !== undefined; + const closeSize = params.size ?? String(Math.abs(signedSize)); + // Reduce-only order on the opposite side; the caller's full sizing + // and protection intent (usdAmount, slippage, price snapshot, limit + // price) rides through the placement path unchanged. + return await this.placeOrder( + { + symbol: params.symbol, + isBuy: signedSize < 0, + size: closeSize, + usdAmount: params.usdAmount, + orderType: closeOrderType, + price: params.price, + reduceOnly: true, + // Without explicit sizing this is a full close and must never be + // rejected by the minimum-notional check on a dust position. + isFullClose: !explicitSizing, + currentPrice: params.currentPrice, + priceAtCalculation: params.priceAtCalculation, + maxSlippageBps: params.maxSlippageBps, + }, + generationAtIntent, + ); + } catch (error) { + const wrappedError = ensureError(error, 'LighterProvider.closePosition'); + this.#deps.debugLogger.log('[LighterProvider] closePosition failed', { + error: String(wrappedError), + ...this.#getErrorContext('closePosition'), + }); + return { success: false, error: wrappedError.message }; + } + } + + async updatePositionTPSL( + params: UpdatePositionTPSLParams, + ): Promise { + try { + // Partial TP/SL sizes are NOT wired to this venue path: it always + // covers the full position. Silently ignoring a requested partial + // size would close the entire position when the trigger fires, so + // the request is refused before any read, signer setup or mutation. + if ( + params.takeProfitSize !== undefined || + params.stopLossSize !== undefined + ) { + return { + success: false, + error: + 'Lighter TP/SL covers the full position: partial takeProfitSize/stopLossSize are not supported', + }; + } + this.#ensureSessionBinding(); + const generationAtIntent = this.#sessionGeneration; + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + if (!market) { + return { + success: false, + error: `Unknown Lighter market: ${params.symbol}`, + }; + } + // The lifecycle boundary is captured BEFORE the position read: a + // fill landing DURING the read belongs to the operation's window + // and must count as lifecycle evidence. + const lifecycleBoundary = Date.now(); + const positions = await this.getPositions(); + const position = positions.find( + (entry) => entry.symbol === params.symbol, + ); + if (!position) { + return { + success: false, + error: `No open Lighter position for ${params.symbol}`, + }; + } + + // FULL local preflight: construct the entire deterministic + // replacement payload BEFORE signer setup, the open-orders read and + // any cancellation. Everything that can fail locally — venue + // position-size parsing/integerization, trigger/execution price + // parsing/integerization, bounded client-id allocation — must fail + // while the existing protection is still in place. + const wantsReplacement = + Boolean(params.takeProfitPrice) || Boolean(params.stopLossPrice); + let groupedPayload: (string | number)[] | null = null; + let createdClientIds: number[] = []; + let createdIdsNeedingFinalCheck: number[] = []; + let groupedOrderCount = 0; + let groupedType = 0; + if (wantsReplacement) { + // getPositions does not validate venue sizes; a non-finite or + // sub-tick size must abort here, not after the cancels. + const signedSize = parseFloat(position.size); + if (!Number.isFinite(signedSize) || signedSize === 0) { + return { + success: false, + error: `Invalid live position size ${position.size} for ${params.symbol}`, + }; + } + const isLong = signedSize > 0; + const coverSize = Math.abs(signedSize); + const sizeInt = toSignerWireInteger( + coverSize, + market.supportedSizeDecimals, + ); + // Closing side is opposite the position; trigger market orders + // execute at a protection price 5% beyond the trigger in the taker + // direction. + const isAsk = isLong ? 1 : 0; + const orderIntents: { + orderType: number; + raw: string; + label: string; + }[] = []; + if (params.takeProfitPrice) { + orderIntents.push({ + orderType: LIGHTER_ORDER_TYPE_TAKE_PROFIT, + raw: params.takeProfitPrice, + label: 'takeProfitPrice', + }); + } + if (params.stopLossPrice) { + orderIntents.push({ + orderType: LIGHTER_ORDER_TYPE_STOP_LOSS, + raw: params.stopLossPrice, + label: 'stopLossPrice', + }); + } + const validatedOrders: { + orderType: number; + execInt: number; + triggerInt: number; + }[] = []; + for (const intent of orderIntents) { + const trigger = parseFinitePositive(intent.raw); + if (trigger === null) { + return { + success: false, + error: `Invalid ${intent.label} ${intent.raw}: must be a positive number`, + }; + } + const execution = isLong ? trigger * 0.95 : trigger * 1.05; + validatedOrders.push({ + orderType: intent.orderType, + execInt: toSignerWirePriceInteger( + execution, + market.supportedPriceDecimals, + ), + triggerInt: toSignerWirePriceInteger( + trigger, + market.supportedPriceDecimals, + ), + }); + } + // Only the ids actually required: allocation attempts are bounded + // and a degenerate RNG must exhaust BEFORE any cancellation. + const clientOrderIds = this.#allocateClientOrderIndexes( + validatedOrders.length, + ); + createdClientIds = clientOrderIds; + // VENUE CONTRACT (proven live: 'GroupingType is not valid'): + // CreateGroupedOrders only accepts grouping types 1/2/3 and OCO + // requires two siblings, so a SINGLE TP or SL must be an ordinary + // CreateOrder trigger; grouped OCO is reserved for both together. + groupedPayload = validatedOrders.flatMap((entry, index) => [ + market.marketId, + clientOrderIds[index], + String(sizeInt), + String(entry.execInt), + isAsk, + entry.orderType, + LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + 1, + String(entry.triggerInt), + // Trigger orders rest until fired: the signer expands the -1 + // sentinel to the 28-day default expiry. + LIGHTER_ORDER_EXPIRY_NONE, + ]); + groupedOrderCount = validatedOrders.length; + groupedType = + groupedOrderCount === 2 ? LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER : 0; + } + + // Re-fence BEFORE signer setup: the preflight awaited public reads + // during which the wallet may have switched, and a stale intent must + // never create or register the new account's venue key. + this.#assertSession(generationAtIntent); + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generationAtIntent); + // Pre-mint the auth token OUTSIDE the write lock: #getAuthToken can + // trigger signer setup, and signer setup queues on the write chain — + // calling any setup-capable helper from inside the held section + // would self-deadlock after a bridge reset or unobserved switch. + const authToken = await this.#getAuthToken(); + this.#assertSession(generationAtIntent); + // Settlement identity: pending expectations are keyed by the + // captured normalized address + account index + symbol so another + // account can never consume (or be blocked by) this account's ids, + // while a same-account bridge reset or switch-away-and-back retains + // the reconciliation obligation. + // Includes the API KEY SLOT: nonces are per key slot, so a journal + // recorded under one slot must never be reconciled under another. + const settlementKey = `${this.#boundAddress ?? 'unbound'}:${accountIndex}:${this.#apiKeyIndex}:${params.symbol}`; + + // The ENTIRE snapshot -> create -> cancel lifecycle runs as ONE + // serialized transition on the account's write chain. Two concurrent + // replacements would otherwise both snapshot the same old trigger, + // each create a new set, and each cancel only the original — leaving + // both protection sets live; a concurrent remove could miss a + // just-created replacement. Cancels are INLINED (not this.cancelOrder) + // so no nested lock acquisition can deadlock; nonce serialization is + // preserved because every nonce comes from this section's nextNonce. + await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + // STRICT direct read with the CAPTURED account/auth/generation: + // a swallowed [] would make remove "succeed" cancelling nothing; + // a setup-capable helper here could self-deadlock (see auth + // pre-mint above). Session fences on every read. + const readActiveRaw = async (): Promise => { + this.#assertSession(generationAtIntent); + const response = await this.#clientService.getActiveOrders( + accountIndex, + authToken, + ); + this.#assertSession(generationAtIntent); + return response.orders; + }; + // Shared targeted inactive reader (cached, active-first callers, + // one bounded deep cursor walk per section, market-scoped). + const readInactiveFor = this.#makeInactiveReader( + accountIndex, + authToken, + generationAtIntent, + market.marketId, + ); + + // VENUE LINEARIZABILITY: if a previous TP/SL transition's + // settlement never became visible, run it through the SAME + // obligation state machine as startup recovery — a pending + // 'cancelling'/'restoring' journal may owe a rollback or a + // RESTORE, and merely reconciling-then-clearing it here would + // erase that obligation and leave the position naked. + // Pending obligations survive provider death via the durable + // journal. DISK IS AUTHORITATIVE: absence means the obligation + // was resolved (possibly by another provider) — a stale + // in-memory copy is dropped, never resurrected. + const unsettled = await this.#loadTpslJournal(settlementKey); + if (unsettled === null) { + this.#tpslUnsettled.delete(settlementKey); + } + if (unsettled) { + const resolved = await this.#settleTpslObligation({ + settlementKey, + symbol: params.symbol, + journalEntry: unsettled, + market, + accountIndex, + authToken, + generation: generationAtIntent, + readActiveRaw, + readInactiveFor, + nextNonce, + submit, + }); + if (!resolved) { + // Keep both records for the next attempt. + this.#tpslUnsettled.set(settlementKey, unsettled); + throw new Error( + `Lighter TP/SL settlement for ${params.symbol} is unresolved; refusing further protection changes until the venue reflects the previous update`, + ); + } + // The machine may have PARKED the obligation into the + // durable manual-recovery doc (releasing the journal slot). + // The doc is NOT cleared here: only this operation's own + // SUCCESS — the successor protection authoritatively in + // force — clears the warning below. + this.#assertSession(generationAtIntent); + } + + const rawOrders = await readActiveRaw(); + const openOrders = rawOrders.map((order) => + adaptOrderFromLighter( + order, + this.#marketsById.get(order.marketIndex)?.symbol ?? + String(order.marketIndex), + ), + ); + const staleTriggers = openOrders.filter( + (order) => + order.symbol === params.symbol && + order.reduceOnly && + (Boolean(order.orderType?.includes('stop')) || + Boolean(order.orderType?.includes('take')) || + order.isTrigger === true), + ); + // The prior triggers' EXACT wire intents ride along with the + // journal: a crash can still restore/rollback faithfully. A + // stale trigger that CANNOT be faithfully restored (unknown + // venue type/TIF) refuses the whole mutation BEFORE any cancel + // or create — coercing its semantics on restore is worse than + // rejecting the update. + const priorTriggers: TpslPriorTrigger[] = []; + for (const stale of staleTriggers) { + const rawRow = rawOrders.find( + (order) => String(order.orderIndex) === stale.orderId, + ); + const priorIntent = rawRow + ? mapRawTriggerToPriorIntent(rawRow, market) + : null; + if (!priorIntent) { + throw new Error( + `Lighter TP/SL update for ${params.symbol} refused: existing trigger order ${stale.orderId} cannot be faithfully restored (unsupported type/time-in-force), so it will not be cancelled`, + ); + } + priorTriggers.push(priorIntent); + } + // OCO grouping is decided by the VENUE'S OWN linkage fields — + // never inferred from "one TP plus one SL". Linkage FAILS + // CLOSED: ANY dangling or one-sided linkage (parent, to_cancel + // or to_trigger references that do not form an exact mutual + // two-leg pair among the triggers being replaced) is an order + // relationship this integration cannot faithfully re-establish + // — the mutation is refused BEFORE anything is touched, never + // classified independent. + const staleRawRows = staleTriggers.map((stale) => + rawOrders.find( + (order) => String(order.orderIndex) === stale.orderId, + ), + ); + // LIVE-VENUE contract (probed): ABSENT linkage is the string + // sentinel '0' (parent_order_id, to_trigger_order_id_*), never + // an empty string. + const linkageSet = (value: string | undefined): boolean => + typeof value === 'string' && value.length > 0 && value !== '0'; + const hasAnyLinkage = (row: LighterApiOrder | undefined): boolean => + row !== undefined && + (linkageSet(row.toCancelOrderId0) || + linkageSet(row.parentOrderId) || + (typeof row.parentOrderIndex === 'number' && + row.parentOrderIndex > 0) || + linkageSet(row.toTriggerOrderId0) || + linkageSet(row.toTriggerOrderId1)); + const rowLinksTo = ( + source: LighterApiOrder | undefined, + target: LighterApiOrder | undefined, + ): boolean => + source !== undefined && + target !== undefined && + linkageSet(source.toCancelOrderId0) && + [String(target.orderIndex), target.orderId ?? ''].includes( + source.toCancelOrderId0 as string, + ); + const mutualPair = + priorTriggers.length === 2 && + rowLinksTo(staleRawRows[0], staleRawRows[1]) && + rowLinksTo(staleRawRows[1], staleRawRows[0]) && + // A mutual pair must not ALSO carry parent/OTO relations. + staleRawRows.every( + (row) => + row !== undefined && + !( + linkageSet(row.parentOrderId) || + (typeof row.parentOrderIndex === 'number' && + row.parentOrderIndex > 0) || + linkageSet(row.toTriggerOrderId0) || + linkageSet(row.toTriggerOrderId1) + ), + ); + if (!mutualPair && staleRawRows.some(hasAnyLinkage)) { + throw new Error( + `Lighter TP/SL update for ${params.symbol} refused: an existing trigger carries venue linkage (OCO/OTO/parent) this integration cannot faithfully re-establish, so it will not be cancelled`, + ); + } + const priorGrouping: 'oco' | 'independent' = mutualPair + ? 'oco' + : 'independent'; + if (priorGrouping === 'oco') { + // Pinned grouped invariants (same closing side, size AND + // expiry): a linked pair violating them cannot be faithfully + // re-signed as one group — refuse BEFORE touching it. + if ( + priorTriggers[0].side !== priorTriggers[1].side || + parseStrictDecimal(priorTriggers[0].remainingSize) !== + parseStrictDecimal(priorTriggers[1].remainingSize) || + priorTriggers[0].orderExpiry !== priorTriggers[1].orderExpiry + ) { + throw new Error( + `Lighter TP/SL update for ${params.symbol} refused: the existing linked OCO pair cannot be faithfully restored as a group, so it will not be cancelled`, + ); + } + } + // Per-attempt mutation journal, persisted incrementally. + // RESPONSE-LOSS safety: every attempt is recorded UNKNOWN with + // its own venue nonce BEFORE submission (the venue may commit + // even when the response is lost), flips to accepted inside + // onAccepted (pre-fence), and reconciliation disambiguates each + // attempt individually via books + nonce. + const journal: TpslJournalState = { + attempts: [], + recordedAt: Date.now(), + // Collision-resistant across processes: time + counter + two + // independent random draws (~104 bits of entropy). + operationId: `op-${Date.now().toString(36)}-${(this.#tpslOperationCounter += 1).toString(36)}-${randomIdSuffix()}`, + createdAt: lifecycleBoundary, + nextAttemptId: 1, + intent: wantsReplacement ? 'replace' : 'remove', + phase: 'creating', + priorGrouping, + priorTriggers, + }; + const persistJournal = async (): Promise => { + journal.recordedAt = Date.now(); + await this.#persistTpslJournal(settlementKey, journal); + }; + // Sign+journal+submit one tracked cancel (stale protection or a + // rollback of a surviving replacement leg). + const submitTrackedCancel = async ( + orderId: string, + role: 'stale' | 'rollback', + ): Promise => { + if (role === 'stale' && journal.intent === 'replace') { + // Durable phase transition BEFORE the old protection is + // touched: a crash from here on may require a RESTORE. + // (A 'remove' journal never restores — phase is moot.) + journal.phase = 'cancelling'; + } + const cancelNonce = await nextNonce(); + const signedCancel = + await this.#getSignerBridge().execute({ + function: '_signCancelOrder', + params: [accountIndex, market.marketId, orderId, cancelNonce], + }); + if (signedCancel.error) { + throw new Error( + `Failed to cancel trigger order ${orderId}: ${signedCancel.error}`, + ); + } + const cancelIdentity = requireSignedTxIdentity(signedCancel); + const cancelAttempt: TpslCancelAttempt = { + kind: 'cancel', + attemptId: nextAttemptIdFor(journal), + nonce: cancelNonce, + outcome: 'unknown', + orderId, + txHash: cancelIdentity.txHash, + expiresAt: cancelIdentity.expiresAt, + role, + }; + journal.attempts.push(cancelAttempt); + this.#tpslUnsettled.set(settlementKey, journal); + await persistJournal(); + await submit( + LIGHTER_TX_TYPE_CANCEL_ORDER, + signedCancel.txInfo, + () => { + cancelAttempt.outcome = 'accepted'; + }, + { + txHash: cancelIdentity.txHash, + expiresAt: cancelIdentity.expiresAt, + owner: journal.operationId, + }, + ); + }; + + // CREATE FIRST, cancel after: if signing or submission of the + // new protection fails, the old triggers were never touched and + // the position is never left naked. The temporary overlap is + // safe — both sets are reduce-only and clamp to the position. + if (wantsReplacement && groupedPayload !== null) { + const payload = groupedPayload; + const isSingleTrigger = groupedOrderCount === 1; + const createNonce = await nextNonce(); + // A lone trigger is an ordinary CreateOrder (same wire + // layout); only a TP+SL pair uses the grouped OCO transaction. + const signed = + await this.#getSignerBridge().execute( + isSingleTrigger + ? { + function: '_signCreateOrder', + params: [accountIndex, ...payload, createNonce], + } + : { + function: '_signCreateGroupedOrders', + params: [ + accountIndex, + groupedType, + groupedOrderCount, + ...payload, + createNonce, + ], + }, + ); + if (signed.error) { + throw new Error(signed.error); + } + // UNKNOWN recorded BEFORE the wire — in memory AND durably + // (awaited): a transport failure after venue commit, or + // provider/process death, must still leave a reconciliation + // obligation resolvable by EXACT tx hash. A failed durable + // write, or a signing result without hash/expiry, aborts the + // mutation before submission. + const createIdentity = requireSignedTxIdentity(signed); + const createAttempt: TpslCreateAttempt = { + kind: 'create', + attemptId: nextAttemptIdFor(journal), + nonce: createNonce, + outcome: 'unknown', + clientIds: [...createdClientIds], + txHash: createIdentity.txHash, + expiresAt: createIdentity.expiresAt, + role: 'replacement', + }; + journal.attempts.push(createAttempt); + this.#tpslUnsettled.set(settlementKey, journal); + await persistJournal(); + await submit( + isSingleTrigger + ? LIGHTER_TX_TYPE_CREATE_ORDER + : LIGHTER_TX_TYPE_CREATE_GROUPED_ORDERS, + signed.txInfo, + () => { + // Acceptance OBSERVED (pre-fence): absence from the books + // can now only mean visibility lag, never never-landed. + createAttempt.outcome = 'accepted'; + }, + { + txHash: createIdentity.txHash, + expiresAt: createIdentity.expiresAt, + owner: journal.operationId, + }, + ); + + // PHASE BARRIER: prove the replacement is on the venue's books + // BEFORE touching the old protection. An accepted create can + // be asynchronously rejected/venue-cancelled; cancelling stale + // triggers first would strip valid protection and discover it + // afterwards. + const createVisibility = await this.#awaitTpslVisibility( + readActiveRaw, + readInactiveFor, + { + createdClientIds, + cancelledOrderIds: [], + }, + ); + if (createVisibility.outcome === 'timeout') { + throw new Error( + `Lighter TP/SL update for ${params.symbol} was submitted but its settlement is not yet visible; further protection changes are blocked until the venue reflects it`, + ); + } + if (createVisibility.outcome === 'created-terminal-failed') { + // The replacement (or one OCO leg) failed before the old + // protection was touched. ROLL BACK any leg still resting + // active so the venue returns to exactly the prior + // protection, then resolve the obligation for a retry. + if (createVisibility.survivingActiveClientIds.length > 0) { + const activeNow = await readActiveRaw(); + const survivorOrderIds: string[] = []; + for (const clientId of createVisibility.survivingActiveClientIds) { + const survivor = activeNow.find( + (order) => + String(order.clientOrderIndex) === String(clientId), + ); + if (survivor) { + survivorOrderIds.push(String(survivor.orderIndex)); + await submitTrackedCancel( + String(survivor.orderIndex), + 'rollback', + ); + } + } + const rollback = await this.#awaitTpslVisibility( + readActiveRaw, + readInactiveFor, + { createdClientIds: [], cancelledOrderIds: survivorOrderIds }, + ); + if (rollback.outcome === 'timeout') { + throw new Error( + `Lighter TP/SL update for ${params.symbol} was submitted but its settlement is not yet visible; further protection changes are blocked until the venue reflects it`, + ); + } + } + await this.#clearTpslJournal(settlementKey, journal.operationId); + throw new Error( + `Lighter replacement TP/SL for ${params.symbol} was cancelled or rejected by the venue before becoming active; the existing protection was left untouched`, + ); + } + // Barrier-proved TERMINAL success is immutable: skip those ids + // in the final settlement check (no duplicate high-weight + // inactive read); active-at-barrier ids are still re-verified + // there (they can terminal-fail before the cancels settle). + createdIdsNeedingFinalCheck = createVisibility.executedCreated + ? [] + : createdClientIds; + if (createVisibility.executedCreated) { + // The trigger EXECUTED before activation was observed (an + // immediate/crossed TP/SL): not a failure — the position may + // already be closed. Stale triggers below are still cleaned + // up as reduce-only leftovers. + this.#deps.debugLogger.log( + '[LighterProvider] replacement trigger executed immediately', + { symbol: params.symbol }, + ); + } + } + + for (const order of staleTriggers) { + await submitTrackedCancel(order.orderId, 'stale'); + } + + // Await authoritative visibility of the CANCELS before releasing + // the lock (created ids were proven at the phase barrier): the + // next queued transition must never snapshot a stale book. + if (journal.attempts.length > 0) { + const settled = await this.#awaitTpslVisibility( + readActiveRaw, + readInactiveFor, + { + // Barrier-proved terminal successes are immutable and + // excluded; active-at-barrier ids are re-verified. + createdClientIds: createdIdsNeedingFinalCheck, + cancelledOrderIds: journal.attempts + .filter( + (attempt): attempt is TpslCancelAttempt => + attempt.kind === 'cancel', + ) + .map((attempt) => attempt.orderId), + }, + ); + if (settled.outcome === 'timeout') { + throw new Error( + `Lighter TP/SL update for ${params.symbol} was submitted but its settlement is not yet visible; further protection changes are blocked until the venue reflects it`, + ); + } + if (settled.outcome === 'created-terminal-failed') { + // Active at the phase barrier but venue-cancelled/rejected + // AFTER the old protection was already cancelled. The + // venue exposes no atomic primitive that could prove a + // re-created trigger attaches to the same position + // lifecycle, so nothing is auto-restored: the warning + // parks DURABLY in the manual-recovery doc (surfaced via + // getPendingManualRecoveries) and any surviving leg is + // deliberately left as the only remaining protection. + const rawNow = await readActiveRaw(); + const survivingOrderIds = rawNow + .filter((order) => + journal.attempts.some( + (attempt) => + attempt.kind === 'create' && + attempt.clientIds.some( + (clientId) => + String(order.clientOrderIndex) === String(clientId), + ), + ), + ) + .map((order) => String(order.orderIndex)); + await this.#writeTpslManualRecovery({ + settlementKey, + symbol: params.symbol, + reason: + 'Replacement TP/SL order was cancelled or rejected by the venue after the previous protection was already removed', + priorIntent: journal.intent, + priorTriggers: journal.priorTriggers, + survivingOrderIds, + operationId: journal.operationId, + recordedAt: Date.now(), + }); + await this.#clearTpslJournal(settlementKey, journal.operationId); + this.#assertSession(generationAtIntent); + throw new Error( + `Lighter replacement TP/SL for ${params.symbol} was cancelled or rejected by the venue after the previous protection was already removed; the position's protection could NOT be safely re-established automatically — MANUAL re-establishment is required (a new explicit TP/SL update resolves this state)`, + ); + } + await this.#clearTpslJournal(settlementKey, journal.operationId); + // A switch DURING the final journal-clear await must not let + // stale A protection report success under B. + this.#assertSession(generationAtIntent); + } + // ONLY here — the successor protection intent authoritatively + // in force (created and settled, or removal completed) — may a + // parked manual-recovery warning for this symbol be cleared. A + // failed successor leaves the warning untouched. + await this.#clearTpslManualRecovery(settlementKey); + this.#assertSession(generationAtIntent); + }, + generationAtIntent, + ); + return { success: true }; + } catch (error) { + const wrappedError = ensureError( + error, + 'LighterProvider.updatePositionTPSL', + ); + this.#deps.debugLogger.log( + '[LighterProvider] updatePositionTPSL failed', + { + error: String(wrappedError), + ...this.#getErrorContext('updatePositionTPSL'), + }, + ); + return { success: false, error: wrappedError.message }; + } + } + + async updateMargin(params: UpdateMarginParams): Promise { + try { + this.#ensureSessionBinding(); + const generationAtIntent = this.#sessionGeneration; + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + if (!market) { + return { + success: false, + error: `Unknown Lighter market: ${params.symbol}`, + }; + } + // Strict full-string parse: '5USD' must not prefix-parse into + // signed intent. Signed values are meaningful here (add/remove). + const amount = parseStrictDecimal(params.amount) ?? Number.NaN; + if (!Number.isFinite(amount) || amount === 0) { + return { + success: false, + error: 'updateMargin requires a non-zero amount', + }; + } + // USDC uses 6 decimals. Integerize BEFORE signer setup so a huge + // finite amount fails closed with zero bridge calls instead of + // raw-scaling to an unsafe integer inside signer params. + const marginAmountInt = toSignerWireInteger(Math.abs(amount), 6); + // Re-fence before signer setup: the market lookup above awaited, and + // a stale intent must never initialize the new account's signer. + this.#assertSession(generationAtIntent); + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + // USDC uses 6 decimals; direction 1 adds isolated margin, 0 removes it + // (types/txtypes/constants.go: RemoveFromIsolatedMargin=0, Add=1). + await this.#withVenueNonce( + accountIndex, + async (nonce, submit) => { + const signed = await this.#getSignerBridge().execute( + { + function: '_signUpdateMargin', + params: [ + accountIndex, + market.marketId, + marginAmountInt, + amount > 0 ? 1 : 0, + nonce, + ], + }, + ); + if (signed.error) { + throw new Error(signed.error); + } + return await submit( + LIGHTER_TX_TYPE_UPDATE_MARGIN, + signed.txInfo, + undefined, + { + ...extractDispatchIdentity(signed), + intent: `updateMargin:${params.symbol}:${params.amount}`, + }, + ); + }, + generationAtIntent, + ); + return { success: true }; + } catch (error) { + const wrappedError = ensureError(error, 'LighterProvider.updateMargin'); + this.#deps.debugLogger.log('[LighterProvider] updateMargin failed', { + error: String(wrappedError), + ...this.#getErrorContext('updateMargin'), + }); + return { success: false, error: wrappedError.message }; + } + } + + async withdraw(params: WithdrawParams): Promise { + try { + this.#ensureSessionBinding(); + const generationAtIntent = this.#sessionGeneration; + const amount = parseFinitePositive(params.amount); + if (amount === null) { + return { success: false, error: 'withdraw requires a positive amount' }; + } + // Enforce the advertised route minimum: getWithdrawalRoutes reports + // minWithdrawUsdc, and signing below it would either burn a nonce on + // a venue rejection or strand dust. + const minWithdraw = parseFloat( + LIGHTER_BRIDGE_CONFIG[this.#isTestnet ? 'testnet' : 'mainnet'] + .minWithdrawUsdc, + ); + if (amount < minWithdraw) { + return { + success: false, + error: `Withdrawal amount ${params.amount} is below the Lighter minimum of ${minWithdraw} USDC`, + }; + } + // USDC uses 6 decimals on zkLighter. Integerize BEFORE signer setup: + // overflow/sub-tick amounts fail closed with zero bridge calls, + // matching validateWithdrawal exactly. + const assetAmount = String(toSignerWireInteger(amount, 6)); + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + const result = await this.#withVenueNonce( + accountIndex, + async (nonce, submit) => { + const signed = await this.#getSignerBridge().execute( + { + function: '_signWithdraw', + params: [ + accountIndex, + LIGHTER_USDC_ASSET_INDEX, + 0, + assetAmount, + nonce, + ], + }, + ); + if (signed.error) { + throw new Error(signed.error); + } + return await submit( + LIGHTER_TX_TYPE_WITHDRAW, + signed.txInfo, + undefined, + { + ...extractDispatchIdentity(signed), + intent: `withdraw:${params.amount}`, + }, + ); + }, + generationAtIntent, + ); + return { success: true, txHash: result.txHash }; + } catch (error) { + const wrappedError = ensureError(error, 'LighterProvider.withdraw'); + this.#deps.debugLogger.log('[LighterProvider] withdraw failed', { + error: String(wrappedError), + ...this.#getErrorContext('withdraw'), + }); + return { success: false, error: wrappedError.message }; + } + } + + // ============================================================================ + // History Operations (POC: stubbed) + // ============================================================================ + + async getOrderFills( + params?: GetOrderFillsParams, + _options?: PerpsReadOptions, + ): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const token = await this.#getAuthToken(); + await this.#ensureMarkets(); + const response = await this.#clientService.getTrades( + accountIndex, + token, + params?.limit ?? 50, + ); + this.#assertSession(generation); + return (response.trades ?? []).map((trade) => + adaptFillFromLighterTrade( + trade, + this.#marketsById.get(trade.marketId)?.symbol ?? + String(trade.marketId), + accountIndex, + ), + ); + } catch (error) { + if (this.#isUnsupportedCapabilityError(error)) { + // Capability gates must surface, never degrade into empty state. + throw error; + } + this.#deps.debugLogger.log('[LighterProvider] getOrderFills failed', { + error: String(error), + }); + return []; + } + } + + async getOrFetchFills(params?: GetOrFetchFillsParams): Promise { + return await this.getOrderFills(params); + } + + async getHistoricalPortfolio( + _params?: GetHistoricalPortfolioParams, + ): Promise { + // Capability-gated: the venue's PnLEntry carries trade, pool, spot, and + // staking flows; reconstructing account value from the trade flows + // alone is materially wrong for accounts using the other routes, and + // no captured payload proves the full-flow semantics. Reporting a + // plausible number would show false daily history — fail explicitly. + throw new Error( + 'Historical portfolio is unavailable for Lighter: account-value reconstruction requires pool/spot/staking flow semantics that are not yet verified against the venue', + ); + } + + async getFunding( + _params?: GetFundingParams, + _options?: PerpsReadOptions, + ): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const token = await this.#getAuthToken(); + await this.#ensureMarkets(); + const response = await this.#clientService.getPositionFundings( + accountIndex, + token, + ); + this.#assertSession(generation); + return (response.positionFundings ?? []).map((entry) => ({ + symbol: + this.#marketsById.get(entry.marketId)?.symbol ?? + String(entry.marketId), + // `change` is the signed USDC funding flow for the account's side. + amountUsd: entry.change, + rate: entry.rate, + timestamp: entry.timestamp * 1000, + })); + } catch (error) { + if (this.#isUnsupportedCapabilityError(error)) { + // Capability gates must surface, never degrade into empty state. + throw error; + } + this.#deps.debugLogger.log('[LighterProvider] getFunding failed', { + error: String(error), + }); + return []; + } + } + + async getUserNonFundingLedgerUpdates(params?: { + accountId?: string; + startTime?: number; + endTime?: number; + }): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const authToken = await this.#getAuthToken(); + const l1Address = this.#walletService.getUserAddress(); + const [deposits, withdraws, transfers] = await Promise.all([ + this.#clientService.getDepositHistory( + accountIndex, + l1Address, + authToken, + ), + this.#clientService.getWithdrawHistory(accountIndex, authToken), + this.#clientService.getTransferHistory(accountIndex, authToken), + ]); + const updates: RawLedgerUpdate[] = [ + ...(deposits.deposits ?? []).map((entry) => ({ + hash: entry.l1TxHash, + time: entry.timestamp, + delta: { type: 'deposit', usdc: entry.amount }, + })), + ...(withdraws.withdraws ?? []).map((entry) => ({ + hash: entry.l1TxHash, + time: entry.timestamp, + delta: { type: 'withdraw', usdc: `-${entry.amount}` }, + })), + ...(transfers.transfers ?? []).map((entry) => ({ + hash: entry.txHash, + time: entry.timestamp, + delta: { + // Venue types are L2TransferInflow / L2TransferOutflow. + type: entry.type.includes('Outflow') ? 'transferOut' : 'transferIn', + usdc: entry.type.includes('Outflow') + ? `-${entry.amount}` + : entry.amount, + }, + })), + ].sort((first, second) => second.time - first.time); + const { startTime, endTime } = params ?? {}; + this.#assertSession(generation); + return updates.filter( + (update) => + (startTime === undefined || update.time >= startTime) && + (endTime === undefined || update.time <= endTime), + ); + } catch (error) { + if (this.#isUnsupportedCapabilityError(error)) { + // Capability gates must surface, never degrade into empty state. + throw error; + } + this.#deps.debugLogger.log( + '[LighterProvider] getUserNonFundingLedgerUpdates failed', + { error: String(error) }, + ); + return []; + } + } + + async getUserHistory(params?: { + accountId?: CaipAccountId; + startTime?: number; + endTime?: number; + }): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + const authToken = await this.#getAuthToken(); + const l1Address = this.#walletService.getUserAddress(); + const [deposits, withdraws] = await Promise.all([ + this.#clientService.getDepositHistory( + accountIndex, + l1Address, + authToken, + ), + this.#clientService.getWithdrawHistory(accountIndex, authToken), + ]); + const toStatus = (venueStatus: string): UserHistoryItem['status'] => { + if (venueStatus === 'completed') { + return 'completed'; + } + return venueStatus === 'failed' ? 'failed' : 'pending'; + }; + const items: UserHistoryItem[] = [ + ...(deposits.deposits ?? []).map((entry) => ({ + id: `deposit-${entry.id}`, + timestamp: entry.timestamp, + type: 'deposit' as const, + amount: entry.amount, + asset: 'USDC', + txHash: entry.l1TxHash, + status: toStatus(entry.status), + details: { source: 'lighter' }, + })), + ...(withdraws.withdraws ?? []).map((entry) => ({ + id: `withdrawal-${entry.id}`, + timestamp: entry.timestamp, + type: 'withdrawal' as const, + amount: entry.amount, + asset: 'USDC', + txHash: entry.l1TxHash, + status: toStatus(entry.status), + details: { source: 'lighter' }, + })), + ].sort((first, second) => second.timestamp - first.timestamp); + const { startTime, endTime } = params ?? {}; + this.#assertSession(generation); + return items.filter( + (item) => + (startTime === undefined || item.timestamp >= startTime) && + (endTime === undefined || item.timestamp <= endTime), + ); + } catch (error) { + if (this.#isUnsupportedCapabilityError(error)) { + // Capability gates must surface, never degrade into empty state. + throw error; + } + this.#deps.debugLogger.log('[LighterProvider] getUserHistory failed', { + error: String(error), + }); + return []; + } + } + + // ============================================================================ + // Validation (POC: minimal) + // ============================================================================ + + async validateDeposit( + _params: DepositParams, + ): Promise<{ isValid: boolean; error?: string }> { + return { isValid: false, error: LIGHTER_NOT_SUPPORTED_ERROR }; + } + + async validateOrder( + params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }> { + // ONE error-to-invalid boundary: a validator RESOLVES, never rejects, + // whichever awaited venue read fails (markets, margin metadata, fresh + // price, live positions, data integrity). + try { + return await this.#validateOrderChecks(params); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateOrder').message, + }; + } + } + + readonly #validateOrderChecks = async ( + params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }> => { + // Mirrors placeOrder's own rejections so validation never approves an + // order shape the placement path would refuse. + if (params.orderType !== 'limit' && params.orderType !== 'market') { + return { isValid: false, error: LIGHTER_NOT_SUPPORTED_ERROR }; + } + if (params.takeProfitPrice || params.stopLossPrice) { + return { + isValid: false, + error: + 'Lighter does not support TP/SL attached at placement; place the order, then call updatePositionTPSL', + }; + } + if (params.timeInForce === 'ALO') { + return { + isValid: false, + error: 'Lighter placement does not support post-only (ALO) yet', + }; + } + if (params.orderType === 'limit' && !params.price) { + return { isValid: false, error: 'Limit order requires a price' }; + } + if (params.orderType === 'limit' && params.price !== undefined) { + // Strict finite parity with placement, LIMIT ONLY: 'Infinity' and + // prefix-numeric strings ('90000USD') both parse under a bare + // parseFloat check but placement refuses them. Market placement + // ignores params.price entirely (fresh venue price), so rejecting + // it here would fail orders placement accepts. + if (parseFinitePositive(params.price) === null) { + return { + isValid: false, + error: `Invalid limit price ${params.price}: must be a positive number`, + }; + } + } + const leverageError = lighterLeverageError(params.leverage); + if (leverageError) { + return { isValid: false, error: leverageError }; + } + let usdAmount: number | undefined; + if (params.usdAmount !== undefined) { + const parsedUsd = parseFinitePositive(params.usdAmount); + if (parsedUsd === null) { + return { + isValid: false, + error: `Invalid usdAmount ${params.usdAmount}: must be a positive number`, + }; + } + usdAmount = parsedUsd; + } + const hasUsdSizing = usdAmount !== undefined; + if (!hasUsdSizing && parseFinitePositive(params.size) === null) { + return { isValid: false, error: 'Order size must be positive' }; + } + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + if (!market) { + return { + isValid: false, + error: `Unknown Lighter market: ${params.symbol}`, + }; + } + if (params.leverage !== undefined) { + // Same authoritative-metadata requirement as placement. + const maxLeverage = await this.#requireMarketMaxLeverage(params.symbol); + if (maxLeverage === null) { + return { + isValid: false, + error: `Cannot validate leverage for ${params.symbol}: venue margin metadata unavailable`, + }; + } + if (params.leverage > maxLeverage) { + return { + isValid: false, + error: `Invalid leverage ${params.leverage}: exceeds the ${params.symbol} maximum of ${maxLeverage}x`, + }; + } + } + // Reference-price parity with placement: a MARKET order sizes at the + // FRESH venue price through the SAME resolver (fail-closed missing + // price, snapshot and slippage intent validation, drift) — the + // caller's price/currentPrice is never trusted for min-size. A LIMIT + // order sizes at the caller's (finite-validated) price. The EXECUTION + // price is derived through the same helper placement signs with, so + // the wire-range check below inspects the exact signed value. + let referencePrice: number; + let executionPrice: number; + if (params.orderType === 'market') { + const slippageFraction = + params.maxSlippageBps === undefined + ? (params.slippage ?? 0.05) + : params.maxSlippageBps / 10_000; + // A validator must RESOLVE to an invalid result, never reject: the + // fresh-price lookup can throw on REST failure. + let resolved: + | { referencePrice: number; error: null } + | { referencePrice: null; error: string }; + try { + resolved = await this.#resolveMarketReferencePrice( + params.symbol, + slippageFraction, + params.priceAtCalculation, + ); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateOrder').message, + }; + } + if (resolved.error !== null) { + return { isValid: false, error: resolved.error }; + } + referencePrice = resolved.referencePrice; + executionPrice = deriveLighterExecutionPrice( + referencePrice, + params.isBuy, + slippageFraction, + ); + } else { + referencePrice = parseFloat( + params.price ?? String(params.currentPrice ?? 0), + ); + executionPrice = referencePrice; + } + if (referencePrice > 0) { + // USD-derived sizes snap onto the venue grid (placement parity); + // explicit size strings stay verbatim. + const requestedSize = + usdAmount === undefined + ? parseFloat(params.size) + : snapToLighterSizeGrid( + usdAmount / referencePrice, + market.supportedSizeDecimals, + ); + const minSize = computeLighterMinOrderSize(market, referencePrice); + if (requestedSize < minSize) { + // EXACTLY the placement rule: only reduce-only orders may bump to + // the venue minimum, and only when the live position verifies a + // full close; isFullClose remains an untrusted hint. The live read + // can THROW (capability gates, venue-data integrity): a validator + // must resolve to an explicit invalid result, never reject. + let verifiedFullClose = false; + if (params.reduceOnly) { + try { + verifiedFullClose = await this.#isVerifiedFullClose( + params.symbol, + requestedSize, + ); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateOrder') + .message, + }; + } + } + if (!verifiedFullClose) { + return { + isValid: false, + error: `Order size ${requestedSize} is below the Lighter minimum of ${minSize} ${params.symbol}`, + }; + } + } + // Wire-format parity: placement integerizes size and the + // slippage-adjusted EXECUTION price; toLighterInteger throws on + // safe-integer overflow and wire-zero there; surface the identical + // error here so validation never approves an order the signer path + // refuses (a safe reference can still overflow after +5%). + try { + toSignerWireInteger(requestedSize, market.supportedSizeDecimals); + toSignerWirePriceInteger(executionPrice, market.supportedPriceDecimals); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateOrder').message, + }; + } + } + return { isValid: true }; + }; + + async validateClosePosition( + params: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }> { + // Same single error-to-invalid boundary as validateOrder. + try { + return await this.#validateClosePositionChecks(params); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateClosePosition') + .message, + }; + } + } + + readonly #validateClosePositionChecks = async ( + rawParams: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }> => { + const params = this.#normalizeCloseParams(rawParams); + // Same shape rules the execution path enforces. + const shapeError = this.#validateCloseShape(params); + if (shapeError) { + return { isValid: false, error: shapeError }; + } + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + if (!market) { + return { + isValid: false, + error: `Unknown Lighter market ${params.symbol}`, + }; + } + // Live sizing parity with closePosition→placeOrder: a validator that + // approves a close the execution path rejects is worse than none. + // Capability and data-integrity errors from the read surface as an + // explicit invalid result, never an exception or a silent empty. + let positions: Position[]; + try { + positions = await this.getPositions(); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateClosePosition') + .message, + }; + } + const signedHeld = parseFloat( + positions.find((entry) => entry.symbol === params.symbol)?.size ?? '0', + ); + const held = Math.abs(signedHeld); + if (held === 0) { + return { + isValid: false, + error: `No open Lighter position for ${params.symbol}`, + }; + } + // Order-type-specific pricing, matching execution exactly: a LIMIT + // close is sized at the caller's price (which must be a finite + // positive number — never silently replaced by a live price the + // execution path would not use); a MARKET close resolves the FRESH + // venue price through the SAME helper as placement, inheriting its + // fail-closed missing-price and drift semantics. + let referencePrice: number; + let executionPrice: number; + if ((params.orderType ?? 'market') === 'limit') { + const parsedLimitPrice = parseFinitePositive(params.price ?? ''); + if (parsedLimitPrice === null) { + return { + isValid: false, + error: `Invalid limit price ${params.price}: must be a positive number`, + }; + } + referencePrice = parsedLimitPrice; + executionPrice = referencePrice; + } else { + const slippageFraction = + params.maxSlippageBps === undefined + ? 0.05 + : params.maxSlippageBps / 10_000; + // Same validator contract as validateOrder: REST failures resolve. + let resolved: + | { referencePrice: number; error: null } + | { referencePrice: null; error: string }; + try { + resolved = await this.#resolveMarketReferencePrice( + params.symbol, + slippageFraction, + params.priceAtCalculation, + ); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateClosePosition') + .message, + }; + } + if (resolved.error !== null) { + return { isValid: false, error: resolved.error }; + } + referencePrice = resolved.referencePrice; + // Closing is the opposite side: a SHORT closes with a BUY, whose + // +slippage protection price is what placement actually signs. + executionPrice = deriveLighterExecutionPrice( + referencePrice, + signedHeld < 0, + slippageFraction, + ); + } + if (referencePrice > 0) { + const usdAmount = parseFloat(params.usdAmount ?? ''); + // USD-derived sizes snap onto the venue grid (placement parity); + // explicit size strings stay verbatim. + const requestedSize = + Number.isFinite(usdAmount) && usdAmount > 0 + ? snapToLighterSizeGrid( + usdAmount / referencePrice, + market.supportedSizeDecimals, + ) + : parseFloat(params.size ?? String(held)); + const minSize = computeLighterMinOrderSize(market, referencePrice); + if (requestedSize < minSize && !(requestedSize >= held * (1 - 1e-9))) { + return { + isValid: false, + error: `Order size ${requestedSize} is below the Lighter minimum of ${minSize} ${params.symbol}`, + }; + } + // Wire-format parity with the placement path closePosition uses: + // the EXECUTION price is what gets integerized and signed. + try { + toSignerWireInteger(requestedSize, market.supportedSizeDecimals); + toSignerWirePriceInteger(executionPrice, market.supportedPriceDecimals); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateClosePosition') + .message, + }; + } + } + return { isValid: true }; + }; + + async validateWithdrawal( + params: WithdrawParams, + ): Promise<{ isValid: boolean; error?: string }> { + const amount = parseFinitePositive(params.amount ?? ''); + if (amount === null) { + return { isValid: false, error: 'Withdrawal amount must be positive' }; + } + // Advertised route-minimum parity with withdraw. + const minWithdraw = parseFloat( + LIGHTER_BRIDGE_CONFIG[this.#isTestnet ? 'testnet' : 'mainnet'] + .minWithdrawUsdc, + ); + if (amount < minWithdraw) { + return { + isValid: false, + error: `Withdrawal amount ${params.amount} is below the Lighter minimum of ${minWithdraw} USDC`, + }; + } + // Scaled wire-range parity with withdraw's own integerization. + try { + toSignerWireInteger(amount, 6); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'LighterProvider.validateWithdrawal').message, + }; + } + return { isValid: true }; + } + + // ============================================================================ + // Calculations (POC: coarse) + // ============================================================================ + + async calculateLiquidationPrice( + params: LiquidationPriceParams, + ): Promise { + // ISOLATED preview: the app opens Lighter positions with isolated + // margin by default, so the standard per-position formula applies — + // with the venue's own per-market maintenance fraction rather than a + // constant approximation. Live positions still carry the venue's own + // liquidationPrice (authoritative; cross positions opened elsewhere + // may legitimately have none). + const { entryPrice, leverage, direction } = params; + if ( + !isFinite(entryPrice) || + !isFinite(leverage) || + entryPrice <= 0 || + leverage <= 0 + ) { + return '0.00'; + } + const maintenanceFraction = await this.calculateMaintenanceMargin({ + asset: params.asset ?? '', + }); + const initialMargin = 1 / leverage; + if (initialMargin <= maintenanceFraction) { + throw new Error( + `Invalid leverage: ${leverage}x cannot cover the ${maintenanceFraction * 100}% maintenance requirement`, + ); + } + const side = direction === 'long' ? 1 : -1; + const marginAvailable = initialMargin - maintenanceFraction; + const denominator = 1 - maintenanceFraction * side; + if (Math.abs(denominator) < 0.0001) { + return String(entryPrice); + } + const liquidationPrice = + entryPrice - (side * marginAvailable * entryPrice) / denominator; + return String(Math.max(0, liquidationPrice)); + } + + async calculateMaintenanceMargin( + params: MaintenanceMarginParams, + ): Promise { + // The venue publishes per-market maintenance margin fractions + // (hundredths of a percent, e.g. 240 = 2.4%) in orderBookDetails. + try { + await this.#ensureMarketMargins(); + const maintenance = this.#marginBySymbol.get(params.asset)?.maintenance; + if (maintenance && maintenance > 0) { + return maintenance / 10_000; + } + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] maintenance margin fallback', + { error: String(error) }, + ); + } + // Fallback: half the initial margin at the max-leverage constant. + return 1 / (2 * LIGHTER_MAX_LEVERAGE); + } + + /** + * Margin mode the venue will accept for a leverage update on this + * market: an existing position's current mode (the venue refuses mode + * changes while a position is open; a missing field means the venue + * default, cross), otherwise ISOLATED — the only mode the app manages. + * + * @param symbol - Market symbol. + * @returns The wire margin mode for UpdateLeverage. + */ + readonly #resolveMarginModeForSymbol = async ( + symbol: string, + ): Promise => { + try { + const accountIndex = await this.#ensureAccountIndex(); + const response = + await this.#clientService.getAccountByIndex(accountIndex); + const row = response.accounts?.[0]?.positions?.find( + (position) => + position.symbol === symbol && parseFloat(position.position) !== 0, + ); + if (row) { + return row.marginMode ?? LIGHTER_MARGIN_MODE_CROSS; + } + } catch { + // Fall through: prefer isolated; a wrong guess surfaces as an + // explicit venue rejection of the leverage update, never as state. + } + return LIGHTER_MARGIN_MODE_ISOLATED; + }; + + /** Per-market margin fractions + last price from orderBookDetails. */ + readonly #marginBySymbol: Map< + string, + { minInitial?: number; maintenance?: number; lastTradePrice?: number } + > = new Map(); + + /** + * Synchronous best-effort per-market max leverage from the margin cache + * (populated by #ensureMarketMargins); the constant covers cache misses. + * + * @param marketId - Numeric Lighter market id. + * @returns Max leverage for the market. + */ + readonly #maxLeverageForMarketId = (marketId: number): number => { + const symbol = this.#marketsById.get(marketId)?.symbol; + const minInitial = symbol + ? this.#marginBySymbol.get(symbol)?.minInitial + : undefined; + return minInitial && minInitial > 0 + ? Math.floor(10_000 / minInitial) + : LIGHTER_MAX_LEVERAGE; + }; + + /** + * Authoritative per-market max leverage for TRADING validation: unlike + * getMaxLeverage (which may fall back to the global constant for + * display), this returns null when the venue's margin metadata is + * missing or unreadable so leverage validation fails CLOSED — the 50x + * fallback must never approve 26x for what may be a 25x market. + * + * @param symbol - Market symbol. + * @returns The published max leverage, or null when unavailable. + */ + readonly #requireMarketMaxLeverage = async ( + symbol: string, + ): Promise => { + try { + await this.#ensureMarketMargins(); + } catch { + return null; + } + const minInitial = this.#marginBySymbol.get(symbol)?.minInitial; + if (typeof minInitial !== 'number' || !(minInitial > 0)) { + return null; + } + return Math.floor(10_000 / minInitial); + }; + + /** When the margin-metadata cache was last refreshed (0 = never). */ + #marginFetchedAt = 0; + + /** In-flight authoritative margin refresh, shared by the stale epoch. */ + #marginRefreshInFlight: Promise | null = null; + + readonly #ensureMarketMargins = async (): Promise => { + // TTL refresh: metadata cached once for the whole session would keep + // validating leverage against a stale (possibly higher) max. On + // expiry the fetch re-runs; if it fails, the throw propagates and + // #requireMarketMaxLeverage fails CLOSED for explicit leverage while + // display callers keep their catch+fallback behavior. + if ( + this.#marginBySymbol.size > 0 && + Date.now() - this.#marginFetchedAt < LIGHTER_MARGIN_METADATA_TTL_MS + ) { + return; + } + // ONE authoritative request per stale epoch: overlapping independent + // fetches can resolve out of order, letting a DELAYED older payload + // overwrite a fresher cap for a full TTL. A rejection propagates to + // every waiter of this epoch (fail closed) and clears the in-flight + // slot in finally so a later call can retry. + if (!this.#marginRefreshInFlight) { + this.#marginRefreshInFlight = (async (): Promise => { + try { + const details = await this.#clientService.getOrderBookDetails(); + // Atomic replacement: set()-ing into the old map would let a + // symbol REMOVED from fresh metadata keep its stale cap forever. + // The timestamp only advances on success. + const fresh = new Map< + string, + { + minInitial?: number; + maintenance?: number; + lastTradePrice?: number; + } + >(); + for (const detail of details.orderBookDetails) { + fresh.set(detail.symbol, { + minInitial: detail.minInitialMarginFraction, + maintenance: detail.maintenanceMarginFraction, + lastTradePrice: detail.lastTradePrice, + }); + } + this.#marginBySymbol.clear(); + for (const [symbol, entry] of fresh) { + this.#marginBySymbol.set(symbol, entry); + } + this.#marginFetchedAt = Date.now(); + } finally { + this.#marginRefreshInFlight = null; + } + })(); + } + await this.#marginRefreshInFlight; + }; + + async getMaxLeverage(asset: string): Promise { + // The venue publishes per-market minimum initial margin fractions + // (hundredths of a percent): 400 → 25x. The global constant is only a + // fallback when the market is unknown. + try { + await this.#ensureMarketMargins(); + const minInitial = this.#marginBySymbol.get(asset)?.minInitial; + if (minInitial && minInitial > 0) { + return Math.floor(10_000 / minInitial); + } + } catch (error) { + this.#deps.debugLogger.log('[LighterProvider] getMaxLeverage fallback', { + error: String(error), + }); + } + return LIGHTER_MAX_LEVERAGE; + } + + async calculateFees( + params: FeeCalculationParams, + ): Promise { + // The market metadata's zero fee is only true for Standard accounts — + // resolve and gate the account tier first so a Premium account can + // never be quoted a false zero (throws for Premium/unverified). + await this.#ensureAccountIndex(); + // Sourced from the venue's own per-market metadata rather than assumed: + // Lighter standard accounts currently report 0 maker/taker fees. + const markets = await this.#ensureMarkets(); + const market = markets.get(params.symbol); + const feeRate = parseFloat( + (params.isMaker ? market?.makerFee : market?.takerFee) ?? '0', + ); + const amount = parseFloat(params.amount ?? '0'); + return { + feeRate, + feeAmount: Number.isFinite(amount) ? amount * feeRate : 0, + protocolFeeRate: feeRate, + metamaskFeeRate: 0, + }; + } + + // ============================================================================ + // Subscriptions (POC: REST polling stands in for a WS feed; prices are live, + // the remaining channels emit empty snapshots) + // ============================================================================ + + subscribeToPrices(params: SubscribePricesParams): () => void { + this.#priceSubscribers.add(params); + if (this.#lastPriceBySymbol.size > 0) { + this.#deliverPrices(params, [...this.#lastPriceBySymbol.values()]); + } + this.#requestChannel('market_stats/all'); + this.#ensureStream(); + return () => { + this.#priceSubscribers.delete(params); + this.#releaseChannelIfUnused(); + }; + } + + subscribeToOICaps(params: SubscribeOICapsParams): () => void { + this.#oiCapSubscribers.add(params); + this.#requestChannel('market_stats/all'); + this.#ensureStream(); + return () => { + this.#oiCapSubscribers.delete(params); + this.#releaseChannelIfUnused(); + }; + } + + subscribeToAccount(params: SubscribeAccountParams): () => void { + this.#accountSubscribers.add(params); + this.#ensureAccountChannels(); + return () => { + this.#accountSubscribers.delete(params); + this.#releaseChannelIfUnused(); + }; + } + + subscribeToPositions(params: SubscribePositionsParams): () => void { + this.#positionSubscribers.add(params); + if (this.#wsPositions.size > 0) { + params.callback([...this.#wsPositions.values()]); + } + this.#ensureAccountChannels(); + return () => { + this.#positionSubscribers.delete(params); + this.#releaseChannelIfUnused(); + }; + } + + subscribeToOrders(params: SubscribeOrdersParams): () => void { + this.#orderSubscribers.add(params); + if (this.#wsOrders.size > 0) { + params.callback([...this.#wsOrders.values()]); + } + this.#ensureAccountChannels(); + return () => { + this.#orderSubscribers.delete(params); + this.#releaseChannelIfUnused(); + }; + } + + subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void { + this.#fillSubscribers.add(params); + this.#ensureAccountChannels(); + return () => { + this.#fillSubscribers.delete(params); + this.#releaseChannelIfUnused(); + }; + } + + // ============================================================================ + // Shared WebSocket stream manager (market_stats / user_stats / + // account_all_positions / account_all_orders), REST polling fallback for + // prices when no WebSocket implementation is available. + // ============================================================================ + + /** + * Resolve the Lighter account index and request the account-scoped + * channels; without a Lighter account the account-ish subscribers get one + * empty emission (graceful degradation, matching REST reads). + */ + readonly #ensureAccountChannels = (): void => { + if (this.#accountChannelsPromise) { + this.#ensureStream(); + return; + } + const generation = this.#sessionGeneration; + let channelsRequested = false; + const setupPromise = (async (): Promise => { + try { + // Warm the margin cache before any WS position frame is adapted. + await this.#ensureMarketMargins().catch(() => undefined); + const accountIndex = await this.#ensureAccountIndex(); + // Address-aware: an EXTERNAL switch during the lookup (with no other + // provider call to advance the generation) must also stop these + // channels from being requested for the old account. The rebind + // inside the binding call triggers its own rebuild for the new one. + // Fails closed when no wallet account is bound — a configured + // account index alone must never subscribe user channels. + this.#assertSession(generation); + this.#requestChannel(`user_stats/${accountIndex}`); + this.#requestChannel(`account_all_positions/${accountIndex}`); + this.#requestChannel(`account_all_trades/${accountIndex}`); + channelsRequested = true; + try { + const auth = await this.#getAuthToken(); + this.#assertSession(generation); + this.#requestChannel(`account_all_orders/${accountIndex}`, auth); + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] orders channel skipped (no auth token)', + { error: String(error) }, + ); + // Only the CURRENT session may blank the order subscribers: an + // auth failure from an aborted previous-account setup must not + // overwrite the new account's live orders with []. + if (generation === this.#sessionGeneration) { + this.#emitToOrderSubscribers([]); + } + } + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] account channels unavailable', + { error: String(error) }, + ); + // Only the CURRENT session may blank the subscribers: an aborted + // previous-account setup must not overwrite the new account's data + // with empty emissions. + if (generation !== this.#sessionGeneration) { + return; + } + // Capability gates (Premium/unverified tier, cross-owner config) + // are not "no data": emitting empty state for them would present + // false emptiness where reads surface an explicit error. Preserve + // whatever the subscribers last saw and only log. + if (this.#isUnsupportedCapabilityError(error)) { + return; + } + for (const subscriber of this.#accountSubscribers) { + subscriber.callback(EMPTY_ACCOUNT_STATE); + } + for (const subscriber of this.#positionSubscribers) { + subscriber.callback([]); + } + this.#emitToOrderSubscribers([]); + } + })(); + this.#accountChannelsPromise = setupPromise; + // A setup that never requested channels (no wallet account yet, or an + // aborted switch) must not satisfy future ensure calls — clear it so + // the next bind retries, without clobbering a newer session's promise. + setupPromise + .then(() => { + if ( + !channelsRequested && + this.#accountChannelsPromise === setupPromise + ) { + this.#accountChannelsPromise = null; + } + return undefined; + }) + .catch(() => undefined); + this.#ensureStream(); + }; + + readonly #hasAnySubscriber = (): boolean => { + return ( + this.#priceSubscribers.size > 0 || + this.#oiCapSubscribers.size > 0 || + this.#accountSubscribers.size > 0 || + this.#positionSubscribers.size > 0 || + this.#orderSubscribers.size > 0 || + this.#fillSubscribers.size > 0 || + [...this.#orderBookSubscribers.values()].some( + (subscribers) => subscribers.size > 0, + ) || + [...this.#candleSubscribers.values()].some( + (subscribers) => subscribers.size > 0, + ) + ); + }; + + readonly #requestChannel = (channel: string, auth?: string): void => { + if (this.#wsWantedChannels.has(channel)) { + return; + } + this.#wsWantedChannels.set(channel, { auth }); + if (this.#priceWs && this.#priceWs.readyState === 1) { + this.#sendSubscribe(channel, auth); + } + }; + + readonly #sendSubscribe = (channel: string, auth?: string): void => { + this.#priceWs?.send( + JSON.stringify( + auth + ? { type: 'subscribe', channel, auth } + : { type: 'subscribe', channel }, + ), + ); + }; + + readonly #releaseChannelIfUnused = (): void => { + if (!this.#hasAnySubscriber()) { + this.#teardownStream(); + } + }; + + readonly #ensureStream = (): void => { + if (this.#priceWs || this.#pricePollTimer) { + return; + } + if (this.#webSocketCtor) { + this.#connectWs(); + } else { + this.#startPricePolling(); + } + }; + + readonly #connectWs = (): void => { + if (!this.#webSocketCtor) { + return; + } + const url = getLighterWsEndpoint(this.#isTestnet ? 'testnet' : 'mainnet'); + const WebSocketCtor = this.#webSocketCtor; + const ws = new WebSocketCtor(url); + this.#priceWs = ws; + this.#setConnectionState(WebSocketConnectionState.Connecting); + + ws.onopen = (): void => { + // Observe any external switch first, then drop if this socket was + // replaced (by that rebind or an earlier one). + this.#ensureSessionBinding(); + if (this.#priceWs !== ws) { + return; + } + const generationAtOpen = this.#sessionGeneration; + this.#wsReconnectAttempts = 0; + this.#setConnectionState(WebSocketConnectionState.Connected); + for (const [channel, meta] of this.#wsWantedChannels) { + if (meta.auth) { + // Auth tokens are short-lived; a reconnect after the deadline must + // re-mint instead of replaying the token captured at subscribe + // time. #getAuthToken reuses the cached token while it is fresh. + this.#getAuthToken() + .then((freshToken) => { + // The async continuation may resolve after an account switch + // replaced the socket or the channel set: never reinsert a + // stale channel or pair it with the new session's token. + if ( + this.#priceWs !== ws || + generationAtOpen !== this.#sessionGeneration || + !this.#wsWantedChannels.has(channel) + ) { + return undefined; + } + this.#wsWantedChannels.set(channel, { auth: freshToken }); + this.#sendSubscribe(channel, freshToken); + return undefined; + }) + .catch((error) => { + this.#deps.debugLogger.log( + '[LighterProvider] auth channel resubscribe failed', + { channel, error: String(error) }, + ); + }); + } else { + this.#sendSubscribe(channel, meta.auth); + } + } + // The server closes idle sockets; any frame under 2 minutes keeps it up. + // Unconditional replacement: `??=` would keep a timer bound to a dead + // socket when a new one opens before the old socket's onclose fired. + this.#clearKeepalive(); + this.#wsKeepaliveTimer = setInterval(() => { + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch { + // Socket closing; onclose handles recovery. + } + }, 60_000); + this.#deps.debugLogger.log( + '[LighterProvider] price stream connected (ws)', + { url, channels: [...this.#wsWantedChannels.keys()] }, + ); + }; + + ws.onmessage = (event: { data: unknown }): void => { + // Re-run the live binding first: an EXTERNAL account switch that no + // provider call has observed yet must tear this socket down (the + // rebind replaces it) before any frame routes into current UI. + this.#ensureSessionBinding(); + // Frames from a socket that was replaced (account rebind, reconnect) + // must never reach the router — they carry the previous session's data. + if (this.#priceWs !== ws) { + return; + } + this.#handleWsMessage(String(event.data)); + }; + + ws.onclose = (): void => { + if (this.#priceWs !== ws) { + return; + } + this.#priceWs = null; + this.#clearKeepalive(); + this.#setConnectionState(WebSocketConnectionState.Disconnected); + if (this.#hasAnySubscriber()) { + this.#deps.debugLogger.log( + '[LighterProvider] price stream closed; reconnecting in 5s', + ); + this.#wsReconnectAttempts += 1; + this.#wsReconnectTimer = setTimeout((): void => { + this.#wsReconnectTimer = null; + this.#ensureStream(); + }, 5_000); + } + }; + + ws.onerror = (): void => { + this.#deps.debugLogger.log('[LighterProvider] price stream ws error'); + }; + }; + + readonly #handleWsMessage = (raw: string): void => { + let message: LighterWsMarketStatsMessage & LighterWsAccountMessage; + try { + message = convertKeysToCamelCase(JSON.parse(raw)) as typeof message; + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] price stream message parse failed', + { error: String(error) }, + ); + return; + } + const type = message.type ?? ''; + if (type.includes('market_stats') && message.marketStats) { + const timestamp = message.timestamp ?? Date.now(); + const updates = Object.values(message.marketStats).map((stat) => + adaptPriceUpdateFromLighterWsStat(stat, timestamp), + ); + this.#dispatchPriceUpdates(updates, 'ws'); + this.#dispatchOICaps(Object.values(message.marketStats)); + return; + } + if (type.includes('user_stats') && message.stats) { + const accountState = adaptAccountStateFromLighterUserStats(message.stats); + for (const subscriber of this.#accountSubscribers) { + try { + subscriber.callback(accountState); + } catch (error) { + this.#logSubscriberError('account', error); + } + } + return; + } + if (type.includes('account_all_positions') && message.positions) { + const isSnapshot = type.startsWith('subscribed'); + if (isSnapshot) { + this.#wsPositions.clear(); + } + for (const [marketId, position] of Object.entries(message.positions)) { + const adapted = adaptPositionFromLighter( + position, + this.#maxLeverageForMarketId(position.marketId), + ); + if (parseFloat(adapted.size) === 0) { + this.#wsPositions.delete(Number(marketId)); + } else { + this.#wsPositions.set(Number(marketId), adapted); + } + } + const positions = [...this.#wsPositions.values()]; + for (const subscriber of this.#positionSubscribers) { + try { + subscriber.callback(positions); + } catch (error) { + this.#logSubscriberError('positions', error); + } + } + return; + } + if (type.includes('order_book')) { + this.#handleOrderBookMessage(type, message as LighterWsOrderBookMessage); + return; + } + if (type.includes('candle')) { + this.#handleCandleMessage(message as LighterWsCandleMessage); + return; + } + if (type.includes('account_all_trades')) { + this.#handleTradesMessage(message as LighterWsTradesMessage); + return; + } + if (type.includes('account_all_orders') && message.orders) { + const isSnapshot = type.startsWith('subscribed'); + if (isSnapshot) { + this.#wsOrders.clear(); + } + for (const marketOrders of Object.values(message.orders)) { + for (const order of marketOrders) { + const adapted = adaptOrderFromLighter( + order, + this.#marketsById.get(order.marketIndex)?.symbol ?? + String(order.marketIndex), + ); + const isOpen = + adapted.status === 'queued' || adapted.status === 'open'; + if (isOpen) { + this.#wsOrders.set(adapted.orderId, adapted); + } else { + this.#wsOrders.delete(adapted.orderId); + } + } + } + this.#emitToOrderSubscribers([...this.#wsOrders.values()]); + } + }; + + /** + * Apply an order_book snapshot/delta and fan the assembled book out. + * + * @param type - Message type (subscribed = full snapshot, update = delta). + * @param message - Camelized order_book payload. + */ + readonly #handleOrderBookMessage = ( + type: string, + message: LighterWsOrderBookMessage, + ): void => { + const channel = message.channel ?? ''; + const marketId = Number(channel.split(':')[1] ?? Number.NaN); + if (!Number.isFinite(marketId) || !message.orderBook) { + return; + } + let state = this.#orderBookState.get(marketId); + if (!state || type.startsWith('subscribed')) { + state = { bids: new Map(), asks: new Map() }; + this.#orderBookState.set(marketId, state); + } + for (const side of ['bids', 'asks'] as const) { + for (const level of message.orderBook[side] ?? []) { + // Boundary guard: the payload is cast, not validated — a level + // with a malformed price or size would flow into the + // cumulative-total math below as NaN and reach the depth chart + // as an invalid SVG coordinate. + const price = parseFloat(level?.price); + const size = parseFloat(level?.size); + if (!Number.isFinite(price) || !Number.isFinite(size)) { + continue; + } + if (size === 0) { + state[side].delete(level.price); + } else { + state[side].set(level.price, level.size); + } + } + } + const subscribers = this.#orderBookSubscribers.get(marketId); + if (!subscribers || subscribers.size === 0) { + return; + } + // Levels must carry the FULL OrderBookLevel contract. The depth chart + // draws Y-coordinates from parseFloat(level.total): a bare {price, size} + // level renders as an SVG path full of NaN and crashes the native path + // parser (found live on device, RNSVGPathParser InvalidNumber). + const toContractLevels = ( + entries: [string, string][], + ): OrderBookLevel[] => { + let cumulativeSize = 0; + let cumulativeNotional = 0; + return entries.map(([price, size]) => { + const sizeNum = parseFloat(size); + const notional = parseFloat(price) * sizeNum; + cumulativeSize += sizeNum; + cumulativeNotional += notional; + return { + price, + size, + total: String(cumulativeSize), + notional: String(notional), + totalNotional: String(cumulativeNotional), + }; + }); + }; + for (const subscriber of subscribers) { + const levels = subscriber.levels ?? 10; + const bids = toContractLevels( + [...state.bids.entries()] + .sort((a, b) => parseFloat(b[0]) - parseFloat(a[0])) + .slice(0, levels), + ); + const asks = toContractLevels( + [...state.asks.entries()] + .sort((a, b) => parseFloat(a[0]) - parseFloat(b[0])) + .slice(0, levels), + ); + const bestBid = parseFloat(bids[0]?.price ?? '0'); + const bestAsk = parseFloat(asks[0]?.price ?? '0'); + const mid = bestBid > 0 && bestAsk > 0 ? (bestBid + bestAsk) / 2 : 0; + const maxTotal = Math.max( + parseFloat(bids[bids.length - 1]?.total ?? '0'), + parseFloat(asks[asks.length - 1]?.total ?? '0'), + ); + const book: OrderBookData = { + bids, + asks, + spread: String(bestAsk - bestBid), + spreadPercentage: + mid > 0 ? String(((bestAsk - bestBid) / mid) * 100) : '0', + midPrice: String(mid), + lastUpdated: Date.now(), + maxTotal: String(maxTotal), + }; + try { + subscriber.callback(book); + } catch (error) { + this.#logSubscriberError('orderBook', error); + } + } + }; + + /** + * Merge live candle updates into the cached series and fan out. + * + * @param message - Camelized candle payload. + */ + readonly #handleCandleMessage = (message: LighterWsCandleMessage): void => { + const channel = message.channel ?? ''; + const [, marketIdRaw, resolution] = channel.split(':'); + const key = `${marketIdRaw}:${resolution}`; + const series = this.#candleSeries.get(key); + const subscribers = this.#candleSubscribers.get(key); + if (!series || !subscribers || subscribers.size === 0) { + return; + } + for (const candle of message.candles ?? []) { + const mapped = toFiniteCandle(candle); + if (mapped) { + series.set(mapped.time, mapped); + } + } + const candles = [...series.values()].sort((a, b) => a.time - b.time); + for (const subscriber of subscribers) { + try { + subscriber.callback({ + symbol: subscriber.symbol, + interval: subscriber.interval, + candles, + }); + } catch (error) { + this.#logSubscriberError('candles', error); + } + } + }; + + /** + * Adapt live account trades into OrderFill emissions. + * + * @param message - Camelized account_all_trades payload. + */ + readonly #handleTradesMessage = (message: LighterWsTradesMessage): void => { + if (this.#fillSubscribers.size === 0) { + return; + } + const isSnapshot = (message.type ?? '').startsWith('subscribed'); + const fills: OrderFill[] = []; + let droppedUnsupportedFill = false; + for (const marketTrades of Object.values(message.trades ?? {})) { + for (const trade of marketTrades) { + const symbol = + this.#marketsById.get(trade.marketId)?.symbol ?? + String(trade.marketId); + // One adapter serves REST history and the live stream so pnl, + // fees, and direction vocabulary can never diverge between them. + // A capability-refused fill (unverified nonzero fee) must never be + // rendered with a false zero fee, nor crash the event handler. + try { + fills.push( + adaptFillFromLighterTrade(trade, symbol, this.#accountIndex ?? -1), + ); + } catch (error) { + droppedUnsupportedFill = true; + this.#deps.debugLogger.log( + '[LighterProvider] dropped unsupported fill from stream', + { tradeId: trade.tradeId, error: String(error) }, + ); + } + } + } + if (fills.length === 0 && !isSnapshot) { + return; + } + // A snapshot that lost fills to a capability refusal is PARTIAL: + // emitting it would overwrite valid cached history with false + // emptiness. Preserve what subscribers already have; REST reads + // surface the capability error explicitly. + if (isSnapshot && droppedUnsupportedFill) { + this.#deps.debugLogger.log( + '[LighterProvider] withholding partial fills snapshot (unsupported fills present)', + ); + return; + } + for (const subscriber of this.#fillSubscribers) { + try { + subscriber.callback(fills, isSnapshot); + } catch (error) { + this.#logSubscriberError('fills', error); + } + } + }; + + readonly #dispatchOICaps = (stats: LighterWsMarketStat[]): void => { + if (this.#oiCapSubscribers.size === 0) { + return; + } + const capped = stats + .filter((stat) => { + const openInterest = parseFloat(stat.openInterest ?? '0'); + const limit = parseFloat( + (stat as { openInterestLimit?: string }).openInterestLimit ?? '0', + ); + return limit > 0 && openInterest >= limit; + }) + .map((stat) => stat.symbol); + for (const subscriber of this.#oiCapSubscribers) { + try { + subscriber.callback(capped); + } catch (error) { + this.#logSubscriberError('oiCaps', error); + } + } + }; + + readonly #emitToOrderSubscribers = (orders: Order[]): void => { + for (const subscriber of this.#orderSubscribers) { + try { + subscriber.callback(orders); + } catch (error) { + this.#logSubscriberError('orders', error); + } + } + }; + + readonly #logSubscriberError = (channel: string, error: unknown): void => { + this.#deps.debugLogger.log( + `[LighterProvider] ${channel} subscriber callback failed`, + { error: String(error) }, + ); + }; + + readonly #startPricePolling = (): void => { + if (this.#pricePollTimer) { + return; + } + const poll = (): void => { + this.#emitPolledPrices().catch((error: unknown) => { + this.#deps.debugLogger.log('[LighterProvider] price poll failed', { + error: String(error), + }); + }); + }; + poll(); + this.#pricePollTimer = setInterval(poll, LIGHTER_PRICE_POLLING_INTERVAL_MS); + }; + + /** + * REST fallback: fetch market stats once and fan them out. + */ + readonly #emitPolledPrices = async (): Promise => { + if (this.#priceSubscribers.size === 0) { + return; + } + const response = await this.#clientService.getOrderBookDetails(); + const timestamp = Date.now(); + const updates = (response.orderBookDetails ?? []).map((detail) => + adaptPriceUpdateFromLighter(detail, timestamp), + ); + this.#dispatchPriceUpdates(updates, 'poll'); + }; + + /** + * Fan price updates out to every subscriber, honoring symbol filters. + * + * @param updates - Adapted price updates for this cycle. + * @param transport - Which transport produced the cycle (ws or poll). + */ + readonly #dispatchPriceUpdates = ( + updates: PriceUpdate[], + transport: string, + ): void => { + if (updates.length === 0) { + return; + } + for (const update of updates) { + this.#lastPriceBySymbol.set(update.symbol, update); + } + this.#pricePollCycle += 1; + this.#deps.debugLogger.log( + `[LighterProvider] price stream cycle=${this.#pricePollCycle} transport=${transport} updates=${updates.length}`, + ); + for (const subscriber of this.#priceSubscribers) { + this.#deliverPrices(subscriber, updates); + } + }; + + readonly #deliverPrices = ( + subscriber: SubscribePricesParams, + updates: PriceUpdate[], + ): void => { + const filtered = + subscriber.symbols.length > 0 + ? updates.filter((update) => subscriber.symbols.includes(update.symbol)) + : updates; + if (filtered.length === 0) { + return; + } + try { + subscriber.callback(filtered); + } catch (error) { + this.#logSubscriberError('prices', error); + } + }; + + readonly #clearKeepalive = (): void => { + if (this.#wsKeepaliveTimer) { + clearInterval(this.#wsKeepaliveTimer); + this.#wsKeepaliveTimer = null; + } + }; + + readonly #teardownStream = (): void => { + if (this.#pricePollTimer) { + clearInterval(this.#pricePollTimer); + this.#pricePollTimer = null; + } + if (this.#wsReconnectTimer) { + clearTimeout(this.#wsReconnectTimer); + this.#wsReconnectTimer = null; + } + this.#clearKeepalive(); + this.#wsWantedChannels.clear(); + this.#accountChannelsPromise = null; + this.#wsPositions.clear(); + this.#wsOrders.clear(); + this.#orderBookState.clear(); + this.#candleSeries.clear(); + this.#lastPriceBySymbol.clear(); + if (this.#priceWs) { + const ws = this.#priceWs; + this.#priceWs = null; + try { + ws.close(); + } catch { + // Socket may already be closed. + } + } + this.#setConnectionState(WebSocketConnectionState.Disconnected); + }; + + subscribeToCandles(params: SubscribeCandlesParams): () => void { + let released = false; + let seriesKey: string | null = null; + const resolution = LIGHTER_SUPPORTED_RESOLUTIONS.has(params.interval) + ? params.interval + : '15m'; + this.#ensureMarkets() + .then(async (markets) => { + const market = markets.get(params.symbol); + if (!market || released) { + return undefined; + } + seriesKey = `${market.marketId}:${resolution}`; + // Seed with history so charts render immediately, then let the WS + // candle channel keep the series live. + const seeded = await this.fetchHistoricalCandles({ + symbol: params.symbol, + interval: params.interval, + limit: 120, + }); + if (released) { + return undefined; + } + const series = new Map(); + for (const candle of seeded.candles) { + series.set(candle.time, candle); + } + this.#candleSeries.set(seriesKey, series); + let subscribers = this.#candleSubscribers.get(seriesKey); + if (!subscribers) { + subscribers = new Set(); + this.#candleSubscribers.set(seriesKey, subscribers); + } + subscribers.add(params); + params.callback(seeded); + this.#requestChannel(`candle/${market.marketId}/${resolution}`); + this.#ensureStream(); + return undefined; + }) + .catch((error: unknown) => { + this.#deps.debugLogger.log('[LighterProvider] candle seed failed', { + error: String(error), + }); + }); + return () => { + released = true; + if (seriesKey !== null) { + this.#candleSubscribers.get(seriesKey)?.delete(params); + } + this.#releaseChannelIfUnused(); + }; + } + + readonly fetchHistoricalCandles = async (options: { + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + }): Promise => { + const empty: CandleData = { + symbol: options.symbol, + interval: options.interval, + candles: [], + }; + try { + const markets = await this.#ensureMarkets(); + const market = markets.get(options.symbol); + if (!market) { + return empty; + } + const resolution = LIGHTER_SUPPORTED_RESOLUTIONS.has(options.interval) + ? options.interval + : '15m'; + const intervalMs = + LIGHTER_RESOLUTION_MS[resolution] ?? LIGHTER_RESOLUTION_MS['15m']; + const limit = options.limit ?? 120; + const endTimestamp = options.endTime ?? Date.now(); + const startTimestamp = endTimestamp - intervalMs * limit; + const response = await this.#clientService.getCandles( + market.marketId, + resolution, + startTimestamp, + endTimestamp, + limit, + ); + return { + symbol: options.symbol, + interval: options.interval, + candles: (response.c ?? []).flatMap((candle) => { + const mapped = toFiniteCandle(candle); + return mapped ? [mapped] : []; + }), + }; + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] fetchHistoricalCandles failed', + { error: String(error) }, + ); + return empty; + } + }; + + subscribeToOrderBook(params: SubscribeOrderBookParams): () => void { + let released = false; + let marketId: number | null = null; + this.#ensureMarkets() + .then((markets) => { + const market = markets.get(params.symbol); + if (!market || released) { + return undefined; + } + marketId = market.marketId; + let subscribers = this.#orderBookSubscribers.get(marketId); + if (!subscribers) { + subscribers = new Set(); + this.#orderBookSubscribers.set(marketId, subscribers); + } + subscribers.add(params); + this.#requestChannel(`order_book/${marketId}`); + this.#ensureStream(); + return undefined; + }) + .catch((error: unknown) => { + params.onError?.(ensureError(error)); + }); + return () => { + released = true; + if (marketId !== null) { + this.#orderBookSubscribers.get(marketId)?.delete(params); + } + this.#releaseChannelIfUnused(); + }; + } + + setLiveDataConfig(_config: Partial): void { + // POC: no live data configuration + } + + getWebSocketConnectionState(): WebSocketConnectionState { + // REST-polling transport has no socket to report on; treat an active + // poll loop as connected so callers don't tear down live subscriptions. + if (!this.#webSocketCtor) { + return WebSocketConnectionState.Connected; + } + return this.#connectionState; + } + + subscribeToConnectionState( + listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void { + this.#connectionListeners.add(listener); + listener(this.getWebSocketConnectionState(), this.#wsReconnectAttempts); + return (): void => { + this.#connectionListeners.delete(listener); + }; + } + + async reconnect(): Promise { + const ws = this.#priceWs; + if (ws) { + // Detach first so the onclose handler's 5s backoff never races the + // immediate reconnect below. + this.#priceWs = null; + this.#clearKeepalive(); + try { + ws.close(); + } catch { + // Socket may already be closed. + } + this.#setConnectionState(WebSocketConnectionState.Disconnected); + } + if (this.#wsReconnectTimer) { + clearTimeout(this.#wsReconnectTimer); + this.#wsReconnectTimer = null; + } + if (this.#hasAnySubscriber()) { + this.#ensureStream(); + } + } + + // ============================================================================ + // Asset Routes + // ============================================================================ + + /** + * The venue's USDC bridge route for the active network, in AssetRoute + * shape. Facts sourced live from `layer1BasicInfo` + venue docs (see + * LIGHTER_BRIDGE_CONFIG). + * + * @param minAmount - Which venue minimum applies (deposit vs withdrawal). + * @returns Single-element route list. + */ + readonly #bridgeRoute = (minAmount: string): AssetRoute[] => { + // Only the MAINNET bridge is ever advertised: the effective-testnet + // branches return [] before reaching here (devnet L1 unreachable). + const bridge = LIGHTER_BRIDGE_CONFIG.mainnet; + return [ + { + assetId: + `${bridge.chainId}/erc20:${bridge.usdcContract}/default` as AssetRoute['assetId'], + chainId: bridge.chainId as AssetRoute['chainId'], + contractAddress: bridge.bridgeContract as AssetRoute['contractAddress'], + constraints: { minAmount }, + }, + ]; + }; + + getDepositRoutes(params?: GetSupportedPathsParams): AssetRoute[] { + // The params.isTestnet OVERRIDE is part of the route contract (HL + // honors it too): DepositService requests { isTestnet: false } to + // scaffold the deposit-and-trade transaction on a chain the wallet + // can reach. Lighter TESTNET itself settles on a venue-hosted devnet + // L1 (chain 123456) the wallet cannot reach — advertising it made + // pay-with flows build a transaction on an unknown chain ("Invalid + // chain ID 0x1e240") — so the effective-testnet answer is NO routes: + // trade from the venue balance, top up via the venue faucet. + const isTestnet = params?.isTestnet ?? this.#isTestnet; + if (isTestnet) { + return []; + } + return this.#bridgeRoute(LIGHTER_BRIDGE_CONFIG.mainnet.minDepositUsdc); + } + + getWithdrawalRoutes(params?: GetSupportedPathsParams): AssetRoute[] { + // Same devnet-L1 reality and the same override contract as deposits. + const isTestnet = params?.isTestnet ?? this.#isTestnet; + if (isTestnet) { + return []; + } + return this.#bridgeRoute(LIGHTER_BRIDGE_CONFIG.mainnet.minWithdrawUsdc); + } + + // ============================================================================ + // Block Explorer + // ============================================================================ + + getBlockExplorerUrl(address?: string): string { + const baseUrl = this.#isTestnet + ? LIGHTER_TESTNET_EXPLORER_URL + : LIGHTER_MAINNET_EXPLORER_URL; + return address ? `${baseUrl}/address/${address}` : baseUrl; + } +} diff --git a/packages/perps-controller/src/services/DepositService.ts b/packages/perps-controller/src/services/DepositService.ts index 76afdea10f..8634b9877a 100644 --- a/packages/perps-controller/src/services/DepositService.ts +++ b/packages/perps-controller/src/services/DepositService.ts @@ -68,6 +68,14 @@ export class DepositService { // Get deposit routes from provider const depositRoutes = provider.getDepositRoutes({ isTestnet: false }); const route = depositRoutes[0]; + if (!route) { + // Fail CLOSED with a routable message instead of a TypeError: some + // venues (Lighter testnet) settle on a chain the wallet cannot + // reach and advertise no deposit route at all. + throw new Error( + 'The active perps provider has no deposit route on this network', + ); + } const bridgeContractAddress = route.contractAddress; // Generate transfer data for ERC-20 token transfer (portable, no mobile imports) diff --git a/packages/perps-controller/src/services/LighterClientService.ts b/packages/perps-controller/src/services/LighterClientService.ts new file mode 100644 index 0000000000..387d1fd1a0 --- /dev/null +++ b/packages/perps-controller/src/services/LighterClientService.ts @@ -0,0 +1,505 @@ +/** + * Lighter Client Service + * + * Thin REST client for the zkLighter API. No SDK dependency — endpoints are + * called with the platform `fetch` global. Response shapes are validated + * minimally (code field) and returned as typed payloads for the adapter + * layer. + * + * Endpoints used (https://apidocs.lighter.xyz): + * - GET /api/v1/orderBooks market metadata + * - GET /api/v1/orderBookDetails market stats + * - GET /api/v1/account account (+positions) by index + * - GET /api/v1/accountsByL1Address account discovery + * - GET /api/v1/apikeys registered venue keys + * - GET /api/v1/nextNonce per-key nonce + * - GET /api/v1/accountActiveOrders open orders (auth token header) + * - POST /api/v1/sendTx submit signed L2 transaction + */ + +import { + getLighterHttpEndpoint, + LIGHTER_HTTP_TIMEOUT_MS, +} from '../constants/lighterConfig.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; +import type { + LighterAccountResponse, + LighterAccountsByL1AddressResponse, + LighterActiveOrdersResponse, + LighterApiKeysResponse, + LighterNetwork, + LighterNextNonceResponse, + LighterTxLookupResponse, + LighterOrderBookMeta, + LighterOrderBookDetailsResponse, + LighterOrderBooksResponse, + LighterCandlesResponse, + LighterDepositHistoryResponse, + LighterInactiveOrdersResponse, + LighterPnlResponse, + LighterPositionFundingsResponse, + LighterSendTxResponse, + LighterTradesResponse, + LighterTransferHistoryResponse, + LighterWithdrawHistoryResponse, +} from '../types/lighter-types.js'; + +/** + * Duration market metadata stays cached before a refetch. + */ +const MARKETS_CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Convert a snake_case wire key to camelCase. + * + * @param key - Wire key (e.g. `min_base_amount`). + * @returns camelCase key (e.g. `minBaseAmount`). + */ +function toCamelKey(key: string): string { + return key.replace(/_([a-z0-9])/gu, (_match, char: string) => + char.toUpperCase(), + ); +} + +/** + * Recursively convert all object keys from snake_case to camelCase. + * The zkLighter wire format is snake_case; parsed shapes in this package + * follow camelCase conventions (see types/lighter-types.ts). + * + * @param value - Parsed JSON value. + * @returns The value with camelCase keys. + */ +export function convertKeysToCamelCase(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(convertKeysToCamelCase); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + toCamelKey(key), + convertKeysToCamelCase(entry), + ]), + ); + } + return value; +} + +/** + * Error thrown for non-2xx HTTP responses or API-level error codes. + */ +export class LighterApiError extends Error { + readonly code: number | undefined; + + constructor(message: string, code?: number) { + super(message); + this.name = 'LighterApiError'; + this.code = code; + } +} + +/** + * REST client for the zkLighter API. + */ +export class LighterClientService { + readonly #deps: PerpsPlatformDependencies; + + readonly #isTestnet: boolean; + + #marketsCache: LighterOrderBookMeta[] | null = null; + + #marketsCacheTime = 0; + + constructor(deps: PerpsPlatformDependencies, config: { isTestnet: boolean }) { + this.#deps = deps; + this.#isTestnet = config.isTestnet; + } + + get network(): LighterNetwork { + return this.#isTestnet ? 'testnet' : 'mainnet'; + } + + get baseUrl(): string { + return getLighterHttpEndpoint(this.network); + } + + /** + * Fetch market metadata, cached for 5 minutes. + * + * @param forceRefresh - Skip the cache and refetch. + * @returns Market metadata entries. + */ + async getOrderBooks(forceRefresh = false): Promise { + const now = Date.now(); + if ( + !forceRefresh && + this.#marketsCache && + now - this.#marketsCacheTime < MARKETS_CACHE_TTL_MS + ) { + return this.#marketsCache; + } + + const response = + await this.#get('/api/v1/orderBooks'); + this.#marketsCache = response.orderBooks; + this.#marketsCacheTime = now; + return response.orderBooks; + } + + /** + * Fetch market stats for all markets. + * + * @returns Order book details entries. + */ + async getOrderBookDetails(): Promise { + return await this.#get( + '/api/v1/orderBookDetails', + ); + } + + /** + * Fetch an account (including positions) by its Lighter index. + * + * @param accountIndex - The Lighter account index. + * @returns Account payload. + */ + async getAccountByIndex( + accountIndex: number, + ): Promise { + return await this.#get( + `/api/v1/account?by=index&value=${accountIndex}`, + ); + } + + /** + * Discover Lighter accounts owned by an L1 address. + * + * @param l1Address - The owning EVM address. + * @returns Accounts payload. + */ + async getAccountsByL1Address( + l1Address: string, + ): Promise { + return await this.#get( + `/api/v1/accountsByL1Address?l1_address=${l1Address}`, + ); + } + + /** + * Fetch registered API keys for an account. + * + * @param accountIndex - The Lighter account index. + * @param apiKeyIndex - Key slot, or 255 for all slots. + * @returns API keys payload. + */ + async getApiKeys( + accountIndex: number, + apiKeyIndex = 255, + ): Promise { + return await this.#get( + `/api/v1/apikeys?account_index=${accountIndex}&api_key_index=${apiKeyIndex}`, + ); + } + + /** + * Fetch the next nonce for a key slot. + * + * @param accountIndex - The Lighter account index. + * @param apiKeyIndex - Key slot. + * @returns Next nonce payload. + */ + async getNextNonce( + accountIndex: number, + apiKeyIndex: number, + ): Promise { + return await this.#get( + `/api/v1/nextNonce?account_index=${accountIndex}&api_key_index=${apiKeyIndex}`, + ); + } + + /** + * Look up a transaction by its exact hash (`GET /api/v1/tx`). Used to + * resolve submission-acceptance ambiguity authoritatively: an exact-hash + * match proves the signed payload reached the sequencer. + * + * Contract: a venue-confirmed "transaction not found" (API error code + * 21500) resolves to NULL; transport failures and every other API error + * RETHROW — they are ambiguity, never evidence of non-acceptance. + * + * @param txHash - The signed transaction hash. + * @returns The venue's transaction payload, or null when the venue + * confirms the hash is unknown. + */ + async getTx(txHash: string): Promise { + try { + return await this.#get( + `/api/v1/tx?by=hash&value=${encodeURIComponent(txHash)}`, + ); + } catch (error) { + if (error instanceof LighterApiError && error.code === 21500) { + return null; + } + throw error; + } + } + + /** + * Fetch active (open) orders for an account. + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer (`_createAuthToken`). + * @param marketId - Optional market filter (255 = all markets). + * @returns Active orders payload. + */ + async getActiveOrders( + accountIndex: number, + authToken: string, + marketId = 255, + ): Promise { + return await this.#get( + `/api/v1/accountActiveOrders?account_index=${accountIndex}&market_id=${marketId}`, + { authorization: authToken }, + ); + } + + /** + * Fetch historical (inactive) orders: filled and canceled lifecycle + * states, newest first (auth token required). + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer. + * @param limit - Max entries (1-100). + * @param cursor - Pagination cursor from a previous page's `nextCursor`. + * @param marketId - Optional market filter (official `market_id` query + * param) — sharply bounds history scans to one symbol. + * @returns Inactive orders payload. + */ + async getInactiveOrders( + accountIndex: number, + authToken: string, + limit = 50, + cursor?: string, + marketId?: number, + ): Promise { + return await this.#get( + `/api/v1/accountInactiveOrders?account_index=${accountIndex}&limit=${limit}${ + cursor === undefined ? '' : `&cursor=${encodeURIComponent(cursor)}` + }${marketId === undefined ? '' : `&market_id=${marketId}`}`, + { authorization: authToken }, + ); + } + + /** + * Fetch L1→L2 deposit history (auth token required). The venue requires + * both the account index and its L1 address on this endpoint. + * + * @param accountIndex - The Lighter account index. + * @param l1Address - The account's L1 address. + * @param authToken - Auth token minted by the signer. + * @returns Deposit history payload (newest first, cursor-paged). + */ + async getDepositHistory( + accountIndex: number, + l1Address: string, + authToken: string, + ): Promise { + return await this.#get( + `/api/v1/deposit/history?account_index=${accountIndex}&l1_address=${l1Address}`, + { authorization: authToken }, + ); + } + + /** + * Fetch L2→L1 withdrawal history (auth token required). + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer. + * @returns Withdrawal history payload (newest first, cursor-paged). + */ + async getWithdrawHistory( + accountIndex: number, + authToken: string, + ): Promise { + return await this.#get( + `/api/v1/withdraw/history?account_index=${accountIndex}`, + { authorization: authToken }, + ); + } + + /** + * Fetch L2 transfer history (auth token required). + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer. + * @returns Transfer history payload (newest first, cursor-paged). + */ + async getTransferHistory( + accountIndex: number, + authToken: string, + ): Promise { + return await this.#get( + `/api/v1/transfer/history?account_index=${accountIndex}`, + { authorization: authToken }, + ); + } + + /** + * Fetch account trade history (auth token required). + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer. + * @param limit - Max entries (1-100). + * @returns Trades payload (newest first). + */ + async getTrades( + accountIndex: number, + authToken: string, + limit = 50, + ): Promise { + return await this.#get( + `/api/v1/trades?sort_by=timestamp&limit=${limit}&account_index=${accountIndex}&market_type=perp`, + { authorization: authToken }, + ); + } + + /** + * Fetch user funding payment history (auth token required). + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer. + * @param limit - Max entries. + * @returns Position fundings payload. + */ + async getPositionFundings( + accountIndex: number, + authToken: string, + limit = 50, + ): Promise { + return await this.#get( + `/api/v1/positionFunding?account_index=${accountIndex}&market_id=255&limit=${limit}&sort_by=timestamp&side=all`, + { authorization: authToken }, + ); + } + + /** + * Fetch account PnL history (auth token required). + * + * @param accountIndex - The Lighter account index. + * @param authToken - Auth token minted by the signer. + * @param startTimestamp - Range start (ms). + * @param endTimestamp - Range end (ms). + * @param countBack - Records counted back from range end. + * @returns PnL payload. + */ + async getPnl( + accountIndex: number, + authToken: string, + startTimestamp: number, + endTimestamp: number, + countBack: number, + ): Promise { + return await this.#get( + `/api/v1/pnl?by=index&value=${accountIndex}&resolution=1d&count_back=${countBack}&start_timestamp=${startTimestamp}&end_timestamp=${endTimestamp}`, + { authorization: authToken }, + ); + } + + /** + * Fetch an OHLCV candle series for a market. + * + * @param marketId - Numeric Lighter market id. + * @param resolution - Candle resolution (e.g. `1m`, `15m`, `1h`, `1d`). + * @param startTimestamp - Range start (ms). + * @param endTimestamp - Range end (ms). + * @param countBack - Number of candles counted back from the range end. + * @returns Candle series payload. + */ + async getCandles( + marketId: number, + resolution: string, + startTimestamp: number, + endTimestamp: number, + countBack: number, + ): Promise { + return await this.#get( + `/api/v1/candles?market_id=${marketId}&resolution=${resolution}&start_timestamp=${startTimestamp}&end_timestamp=${endTimestamp}&count_back=${countBack}`, + ); + } + + /** + * Submit a signed L2 transaction. + * + * @param txType - L2 transaction type code (see lighterConfig). + * @param txInfo - Serialized signed transaction JSON. + * @returns Send result payload. + */ + async sendTx(txType: number, txInfo: string): Promise { + const body = new URLSearchParams({ + tx_type: String(txType), + tx_info: txInfo, + }); + + const response = await this.#request( + '/api/v1/sendTx', + { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }, + ); + return response; + } + + readonly #get = async ( + path: string, + headers?: Record, + ): Promise => { + return await this.#request(path, { method: 'GET', headers }); + }; + + readonly #request = async ( + path: string, + init: { method: string; headers?: Record; body?: string }, + ): Promise => { + const url = `${this.baseUrl}${path}`; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + LIGHTER_HTTP_TIMEOUT_MS, + ); + + try { + const response = await fetch(url, { + method: init.method, + headers: init.headers, + body: init.body, + signal: controller.signal, + }); + + const payload = convertKeysToCamelCase(await response.json()) as Result; + + // Lighter returns HTTP 200 with an application-level error code, and + // 4xx/5xx with `{code, message}` bodies — treat both uniformly. + if (!response.ok || payload.code !== 200) { + throw new LighterApiError( + payload.message ?? `Lighter API error (HTTP ${response.status})`, + payload.code ?? response.status, + ); + } + + return payload; + } catch (error) { + if (error instanceof LighterApiError) { + throw error; + } + this.#deps.debugLogger?.log?.('LighterClientService request failed', { + url, + error, + }); + throw new LighterApiError( + error instanceof Error ? error.message : String(error), + ); + } finally { + clearTimeout(timeout); + } + }; +} diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts new file mode 100644 index 0000000000..0f7e5f9db8 --- /dev/null +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -0,0 +1,165 @@ +/** + * LighterWalletService + * + * Derives and manages the Lighter venue key seed and routes the L1 + * (EVM) signatures Lighter requires. + * + * Lighter's protocol needs two kinds of signatures: + * 1. An EIP-191 `personal_sign` over the ChangePubKey plaintext produced by + * the WASM signer — this registers the venue key on the account. The + * signature is injected into the L2 transaction (`L1Sig`); the raw EVM + * private key is never required, so hardware wallets are supported. + * 2. Venue-key (Schnorr/ECgFp5) signatures over L2 transactions — produced + * inside the WASM signer from a seed. + * + * The seed is derived deterministically: the user's account signs a fixed + * domain message (also EIP-191, deterministic per RFC 6979) and the + * signature is hashed with SHA-256. The same wallet therefore always + * derives the same venue key — recoverable across devices with no stored + * key material. + * + * Signature routing mirrors MYXWalletService: through + * `KeyringController:signPersonalMessage` when a messenger is available, + * or through an injected `LighterPersonalSigner` for headless use. + */ + +import { bytesToHex, hexToBytes, remove0x, sha256 } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { + buildLighterKeyDerivationMessage, + getLighterChainId, +} from '../constants/lighterConfig.js'; +import type { PerpsControllerMessenger } from '../PerpsController.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; +import type { + LighterNetwork, + LighterPersonalSigner, +} from '../types/lighter-types.js'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; + +export class LighterWalletService { + #isTestnet: boolean; + + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessenger | undefined; + + readonly #personalSigner: LighterPersonalSigner | undefined; + + readonly #l1Address: string | undefined; + + constructor( + deps: PerpsPlatformDependencies, + options: { + isTestnet?: boolean; + messenger?: PerpsControllerMessenger; + personalSigner?: LighterPersonalSigner; + l1Address?: string; + } = {}, + ) { + this.#deps = deps; + this.#messenger = options.messenger; + this.#personalSigner = options.personalSigner; + this.#l1Address = options.l1Address; + this.#isTestnet = options.isTestnet ?? true; + } + + get network(): LighterNetwork { + return this.#isTestnet ? 'testnet' : 'mainnet'; + } + + /** + * Resolve the L1 address whose account owns the Lighter account. + * + * @returns The EVM address. + */ + getUserAddress(): string { + if (this.#messenger) { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + return evmAccount.address; + } + if (this.#l1Address) { + return this.#l1Address; + } + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + /** + * Sign an EIP-191 personal message with the user's L1 account. + * + * Routes through the keyring when a messenger is present, else the + * injected headless signer. + * + * @param message - Plaintext message to sign. + * @returns 65-byte signature as 0x-prefixed hex. + */ + async signPersonalMessage(message: string): Promise { + if (this.#messenger) { + const { isUnlocked } = this.#messenger.call('KeyringController:getState'); + if (!isUnlocked) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + const address = this.getUserAddress() as Hex; + this.#deps.debugLogger.log('LighterWalletService: personal_sign', { + address, + }); + // KeyringController:signPersonalMessage expects hex-encoded data. + const data = bytesToHex(new TextEncoder().encode(message)); + return await this.#messenger.call( + 'KeyringController:signPersonalMessage', + { from: address, data }, + ); + } + + if (this.#personalSigner) { + return await this.#personalSigner(message); + } + + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + /** + * Derive the deterministic venue-key seed for a key slot. + * + * seed = sha256(personal_sign(derivation message)) — 32 bytes, hex. + * The WASM signer requires >= 32 bytes of hex seed. + * + * @param apiKeyIndex - API key slot the seed is bound to. + * @returns Seed as 0x-prefixed 32-byte hex string. + */ + async deriveKeySeed(apiKeyIndex: number): Promise { + const address = this.getUserAddress(); + const message = buildLighterKeyDerivationMessage({ + address, + chainId: getLighterChainId(this.network), + apiKeyIndex, + }); + const signature = await this.signPersonalMessage(message); + const seedBytes = await sha256(hexToBytes(signature)); + return bytesToHex(seedBytes); + } + + /** + * Derive the seed without the 0x prefix (the WASM `_createClient` + * strips one, but plain hex keeps parity with the reference SDK usage). + * + * @param apiKeyIndex - API key slot the seed is bound to. + * @returns Seed as plain hex string. + */ + async deriveKeySeedPlain(apiKeyIndex: number): Promise { + return remove0x((await this.deriveKeySeed(apiKeyIndex)) as Hex); + } + + public setTestnetMode(isTestnet: boolean): void { + this.#isTestnet = isTestnet; + } + + public isTestnetMode(): boolean { + return this.#isTestnet; + } +} diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index df8d9758ae..f9710e41b7 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -733,7 +733,14 @@ export class MarketDataService { isMarketAllowed?: (symbol: string) => boolean; }): Promise { const { provider, params, context, isMarketAllowed } = options; - const useTerminalApi = params?.useTerminalApi; + // The Terminal API describes HYPERLIQUID markets only: serving its + // metadata (minimums, leverage caps) while another venue is active + // would hand the UI the wrong venue's trading rules — found on + // device as a Lighter order form defaulting below the venue floor. + const useTerminalApi = + params?.useTerminalApi && + (provider.protocolId === 'hyperliquid' || + provider.protocolId === 'aggregated'); const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index a49ee71eb4..f62fda40da 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -7,6 +7,7 @@ import type { } from '@metamask/utils'; import type { CandlePeriod, TimeDuration } from '../constants/chartConfig.js'; +import type { LighterSignerBridge } from './lighter-types.js'; import type { CandleData, OrderType, @@ -329,6 +330,55 @@ export type OrderResult = { // Absent for every non-strategy placement. childOrderIds?: string[]; providerId?: PerpsProviderType; // Multi-provider: which provider executed this order (injected by aggregator) + /** + * Structured record of venue state that was ALREADY committed before the + * placement failed — e.g. a leverage change that landed before the order + * was rejected. Present only on failure results where such state exists; + * callers must not treat the failure as "nothing happened". + */ + partialState?: { + /** Leverage (in x) the venue already applied for this symbol. */ + leverageUpdated?: number; + }; +}; + +/** + * A TP/SL protection change that could not be safely completed + * automatically and requires an explicit new protection intent from the + * user. Surfaced by providers with durable settlement state (Lighter). + */ +export type PerpsPendingManualRecovery = { + symbol: string; + /** Durable identity (address:accountIndex:apiKey:symbol). */ + settlementKey: string; + recordedAt: number; + /** Human-readable cause of the parked state. */ + reason: string; + /** Whether the interrupted operation replaced or removed protection. */ + priorIntent: 'replace' | 'remove'; + /** Venue order ids still on the books when the state was parked. */ + survivingOrderIds: string[]; + /** What the user should do to resolve the state. */ + actionNeeded: string; +}; + +/** + * A previously ambiguous dispatch whose outcome was later resolved. + * Writes stay blocked until each outcome is explicitly acknowledged via + * `acknowledgeRecoveredDispatch` (after the caller refreshes venue + * state) — except `failed`, which is retry-safe and non-blocking. + */ +export type PerpsRecoveredDispatch = { + /** Stable id for selective acknowledgment. */ + recoveryId: string; + /** Venue transaction type of the dispatch. */ + kind: number; + /** Human-readable operation intent. */ + intent: string; + txHash: string | null; + outcome: 'succeeded' | 'failed' | 'unknown'; + /** How the outcome was determined (e.g. `tx-status:3`, `rest-advance`). */ + evidence: string; }; export type Position = { @@ -920,9 +970,28 @@ export type MYXCredentials = { brokerAddressMainnet?: string; }; +export type LighterCredentials = { + /** Whether Lighter provider is enabled via local env var. */ + enabled?: boolean; + /** Lighter account index override (testnet tooling). */ + accountIndexTestnet?: number; + accountIndexMainnet?: number; + /** API key slot to register/use (defaults to LIGHTER_DEFAULT_API_KEY_INDEX). */ + apiKeyIndex?: number; + /** + * Transport for the Lighter Go/WASM signer, provided by the client + * (mobile: off-screen WebView bridge; headless: in-process WASM). + * Optional — without it the Lighter provider is read-only. Lives on the + * Lighter credentials bag, not PerpsPlatformDependencies, so the shared + * platform surface stays venue-agnostic. + */ + signerBridge?: LighterSignerBridge; +}; + export type PerpsProviderCredentials = { hyperliquid?: HyperLiquidCredentials; myx?: MYXCredentials; + lighter?: LighterCredentials; }; export type PriceUpdate = { @@ -1441,6 +1510,12 @@ export type PerpsProvider = { closePositions?(params: ClosePositionsParams): Promise; // Optional: batch close for protocols that support it updatePositionTPSL(params: UpdatePositionTPSLParams): Promise; updateMargin(params: UpdateMarginParams): Promise; + // Durable-settlement surfacing (optional: providers with durable local + // settlement state — Lighter). Read-only listings plus selective, + // explicit acknowledgment; never destructive read-all. + getPendingManualRecoveries?(): Promise; + getRecoveredDispatches?(): Promise; + acknowledgeRecoveredDispatch?(recoveryId: string): Promise; getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; getUserDataSnapshot?( @@ -1622,7 +1697,7 @@ export type PerpsProvider = { * Provider identifier type for multi-provider support. * Add new providers here as they are implemented. */ -export type PerpsProviderType = 'hyperliquid' | 'myx'; +export type PerpsProviderType = 'hyperliquid' | 'myx' | 'lighter'; /** * Active provider mode for PerpsController state. diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts new file mode 100644 index 0000000000..d313878fcc --- /dev/null +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -0,0 +1,723 @@ +/** + * Lighter Protocol Type Definitions + * + * Types for the zkLighter REST API, the Go/WASM signer bridge, and + * provider configuration. No SDK dependency — shapes are derived from + * the public API (https://apidocs.lighter.xyz) and the reference + * WebView bridge (elliottech/lighter-go, `web-wasm` branch). + */ + +// ============================================================================ +// Network Configuration Types +// ============================================================================ + +/** + * Lighter Network type - mainnet or testnet + */ +export type LighterNetwork = 'mainnet' | 'testnet'; + +/** + * Lighter endpoint configuration for a single network + */ +export type LighterEndpointConfig = { + http: string; + ws: string; +}; + +/** + * Lighter endpoints for all networks + */ +export type LighterEndpoints = { + mainnet: LighterEndpointConfig; + testnet: LighterEndpointConfig; +}; + +// ============================================================================ +// Signer Bridge (WASM seam) +// ============================================================================ + +/** + * A single call into the Lighter Go/WASM signer. + * + * Mirrors the postMessage protocol of the reference React Native WebView + * bridge (`{ function, params }`), so a WebView-backed bridge on mobile and + * an in-process WASM bridge in Node are interchangeable implementations. + */ +export type LighterWasmCall = { + /** Global function name registered by the WASM module (e.g. `_createClient`). */ + function: string; + /** Positional arguments forwarded verbatim to the WASM function. */ + params: unknown[]; +}; + +/** + * Transport-agnostic seam to the Lighter Go/WASM signer. + * + * Implementations: + * - Mobile: off-screen WebView loading the locally-bundled + * `wasm-wrapper.standalone.html`, dispatching calls via postMessage. + * - Node (e2e): in-process `WebAssembly.instantiate` via Go's `wasm_exec.js`. + */ +export type LighterSignerBridge = { + /** + * Execute one WASM signer call and resolve with its result object. + * + * @param call - The function name and positional params to invoke. + */ + execute(call: LighterWasmCall): Promise; + + /** + * Optional subscription to bridge resets (WebView reload / process + * loss). The provider uses it to invalidate its cached signer session + * IMMEDIATELY instead of learning about the reset from the next failed + * trading call. + * + * @param listener - Invoked on every reset. + * @returns Unsubscribe function. + */ + onReset?(listener: () => void): () => void; + + /** + * Optional hook to re-arm the bridge after a reload (WebView remount). + */ + reset?(): void; +}; + +// ============================================================================ +// WASM signer response shapes (from lighter-go react-native/src/lighterSdk.ts) +// ============================================================================ + +/** + * Response of `_createClient` / `_createClientByPrv`. + */ +export type LighterCreateClientResult = { + success: boolean; + /** Venue public key, hex (80 chars / 40 bytes, Schnorr over ECgFp5). */ + pk: string; + /** + * Venue private key, hex. Present only when the signer host runs + * in-process (headless Node); the mobile WebView redacts it before the + * result crosses the bridge. Never persist, forward, or log it. + */ + prv?: string; + pubKeySuccess: boolean; + /** + * ChangePubKey plaintext body to be signed with EIP-191 `personal_sign` + * by the user's L1 (EVM) account. + */ + body: string; + error?: string; +}; + +/** + * Response of `_signChangePubKey`. + */ +export type LighterSignChangePubKeyResult = { + /** Serialized L2 transaction JSON (includes the injected `L1Sig`). */ + txInfo: string; + /** + * Signed transaction hash (pinned WASM contract: the signing RESULT + * carries the hash; `txInfo` never does). Required for the durable + * dispatch ledger's exact-identity reconciliation. + */ + txHash?: string; + error?: string; +}; + +/** + * Response of `_createAuthToken`. + */ +export type LighterCreateAuthTokenResult = { + token: string; + deadline: number; + error?: string; +}; + +/** + * Response of signing functions returning a full L2 transaction + * (`_signCreateOrder`, `_signCancelOrder`, ...). + */ +export type LighterTxResult = { + txInfo: string; + txHash?: string; + error?: string; +}; + +// ============================================================================ +// Auth Configuration +// ============================================================================ + +/** + * Signs an EIP-191 personal message and resolves with the 65-byte signature + * as a 0x-prefixed hex string. Injected for headless use; when a messenger + * is available the wallet service routes through + * `KeyringController:signPersonalMessage` instead. + */ +export type LighterPersonalSigner = (message: string) => Promise; + +/** + * Lighter auth/config passed at construction time. + */ +export type LighterAuthConfig = { + /** Whether the Lighter provider is enabled via local override. */ + enabled?: boolean; + /** Lighter account index (assigned at first deposit). */ + accountIndex?: number; + /** API key slot to register/use (0-254). */ + apiKeyIndex?: number; + /** L1 address owning the Lighter account. */ + l1Address?: string; + /** Headless personal_sign implementation (e2e / tooling). */ + personalSigner?: LighterPersonalSigner; +}; + +// ============================================================================ +// REST API response shapes (subset used by the POC) +// +// The zkLighter wire format is snake_case; LighterClientService converts +// keys to camelCase at the fetch boundary so these parsed shapes follow +// package conventions. +// ============================================================================ + +/** + * One market entry from `GET /api/v1/orderBooks`. + */ +export type LighterOrderBookMeta = { + symbol: string; + marketId: number; + marketType: string; + status: string; + takerFee: string; + makerFee: string; + minBaseAmount: string; + minQuoteAmount: string; + supportedSizeDecimals: number; + supportedPriceDecimals: number; + supportedQuoteDecimals: number; +}; + +/** + * Response of `GET /api/v1/orderBooks`. + */ +export type LighterOrderBooksResponse = { + code: number; + orderBooks: LighterOrderBookMeta[]; +}; + +/** + * Market stats from `GET /api/v1/orderBookDetails`. + */ +export type LighterOrderBookDetail = LighterOrderBookMeta & { + lastTradePrice: number; + /** Default initial margin fraction, hundredths of a percent (666 = 6.66%). */ + defaultInitialMarginFraction?: number; + /** Minimum initial margin fraction, hundredths of a percent (400 → 25x max). */ + minInitialMarginFraction?: number; + /** Maintenance margin fraction, hundredths of a percent (240 = 2.4%). */ + maintenanceMarginFraction?: number; + dailyTradesCount: number; + dailyBaseTokenVolume: number; + dailyQuoteTokenVolume: number; + dailyPriceLow: number; + dailyPriceHigh: number; + dailyPriceChange: number; + openInterest: number; + dailyChart: Record; +}; + +/** + * Response of `GET /api/v1/orderBookDetails`. + */ +export type LighterOrderBookDetailsResponse = { + code: number; + orderBookDetails: LighterOrderBookDetail[]; +}; + +/** + * One sub-account from `GET /api/v1/accountsByL1Address` or `account`. + */ +export type LighterSubAccount = { + code: number; + accountType: number; + index: number; + l1Address: string; + cancelAllTime: number; + totalOrderCount: number; + pendingOrderCount: number; + status: number; + collateral: string; + availableBalance: string; + positions?: LighterApiPosition[]; +}; + +/** + * Response of `GET /api/v1/accountsByL1Address`. + */ +export type LighterAccountsByL1AddressResponse = { + code: number; + message?: string; + l1Address: string; + subAccounts: LighterSubAccount[]; +}; + +/** + * Response of `GET /api/v1/account` (`by=index`). + */ +export type LighterAccountResponse = { + code: number; + message?: string; + accounts: LighterSubAccount[]; +}; + +/** + * One position inside an account payload. + */ +export type LighterApiPosition = { + marketId: number; + symbol: string; + initialMarginFraction: string; + openOrderCount: number; + /** 1 = long, -1 = short (sign convention per API). */ + sign: number; + position: string; + avgEntryPrice: string; + positionValue: string; + unrealizedPnl: string; + realizedPnl: string; + liquidationPrice: string; + /** 0 = cross, 1 = isolated. Older captures omit it (venue default cross). */ + marginMode?: number; +}; + +/** + * One API key entry from `GET /api/v1/apikeys`. + */ +export type LighterApiKey = { + accountIndex: number; + apiKeyIndex: number; + nonce: number; + publicKey: string; +}; + +/** + * Response of `GET /api/v1/apikeys`. + */ +export type LighterApiKeysResponse = { + code: number; + message?: string; + apiKeys: LighterApiKey[]; +}; + +/** + * Response of `GET /api/v1/nextNonce`. + */ +export type LighterNextNonceResponse = { + code: number; + message?: string; + nonce: number; +}; + +/** + * Response of `GET /api/v1/tx?by=hash&value=...` (EnrichedTx). Only the + * identity fields the settlement reconciler verifies are typed; a + * successful exact-hash match proves the signed payload reached the + * sequencer. + */ +export type LighterTxLookupResponse = { + code: number; + message?: string; + hash?: string; + accountIndex?: number; + apiKeyIndex?: number; + nonce?: number; + status?: number | string; +}; + +/** + * Response of `POST /api/v1/sendTx`. + */ +export type LighterSendTxResponse = { + code: number; + message?: string; + txHash?: string; +}; + +/** + * One market entry from the `market_stats/all` WebSocket channel + * (`subscribed/market_stats` snapshot and `update/market_stats` deltas), + * after snake→camel conversion at the socket boundary. + */ +export type LighterWsMarketStat = { + symbol: string; + marketId: number; + indexPrice: string; + markPrice: string; + midPrice: string; + bestAskPrice: string; + bestBidPrice: string; + lastTradePrice: string; + openInterest: string; + fundingRate: string; + currentFundingRate?: string; + dailyQuoteTokenVolume: number; + dailyPriceChange: number; +}; + +/** + * Envelope of a `market_stats` WebSocket message (post-camelization). + */ +export type LighterWsMarketStatsMessage = { + type: string; + channel?: string; + timestamp?: number; + marketStats?: Record; +}; + +/** + * Stats block of a `user_stats/{account_index}` WebSocket message + * (post-camelization). Live-verified against testnet account 28. + */ +export type LighterWsUserStats = { + collateral: string; + portfolioValue: string; + leverage: string; + availableBalance: string; + marginUsage: string; + buyingPower: string; +}; + +/** + * Generic account-channel WebSocket envelope (post-camelization): + * `user_stats` carries `stats`, `account_all_positions` carries `positions` + * (same field shape as the REST account payload), `account_all_orders` + * carries `orders` keyed by market id. + */ +export type LighterWsAccountMessage = { + type: string; + channel?: string; + timestamp?: number; + stats?: LighterWsUserStats; + positions?: Record; + orders?: Record; +}; + +/** + * Minimal structural WebSocket surface the Lighter stream manager uses. + * Structural (rather than the DOM/Node `WebSocket` type) so platforms and + * tests can supply any compatible implementation. + */ +export type LighterWebSocketLike = { + readyState: number; + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: (() => void) | null; + onerror: (() => void) | null; +}; + +/** + * Constructor for {@link LighterWebSocketLike} transports. + */ +export type LighterWebSocketCtor = new (url: string) => LighterWebSocketLike; + +/** + * `order_book/{market_id}` WebSocket payload (post-camelization). + * `subscribed/*` carries a full snapshot; `update/*` carries deltas where + * `size: "0.000"` removes a level. + */ +export type LighterWsOrderBookMessage = { + type: string; + channel?: string; + timestamp?: number; + orderBook?: { + bids?: { price: string; size: string }[]; + asks?: { price: string; size: string }[]; + }; +}; + +/** + * `candle/{market_id}/{resolution}` WebSocket payload (post-camelization, + * candle entries keep the compact t/o/h/l/c/v wire keys). + */ +export type LighterWsCandleMessage = { + type: string; + channel?: string; + timestamp?: number; + candles?: LighterCandle[]; +}; + +/** + * One trade entry from the `account_all_trades/{account_index}` channel + * (post-camelization). + */ +export type LighterWsTrade = { + tradeId: number; + marketId: number; + size: string; + price: string; + askId: number; + bidId: number; + askAccountId: number; + bidAccountId: number; + isMakerAsk: boolean; + timestamp: number; + /** Realized pnl per side — same wire shape as the REST trade payload. */ + askAccountPnl?: string; + bidAccountPnl?: string; + /** Fees, present when nonzero; unit unproven — see LighterRestTrade. */ + takerFee?: number | string; + makerFee?: number | string; + takerPositionSizeBefore?: string; + makerPositionSizeBefore?: string; + takerPositionSignChanged?: boolean; + makerPositionSignChanged?: boolean; +}; + +/** + * `account_all_trades/{account_index}` WebSocket payload (post-camelization). + */ +export type LighterWsTradesMessage = { + type: string; + channel?: string; + timestamp?: number; + trades?: Record; +}; + +/** + * One trade from `GET /api/v1/trades` (post-camelization). + */ +export type LighterRestTrade = { + tradeId: number; + txHash?: string; + type: string; + marketId: number; + size: string; + price: string; + usdAmount?: string; + askId: number; + bidId: number; + askAccountId: number; + bidAccountId: number; + isMakerAsk?: boolean; + timestamp: number; + /** Realized pnl for the ask-side account, signed USDC. */ + askAccountPnl?: string; + /** Realized pnl for the bid-side account, signed USDC. */ + bidAccountPnl?: string; + /** + * Taker/maker fees, present when nonzero. The official model types them + * as StrictInt with NO documented unit or scale; until a captured + * nonzero payload proves one, adapters must treat these as unavailable. + */ + takerFee?: number | string; + makerFee?: number | string; + /** Position size (absolute) of each side before the trade executed. */ + takerPositionSizeBefore?: string; + makerPositionSizeBefore?: string; + /** Whether the side's position sign changed (crossed or left zero). */ + takerPositionSignChanged?: boolean; + makerPositionSignChanged?: boolean; +}; + +/** + * Response of `GET /api/v1/trades`. + */ +export type LighterTradesResponse = { + code: number; + message?: string; + trades?: LighterRestTrade[]; +}; + +/** + * One entry from `GET /api/v1/positionFunding` (post-camelization). + */ +export type LighterPositionFunding = { + timestamp: number; + marketId: number; + fundingId: number; + change: string; + rate: string; + positionSize: string; + positionSide: string; +}; + +/** + * Response of `GET /api/v1/positionFunding`. + */ +export type LighterPositionFundingsResponse = { + code: number; + message?: string; + positionFundings?: LighterPositionFunding[]; +}; + +/** + * One record from `GET /api/v1/pnl` (post-camelization). + */ +export type LighterPnlRecord = { + timestamp: number; + tradePnl: number; + inflow: number; + outflow: number; + volume: number; +}; + +/** + * Response of `GET /api/v1/pnl`. + */ +export type LighterPnlResponse = { + code: number; + message?: string; + resolution?: string; + pnl?: LighterPnlRecord[]; +}; + +/** + * One candle from `GET /api/v1/candles` (compact wire keys: t/o/h/l/c/v). + */ +export type LighterCandle = { + t: number; + o: number; + h: number; + l: number; + c: number; + v: number; +}; + +/** + * Response of `GET /api/v1/candles`. + */ +export type LighterCandlesResponse = { + code: number; + message?: string; + /** Resolution echo (e.g. `15m`). */ + r?: string; + /** Ascending candle series. */ + c?: LighterCandle[]; +}; + +/** + * One order from `GET /api/v1/accountActiveOrders`. + */ +export type LighterApiOrder = { + orderIndex: number; + clientOrderIndex: number; + marketIndex: number; + ownerAccountIndex: number; + initialBaseAmount: string; + remainingBaseAmount: string; + price: string; + isAsk: boolean; + type: string; + timeInForce: string; + reduceOnly: number | boolean; + status: string; + orderExpiry: number; + timestamp: number; + /** + * Trigger level for stop-loss/take-profit orders. Note `price` on a + * trigger order is the ±5% protection EXECUTION price, not this level. + */ + triggerPrice?: string; + /** Venue string order id (linkage fields reference this form). */ + orderId?: string; + /** OCO/linkage: parent order references. */ + parentOrderIndex?: number; + parentOrderId?: string; + /** OCO linkage: the sibling this order auto-cancels when it fires. */ + toCancelOrderId0?: string; + toTriggerOrderId0?: string; + toTriggerOrderId1?: string; +}; + +/** + * Response of `GET /api/v1/accountActiveOrders`. + */ +export type LighterActiveOrdersResponse = { + code: number; + message?: string; + orders: LighterApiOrder[]; +}; + +/** + * Response of `GET /api/v1/accountInactiveOrders` (historical order + * lifecycle: filled / canceled orders, newest first, cursor-paged). + */ +export type LighterInactiveOrdersResponse = { + code: number; + message?: string; + nextCursor?: string; + orders: LighterApiOrder[]; +}; + +/** + * One entry of `GET /api/v1/deposit/history` (camelized). + */ +export type LighterDepositHistoryItem = { + id: string; + assetId: number; + amount: string; + timestamp: number; + status: string; + l1TxHash: string; +}; + +/** + * Response of `GET /api/v1/deposit/history`. + */ +export type LighterDepositHistoryResponse = { + code: number; + message?: string; + deposits: LighterDepositHistoryItem[]; + cursor?: string; +}; + +/** + * One entry of `GET /api/v1/withdraw/history` (camelized). + */ +export type LighterWithdrawHistoryItem = { + id: string; + assetId: number; + amount: string; + timestamp: number; + status: string; + type: string; + l1TxHash: string; +}; + +/** + * Response of `GET /api/v1/withdraw/history`. + */ +export type LighterWithdrawHistoryResponse = { + code: number; + message?: string; + withdraws: LighterWithdrawHistoryItem[]; + cursor?: string; +}; + +/** + * One entry of `GET /api/v1/transfer/history` (camelized). + */ +export type LighterTransferHistoryItem = { + id: string; + assetId: number; + amount: string; + fee: string; + timestamp: number; + type: string; + fromL1Address: string; + toL1Address: string; + fromAccountIndex: number; + toAccountIndex: number; + txHash: string; +}; + +/** + * Response of `GET /api/v1/transfer/history`. + */ +export type LighterTransferHistoryResponse = { + code: number; + message?: string; + transfers: LighterTransferHistoryItem[]; + cursor?: string; +}; diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index 96d3a1b6f6..e547d36218 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -13,6 +13,7 @@ import type { import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, + KeyringControllerSignPersonalMessageAction, KeyringControllerSignTypedMessageAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; @@ -38,6 +39,7 @@ export type PerpsControllerAllowedActions = | NetworkControllerFindNetworkClientIdByChainIdAction | KeyringControllerGetStateAction | KeyringControllerSignTypedMessageAction + | KeyringControllerSignPersonalMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction | AccountsControllerGetSelectedAccountAction diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts new file mode 100644 index 0000000000..f36507639e --- /dev/null +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -0,0 +1,577 @@ +/** + * Lighter API Adapter Utilities + * + * Adapters transforming zkLighter REST payloads into the MetaMask Perps API + * canonical types. Portable: no mobile-specific imports; formatters are + * injected via the MarketDataFormatters interface (same pattern as + * myxAdapter.ts). + * + * Key differences from HyperLiquid: + * - Prices/sizes are human-readable decimal strings in REST responses, but + * integers scaled by `supported_*_decimals` on the signing path. + * - Position side is a `sign` field (1 = long, -1 = short). + * - USDC collateral, single margin mode per account in the POC (cross). + */ + +import { + LIGHTER_DATA_INTEGRITY_PREFIX, + LIGHTER_MAX_LEVERAGE, + LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX, + parseLighterStrictDecimal, +} from '../constants/lighterConfig.js'; +import type { + AccountState, + MarketDataFormatters, + MarketInfo, + Order, + OrderFill, + PerpsMarketData, + Position, + PriceUpdate, + TriggerOrderType, +} from '../types/index.js'; +import type { + LighterApiOrder, + LighterApiPosition, + LighterOrderBookDetail, + LighterOrderBookMeta, + LighterRestTrade, + LighterWsTrade, + LighterSubAccount, + LighterWsMarketStat, + LighterWsUserStats, +} from '../types/lighter-types.js'; + +/** + * Format a price change value with sign prefix. + * + * @param change - The price change value to format. + * @param formatters - Injectable formatters for platform-agnostic formatting. + * @returns The formatted change string with sign and dollar symbol. + */ +function formatChange( + change: number, + formatters: MarketDataFormatters, +): string { + if (isNaN(change) || !isFinite(change) || change === 0) { + return '$0.00'; + } + + const formatted = formatters.formatPerpsFiat(Math.abs(change), { + ranges: formatters.priceRangesUniversal, + }); + const valueWithoutDollar = formatted.replace('$', ''); + return change > 0 ? `+$${valueWithoutDollar}` : `-$${valueWithoutDollar}`; +} + +// ============================================================================ +// Market Transformation +// ============================================================================ + +/** + * Transform a Lighter order book meta entry into canonical MarketInfo. + * + * @param market - Market metadata from `GET /api/v1/orderBooks`. + * @returns MetaMask Perps API market info object. + */ +export function adaptMarketFromLighter( + market: LighterOrderBookMeta, +): MarketInfo { + return { + name: market.symbol, + szDecimals: market.supportedSizeDecimals, + maxLeverage: LIGHTER_MAX_LEVERAGE, + marginTableId: 0, // Lighter does not use margin tables + minimumOrderSize: parseFloat(market.minQuoteAmount), + providerId: 'lighter', + ...(market.status === 'active' ? {} : { isDelisted: true as const }), + }; +} + +/** + * Transform a Lighter order book detail into UI-ready PerpsMarketData. + * + * @param detail - Market stats from `GET /api/v1/orderBookDetails`. + * @param formatters - Injectable formatters for platform-agnostic formatting. + * @returns MetaMask Perps API market data object. + */ +export function adaptMarketDataFromLighter( + detail: LighterOrderBookDetail, + formatters: MarketDataFormatters, +): PerpsMarketData { + const price = detail.lastTradePrice ?? 0; + const changePercent = detail.dailyPriceChange ?? 0; + // dailyPriceChange is a percentage; recover the absolute change. + const changeAbs = + changePercent === 0 ? 0 : (price * changePercent) / (100 + changePercent); + + const maxLeverage = + detail.minInitialMarginFraction && detail.minInitialMarginFraction > 0 + ? Math.floor(10_000 / detail.minInitialMarginFraction) + : LIGHTER_MAX_LEVERAGE; + return { + symbol: detail.symbol, + name: detail.symbol, + maxLeverage: `${maxLeverage}x`, + price: formatters.formatPerpsFiat(price, { + ranges: formatters.priceRangesUniversal, + }), + change24h: formatChange(changeAbs, formatters), + change24hPercent: `${changePercent >= 0 ? '+' : ''}${changePercent.toFixed(2)}%`, + volume: formatters.formatVolume(detail.dailyQuoteTokenVolume ?? 0), + openInterest: formatters.formatVolume(detail.openInterest ?? 0), + }; +} + +/** + * Transform a Lighter order book detail into a canonical PriceUpdate for + * price-stream subscribers (REST polling stands in for a WS feed in the POC). + * + * @param detail - Market stats from `GET /api/v1/orderBookDetails`. + * @param timestamp - Update timestamp (injected for determinism in tests). + * @returns MetaMask Perps API price update object. + */ +export function adaptPriceUpdateFromLighter( + detail: LighterOrderBookDetail, + timestamp: number, +): PriceUpdate { + return { + symbol: detail.symbol, + price: String(detail.lastTradePrice ?? 0), + timestamp, + percentChange24h: String(detail.dailyPriceChange ?? 0), + volume24h: detail.dailyQuoteTokenVolume ?? 0, + openInterest: detail.openInterest ?? 0, + isTradable: detail.status === 'active', + }; +} + +/** + * Transform a `market_stats` WebSocket entry into a canonical PriceUpdate. + * Richer than the REST fallback: carries mid/bid/ask, mark price, and funding. + * + * @param stat - Market stats entry from the `market_stats/all` WS channel. + * @param timestamp - Update timestamp (injected for determinism in tests). + * @returns MetaMask Perps API price update object. + */ +export function adaptPriceUpdateFromLighterWsStat( + stat: LighterWsMarketStat, + timestamp: number, +): PriceUpdate { + const bestBid = parseFloat(stat.bestBidPrice); + const bestAsk = parseFloat(stat.bestAskPrice); + const spread = + Number.isFinite(bestBid) && Number.isFinite(bestAsk) + ? String(bestAsk - bestBid) + : undefined; + return { + symbol: stat.symbol, + price: stat.midPrice, + timestamp, + percentChange24h: String(stat.dailyPriceChange ?? 0), + bestBid: stat.bestBidPrice, + bestAsk: stat.bestAskPrice, + spread, + markPrice: stat.markPrice, + funding: parseFloat(stat.currentFundingRate ?? stat.fundingRate ?? '0'), + openInterest: parseFloat(stat.openInterest ?? '0'), + volume24h: stat.dailyQuoteTokenVolume ?? 0, + isTradable: true, + }; +} + +/** + * Transform a `user_stats` WebSocket stats block into canonical AccountState. + * + * @param stats - Stats block from the `user_stats/{account_index}` channel. + * @returns MetaMask Perps API account state object. + */ +export function adaptAccountStateFromLighterUserStats( + stats: LighterWsUserStats, +): AccountState { + const collateral = parseFloat(stats.collateral || '0'); + const available = parseFloat(stats.availableBalance || '0'); + const portfolioValue = parseFloat(stats.portfolioValue || '0'); + return { + totalBalance: String(portfolioValue), + spendableBalance: String(available), + withdrawableBalance: String(available), + marginUsed: String(Math.max(collateral - available, 0)), + unrealizedPnl: String(portfolioValue - collateral), + returnOnEquity: + collateral > 0 + ? String(((portfolioValue - collateral) / collateral) * 100) + : '0', + }; +} + +/** + * Transform a Lighter trade (REST `/trades` or WS `account_all_trades`) into + * a canonical OrderFill from the perspective of `accountIndex`. + * + * @param trade - Trade entry (post-camelization). + * @param symbol - Market symbol resolved from the market id. + * @param accountIndex - The account whose perspective determines the side. + * @returns MetaMask Perps API order fill object. + */ +/** + * Derive the lifecycle direction of a fill from the venue's + * position-before context, in the vocabulary client transforms consume + * (`Open Long`, `Close Short`, `Long > Short`, ...). + * + * The venue reports the side's ABSOLUTE position size before the trade and + * whether its sign changed. Combined with the trade side that is enough: + * buying reduces shorts and opens longs; selling reduces longs and opens + * shorts. A partial fill with no sign change is disambiguated by realized + * pnl (closing realizes pnl, opening does not). Without position context + * the side-only `Buy`/`Sell` vocabulary is used. + * + * @param context - Trade side, size, and position-before data. + * @param context.isBuy - Whether our side bought. + * @param context.size - Trade size (base units, absolute). + * @param context.positionBefore - Our side's absolute position size before. + * @param context.signChanged - Whether our side's position sign changed. + * @param context.pnl - Realized pnl for our side. + * @returns Client-facing direction string. + */ +export function deriveLighterFillDirection(context: { + isBuy: boolean; + size: number; + positionBefore: number; + signChanged: boolean | undefined; + pnl: number; +}): string { + const { isBuy, size, positionBefore, signChanged, pnl } = context; + if (!Number.isFinite(positionBefore) || signChanged === undefined) { + return isBuy ? 'Buy' : 'Sell'; + } + if (positionBefore === 0) { + return isBuy ? 'Open Long' : 'Open Short'; + } + if (signChanged) { + // Crossed past zero → flipped; landed exactly on zero → full close. + if (size > positionBefore * 1.000001) { + return isBuy ? 'Short > Long' : 'Long > Short'; + } + return isBuy ? 'Close Short' : 'Close Long'; + } + // Partial fill on an existing position: realized pnl proves it reduced. + if (pnl !== 0) { + return isBuy ? 'Close Short' : 'Close Long'; + } + // Zero-pnl partial with no sign change is genuinely ambiguous from this + // payload (a break-even partial close and an add both fit): fall back to + // the side-only vocabulary instead of asserting Open without evidence. + return isBuy ? 'Buy' : 'Sell'; +} + +export function adaptFillFromLighterTrade( + trade: LighterRestTrade | LighterWsTrade, + symbol: string, + accountIndex: number, +): OrderFill { + const accountIsAsk = trade.askAccountId === accountIndex; + // Our side's role decides which fee applies; the venue includes the fee + // fields only when nonzero (zero is the current standard-account truth). + const accountIsMaker = + trade.isMakerAsk === undefined + ? undefined + : accountIsAsk === trade.isMakerAsk; + // Standard accounts (the only supported type — the provider gates + // Premium at account resolution) pay zero fees, so zero is venue truth. + // A PRESENT nonzero fee ON OUR SIDE contradicts that gate and its wire + // unit is unverified: refusing loudly beats silently coercing a real fee + // to $0. The counterparty's fee is irrelevant — a Standard user trading + // against a Premium account must keep their valid fill. + let ourFee: number | string | undefined; + if (accountIsMaker !== undefined) { + ourFee = accountIsMaker ? trade.makerFee : trade.takerFee; + } + const ourFeeNumeric = + typeof ourFee === 'number' ? ourFee : parseFloat(ourFee ?? '0'); + if (Number.isFinite(ourFeeNumeric) && ourFeeNumeric !== 0) { + throw new Error( + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} nonzero fee in trade ${trade.tradeId}: fee unit is unverified`, + ); + } + const fee = '0'; + const pnl = (accountIsAsk ? trade.askAccountPnl : trade.bidAccountPnl) ?? '0'; + const isBuy = !accountIsAsk; + // Without isMakerAsk our maker/taker role is unknown — never guess which + // side's position context applies; fall back to the neutral side-only + // vocabulary instead of deriving lifecycle from the wrong side. + let positionBefore = NaN; + let signChanged: boolean | undefined; + if (accountIsMaker !== undefined) { + positionBefore = parseFloat( + (accountIsMaker + ? trade.makerPositionSizeBefore + : trade.takerPositionSizeBefore) ?? '', + ); + signChanged = accountIsMaker + ? trade.makerPositionSignChanged + : trade.takerPositionSignChanged; + } + const direction = deriveLighterFillDirection({ + isBuy, + size: parseFloat(trade.size), + positionBefore, + signChanged, + pnl: parseFloat(pnl), + }); + // Signed pre-trade position, derivable whenever the direction proved the + // orientation: closing/flipping a long means it was +before, a short + // -before; opens start from zero. Clients size flip displays from this. + let startPosition: string | undefined; + if (direction.startsWith('Open')) { + startPosition = '0'; + } else if (direction === 'Close Long' || direction === 'Long > Short') { + startPosition = String(positionBefore); + } else if (direction === 'Close Short' || direction === 'Short > Long') { + startPosition = String(-positionBefore); + } + return { + orderId: String(accountIsAsk ? trade.askId : trade.bidId), + symbol, + side: accountIsAsk ? 'sell' : 'buy', + size: trade.size, + price: trade.price, + // The venue reports realized pnl per side of the trade. + pnl, + direction, + ...(startPosition === undefined ? {} : { startPosition }), + fee, + feeToken: 'USDC', + timestamp: trade.timestamp, + // Lets clients apply venue-specific presentation rules (e.g. the + // ambiguous side-only vocabulary) without guessing the source. + providerId: 'lighter', + }; +} + +// ============================================================================ +// Position Transformation +// ============================================================================ + +/** + * Transform a Lighter account position into canonical Position. + * + * @param position - Position entry from an account payload. + * @param maxLeverage - Per-market max leverage (venue margin fractions). + * @returns MetaMask Perps API position object. + */ +export function adaptPositionFromLighter( + position: LighterApiPosition, + maxLeverage: number = LIGHTER_MAX_LEVERAGE, +): Position { + // Venue-input integrity boundary: the REST layer type-casts JSON without + // runtime validation, and a prefix-parsed '0.1oops' would become a + // canonical '0.1' that TP/SL cover-sizing and close paths then SIGN. + // The documented representation is a NONNEGATIVE magnitude with sign + // exactly 1 or -1: a negative magnitude with sign 1 would flip the + // canonical direction and make close/TPSL act opposite the real + // position; sign 0/2/'1' would be silently coerced by a > 0 ternary. + const magnitude = parseLighterStrictDecimal(position.position); + if (magnitude === null || !Number.isFinite(magnitude) || magnitude < 0) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} position size '${position.position}' for ${position.symbol}`, + ); + } + // The documented contract is sign EXACTLY 1 or -1, including for flat + // positions (zero magnitudes are filtered downstream); anything else is + // malformed venue data, never something to coerce. + if (position.sign !== 1 && position.sign !== -1) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} position sign '${String(position.sign)}' for ${position.symbol}`, + ); + } + const size = magnitude * position.sign; + const positionValue = parseFloat(position.positionValue); + const marginFraction = parseFloat(position.initialMarginFraction); + // initialMarginFraction is a percentage (e.g. "20" => 5x leverage). + const leverageValue = + marginFraction > 0 ? Math.round(100 / marginFraction) : 1; + const marginUsed = + marginFraction > 0 ? (positionValue * marginFraction) / 100 : positionValue; + const unrealizedPnl = parseFloat(position.unrealizedPnl); + const liquidationPrice = parseFloat(position.liquidationPrice); + + return { + symbol: position.symbol, + size: String(size), + entryPrice: position.avgEntryPrice, + positionValue: position.positionValue, + unrealizedPnl: position.unrealizedPnl, + marginUsed: String(marginUsed), + leverage: { + type: 'cross', + value: leverageValue, + }, + liquidationPrice: + isNaN(liquidationPrice) || liquidationPrice === 0 + ? null + : position.liquidationPrice, + maxLeverage, + returnOnEquity: + marginUsed > 0 ? String((unrealizedPnl / marginUsed) * 100) : '0', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, + providerId: 'lighter', + }; +} + +// ============================================================================ +// Account Transformation +// ============================================================================ + +/** + * Transform a Lighter sub-account into canonical AccountState. + * + * @param account - Sub-account payload from `account`/`accountsByL1Address`. + * @returns MetaMask Perps API account state object. + */ +export function adaptAccountStateFromLighter( + account: LighterSubAccount, +): AccountState { + const collateral = parseFloat(account.collateral || '0'); + const available = parseFloat(account.availableBalance || '0'); + const positions = account.positions ?? []; + const unrealizedPnl = positions.reduce( + (sum, position) => sum + parseFloat(position.unrealizedPnl || '0'), + 0, + ); + const marginUsed = Math.max(collateral - available, 0); + const totalBalance = collateral + unrealizedPnl; + + return { + totalBalance: String(totalBalance), + spendableBalance: String(available), + withdrawableBalance: String(available), + marginUsed: String(marginUsed), + unrealizedPnl: String(unrealizedPnl), + returnOnEquity: + marginUsed > 0 ? String((unrealizedPnl / marginUsed) * 100) : '0', + providerId: 'lighter', + }; +} + +// ============================================================================ +// Order Transformation +// ============================================================================ + +/** + * Map a Lighter order status string onto the canonical status union. + * + * @param status - Raw status from the Lighter API. + * @returns Canonical order status. + */ +function adaptOrderStatus(status: string): Order['status'] { + switch (status) { + case 'open': + case 'pending': + case 'in-progress': + return 'open'; + case 'filled': + return 'filled'; + case 'canceled': + case 'cancelled': + case 'canceled-post-only': + case 'canceled-reduce-only': + case 'canceled-position-not-allowed': + case 'canceled-margin-not-allowed': + case 'canceled-too-much-slippage': + case 'canceled-not-enough-liquidity': + case 'canceled-self-trade': + case 'canceled-expired': + return 'canceled'; + default: + return 'open'; + } +} + +/** + * Transform a Lighter active order into canonical Order. + * + * @param order - Order entry from `GET /api/v1/accountActiveOrders`. + * @param symbol - Market symbol for the order's `marketIndex`. + * @returns MetaMask Perps API order object. + */ +export function adaptOrderFromLighter( + order: LighterApiOrder, + symbol: string, +): Order { + const original = parseFloat(order.initialBaseAmount); + const remaining = parseFloat(order.remainingBaseAmount); + const filled = Math.max(original - remaining, 0); + + const isTrigger = !['market', 'limit'].includes(order.type); + const triggerPrice = + order.triggerPrice !== undefined && parseFloat(order.triggerPrice) > 0 + ? order.triggerPrice + : undefined; + // Semantic trigger typing: without it, a TP/SL renders as a generic + // Limit order in clients. Venue trigger orders execute market-on-trigger + // (IOC with a protection price), the -limit variants rest at a price. + const triggerTypeMeta: Record< + string, + { + orderType: 'market' | 'limit'; + triggerOrderType: TriggerOrderType; + detailed: string; + } + > = { + 'take-profit': { + orderType: 'market', + triggerOrderType: 'take_profit_market', + detailed: 'Take Profit Market', + }, + 'stop-loss': { + orderType: 'market', + triggerOrderType: 'stop_market', + detailed: 'Stop Market', + }, + 'take-profit-limit': { + orderType: 'limit', + triggerOrderType: 'take_profit_limit', + detailed: 'Take Profit Limit', + }, + 'stop-loss-limit': { + orderType: 'limit', + triggerOrderType: 'stop_limit', + detailed: 'Stop Limit', + }, + }; + const triggerMeta = triggerTypeMeta[order.type]; + + return { + orderId: String(order.orderIndex), + symbol, + side: order.isAsk ? 'sell' : 'buy', + orderType: + triggerMeta?.orderType ?? (order.type === 'market' ? 'market' : 'limit'), + ...(triggerMeta + ? { + triggerOrderType: triggerMeta.triggerOrderType, + detailedOrderType: triggerMeta.detailed, + } + : {}), + isTrigger, + size: order.remainingBaseAmount, + originalSize: order.initialBaseAmount, + // On trigger orders `price` is the ±5% protection EXECUTION price; + // the user-facing TP/SL level is `triggerPrice`. + price: order.price, + ...(triggerPrice === undefined ? {} : { triggerPrice }), + filledSize: String(filled), + remainingSize: order.remainingBaseAmount, + status: adaptOrderStatus(order.status), + timestamp: order.timestamp, + reduceOnly: Boolean(order.reduceOnly), + providerId: 'lighter', + }; +} diff --git a/packages/perps-controller/tests/e2e/lighter.e2e.ts b/packages/perps-controller/tests/e2e/lighter.e2e.ts new file mode 100644 index 0000000000..d3c36f0c4d --- /dev/null +++ b/packages/perps-controller/tests/e2e/lighter.e2e.ts @@ -0,0 +1,2110 @@ +/** + * Lighter POC e2e driver (TAT-3766). + * + * Runs REAL calls against Lighter testnet through the Go/WASM signer built + * from source. Phased so a recipe can compose each step as its own command + * node with per-phase assertions: + * + * yarn workspace @metamask/perps-controller exec tsx tests/e2e/lighter.e2e.ts \ + * --phase=sign-only|register|order-lifecycle|controller [--out=DIR] [--market=SOL] + * + * Optional flags: + * --eth-key=0x… L1 private key (default: lighter-python's PUBLIC + * dummy testnet key — accountIndex 28, funded). + * --account-index=N Lighter account index (default 28). + * --api-key-index=N API key slot (default 7). + * --wasm-dir=DIR Cache dir from build-wasm.sh (default + * /temp/lighter-wasm). + * + * Each phase writes /.json and prints PASS/FAIL lines; + * process.exitCode = 1 on failure (advanced-orders e2e conventions). + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { + buildLighterKeyDerivationMessage, + computeLighterMinOrderSize, + LIGHTER_TESTNET_CHAIN_ID, +} from '../../src/constants/lighterConfig.js'; +import { LighterProvider } from '../../src/providers/LighterProvider.js'; +import { + convertKeysToCamelCase, + LighterClientService, +} from '../../src/services/LighterClientService.js'; +import { LighterWalletService } from '../../src/services/LighterWalletService.js'; +import type { PerpsPlatformDependencies } from '../../src/types/index.js'; +import type { + LighterCreateAuthTokenResult, + LighterCreateClientResult, + LighterTxResult, +} from '../../src/types/lighter-types.js'; +import { createNodeWasmBridge } from './lighter/nodeWasmBridge.js'; + +// lighter-python's public dummy testnet key (examples/system_setup.py) — +// accountIndex 28 on testnet, pre-funded. NOT a secret. +const DEFAULT_DUMMY_ETH_KEY = + '0x1234567812345678123456781234567812345678123456781234567812345678'; + +const PACKAGE_ROOT = resolve(__dirname, '..', '..'); +const REPO_ROOT = resolve(PACKAGE_ROOT, '..', '..'); + +type PhaseResult = { + phase: string; + ok: boolean; + checks: { name: string; ok: boolean; detail?: string }[]; + [key: string]: unknown; +}; + +const args = new Map(); +for (const arg of process.argv.slice(2)) { + const match = /^--([^=]+)(?:=(.*))?$/u.exec(arg); + if (match) { + args.set(match[1], match[2] ?? 'true'); + } +} + +const PHASE = args.get('phase') ?? 'sign-only'; +const OUT_DIR = resolve( + args.get('out') ?? join(REPO_ROOT, 'temp', 'lighter-e2e'), +); +const MARKET = args.get('market') ?? 'SOL'; +const WASM_DIR = resolve( + args.get('wasm-dir') ?? join(REPO_ROOT, 'temp', 'lighter-wasm'), +); +const ETH_KEY = (args.get('eth-key') ?? DEFAULT_DUMMY_ETH_KEY) as `0x${string}`; +const ACCOUNT_INDEX = Number(args.get('account-index') ?? 28); +const API_KEY_INDEX = Number(args.get('api-key-index') ?? 7); + +const viemAccount = privateKeyToAccount(ETH_KEY); + +/** + * Minimal faithful PerpsPlatformDependencies (mirrors the mm-harness core + * adapter's buildInfrastructure — read/write paths only touch loggers and + * formatters). + * + * @returns Infrastructure object. + */ +function buildInfrastructure(): PerpsPlatformDependencies { + const noop = (): undefined => undefined; + return { + logger: { + error: (error: unknown, meta?: unknown) => + process.stderr.write( + `[lighter-e2e][error] ${String((error as Error)?.message ?? error)}${meta ? ` ${JSON.stringify(meta)}` : ''}\n`, + ), + }, + debugLogger: { + log: (message: unknown, meta?: unknown) => + process.stderr.write( + `[lighter-e2e] ${String(message)}${meta ? ` ${JSON.stringify(meta)}` : ''}\n`, + ), + }, + metrics: { + trackEvent: noop, + isEnabled: () => false, + trackPerpsEvent: noop, + }, + performance: { now: () => Date.now() }, + tracer: { + trace: noop, + endTrace: noop, + setMeasurement: noop, + addBreadcrumb: noop, + }, + streamManager: { + pauseChannel: noop, + resumeChannel: noop, + clearAllChannels: noop, + }, + featureFlags: { validateVersionGated: () => undefined }, + marketDataFormatters: { + formatVolume: (value: number) => `$${value}`, + formatPerpsFiat: (value: number) => `$${value}`, + formatPercentage: (value: number) => `${value}%`, + priceRangesUniversal: [], + }, + cacheInvalidator: { invalidate: noop, invalidateAll: noop }, + diskCache: { + getItem: async () => null, + getItemSync: () => null, + setItem: async () => undefined, + removeItem: async () => undefined, + }, + rewards: { getPerpsDiscountForAccount: async () => null }, + } as unknown as PerpsPlatformDependencies; +} + +/** + * Sign an EIP-191 personal message with the headless viem account. + * + * @param message - Plaintext to sign. + * @returns 0x signature hex. + */ +async function personalSigner(message: string): Promise { + return await viemAccount.signMessage({ message }); +} + +/** + * Assign summary fields onto the phase result (indirection keeps + * require-atomic-updates satisfied for post-await assignments). + * + * @param target - Phase result accumulator. + * @param fields - Summary fields to record. + */ +function record(target: PhaseResult, fields: Record): void { + Object.assign(target, fields); +} + +function check( + result: PhaseResult, + name: string, + ok: boolean, + detail?: string, +): void { + result.checks.push({ name, ok, ...(detail ? { detail } : {}) }); + process.stdout.write( + `${ok ? 'PASS' : 'FAIL'}: ${name}${detail ? ` — ${detail}` : ''}\n`, + ); + if (!ok) { + result.ok = false; + } +} + +async function poll( + label: string, + fetcher: () => Promise, + predicate: (value: Value) => boolean, + timeoutMs = 30_000, + intervalMs = 1500, +): Promise { + const startedAt = Date.now(); + let last: Value = await fetcher(); + while (!predicate(last)) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out polling: ${label}`); + } + await new Promise((resolveWait) => setTimeout(resolveWait, intervalMs)); + last = await fetcher(); + } + return last; +} + +// ============================================================================ +// Phases +// ============================================================================ + +/** + * Offline signer validation: WASM loads in Node, key derivation is + * deterministic, ChangePubKey plaintext matches the documented template, + * auth token and order signatures are produced. No account mutation. + * + * @param result - Phase result accumulator. + */ +async function phaseSignOnly(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + check(result, 'wasm loads in node', true); + + const wallet = new LighterWalletService(buildInfrastructure(), { + isTestnet: true, + personalSigner, + l1Address: viemAccount.address, + }); + + const seedA = await wallet.deriveKeySeedPlain(API_KEY_INDEX); + const seedB = await wallet.deriveKeySeedPlain(API_KEY_INDEX); + check( + result, + 'seed derivation deterministic (64 hex chars)', + seedA === seedB && /^[0-9a-f]{64}$/u.test(seedA), + ); + check( + result, + 'derivation message binds address/chain/slot', + buildLighterKeyDerivationMessage({ + address: viemAccount.address, + chainId: LIGHTER_TESTNET_CHAIN_ID, + apiKeyIndex: API_KEY_INDEX, + }).includes(viemAccount.address.toLowerCase()), + ); + + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const { nonce } = await client.getNextNonce(ACCOUNT_INDEX, API_KEY_INDEX); + + const created = await bridge.execute({ + function: '_createClient', + params: [ + seedA, + LIGHTER_TESTNET_CHAIN_ID, + ACCOUNT_INDEX, + nonce, + API_KEY_INDEX, + ], + }); + check( + result, + 'createClient returns 80-hex venue pubkey', + Boolean(created.success) && /^[0-9a-f]{80}$/u.test(created.pk), + created.error, + ); + check( + result, + 'ChangePubKey body matches documented template', + typeof created.body === 'string' && + created.body.includes('Register Lighter Account') && + created.body.includes(created.pk) && + created.body.includes('Only sign this message for a trusted client!'), + ); + result.venuePublicKey = created.pk; + + const token = await bridge.execute({ + function: '_createAuthToken', + params: [ACCOUNT_INDEX, API_KEY_INDEX], + }); + check( + result, + 'auth token minted with future deadline', + typeof token.token === 'string' && + token.token.length > 0 && + token.deadline > Math.floor(Date.now() / 1000), + token.error, + ); + + const signed = await bridge.execute({ + function: '_signCreateOrder', + params: [ + ACCOUNT_INDEX, + 1, + 424242, + '100', + '900000', + 0, + 0, + 1, + 0, + '0', + -1, + nonce, + ], + }); + const parsedTx = signed.txInfo ? JSON.parse(signed.txInfo) : null; + check( + result, + 'order signature produced (txInfo has venue Sig)', + Boolean(parsedTx) && + typeof parsedTx.Sig === 'string' && + parsedTx.Sig.length > 0, + signed.error, + ); + // PINNED SIGNER IDENTITY CONTRACT (round-18): the signing RESULT + // carries the tx hash; txInfo is the marshaled wire payload with + // Nonce and ExpiredAt but NEVER the hash. The provider's dispatch + // ledger depends on exactly this shape. + check( + result, + 'signing RESULT carries a hex txHash', + typeof signed.txHash === 'string' && + /^(0x)?[0-9a-fA-F]{8,128}$/u.test(signed.txHash), + signed.error, + ); + check( + result, + 'txInfo carries wire Nonce and ExpiredAt but NO txHash field', + Boolean(parsedTx) && + typeof parsedTx.Nonce === 'number' && + typeof parsedTx.ExpiredAt === 'number' && + parsedTx.ExpiredAt > Date.now() && + parsedTx.txHash === undefined, + signed.error, + ); +} + +/** + * Register the derived venue key on the testnet account via ChangePubKey + * (personal_sign injection path), then prove it landed via /apikeys. + * + * @param result - Phase result accumulator. + */ +async function phaseRegister(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + + const ready = await provider.isReadyToTrade(); + check(result, 'provider isReadyToTrade', ready.ready, ready.error); + check( + result, + 'authenticated address matches L1 account', + ready.authenticatedAddress?.toLowerCase() === + viemAccount.address.toLowerCase(), + ); + + // Independent read-back: the registered pubkey at our slot must equal the + // deterministically derived one. + const wallet = new LighterWalletService(buildInfrastructure(), { + isTestnet: true, + personalSigner, + l1Address: viemAccount.address, + }); + const seed = await wallet.deriveKeySeedPlain(API_KEY_INDEX); + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const { nonce } = await client.getNextNonce(ACCOUNT_INDEX, API_KEY_INDEX); + const created = await bridge.execute({ + function: '_createClient', + params: [ + seed, + LIGHTER_TESTNET_CHAIN_ID, + ACCOUNT_INDEX, + nonce, + API_KEY_INDEX, + ], + }); + + const keys = await poll( + 'apikeys shows derived venue key', + async () => await client.getApiKeys(ACCOUNT_INDEX, API_KEY_INDEX), + (response) => + response.apiKeys.some( + (key) => + key.apiKeyIndex === API_KEY_INDEX && key.publicKey === created.pk, + ), + 60_000, + ); + check( + result, + 'venue key registered at api key slot (strict pubkey equality)', + keys.apiKeys.some( + (key) => + key.apiKeyIndex === API_KEY_INDEX && key.publicKey === created.pk, + ), + ); + result.venuePublicKey = created.pk; + result.accountIndex = ACCOUNT_INDEX; + result.apiKeyIndex = API_KEY_INDEX; +} + +/** + * Place a REAL resting limit order on testnet through LighterProvider, + * prove it is visible via the authenticated open-orders read, cancel it, + * prove it is gone. + * + * @param result - Phase result accumulator. + */ +async function phaseOrderLifecycle(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + + const init = await provider.initialize(); + check( + result, + 'provider initializes with live markets', + init.success, + init.error, + ); + + const markets = await provider.getMarkets(); + const market = markets.find((entry) => entry.name === MARKET); + check(result, `market ${MARKET} exists on testnet`, Boolean(market)); + if (!market) { + return; + } + + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const details = await client.getOrderBookDetails(); + const detail = details.orderBookDetails.find( + (entry) => entry.symbol === MARKET, + ); + const lastPrice = detail?.lastTradePrice ?? 0; + check(result, 'live last trade price available', lastPrice > 0); + + // Resting far below the market so the limit order cannot fill. + const meta = (await client.getOrderBooks()).find( + (entry) => entry.symbol === MARKET, + ); + if (!meta) { + check(result, 'market metadata available', false); + return; + } + const priceDecimals = meta.supportedPriceDecimals; + const restingPrice = Number( + (lastPrice * 0.6).toFixed(Math.max(priceDecimals, 0)), + ); + const size = computeLighterMinOrderSize(meta, restingPrice); + + const placed = await provider.placeOrder({ + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'limit', + price: String(restingPrice), + }); + check(result, 'placeOrder succeeds', Boolean(placed.success), placed.error); + result.placedOrder = { price: restingPrice, size, orderId: placed.orderId }; + + // The resting order must become visible through the authenticated read. + let restingOrderId: string | null = null; + const matchesOurs = ( + orders: Awaited>, + ): boolean => + orders.some((order) => { + const priceMatches = + Math.abs(parseFloat(order.price) - restingPrice) < + 10 ** -Math.max(priceDecimals - 1, 0); + if (order.symbol === MARKET && order.side === 'buy' && priceMatches) { + restingOrderId = order.orderId; + return true; + } + return false; + }); + + await poll( + 'open orders shows the resting order', + async () => await provider.getOpenOrders(), + matchesOurs, + 45_000, + ); + check( + result, + 'resting order visible in open orders', + restingOrderId !== null, + ); + result.restingOrderId = restingOrderId; + + if (!restingOrderId) { + return; + } + + const canceled = await provider.cancelOrder({ + orderId: restingOrderId, + symbol: MARKET, + }); + check(result, 'cancelOrder succeeds', canceled.success, canceled.error); + + await poll( + 'open orders no longer shows the order', + async () => await provider.getOpenOrders(), + (orders) => !orders.some((order) => order.orderId === restingOrderId), + 45_000, + ); + check(result, 'order gone after cancel', true); +} + +/** + * Abstraction-path proof: a real PerpsController (headless messenger pair, + * same wiring as the mm-harness core adapter) with the Lighter provider + * enabled through providerCredentials surfaces Lighter markets through the + * aggregated provider with providerId stamping. + * + * @param result - Phase result accumulator. + */ +async function phaseController(result: PhaseResult): Promise { + const { PerpsController } = await import('../../src/PerpsController.js'); + const { Messenger, MOCK_ANY_NAMESPACE } = await import('@metamask/messenger'); + + const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const messenger = new Messenger({ + namespace: 'PerpsController', + parent: rootMessenger, + }); + rootMessenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + () => ({ + id: 'lighter-e2e-account', + address: viemAccount.address, + type: 'eip155:eoa', + metadata: { keyring: { type: 'HD Key Tree' } }, + }), + ); + rootMessenger.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked: true, + })); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + async (msgParams: { from: string; data: string }) => { + const bytes = Buffer.from(msgParams.data.replace(/^0x/u, ''), 'hex'); + return await viemAccount.signMessage({ message: bytes.toString('utf8') }); + }, + ); + rootMessenger.delegate({ + actions: [ + 'AccountsController:getSelectedAccount', + 'KeyringController:getState', + 'KeyringController:signPersonalMessage', + ], + messenger, + }); + + const controller = new PerpsController({ + messenger: messenger as never, + state: { isTestnet: true, activeProvider: 'aggregated' }, + clientConfig: { + providerCredentials: { + lighter: { + enabled: true, + accountIndexTestnet: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + }, + }, + }, + infrastructure: buildInfrastructure(), + deferEligibilityCheck: true, + } as never); + + await controller.init(); + check(result, 'controller initializes with aggregated provider', true); + + const markets = await controller.getMarkets(); + const lighterMarkets = (markets ?? []).filter( + (market: { providerId?: string }) => market.providerId === 'lighter', + ); + const hyperliquidMarkets = (markets ?? []).filter( + (market: { providerId?: string }) => market.providerId === 'hyperliquid', + ); + check( + result, + 'aggregated getMarkets returns lighter-stamped markets', + lighterMarkets.length > 0, + `lighter=${lighterMarkets.length} hyperliquid=${hyperliquidMarkets.length}`, + ); + check( + result, + 'aggregation preserves other providers (hyperliquid present)', + hyperliquidMarkets.length > 0, + ); + result.lighterMarketCount = lighterMarkets.length; + result.hyperliquidMarketCount = hyperliquidMarkets.length; + + await controller.disconnect?.(); +} + +/** + * Prove the price-stream subscription surface: subscribeToPrices polls the + * live testnet REST feed and fans out repeated PriceUpdate cycles. + * + * @param result - Phase result accumulator. + */ +async function phasePriceStream(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: {}, + }); + + const cycles: { count: number; btcPrice: string | undefined }[] = []; + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback: (updates) => { + cycles.push({ + count: updates.length, + btcPrice: updates.find((update) => update.symbol === 'BTC')?.price, + }); + }, + }); + + // Immediate snapshot + at least two poll cycles (5s interval). + const deadline = Date.now() + 20_000; + while (cycles.length < 3 && Date.now() < deadline) { + await new Promise((resolveSleep) => setTimeout(resolveSleep, 500)); + } + unsubscribe(); + await provider.disconnect(); + + check( + result, + 'price stream emitted at least 3 cycles (snapshot + live updates)', + cycles.length >= 3, + `cycles=${cycles.length}`, + ); + check( + result, + 'every cycle carries live markets', + cycles.every((cycle) => cycle.count > 0), + ); + // The first cycle is the full channel snapshot; later cycles are partial + // per-market deltas, so BTC is only guaranteed in the snapshot. + check( + result, + 'BTC price present and numeric in the snapshot cycle', + cycles[0]?.btcPrice !== undefined && + Number.isFinite(parseFloat(cycles[0].btcPrice)) && + parseFloat(cycles[0].btcPrice) > 0, + `snapshotBtc=${cycles[0]?.btcPrice}`, + ); + check( + result, + 'every BTC price seen is numeric and positive', + cycles.every( + (cycle) => + cycle.btcPrice === undefined || + (Number.isFinite(parseFloat(cycle.btcPrice)) && + parseFloat(cycle.btcPrice) > 0), + ), + ); + result.priceStreamCycles = cycles.length; + result.priceStreamBtcPrices = cycles.map((cycle) => cycle.btcPrice); +} + +/** + * Prove the account stream (user_stats WS channel): live collateral and + * portfolio value for the configured testnet account. + * + * @param result - Phase result accumulator. + */ +async function phaseAccountStream(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + + const emissions: { totalBalance: string; spendableBalance: string }[] = []; + const unsubscribe = provider.subscribeToAccount({ + callback: (account) => { + if (account) { + emissions.push({ + totalBalance: account.totalBalance, + spendableBalance: account.spendableBalance, + }); + } + }, + }); + + const deadline = Date.now() + 20_000; + while (emissions.length < 1 && Date.now() < deadline) { + await new Promise((resolveSleep) => setTimeout(resolveSleep, 500)); + } + unsubscribe(); + await provider.disconnect(); + + check( + result, + 'account stream emitted at least one AccountState', + emissions.length >= 1, + `emissions=${emissions.length}`, + ); + const first = emissions[0]; + check( + result, + 'live total balance is numeric and positive', + first !== undefined && parseFloat(first.totalBalance) > 0, + `totalBalance=${first?.totalBalance}`, + ); + check( + result, + 'live spendable balance is numeric and non-negative', + first !== undefined && parseFloat(first.spendableBalance) >= 0, + `spendableBalance=${first?.spendableBalance}`, + ); + result.accountEmissions = emissions.length; + result.accountFirstEmission = first; +} + +/** + * Prove the positions stream (account_all_positions WS channel): the funded + * shared testnet account holds live positions the channel must deliver. + * + * @param result - Phase result accumulator. + */ +async function phasePositionsStream(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + + const snapshots: { count: number; symbols: string[] }[] = []; + const unsubscribe = provider.subscribeToPositions({ + callback: (positions) => { + snapshots.push({ + count: positions.length, + symbols: positions.map((position) => position.symbol), + }); + }, + }); + + const deadline = Date.now() + 20_000; + while (snapshots.length < 1 && Date.now() < deadline) { + await new Promise((resolveSleep) => setTimeout(resolveSleep, 500)); + } + // Cross-check against the REST read the recipe already trusts. + const restPositions = await provider.getPositions(); + unsubscribe(); + await provider.disconnect(); + + check( + result, + 'positions stream emitted at least one snapshot', + snapshots.length >= 1, + `snapshots=${snapshots.length}`, + ); + check( + result, + 'stream snapshot position count matches REST getPositions', + snapshots[0]?.count === restPositions.length, + `ws=${snapshots[0]?.count} rest=${restPositions.length}`, + ); + result.positionsStreamSnapshots = snapshots; + result.positionsRestCount = restPositions.length; +} + +/** + * Prove the authenticated orders stream (account_all_orders WS channel): + * subscribe, place a real resting order, watch it arrive over the socket, + * cancel it, and watch it leave. + * + * @param result - Phase result accumulator. + */ +async function phaseOrdersStream(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const snapshots: string[][] = []; + const unsubscribe = provider.subscribeToOrders({ + callback: (orders) => { + snapshots.push(orders.map((order) => order.orderId)); + }, + }); + const waitForStream = async ( + label: string, + predicate: () => boolean, + ): Promise => { + const deadline = Date.now() + 45_000; + while (!predicate() && Date.now() < deadline) { + await new Promise((resolveSleep) => setTimeout(resolveSleep, 500)); + } + const ok = predicate(); + check(result, label, ok, `snapshots=${snapshots.length}`); + return ok; + }; + + await waitForStream( + 'orders stream delivered its snapshot', + () => snapshots.length >= 1, + ); + + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const details = await client.getOrderBookDetails(); + const lastPrice = + details.orderBookDetails.find((entry) => entry.symbol === MARKET) + ?.lastTradePrice ?? 0; + const meta = (await client.getOrderBooks()).find( + (entry) => entry.symbol === MARKET, + ); + if (!meta || lastPrice <= 0) { + check(result, 'live market metadata available', false); + unsubscribe(); + await provider.disconnect(); + return; + } + const restingPrice = Number( + (lastPrice * 0.6).toFixed(Math.max(meta.supportedPriceDecimals, 0)), + ); + const size = computeLighterMinOrderSize(meta, restingPrice); + const baselineIds = new Set(snapshots.at(-1) ?? []); + + const placed = await provider.placeOrder({ + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'limit', + price: String(restingPrice), + }); + check(result, 'placeOrder succeeds', Boolean(placed.success), placed.error); + + let streamedOrderId: string | null = null; + await waitForStream('placed order arrives over the ws stream', () => { + const latest = snapshots.at(-1) ?? []; + streamedOrderId = + latest.find((orderId) => !baselineIds.has(orderId)) ?? null; + return streamedOrderId !== null; + }); + + if (streamedOrderId) { + const canceled = await provider.cancelOrder({ + orderId: streamedOrderId, + symbol: MARKET, + }); + check(result, 'cancelOrder succeeds', canceled.success, canceled.error); + const canceledId = streamedOrderId; + await waitForStream( + 'canceled order leaves the ws stream', + () => !(snapshots.at(-1) ?? []).includes(canceledId), + ); + } + + unsubscribe(); + await provider.disconnect(); + record(result, { ordersStreamSnapshots: snapshots.length }); +} + +/** + * Prove the candles endpoint: live OHLCV history for the target market with + * sane values, plus the subscription seed path. + * + * @param result - Phase result accumulator. + */ +async function phaseCandles(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: {}, + }); + + const data = await provider.fetchHistoricalCandles({ + symbol: MARKET, + interval: '15m' as never, + limit: 50, + }); + check( + result, + 'historical candles returned a non-empty series', + data.candles.length > 0, + `count=${data.candles.length}`, + ); + const last = data.candles.at(-1); + check( + result, + 'last candle has numeric OHLC and close > 0', + last !== undefined && + ['open', 'high', 'low', 'close'].every((key) => + Number.isFinite(parseFloat(last[key as keyof typeof last] as string)), + ) && + parseFloat(last.close) > 0, + `close=${last?.close}`, + ); + check( + result, + 'candles are time-ascending', + data.candles.every( + (candle, index) => + index === 0 || candle.time >= data.candles[index - 1].time, + ), + ); + + const seeded = await new Promise((resolveSeed) => { + const unsubscribe = provider.subscribeToCandles({ + symbol: MARKET, + interval: '15m' as never, + callback: (candleData) => { + unsubscribe(); + resolveSeed(candleData.candles.length); + }, + }); + setTimeout(() => { + unsubscribe(); + resolveSeed(-1); + }, 15_000); + }); + check( + result, + 'candle subscription seeds with history', + seeded > 0, + `seeded=${seeded}`, + ); + await provider.disconnect(); + record(result, { candleCount: data.candles.length, lastClose: last?.close }); +} + +/** + * Prove closePosition + the fills stream together: a real market order opens + * a tiny position (fill #1 on the account_all_trades stream), closePosition + * flattens it (fill #2), and the position list ends without the symbol delta. + * + * @param result - Phase result accumulator. + */ +async function phaseClosePosition(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const fills: { side: string; symbol: string }[] = []; + const unsubscribeFills = provider.subscribeToOrderFills({ + callback: (incoming, isSnapshot) => { + if (!isSnapshot) { + fills.push( + ...incoming.map((fill) => ({ side: fill.side, symbol: fill.symbol })), + ); + } + }, + }); + // Allow the trades channel to attach before trading. + await new Promise((resolveWait) => setTimeout(resolveWait, 3000)); + + const startPositions = await provider.getPositions(); + const startSize = parseFloat( + startPositions.find((entry) => entry.symbol === MARKET)?.size ?? '0', + ); + + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const meta = (await client.getOrderBooks()).find( + (entry) => entry.symbol === MARKET, + ); + const lastPrice = + (await client.getOrderBookDetails()).orderBookDetails.find( + (entry) => entry.symbol === MARKET, + )?.lastTradePrice ?? 0; + if (!meta || lastPrice <= 0) { + check(result, 'live market metadata available', false); + unsubscribeFills(); + await provider.disconnect(); + return; + } + const size = computeLighterMinOrderSize(meta, lastPrice); + + const opened = await provider.placeOrder({ + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'market', + }); + check(result, 'market order opens', Boolean(opened.success), opened.error); + + await poll( + 'position grows by the opened size', + async () => await provider.getPositions(), + (positions) => { + const current = parseFloat( + positions.find((entry) => entry.symbol === MARKET)?.size ?? '0', + ); + return Math.abs(current - startSize - size) < size * 0.2; + }, + 45_000, + ); + check(result, 'position visible after open', true); + + const closed = await provider.closePosition({ + symbol: MARKET, + size: String(size), + }); + check( + result, + 'closePosition succeeds', + Boolean(closed.success), + closed.error, + ); + + await poll( + 'position returns to the starting size', + async () => await provider.getPositions(), + (positions) => { + const current = parseFloat( + positions.find((entry) => entry.symbol === MARKET)?.size ?? '0', + ); + return Math.abs(current - startSize) < size * 0.2; + }, + 45_000, + ); + check(result, 'position flat after close', true); + + await poll( + 'both fills arrive on the account_all_trades stream', + async () => fills, + (list) => list.length >= 2, + 30_000, + ); + check( + result, + 'fills stream delivered open+close fills', + fills.length >= 2 && fills.every((fill) => fill.symbol === MARKET), + `fills=${JSON.stringify(fills)}`, + ); + unsubscribeFills(); + await provider.disconnect(); + record(result, { fillCount: fills.length }); +} + +/** + * Prove the order-book stream: live sorted levels with a sane spread. + * + * @param result - Phase result accumulator. + */ +async function phaseOrderBookStream(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: {}, + }); + + const books: { bids: number; asks: number; mid: number; spread: number }[] = + []; + const unsubscribe = provider.subscribeToOrderBook({ + symbol: MARKET, + levels: 5, + callback: (book) => { + books.push({ + bids: book.bids.length, + asks: book.asks.length, + mid: parseFloat(book.midPrice), + spread: parseFloat(book.spread), + }); + }, + }); + const deadline = Date.now() + 20_000; + while (books.length < 3 && Date.now() < deadline) { + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + unsubscribe(); + await provider.disconnect(); + + check( + result, + 'order book emitted at least 3 updates', + books.length >= 3, + `updates=${books.length}`, + ); + const first = books[0]; + check( + result, + 'book has populated bid and ask sides', + first !== undefined && first.bids > 0 && first.asks > 0, + `bids=${first?.bids} asks=${first?.asks}`, + ); + check( + result, + 'mid price positive and spread non-negative', + first !== undefined && first.mid > 0 && first.spread >= 0, + `mid=${first?.mid} spread=${first?.spread}`, + ); + record(result, { orderBookUpdates: books.length }); +} + +/** + * Prove the live candle stream: seeded history plus at least one WS update. + * + * @param result - Phase result accumulator. + */ +async function phaseCandlesStream(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: {}, + }); + + const emissions: number[] = []; + const unsubscribe = provider.subscribeToCandles({ + symbol: MARKET, + interval: '1m' as never, + callback: (data) => { + emissions.push(data.candles.length); + }, + }); + const deadline = Date.now() + 45_000; + while (emissions.length < 2 && Date.now() < deadline) { + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + unsubscribe(); + await provider.disconnect(); + + check( + result, + 'candle stream seeded and delivered at least one live update', + emissions.length >= 2, + `emissions=${emissions.length}`, + ); + check( + result, + 'seed carries a full history window', + (emissions[0] ?? 0) >= 50, + `seedCount=${emissions[0]}`, + ); + record(result, { candleEmissions: emissions.length }); +} + +/** + * Prove editOrder: reprice a real resting order and verify the new price. + * + * @param result - Phase result accumulator. + */ +async function phaseEditOrder(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const meta = (await client.getOrderBooks()).find( + (entry) => entry.symbol === MARKET, + ); + const lastPrice = + (await client.getOrderBookDetails()).orderBookDetails.find( + (entry) => entry.symbol === MARKET, + )?.lastTradePrice ?? 0; + if (!meta || lastPrice <= 0) { + check(result, 'live market metadata available', false); + await provider.disconnect(); + return; + } + const priceDecimals = meta.supportedPriceDecimals; + const restingPrice = Number((lastPrice * 0.6).toFixed(priceDecimals)); + const editedPrice = Number((lastPrice * 0.55).toFixed(priceDecimals)); + const size = computeLighterMinOrderSize(meta, restingPrice); + + const placed = await provider.placeOrder({ + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'limit', + price: String(restingPrice), + }); + check(result, 'resting order placed', Boolean(placed.success), placed.error); + + let orderId: string | null = null; + await poll( + 'resting order visible', + async () => await provider.getOpenOrders(), + (orders) => + orders.some((order) => { + if ( + order.symbol === MARKET && + Math.abs(parseFloat(order.price) - restingPrice) < 0.01 * restingPrice + ) { + orderId = order.orderId; + return true; + } + return false; + }), + 45_000, + ); + if (!orderId) { + check(result, 'resting order id resolved', false); + await provider.disconnect(); + return; + } + + const edited = await provider.editOrder({ + orderId, + newOrder: { + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'limit', + price: String(editedPrice), + }, + }); + check(result, 'editOrder succeeds', Boolean(edited.success), edited.error); + + let editedOrderId: string | null = null; + await poll( + 'order shows the edited price', + async () => await provider.getOpenOrders(), + (orders) => + orders.some((order) => { + if ( + order.symbol === MARKET && + Math.abs(parseFloat(order.price) - editedPrice) < 0.01 * editedPrice + ) { + editedOrderId = order.orderId; + return true; + } + return false; + }), + 45_000, + ); + check(result, 'edited price visible in open orders', editedOrderId !== null); + + if (editedOrderId) { + const canceled = await provider.cancelOrder({ + orderId: editedOrderId, + symbol: MARKET, + }); + check(result, 'cleanup cancel succeeds', canceled.success, canceled.error); + } + await provider.disconnect(); +} + +/** + * Prove the withdraw SIGNING path only — produces a valid signed L2 withdraw + * without submitting it (no funds move on the shared account). + * + * @param result - Phase result accumulator. + */ +async function phaseWithdrawSign(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const wallet = new LighterWalletService(buildInfrastructure(), { + isTestnet: true, + personalSigner, + l1Address: viemAccount.address, + }); + const seed = await wallet.deriveKeySeedPlain(API_KEY_INDEX); + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const { nonce } = await client.getNextNonce(ACCOUNT_INDEX, API_KEY_INDEX); + const created = await bridge.execute({ + function: '_createClient', + params: [ + seed, + LIGHTER_TESTNET_CHAIN_ID, + ACCOUNT_INDEX, + nonce, + API_KEY_INDEX, + ], + }); + check( + result, + 'signer client created', + Boolean(created.success), + created.error, + ); + + const signed = await bridge.execute({ + function: '_signWithdraw', + params: [ACCOUNT_INDEX, 1, 0, '1000000', nonce], + }); + check( + result, + 'withdraw transaction signs (1 USDC, NOT submitted)', + !signed.error && + typeof signed.txInfo === 'string' && + signed.txInfo.length > 0, + signed.error, + ); + record(result, { withdrawTxInfoLength: signed.txInfo?.length }); +} + +/** + * Prove mainnet read paths: full market catalog, live WS prices, candles. + * Read-only — no account, no writes. + * + * @param result - Phase result accumulator. + */ +async function phaseMainnetReads(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: false, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: {}, + }); + + const markets = await provider.getMarkets(); + check( + result, + 'mainnet serves a large active perp catalog (>=100 markets)', + markets.length >= 100, + `markets=${markets.length}`, + ); + + const cycles: number[] = []; + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback: (updates) => { + cycles.push(updates.length); + }, + }); + const deadline = Date.now() + 20_000; + while (cycles.length < 3 && Date.now() < deadline) { + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + unsubscribe(); + check( + result, + 'mainnet WS price stream delivers snapshot + live updates', + cycles.length >= 3, + `cycles=${cycles.length}`, + ); + check( + result, + 'mainnet snapshot covers the catalog', + (cycles[0] ?? 0) >= 100, + `snapshotSize=${cycles[0]}`, + ); + + const candles = await provider.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '15m' as never, + limit: 30, + }); + check( + result, + 'mainnet candles return live history', + candles.candles.length >= 20 && + parseFloat(candles.candles.at(-1)?.close ?? '0') > 0, + `count=${candles.candles.length} close=${candles.candles.at(-1)?.close}`, + ); + await provider.disconnect(); + record(result, { + mainnetMarkets: markets.length, + mainnetSnapshotSize: cycles[0], + }); +} + +/** + * Prove the authenticated history reads: trade fills, user funding payments, + * and PnL records for the funded shared account. + * + * @param result - Phase result accumulator. + */ +async function phaseHistoryReads(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const fills = await provider.getOrderFills({ limit: 20 } as never); + check( + result, + 'trade history returns fills with sane fields', + fills.length > 0 && + fills.every( + (fill) => + parseFloat(fill.price) > 0 && + parseFloat(fill.size) > 0 && + (fill.side === 'buy' || fill.side === 'sell'), + ), + `fills=${fills.length}`, + ); + const funding = await provider.getFunding(); + check( + result, + 'funding history returns signed USDC flows with rates', + funding.length > 0 && + funding.every( + (entry) => + Number.isFinite(parseFloat(entry.amountUsd)) && + entry.timestamp > 1_700_000_000_000, + ), + `fundings=${funding.length}`, + ); + await provider.disconnect(); + record(result, { fillCount: fills.length, fundingCount: funding.length }); +} + +/** + * Prove the parity history surface: historical order lifecycle, user + * deposit/withdrawal history, the non-funding ledger, and the bridge + * routes cross-checked against the venue's live layer1BasicInfo. + * + * @param result - Phase result accumulator. + */ +async function phaseParityHistory(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const orders = await provider.getOrders(); + const historical = orders.filter((order) => order.status !== 'open'); + check( + result, + 'getOrders surfaces historical lifecycle (filled/canceled states)', + orders.length > 0 && historical.length > 0, + `orders=${orders.length} historical=${historical.length}`, + ); + + const history = await provider.getUserHistory(); + check( + result, + 'getUserHistory returns deposits and withdrawals, newest first', + history.some((item) => item.type === 'deposit') && + history.some((item) => item.type === 'withdrawal') && + history.every( + (item, index) => + index === 0 || history[index - 1].timestamp >= item.timestamp, + ), + `items=${history.length}`, + ); + + const ledger = await provider.getUserNonFundingLedgerUpdates(); + check( + result, + 'non-funding ledger merges deposits/withdrawals/transfers with signed flows', + ledger.length > 0 && + ledger.some((update) => update.delta.type.startsWith('transfer')) && + ledger.some((update) => (update.delta.usdc ?? '').startsWith('-')), + `updates=${ledger.length}`, + ); + + const [depositRoute] = provider.getDepositRoutes(); + const [withdrawalRoute] = provider.getWithdrawalRoutes(); + const live = convertKeysToCamelCase( + await ( + await fetch('https://testnet.zklighter.elliot.ai/api/v1/layer1BasicInfo') + ).json(), + ) as { + contractAddresses: { name: string; address: string }[]; + }; + const liveBridge = live.contractAddresses.find( + (entry) => entry.name === 'ZkLighterContract', + )?.address; + check( + result, + 'deposit/withdrawal routes match the venue-reported L1 bridge contract', + Boolean(depositRoute) && + Boolean(withdrawalRoute) && + depositRoute.contractAddress.toLowerCase() === + (liveBridge ?? '').toLowerCase() && + withdrawalRoute.contractAddress === depositRoute.contractAddress && + depositRoute.assetId.includes('erc20:'), + `route=${depositRoute?.contractAddress} live=${liveBridge}`, + ); + await provider.disconnect(); + record(result, { + orderCount: orders.length, + historicalCount: historical.length, + historyCount: history.length, + ledgerCount: ledger.length, + bridgeContract: depositRoute?.contractAddress, + }); +} + +/** + * Prove the connection-state surface over the real venue WebSocket: + * subscribe → connecting→connected transitions, live reads of the state, + * a manual reconnect() cycle that resumes price flow, and teardown. + * + * @param result - Phase result accumulator. + */ +async function phaseConnectionState(result: PhaseResult): Promise { + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const transitions: string[] = []; + const unsubscribeState = provider.subscribeToConnectionState( + (state, attempt) => { + transitions.push(`${state}:${attempt}`); + }, + ); + + // Held for the whole phase so one-shot waiters below never drop the last + // subscriber (which would tear the stream down between checks). + const releaseHold = provider.subscribeToPrices({ + symbols: [MARKET], + callback: () => undefined, + }); + + const waitForPrice = (): Promise => + new Promise((resolvePrice, rejectPrice) => { + const timer = setTimeout( + () => rejectPrice(new Error('no price update within 30s')), + 30_000, + ); + const unsubscribePrices = provider.subscribeToPrices({ + symbols: [MARKET], + callback: (updates) => { + if (updates.length > 0) { + clearTimeout(timer); + unsubscribePrices(); + resolvePrice(); + } + }, + }); + }); + + await waitForPrice(); + check( + result, + 'stream reports connecting → connected on subscribe', + transitions.some((entry) => entry.startsWith('connecting')) && + transitions.some((entry) => entry.startsWith('connected')), + transitions.join(','), + ); + check( + result, + 'getWebSocketConnectionState reads connected while streaming', + provider.getWebSocketConnectionState() === 'connected', + provider.getWebSocketConnectionState(), + ); + + const before = transitions.length; + await provider.reconnect(); + await waitForPrice(); + const afterReconnect = transitions.slice(before); + check( + result, + 'manual reconnect cycles disconnected → connected and price flow resumes', + afterReconnect.some((entry) => entry.startsWith('disconnected')) && + afterReconnect.some((entry) => entry.startsWith('connected')), + afterReconnect.join(','), + ); + + unsubscribeState(); + releaseHold(); + await provider.disconnect(); + check( + result, + 'disconnect tears the stream down to disconnected', + provider.getWebSocketConnectionState() === 'disconnected', + provider.getWebSocketConnectionState(), + ); + record(result, { transitions }); +} + +/** + * Prove position TP/SL: open a tiny position, attach an OCO TP/SL pair, + * verify both trigger orders appear, remove them, and close the position. + * + * @param result - Phase result accumulator. + */ +async function phaseTpsl(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const meta = (await client.getOrderBooks()).find( + (entry) => entry.symbol === MARKET, + ); + const lastPrice = + (await client.getOrderBookDetails()).orderBookDetails.find( + (entry) => entry.symbol === MARKET, + )?.lastTradePrice ?? 0; + if (!meta || lastPrice <= 0) { + check(result, 'live market metadata available', false); + await provider.disconnect(); + return; + } + const size = computeLighterMinOrderSize(meta, lastPrice); + const startSize = parseFloat( + (await provider.getPositions()).find((entry) => entry.symbol === MARKET) + ?.size ?? '0', + ); + + const opened = await provider.placeOrder({ + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'market', + }); + check(result, 'position opens', Boolean(opened.success), opened.error); + await poll( + 'position visible', + async () => await provider.getPositions(), + (positions) => + Math.abs( + parseFloat( + positions.find((entry) => entry.symbol === MARKET)?.size ?? '0', + ) - + startSize - + size, + ) < + size * 0.2, + 45_000, + ); + + const tpPrice = Number( + (lastPrice * 1.5).toFixed(meta.supportedPriceDecimals), + ); + const slPrice = Number( + (lastPrice * 0.5).toFixed(meta.supportedPriceDecimals), + ); + const attached = await provider.updatePositionTPSL({ + symbol: MARKET, + takeProfitPrice: String(tpPrice), + stopLossPrice: String(slPrice), + }); + check( + result, + 'OCO TP/SL pair submits', + Boolean(attached.success), + attached.error, + ); + + await poll( + 'both trigger orders visible in open orders', + async () => await provider.getOpenOrders(), + (orders) => + orders.filter((order) => order.symbol === MARKET && order.isTrigger) + .length >= 2, + 45_000, + ); + check(result, 'TP and SL trigger orders visible', true); + + // REPLACEMENT while triggers exist: proves the venue accepts creating + // the new reduce-only protection BEFORE the old triggers are cancelled + // (create-first ordering), and that the old pair is cancelled after. + const replacementTpPrice = Number( + (lastPrice * 1.6).toFixed(meta.supportedPriceDecimals), + ); + const replacementSlPrice = Number( + (lastPrice * 0.4).toFixed(meta.supportedPriceDecimals), + ); + const replaced = await provider.updatePositionTPSL({ + symbol: MARKET, + takeProfitPrice: String(replacementTpPrice), + stopLossPrice: String(replacementSlPrice), + }); + check( + result, + 'TP/SL replacement (create-before-cancel) submits', + Boolean(replaced.success), + replaced.error, + ); + // Trigger orders report the ±5% protection execution price in `price`; + // the user-facing TP/SL level is `triggerPrice` — assert on that. + const triggerNear = ( + order: { triggerPrice?: string }, + target: number, + ): boolean => + order.triggerPrice !== undefined && + Math.abs(parseFloat(order.triggerPrice) - target) < lastPrice * 0.01; + await poll( + 'replacement settles to exactly the new TP+SL pair with the old pair gone', + async () => await provider.getOpenOrders(), + (orders) => { + const triggers = orders.filter( + (order) => order.symbol === MARKET && order.isTrigger, + ); + // Strict: BOTH replacement trigger levels present, BOTH old levels + // absent, and nothing else — "two triggers + some new TP" could + // false-pass as new TP + old SL after a partial cancellation. + return ( + triggers.length === 2 && + triggers.some((order) => triggerNear(order, replacementTpPrice)) && + triggers.some((order) => triggerNear(order, replacementSlPrice)) && + !triggers.some((order) => triggerNear(order, tpPrice)) && + !triggers.some((order) => triggerNear(order, slPrice)) + ); + }, + 90_000, + ); + check( + result, + 'both old triggers cancelled after both replacements created', + true, + ); + + const removed = await provider.updatePositionTPSL({ symbol: MARKET }); + check( + result, + 'TP/SL removal succeeds', + Boolean(removed.success), + removed.error, + ); + await poll( + 'trigger orders gone', + async () => await provider.getOpenOrders(), + (orders) => + orders.filter((order) => order.symbol === MARKET && order.isTrigger) + .length === 0, + 45_000, + ); + check(result, 'trigger orders removed', true); + + // SINGLE-trigger contract: a lone SL must submit as an ordinary + // CreateOrder trigger (the venue rejects CreateGroupedOrders with + // grouping type 0), and a single->single replacement must land on the + // new trigger with the old one gone. + const singleSlPrice = Number( + (lastPrice * 0.45).toFixed(meta.supportedPriceDecimals), + ); + const singleAttached = await provider.updatePositionTPSL({ + symbol: MARKET, + stopLossPrice: String(singleSlPrice), + }); + check( + result, + 'single SL submits as ordinary trigger order', + Boolean(singleAttached.success), + singleAttached.error, + ); + await poll( + 'single SL trigger visible at its trigger level', + async () => await provider.getOpenOrders(), + (orders) => { + const triggers = orders.filter( + (order) => order.symbol === MARKET && order.isTrigger, + ); + return triggers.length === 1 && triggerNear(triggers[0], singleSlPrice); + }, + 45_000, + ); + const singleTpPrice = Number( + (lastPrice * 1.55).toFixed(meta.supportedPriceDecimals), + ); + const singleReplaced = await provider.updatePositionTPSL({ + symbol: MARKET, + takeProfitPrice: String(singleTpPrice), + }); + check( + result, + 'single TP replaces single SL (create-before-cancel)', + Boolean(singleReplaced.success), + singleReplaced.error, + ); + await poll( + 'single replacement settles to exactly the new TP with the old SL gone', + async () => await provider.getOpenOrders(), + (orders) => { + const triggers = orders.filter( + (order) => order.symbol === MARKET && order.isTrigger, + ); + // count===1 alone could false-pass as the OLD SL surviving a failed + // create/cancel pair; the sole trigger must be the NEW TP level. + return ( + triggers.length === 1 && + triggerNear(triggers[0], singleTpPrice) && + !triggerNear(triggers[0], singleSlPrice) + ); + }, + 90_000, + ); + check(result, 'single-trigger replacement settled on the new TP', true); + const singleRemoved = await provider.updatePositionTPSL({ symbol: MARKET }); + check( + result, + 'single trigger removal succeeds', + Boolean(singleRemoved.success), + singleRemoved.error, + ); + + const closed = await provider.closePosition({ + symbol: MARKET, + size: String(size), + }); + check( + result, + 'cleanup close succeeds', + Boolean(closed.success), + closed.error, + ); + await poll( + 'position back to start', + async () => await provider.getPositions(), + (positions) => + Math.abs( + parseFloat( + positions.find((entry) => entry.symbol === MARKET)?.size ?? '0', + ) - startSize, + ) < + size * 0.2, + 45_000, + ); + await provider.disconnect(); +} + +/** + * Prove margin + leverage at protocol level: switch SOL to isolated 10x via + * UpdateLeverage, open a position, add then remove isolated margin via + * updateMargin, close, and restore cross 20x. + * + * @param result - Phase result accumulator. + */ +async function phaseMarginLeverage(result: PhaseResult): Promise { + const bridge = await createNodeWasmBridge(WASM_DIR); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: buildInfrastructure(), + signerBridge: bridge, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + l1Address: viemAccount.address, + personalSigner, + }, + }); + await provider.initialize(); + const client = new LighterClientService(buildInfrastructure(), { + isTestnet: true, + }); + const meta = (await client.getOrderBooks()).find( + (entry) => entry.symbol === MARKET, + ); + const lastPrice = + (await client.getOrderBookDetails()).orderBookDetails.find( + (entry) => entry.symbol === MARKET, + )?.lastTradePrice ?? 0; + if (!meta || lastPrice <= 0) { + check(result, 'live market metadata available', false); + await provider.disconnect(); + return; + } + + const signLeverage = async ( + imfHundredths: number, + marginMode: number, + ): Promise => { + const { nonce } = await client.getNextNonce(ACCOUNT_INDEX, API_KEY_INDEX); + const signed = await bridge.execute({ + function: '_signUpdateLeverage', + params: [ACCOUNT_INDEX, meta.marketId, imfHundredths, marginMode, nonce], + }); + if (signed.error) { + throw new Error(signed.error); + } + await client.sendTx(20, signed.txInfo); + }; + + // Ensure the WASM signer client exists before raw bridge calls. + await provider.isReadyToTrade(); + // Isolated 10x → IMF 10% → hundredths = 1000. + await signLeverage(1000, 1); + check(result, 'UpdateLeverage (isolated 10x) accepted', true); + + const size = computeLighterMinOrderSize(meta, lastPrice); + const opened = await provider.placeOrder({ + symbol: MARKET, + isBuy: true, + size: String(size), + orderType: 'market', + }); + check( + result, + 'isolated position opens', + Boolean(opened.success), + opened.error, + ); + + const readPosition = async (): Promise<{ + imf: string; + size: number; + } | null> => { + const accounts = await client.getAccountByIndex(ACCOUNT_INDEX); + const position = accounts.accounts?.[0]?.positions?.find( + (entry) => entry.marketId === meta.marketId, + ); + return position + ? { + imf: position.initialMarginFraction, + size: parseFloat(position.position), + } + : null; + }; + await poll( + 'position readable with isolated 10x margin fraction', + readPosition, + (position) => position !== null && parseFloat(position.imf) === 10, + 45_000, + ); + check(result, 'position shows 10% initial margin fraction (10x)', true); + + const added = await provider.updateMargin({ symbol: MARKET, amount: '2' }); + check( + result, + 'updateMargin add accepted', + Boolean(added.success), + added.error, + ); + // Let the margin addition settle before drawing it back down. + await new Promise((resolveWait) => setTimeout(resolveWait, 5000)); + const removedMargin = await provider.updateMargin({ + symbol: MARKET, + amount: '-1', + }); + check( + result, + 'updateMargin remove accepted', + Boolean(removedMargin.success), + removedMargin.error, + ); + + const closed = await provider.closePosition({ symbol: MARKET }); + check( + result, + 'cleanup close succeeds', + Boolean(closed.success), + closed.error, + ); + await poll( + 'position flat', + readPosition, + (position) => position === null || position.size === 0, + 45_000, + ); + // Restore cross 20x (IMF 5% → 500). + await signLeverage(500, 0); + check(result, 'leverage restored to cross 20x', true); + await provider.disconnect(); +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main(): Promise { + await mkdir(OUT_DIR, { recursive: true }); + const result: PhaseResult = { phase: PHASE, ok: true, checks: [] }; + + try { + switch (PHASE) { + case 'sign-only': + await phaseSignOnly(result); + break; + case 'register': + await phaseRegister(result); + break; + case 'order-lifecycle': + await phaseOrderLifecycle(result); + break; + case 'controller': + await phaseController(result); + break; + case 'price-stream': + await phasePriceStream(result); + break; + case 'account-stream': + await phaseAccountStream(result); + break; + case 'positions-stream': + await phasePositionsStream(result); + break; + case 'orders-stream': + await phaseOrdersStream(result); + break; + case 'candles': + await phaseCandles(result); + break; + case 'close-position': + await phaseClosePosition(result); + break; + case 'order-book-stream': + await phaseOrderBookStream(result); + break; + case 'candles-stream': + await phaseCandlesStream(result); + break; + case 'edit-order': + await phaseEditOrder(result); + break; + case 'withdraw-sign': + await phaseWithdrawSign(result); + break; + case 'mainnet-reads': + await phaseMainnetReads(result); + break; + case 'history-reads': + await phaseHistoryReads(result); + break; + case 'parity-history': + await phaseParityHistory(result); + break; + case 'connection-state': + await phaseConnectionState(result); + break; + case 'tpsl': + await phaseTpsl(result); + break; + case 'margin-leverage': + await phaseMarginLeverage(result); + break; + default: + throw new Error(`Unknown phase: ${PHASE}`); + } + } catch (error) { + check(result, `${PHASE} completed without exception`, false, String(error)); + } + + await writeFile( + join(OUT_DIR, `${PHASE}.json`), + JSON.stringify(result, null, 2), + ); + process.stdout.write( + `${result.ok ? 'PHASE_PASS' : 'PHASE_FAIL'}: ${PHASE} (${result.checks.filter((entry) => entry.ok).length}/${result.checks.length} checks)\n`, + ); + process.exitCode = result.ok ? 0 : 1; +} + +main().catch((error) => { + process.stderr.write(`FATAL: ${String(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/perps-controller/tests/e2e/lighter/build-wasm.sh b/packages/perps-controller/tests/e2e/lighter/build-wasm.sh new file mode 100755 index 0000000000..392dc5386f --- /dev/null +++ b/packages/perps-controller/tests/e2e/lighter/build-wasm.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Build the Lighter Go/WASM signer from source (elliottech/lighter-go@web-wasm) +# +# Pinned provenance: the signer builds from an exact reviewed commit of the +# web-wasm branch, never its moving HEAD. Override with LIGHTER_GO_REF only +# to intentionally evaluate a newer upstream. +# and stage it, with Go's wasm_exec.js runtime, into a cache directory. +# +# Also computes an informational reproducibility check: sha256 of the locally +# built blob vs the blob committed on the upstream branch. A mismatch is NOT +# a failure (upstream's Go toolchain version is unknown); byte-equality is +# reported in manifest.json for the record. +# +# Usage: build-wasm.sh [--out DIR] +# Output: DIR/main.wasm, DIR/wasm_exec.js, DIR/manifest.json +set -euo pipefail + +LIGHTER_GO_REF="${LIGHTER_GO_REF:-05a2bbcbbc3db2de7941313fd6524e5744ee5336}" + +OUT_DIR="temp/lighter-wasm" +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT_DIR="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +command -v go >/dev/null || { echo "FAIL: go toolchain not found" >&2; exit 1; } + +mkdir -p "$OUT_DIR" +REPO_DIR="$OUT_DIR/lighter-go" + +if [ -d "$REPO_DIR/.git" ]; then + git -C "$REPO_DIR" fetch --depth 1 origin "$LIGHTER_GO_REF" + git -C "$REPO_DIR" checkout -q FETCH_HEAD +else + git clone https://github.com/elliottech/lighter-go.git "$REPO_DIR" + git -C "$REPO_DIR" checkout -q "$LIGHTER_GO_REF" +fi +UPSTREAM_COMMIT="$(git -C "$REPO_DIR" rev-parse HEAD)" + +echo "Building main.wasm from source (commit $UPSTREAM_COMMIT)..." +# DETERMINISTIC build: GOTOOLCHAIN pins the compiler, -trimpath strips +# host paths — two clean builds of the same commit produce the same +# sha256 anywhere. (Upstream's committed blob is NOT reproducible: it +# was built without -trimpath and embeds the author's laptop paths, so +# the upstream compare below stays informational by nature.) +(cd "$REPO_DIR/web-wasm" && GOOS=js GOARCH=wasm GOTOOLCHAIN=go1.26.0 go build -trimpath -ldflags="-s -w" -o main.wasm) + +# Upstream's committed blob, for the informational hash-compare. +UPSTREAM_SHA="$(shasum -a 256 "$REPO_DIR/web-wasm/main.wasm" | awk '{print $1}')" +# Rebuild over the committed blob: build again to a distinct path so both exist. +(cd "$REPO_DIR/web-wasm" && git checkout -q -- main.wasm 2>/dev/null || true) +COMMITTED_SHA="" +if git -C "$REPO_DIR" cat-file -e "HEAD:web-wasm/main.wasm" 2>/dev/null; then + git -C "$REPO_DIR" show "HEAD:web-wasm/main.wasm" > "$OUT_DIR/upstream-main.wasm" + COMMITTED_SHA="$(shasum -a 256 "$OUT_DIR/upstream-main.wasm" | awk '{print $1}')" +fi +# Re-run the build so the artifact we ship is unambiguously source-built, +# and prove SELF-reproducibility: a forced full recompile must produce +# 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) +BUILT_SHA="$(shasum -a 256 "$REPO_DIR/web-wasm/main.wasm" | awk '{print $1}')" +cp "$REPO_DIR/web-wasm/main.wasm" "$OUT_DIR/main.wasm" + +# Stage Go's wasm_exec.js runtime (path moved from misc/ to lib/ in Go 1.24). +GOROOT_DIR="$(go env GOROOT)" +if [ -f "$GOROOT_DIR/lib/wasm/wasm_exec.js" ]; then + cp "$GOROOT_DIR/lib/wasm/wasm_exec.js" "$OUT_DIR/wasm_exec.js" +elif [ -f "$GOROOT_DIR/misc/wasm/wasm_exec.js" ]; then + cp "$GOROOT_DIR/misc/wasm/wasm_exec.js" "$OUT_DIR/wasm_exec.js" +else + echo "FAIL: wasm_exec.js not found in GOROOT" >&2 + exit 1 +fi + +SIZE_BYTES="$(wc -c < "$OUT_DIR/main.wasm" | tr -d ' ')" +MATCH="false" +[ -n "$COMMITTED_SHA" ] && [ "$BUILT_SHA" = "$COMMITTED_SHA" ] && MATCH="true" +if [ "$BUILT_SHA" != "$FIRST_SHA" ]; then + echo "FAIL: build is not self-reproducible ($FIRST_SHA vs $BUILT_SHA)" >&2 + exit 1 +fi + +cat > "$OUT_DIR/manifest.json" <; +}; + +type GoRuntime = new () => GoInstance; + +/** + * Wait until a predicate holds or time out. + * + * @param predicate - Condition to poll. + * @param timeoutMs - Give up after this many milliseconds. + * @param label - Description used in the timeout error. + */ +async function waitFor( + predicate: () => boolean, + timeoutMs: number, + label: string, +): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for ${label}`); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +/** + * Instantiate the WASM signer and return a bridge over its globals. + * + * @param wasmDir - Directory holding `main.wasm` + `wasm_exec.js` + * (produced by build-wasm.sh). + * @returns A ready signer bridge. + */ +export async function createNodeWasmBridge( + wasmDir: string, +): Promise { + const globals = globalThis as Record; + + if (typeof globals.Go !== 'function') { + const execSource = await readFile(join(wasmDir, 'wasm_exec.js'), 'utf8'); + // wasm_exec.js attaches the Go class to globalThis when evaluated in + // global scope; vm.runInThisContext keeps that scope. + runInThisContext(execSource, { filename: 'wasm_exec.js' }); + } + + const GoClass = globals.Go as GoRuntime; + const go = new GoClass(); + const wasmBytes = new Uint8Array(await readFile(join(wasmDir, 'main.wasm'))); + const { instance } = await globalThis.WebAssembly.instantiate( + wasmBytes, + go.importObject, + ); + // The Go program blocks on a channel forever; run() resolves only on exit. + go.run(instance).catch((error: unknown) => { + // Surface unexpected runtime exits; the bridge is dead at this point. + process.stderr.write( + `[nodeWasmBridge] Go runtime exited unexpectedly: ${String(error)}\n`, + ); + }); + + await waitFor( + () => typeof globals._createClient === 'function', + 10_000, + 'WASM signer globals', + ); + + return { + async execute(call: LighterWasmCall): Promise { + const target = globals[call.function]; + if (typeof target !== 'function') { + throw new Error(`WASM function not registered: ${call.function}`); + } + // Go side: fn(...params) returns a function; calling it returns a + // Promise resolving to the result object (or {error} on failure). + const curried = (target as (...args: unknown[]) => unknown)( + ...call.params, + ); + const result = + typeof curried === 'function' + ? await (curried as () => Promise)() + : await (curried as Promise); + return result; + }, + }; +} diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index d24aab9486..5e95529bfa 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -100,12 +100,28 @@ export const createMockInfrastructure = }, // === Disk Cache (cold-start persistence) === - diskCache: { - getItem: jest.fn().mockResolvedValue(null), - getItemSync: jest.fn().mockReturnValue(null), - setItem: jest.fn().mockResolvedValue(undefined), - removeItem: jest.fn().mockResolvedValue(undefined), - }, + // FUNCTIONAL in-memory disk cache: durable-state code treats disk + // absence as authoritative, so the default mock must actually + // store — a null-only stub would silently erase obligations. + diskCache: (() => { + const store = new Map(); + return { + getItem: jest + .fn() + .mockImplementation(async (key: string) => store.get(key) ?? null), + getItemSync: jest + .fn() + .mockImplementation((key: string) => store.get(key) ?? null), + setItem: jest + .fn() + .mockImplementation(async (key: string, value: string) => { + store.set(key, value); + }), + removeItem: jest.fn().mockImplementation(async (key: string) => { + store.delete(key); + }), + }; + })(), }) as unknown as jest.Mocked; /** diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index aeffd0b9d3..26785ea5d3 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1019,6 +1019,62 @@ describe('PerpsController', () => { }); }); + describe('durable-settlement surfacing (manual recoveries / recovered dispatches)', () => { + it('returns empty lists when the active provider has no durable settlement state', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + expect(await controller.getPendingManualRecoveries()).toStrictEqual([]); + expect(await controller.getRecoveredDispatches()).toStrictEqual([]); + await expect( + controller.acknowledgeRecoveredDispatch('42:abcd'), + ).rejects.toThrow('no recovered dispatches'); + }); + + it('routes to the active provider when it implements the durable-settlement contract', async () => { + const pending = [ + { + symbol: 'BTC', + settlementKey: '0xabc:28:7:BTC', + recordedAt: 5, + reason: 'why', + priorIntent: 'replace' as const, + survivingOrderIds: ['9'], + actionNeeded: 'do the thing', + }, + ]; + const outcomes = [ + { + recoveryId: '42:abcd', + kind: 13, + intent: 'withdraw:25', + txHash: 'abcd', + outcome: 'succeeded' as const, + evidence: 'tx-status:3', + }, + ]; + const durableProvider = { + ...mockProvider, + getPendingManualRecoveries: jest.fn().mockResolvedValue(pending), + getRecoveredDispatches: jest.fn().mockResolvedValue(outcomes), + acknowledgeRecoveredDispatch: jest.fn().mockResolvedValue(undefined), + } as unknown as PerpsProvider; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', durableProvider]])); + expect(await controller.getPendingManualRecoveries()).toStrictEqual( + pending, + ); + expect(await controller.getRecoveredDispatches()).toStrictEqual(outcomes); + await controller.acknowledgeRecoveredDispatch('42:abcd'); + expect( + ( + durableProvider as unknown as { + acknowledgeRecoveredDispatch: jest.Mock; + } + ).acknowledgeRecoveredDispatch, + ).toHaveBeenCalledWith('42:abcd'); + }); + }); + describe('getAvailableDexs', () => { beforeEach(() => { markControllerAsInitialized(); diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index 4e1d258757..6162837aad 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -382,6 +382,16 @@ class TestablePerpsController extends PerpsController { public testHandleMYXImportError(error: unknown) { this.handleMYXImportError(error); } + + public testRegisterLighterProvider( + LighterProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerLighterProvider(LighterProvider as never); + } + + public testHandleLighterImportError(error: unknown) { + this.handleLighterImportError(error); + } } describe('PerpsController', () => { @@ -844,6 +854,55 @@ describe('PerpsController', () => { ); }); + it('registerLighterProvider registers the provider and forwards the signer bridge from the Lighter credentials', () => { + // Arrange — the client (mobile WebView / headless WASM) supplies the + // bridge through the Lighter credentials bag; the controller must + // forward it (the shared platform surface stays venue-agnostic). + const mockBridge = { execute: jest.fn() }; + const mockLighterInstance = createMockHyperLiquidProvider(); + const MockLighterConstructor = jest.fn(() => mockLighterInstance); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + providerCredentials: { lighter: { signerBridge: mockBridge } }, + }, + infrastructure: mockInfrastructure, + }); + + // Act + controller.testRegisterLighterProvider( + MockLighterConstructor as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + + // Assert + const providers = controller.testGetProviders(); + expect(providers.get('lighter')).toBe(mockLighterInstance); + expect(MockLighterConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + // Lighter follows the controller's global network (mainnet default); + // mainnet writes are blocked inside LighterProvider instead. + isTestnet: false, + signerBridge: mockBridge, + }), + ); + }); + + it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { + const moduleError = Object.assign( + new Error('Cannot find module ./providers/LighterProvider'), + { code: 'MODULE_NOT_FOUND' }, + ); + + controller.testHandleLighterImportError(moduleError); + + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Lighter provider module not available, skipping registration', + ); + }); + it('handleMYXImportError logs debug for MODULE_NOT_FOUND errors', () => { // Arrange — Node sets code: 'MODULE_NOT_FOUND' on missing modules const moduleError = Object.assign( diff --git a/packages/perps-controller/tests/src/constants/lighterConfig.test.ts b/packages/perps-controller/tests/src/constants/lighterConfig.test.ts new file mode 100644 index 0000000000..fda5c232c7 --- /dev/null +++ b/packages/perps-controller/tests/src/constants/lighterConfig.test.ts @@ -0,0 +1,169 @@ +import { + buildLighterKeyDerivationMessage, + computeLighterMinOrderSize, + fromLighterInteger, + getLighterChainId, + getLighterHttpEndpoint, + LIGHTER_DEFAULT_API_KEY_INDEX, + LIGHTER_ENDPOINTS, + LIGHTER_MAINNET_CHAIN_ID, + LIGHTER_TESTNET_CHAIN_ID, + LIGHTER_TX_TYPE_CANCEL_ORDER, + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + LIGHTER_TX_TYPE_CREATE_ORDER, + parseLighterStrictDecimal, + toLighterInteger, +} from '../../../src/constants/lighterConfig.js'; + +describe('lighterConfig', () => { + describe('chain ids', () => { + it('returns testnet chain id 300', () => { + expect(getLighterChainId('testnet')).toBe(300); + expect(LIGHTER_TESTNET_CHAIN_ID).toBe(300); + }); + + it('returns mainnet chain id 304', () => { + expect(getLighterChainId('mainnet')).toBe(304); + expect(LIGHTER_MAINNET_CHAIN_ID).toBe(304); + }); + }); + + describe('endpoints', () => { + it('returns the testnet HTTP endpoint', () => { + expect(getLighterHttpEndpoint('testnet')).toBe( + 'https://testnet.zklighter.elliot.ai', + ); + }); + + it('returns the mainnet HTTP endpoint', () => { + expect(getLighterHttpEndpoint('mainnet')).toBe( + 'https://mainnet.zklighter.elliot.ai', + ); + }); + + it('defines websocket endpoints per network', () => { + expect(LIGHTER_ENDPOINTS.testnet.ws).toBe( + 'wss://testnet.zklighter.elliot.ai/stream', + ); + expect(LIGHTER_ENDPOINTS.mainnet.ws).toBe( + 'wss://mainnet.zklighter.elliot.ai/stream', + ); + }); + }); + + describe('transaction types', () => { + it('matches the lighter-go txtypes constants', () => { + expect(LIGHTER_TX_TYPE_CHANGE_PUB_KEY).toBe(8); + expect(LIGHTER_TX_TYPE_CREATE_ORDER).toBe(14); + expect(LIGHTER_TX_TYPE_CANCEL_ORDER).toBe(15); + }); + }); + + describe('buildLighterKeyDerivationMessage', () => { + it('substitutes address, chain id and api key index', () => { + const message = buildLighterKeyDerivationMessage({ + address: '0xABCDEF0000000000000000000000000000000001', + chainId: 300, + apiKeyIndex: LIGHTER_DEFAULT_API_KEY_INDEX, + }); + expect(message).toContain( + 'Address: 0xabcdef0000000000000000000000000000000001', + ); + expect(message).toContain('Chain ID: 300'); + expect(message).toContain( + `API key index: ${LIGHTER_DEFAULT_API_KEY_INDEX}`, + ); + expect(message).toContain('Only sign this message for a trusted client!'); + }); + + it('is deterministic for identical inputs', () => { + const params = { address: '0xabc', chainId: 300, apiKeyIndex: 7 }; + expect(buildLighterKeyDerivationMessage(params)).toBe( + buildLighterKeyDerivationMessage(params), + ); + }); + }); + + describe('integerization', () => { + it('converts human values to wire integers', () => { + expect(toLighterInteger(0.05, 5)).toBe(5000); + expect(toLighterInteger(187.25, 1)).toBe(1873); + expect(toLighterInteger(100000, 1)).toBe(1000000); + }); + + it('throws on values that overflow the safe-integer wire format', () => { + // 1e300 * 10^5 = 1e305: finite, but stringifies as '1e+305' in + // signer params instead of an integer. + expect(() => toLighterInteger(1e300, 5)).toThrow( + "outside Lighter's integer range", + ); + expect(() => toLighterInteger(Infinity, 1)).toThrow( + "outside Lighter's integer range", + ); + expect(() => toLighterInteger(NaN, 1)).toThrow( + "outside Lighter's integer range", + ); + // The largest representable value (MAX_SAFE_INTEGER) still passes. + expect(toLighterInteger(90071992547409.9, 2)).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it('strict decimal parsing tolerates unvalidated runtime types and flags prefix-numerics', () => { + // Venue REST is type-cast without runtime validation: missing/null/ + // numeric values must yield null for callers' explicit error paths, + // never a TypeError that generic catches misread as a fetch failure. + expect(parseLighterStrictDecimal(undefined)).toBeNull(); + expect(parseLighterStrictDecimal(null)).toBeNull(); + expect(parseLighterStrictDecimal(0.5)).toBeNull(); + expect(parseLighterStrictDecimal('0.1oops')).toBeNull(); + expect(parseLighterStrictDecimal('')).toBeNull(); + expect(parseLighterStrictDecimal(' 0.5 ')).toBe(0.5); + expect(parseLighterStrictDecimal('-1.5e2')).toBe(-150); + // Overflow exponent parses to Infinity: finiteness is the CALLER's + // check, and every caller performs it. + expect(parseLighterStrictDecimal('1e999')).toBe(Infinity); + }); + + it('returns zero/negative results as-is (positivity policy lives in the signer wrapper)', () => { + // Generic converter contract: range-checked but sign-agnostic. The + // provider's internal signer-wire wrapper enforces positive intent. + expect(toLighterInteger(0.04, 1)).toBe(0); + expect(toLighterInteger(1e-9, 5)).toBe(0); + // Math.round rounds -.5 toward +Infinity. + expect(toLighterInteger(-187.25, 1)).toBe(-1872); + }); + + it('round-trips wire integers back to human values', () => { + expect(fromLighterInteger(5000, 5)).toBe(0.05); + expect(fromLighterInteger(1873, 1)).toBe(187.3); + }); + }); + + describe('computeLighterMinOrderSize', () => { + const market = { + minBaseAmount: '0.00020', + minQuoteAmount: '10.000000', + supportedSizeDecimals: 5, + }; + + it('uses the quote minimum when it dominates', () => { + // At $100/base, 10 USDC requires 0.1 base > 0.0002 base minimum. + expect(computeLighterMinOrderSize(market, 100)).toBeCloseTo(0.1, 5); + }); + + it('uses the base minimum when the price is high', () => { + // At $100k/base, 10 USDC requires 0.0001 base < 0.0002 base minimum. + expect(computeLighterMinOrderSize(market, 100_000)).toBeCloseTo( + 0.0002, + 5, + ); + }); + + it('rounds up to the market size step', () => { + const size = computeLighterMinOrderSize(market, 30_000); + // 10/30000 = 0.000333... → rounded up to 0.00034 at 5 decimals. + expect(size).toBeCloseTo(0.00034, 6); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 8895e86be4..dd0c8d3e9c 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -185,6 +185,88 @@ describe('AggregatedPerpsProvider', () => { }); }); + describe('durable-settlement surfacing', () => { + const pending = { + symbol: 'BTC', + settlementKey: '0xabc:28:7:BTC', + recordedAt: 5, + reason: 'why', + priorIntent: 'replace' as const, + survivingOrderIds: ['9'], + actionNeeded: 'set a new TP/SL', + }; + const outcome = { + recoveryId: '42:abcd', + kind: 13, + intent: 'withdraw:25', + txHash: 'abcd', + outcome: 'succeeded' as const, + evidence: 'tx-status:3', + }; + + it('aggregates recoveries and outcomes from providers implementing the contract', async () => { + const durable = { + ...mockMYXProvider, + getPendingManualRecoveries: jest.fn().mockResolvedValue([pending]), + getRecoveredDispatches: jest.fn().mockResolvedValue([outcome]), + acknowledgeRecoveredDispatch: jest.fn().mockResolvedValue(undefined), + } as unknown as PerpsProvider; + const aggregated = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + ['lighter', durable], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + // The non-durable provider contributes empty lists, never an error. + expect(await aggregated.getPendingManualRecoveries()).toStrictEqual([ + pending, + ]); + expect(await aggregated.getRecoveredDispatches()).toStrictEqual([ + outcome, + ]); + await aggregated.acknowledgeRecoveredDispatch('42:abcd'); + expect( + (durable as unknown as { acknowledgeRecoveredDispatch: jest.Mock }) + .acknowledgeRecoveredDispatch, + ).toHaveBeenCalledWith('42:abcd'); + }); + + it('propagates storage errors and unknown-id refusals instead of hiding them', async () => { + const durable = { + ...mockMYXProvider, + getPendingManualRecoveries: jest + .fn() + .mockRejectedValue(new Error('manual-recovery index is corrupt')), + getRecoveredDispatches: jest.fn().mockResolvedValue([]), + acknowledgeRecoveredDispatch: jest + .fn() + .mockRejectedValue(new Error('No pending recovered')), + } as unknown as PerpsProvider; + const aggregated = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + ['lighter', durable], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + await expect(aggregated.getPendingManualRecoveries()).rejects.toThrow( + 'corrupt', + ); + await expect( + aggregated.acknowledgeRecoveredDispatch('42:zzzz'), + ).rejects.toThrow('No pending recovered'); + }); + + it('acknowledgment throws when no provider implements the contract', async () => { + await expect( + aggregatedProvider.acknowledgeRecoveredDispatch('42:abcd'), + ).rejects.toThrow('No perps provider has recovered dispatches'); + }); + }); + describe('constructor', () => { it('initializes with provided providers', () => { expect(aggregatedProvider.getProviderIds()).toContain('hyperliquid'); diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts new file mode 100644 index 0000000000..ae5bb3d020 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -0,0 +1,9947 @@ +import { LighterProvider } from '../../../src/providers/LighterProvider.js'; +import { + LighterApiError, + LighterClientService, +} from '../../../src/services/LighterClientService.js'; +import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import type { + LighterSignerBridge, + LighterWasmCall, + LighterWebSocketCtor, + LighterWebSocketLike, +} from '../../../src/types/lighter-types.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/LighterClientService', () => ({ + ...jest.requireActual('../../../src/services/LighterClientService'), + // Only the service class is doubled; convertKeysToCamelCase stays real so + // the WebSocket message router operates on faithfully camelized payloads. + LighterClientService: jest.fn(), +})); +jest.mock('../../../src/services/LighterWalletService'); + +const MockedClientService = LighterClientService as jest.MockedClass< + typeof LighterClientService +>; +const MockedWalletService = LighterWalletService as jest.MockedClass< + typeof LighterWalletService +>; + +const BTC_MARKET = { + symbol: 'BTC', + marketId: 1, + marketType: 'perp', + status: 'active', + takerFee: '0.0000', + makerFee: '0.0000', + minBaseAmount: '0.00020', + minQuoteAmount: '10.000000', + supportedSizeDecimals: 5, + supportedPriceDecimals: 1, + supportedQuoteDecimals: 6, +}; + +/** + * Resolve the key holding a journal's PAYLOAD: code-written journals + * store a pointer at the base key and the payload under an + * operation-scoped key; seeded inline journals live at the base key. + * + * @param disk - Test disk map. + * @param baseKey - The base journal key. + * @returns The key whose value contains the journal payload. + */ +/** + * Round-21 acknowledgment protocol: read-only listing + selective + * per-outcome acknowledgment. Helper acks every pending outcome the way + * a caller would after refreshing venue state. + * + * @param provider - The provider under test. + * @returns The outcomes that were acknowledged. + */ +const acknowledgeAllRecovered = async ( + provider: LighterProvider, +): Promise<{ recoveryId: string; kind: number; intent: string }[]> => { + const outcomes = await provider.getRecoveredDispatches(); + for (const outcome of outcomes) { + await provider.acknowledgeRecoveredDispatch(outcome.recoveryId); + } + return outcomes; +}; + +/* eslint-disable n/no-unsupported-features/node-builtins, n/global-require, @typescript-eslint/no-require-imports -- test-only WebCrypto polyfill: Node 20+ exposes the global, the Node 18 CI floor does not; the provider falls back gracefully in production */ +/** + * The WebCrypto object under test. Node 20+ exposes it as a global; + * Node 18 (the CI floor) does not, so fall back to node:crypto's + * webcrypto and INSTALL it as the global the provider reads — the + * spies below must intercept the same object the code under test uses. + * + * @returns The WebCrypto object the provider draws randomness from. + */ +const ensureWebCrypto = (): Crypto => { + const holder = globalThis as { crypto?: Crypto }; + if (!holder.crypto) { + const { webcrypto } = require('crypto') as { webcrypto: Crypto }; + Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, + configurable: true, + }); + } + return holder.crypto as Crypto; +}; +/* eslint-enable n/no-unsupported-features/node-builtins, n/global-require, @typescript-eslint/no-require-imports */ + +const resolveJournalPayloadKey = ( + disk: Map, + baseKey: string, +): string => { + try { + const parsed = JSON.parse(disk.get(baseKey) ?? '') as { + pointerVersion?: number; + operationId?: string; + }; + if (parsed.pointerVersion === 1 && typeof parsed.operationId === 'string') { + return `${baseKey.replace( + 'lighterTpslJournal:', + 'lighterTpslJournalOp:', + )}:${parsed.operationId}`; + } + } catch { + // Inline journal. + } + return baseKey; +}; + +const ACCOUNT = { + code: 0, + accountType: 0, + index: 28, + l1Address: '0x8D7f03FdE1A626223364E592740a233b72395235', + cancelAllTime: 0, + totalOrderCount: 0, + pendingOrderCount: 0, + status: 1, + collateral: '10000', + availableBalance: '9000', + positions: [ + { + marketId: 1, + symbol: 'BTC', + initialMarginFraction: '20', + openOrderCount: 0, + sign: 1, + position: '0.1', + avgEntryPrice: '100000', + positionValue: '10000', + unrealizedPnl: '500', + realizedPnl: '0', + liquidationPrice: '80000', + }, + ], +}; + +/** + * WASM bridge double: replays canned results per function name. + * + * @returns Bridge plus the recorded calls. + */ +function createMockBridge(): { + bridge: LighterSignerBridge; + calls: LighterWasmCall[]; + fireReset: () => void; +} { + const calls: LighterWasmCall[] = []; + const resetListeners: (() => void)[] = []; + let signSequence = 0; + const bridge: LighterSignerBridge = { + onReset: (listener: () => void) => { + resetListeners.push(listener); + return () => undefined; + }, + execute: jest.fn(async (call: LighterWasmCall): Promise => { + calls.push(call); + switch (call.function) { + case '_createClient': + return { + success: true, + pk: '9c'.repeat(40), + prv: '11'.repeat(40), + pubKeySuccess: true, + body: 'Register Lighter Account\n\npubkey: 0x9c...\nOnly sign this message for a trusted client!', + } as Result; + case '_signChangePubKey': { + signSequence += 1; + return { + txInfo: JSON.stringify({ + changePubKey: true, + Nonce: Number((call.params as (string | number)[])[2]), + ExpiredAt: Date.now() + 599_000, + }), + txHash: `dddd${String(signSequence).padStart(12, '0')}`, + } as Result; + } + case '_signCreateOrder': { + signSequence += 1; + // FAITHFUL to the pinned WASM contract (web-wasm + // light_client.go): the signing RESULT carries {txHash, txInfo} + // and txInfo is the marshaled wire payload — it contains Nonce + // and ExpiredAt but NEVER the hash. + const createHash = `aaaa${String(signSequence).padStart(12, '0')}`; + return { + txInfo: JSON.stringify({ + createOrder: true, + Nonce: Number((call.params as (string | number)[]).at(-1)), + ExpiredAt: Date.now() + 599_000, + }), + txHash: createHash, + } as Result; + } + case '_signCancelOrder': { + signSequence += 1; + const cancelHash = `bbbb${String(signSequence).padStart(12, '0')}`; + return { + txInfo: JSON.stringify({ + cancelOrder: true, + Nonce: Number((call.params as (string | number)[]).at(-1)), + ExpiredAt: Date.now() + 599_000, + }), + txHash: cancelHash, + } as Result; + } + case '_signUpdateLeverage': { + signSequence += 1; + return { + txInfo: JSON.stringify({ + updateLeverage: true, + Nonce: Number((call.params as (string | number)[]).at(-1)), + ExpiredAt: Date.now() + 599_000, + }), + txHash: `eeee${String(signSequence).padStart(12, '0')}`, + } as Result; + } + case '_signCreateGroupedOrders': { + signSequence += 1; + const groupedHash = `cccc${String(signSequence).padStart(12, '0')}`; + return { + txInfo: JSON.stringify({ + createGroupedOrders: true, + Nonce: Number((call.params as (string | number)[]).at(-1)), + ExpiredAt: Date.now() + 599_000, + }), + txHash: groupedHash, + } as Result; + } + case '_signUpdateMargin': { + signSequence += 1; + return { + txInfo: JSON.stringify({ + updateMargin: true, + Nonce: Number((call.params as (string | number)[]).at(-1)), + ExpiredAt: Date.now() + 599_000, + }), + txHash: `ffff${String(signSequence).padStart(12, '0')}`, + } as Result; + } + case '_signWithdraw': { + signSequence += 1; + return { + txInfo: JSON.stringify({ + withdraw: true, + Nonce: Number((call.params as (string | number)[]).at(-1)), + ExpiredAt: Date.now() + 599_000, + }), + txHash: `abab${String(signSequence).padStart(12, '0')}`, + } as Result; + } + case '_createAuthToken': + return { + token: 'auth-token', + deadline: Math.floor(Date.now() / 1000) + 600, + } as Result; + default: + throw new Error(`Unexpected WASM call: ${call.function}`); + } + }), + }; + return { + bridge, + calls, + fireReset: () => resetListeners.forEach((listener) => listener()), + }; +} + +type MockClientInstance = { + network: string; + getOrderBooks: jest.Mock; + getOrderBookDetails: jest.Mock; + getAccountsByL1Address: jest.Mock; + getAccountByIndex: jest.Mock; + getApiKeys: jest.Mock; + getNextNonce: jest.Mock; + getActiveOrders: jest.Mock; + getInactiveOrders: jest.Mock; + getTx: jest.Mock; + getDepositHistory: jest.Mock; + getWithdrawHistory: jest.Mock; + getTransferHistory: jest.Mock; + sendTx: jest.Mock; +}; + +/** + * Build a provider wired to mocked services and bridge. + * + * @param options - Overrides. + * @param options.withBridge - Attach the mock WASM bridge. + * @param options.registeredKey - Pubkey the mocked apikeys endpoint reports. + * @param options.webSocketCtor - Transport override (null = REST polling). + * @param options.isTestnet - Network the provider targets (defaults to testnet). + * @param options.configuredAccountIndex - Account index override; null forces resolution via accountsByL1Address. + * @param options.platformDependencies - Shared platform deps (e.g. durable diskCache across simulated lifetimes). + * @param options.apiKeyIndex - API key slot (nonce namespace); defaults to 7. + * @param options.sharedBridge - Share ANOTHER provider's bridge OBJECT (singleton-client model). + * @returns Provider and its collaborators. + */ +function buildProvider( + options: { + withBridge?: boolean; + registeredKey?: string; + webSocketCtor?: LighterWebSocketCtor | null; + isTestnet?: boolean; + /** Pass null to force account resolution through accountsByL1Address. */ + configuredAccountIndex?: number | null; + /** + * Shared platform dependencies (e.g. a durable diskCache across + * simulated provider lifetimes). + */ + platformDependencies?: ReturnType; + /** API key slot (nonce namespace); defaults to 7. */ + apiKeyIndex?: number; + /** Share ANOTHER provider's bridge OBJECT (singleton-client model). */ + sharedBridge?: ReturnType; + } = {}, +): { + provider: LighterProvider; + clientInstance: MockClientInstance; + bridge: LighterSignerBridge; + calls: LighterWasmCall[]; + getUserAddressMock: jest.Mock; + fireReset: () => void; +} { + const { + withBridge = true, + registeredKey, + webSocketCtor, + isTestnet = true, + configuredAccountIndex = 28, + platformDependencies = createMockInfrastructure(), + apiKeyIndex = 7, + sharedBridge, + } = options; + const clientInstance = { + network: 'testnet', + getCandles: jest.fn().mockResolvedValue({ code: 200, c: [] }), + getOrderBooks: jest.fn().mockResolvedValue([BTC_MARKET]), + getOrderBookDetails: jest.fn().mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + dailyTradesCount: 10, + dailyBaseTokenVolume: 1, + dailyQuoteTokenVolume: 100000, + dailyPriceLow: 99000, + dailyPriceHigh: 101000, + dailyPriceChange: 1, + openInterest: 1000000, + dailyChart: {}, + // Authoritative margin metadata (strict leverage gate): 200 + // hundredths of a percent -> 50x max leverage. + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], + }), + getAccountsByL1Address: jest.fn().mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }), + getAccountByIndex: jest + .fn() + .mockResolvedValue({ code: 200, accounts: [ACCOUNT] }), + getApiKeys: jest.fn().mockResolvedValue({ + code: 200, + apiKeys: registeredKey + ? [ + { + accountIndex: 28, + apiKeyIndex: 7, + nonce: 1, + publicKey: registeredKey, + }, + ] + : [], + }), + getNextNonce: jest.fn().mockResolvedValue({ code: 200, nonce: 42 }), + getActiveOrders: jest.fn().mockResolvedValue({ + code: 200, + orders: [ + { + orderIndex: 555, + clientOrderIndex: 1, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + price: '90000', + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + }, + ], + }), + // Default: no transaction is known to the venue. The service contract + // resolves venue-confirmed not-found (code 21500) to null and + // RETHROWS transport/other API errors. + getTx: jest.fn().mockResolvedValue(null), + getInactiveOrders: jest.fn().mockResolvedValue({ + code: 200, + orders: [ + { + orderIndex: 777, + clientOrderIndex: 2, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.002', + remainingBaseAmount: '0.000', + price: '95000', + isAsk: true, + type: 'market', + timeInForce: 'immediate-or-cancel', + reduceOnly: 0, + status: 'filled', + orderExpiry: 0, + timestamp: 1700000001000, + }, + ], + }), + getDepositHistory: jest.fn().mockResolvedValue({ + code: 200, + deposits: [ + { + id: '1', + assetId: 3, + amount: '10000.000000', + timestamp: 1700000002000, + status: 'completed', + l1TxHash: '0xdep', + }, + ], + }), + getWithdrawHistory: jest.fn().mockResolvedValue({ + code: 200, + withdraws: [ + { + id: '2', + assetId: 3, + amount: '1.000000', + timestamp: 1700000003000, + status: 'claimable', + type: 'secure', + l1TxHash: '0xwit', + }, + ], + }), + getTransferHistory: jest.fn().mockResolvedValue({ + code: 200, + transfers: [ + { + id: '3', + assetId: 3, + amount: '100.000000', + fee: '0.000000', + timestamp: 1700000004000, + type: 'L2TransferOutflow', + fromL1Address: ACCOUNT.l1Address, + toL1Address: ACCOUNT.l1Address, + fromAccountIndex: 28, + toAccountIndex: 999, + txHash: '0xtra', + }, + ], + }), + sendTx: jest.fn().mockResolvedValue({ code: 200, txHash: '0xsent' }), + }; + MockedClientService.mockImplementation( + () => clientInstance as unknown as LighterClientService, + ); + const getUserAddressMock = jest + .fn() + .mockReturnValue('0x8D7f03FdE1A626223364E592740a233b72395235'); + MockedWalletService.mockImplementation( + () => + ({ + getUserAddress: getUserAddressMock, + deriveKeySeedPlain: jest.fn().mockResolvedValue('ab'.repeat(32)), + signPersonalMessage: jest + .fn() + .mockResolvedValue(`0x${'cd'.repeat(65)}`), + network: 'testnet', + }) as unknown as LighterWalletService, + ); + + const { bridge, calls, fireReset } = sharedBridge ?? createMockBridge(); + const provider = new LighterProvider({ + isTestnet, + platformDependencies, + lighterAuthConfig: { + ...(configuredAccountIndex === null + ? {} + : { accountIndex: configuredAccountIndex }), + apiKeyIndex, + }, + // Tests default to the REST-polling transport; the WS suite injects a fake. + webSocketCtor: webSocketCtor ?? null, + ...(withBridge ? { signerBridge: bridge } : {}), + }); + + return { + provider, + clientInstance, + bridge, + calls, + getUserAddressMock, + fireReset, + }; +} + +/** Module-scope WS fake for suites outside the price-streaming describe. */ +class StreamFakeWebSocket implements LighterWebSocketLike { + static instances: StreamFakeWebSocket[] = []; + + readyState = 0; + + sent: string[] = []; + + onopen: (() => void) | null = null; + + onmessage: ((event: { data: unknown }) => void) | null = null; + + onclose: (() => void) | null = null; + + onerror: (() => void) | null = null; + + url: string; + + constructor(url: string) { + this.url = url; + StreamFakeWebSocket.instances.push(this); + } + + send = (data: string): void => { + this.sent.push(data); + }; + + close = (): void => { + this.readyState = 3; + this.onclose?.(); + }; + + open = (): void => { + this.readyState = 1; + this.onopen?.(); + }; +} + +const fakeStreamCtor = StreamFakeWebSocket as unknown as LighterWebSocketCtor; + +describe('LighterProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('lifecycle', () => { + it('exposes the lighter protocol id', () => { + const { provider } = buildProvider(); + expect(provider.protocolId).toBe('lighter'); + }); + + it('initializes by loading markets', async () => { + const { provider, clientInstance } = buildProvider(); + const result = await provider.initialize(); + expect(result.success).toBe(true); + expect(clientInstance.getOrderBooks).toHaveBeenCalledWith(true); + }); + + it('reports initialize failure without throwing', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getOrderBooks.mockRejectedValue(new Error('down')); + const result = await provider.initialize(); + expect(result).toStrictEqual({ success: false, error: 'down' }); + }); + + it('disconnects cleanly', async () => { + const { provider } = buildProvider(); + expect(await provider.disconnect()).toStrictEqual({ + success: true, + }); + }); + + it('refuses toggleTestnet', async () => { + const { provider } = buildProvider(); + const result = await provider.toggleTestnet(); + expect(result.success).toBe(false); + expect(result.isTestnet).toBe(true); + }); + + it('pings via the markets endpoint', async () => { + const { provider, clientInstance } = buildProvider(); + await provider.ping(); + expect(clientInstance.getOrderBooks).toHaveBeenCalled(); + }); + }); + + describe('isReadyToTrade', () => { + it('reports not ready without a signer bridge', async () => { + const { provider } = buildProvider({ withBridge: false }); + const result = await provider.isReadyToTrade(); + expect(result.ready).toBe(false); + expect(result.error).toContain('signer bridge'); + }); + + it('sets up the signer and registers the venue key when missing', async () => { + const { provider, clientInstance, calls } = buildProvider(); + const result = await provider.isReadyToTrade(); + expect(result.ready).toBe(true); + const callNames = calls.map((call) => call.function); + expect(callNames).toContain('_createClient'); + expect(callNames).toContain('_signChangePubKey'); + expect(clientInstance.sendTx).toHaveBeenCalledWith( + 8, + expect.stringContaining('"changePubKey":true'), + ); + }); + + it('mainnet signer setup registers the venue key exactly like testnet (rollout gate removed)', async () => { + // Mainnet trading is enabled: the ceremony is identical to testnet — + // client creation, ChangePubKey signing, and dispatch all proceed. + const { provider, calls, clientInstance } = buildProvider({ + isTestnet: false, + }); + const result = await provider.isReadyToTrade(); + expect(result.ready).toBe(true); + const callNames = calls.map((call) => call.function); + expect(callNames).toContain('_createClient'); + expect(callNames).toContain('_signChangePubKey'); + expect(clientInstance.sendTx).toHaveBeenCalledWith( + 8, + expect.stringContaining('"changePubKey":true'), + ); + }); + + it('mainnet AUTHENTICATED reads work when the venue key is already registered (no dispatch needed)', async () => { + const { provider, calls, clientInstance } = buildProvider({ + isTestnet: false, + registeredKey: '9c'.repeat(40), + }); + const orders = await provider.getOpenOrders(); + expect(orders.length).toBeGreaterThan(0); + expect(calls.map((call) => call.function)).toContain('_createAuthToken'); + // Nothing was dispatched to the venue. + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('skips registration when the venue key is already registered', async () => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + const result = await provider.isReadyToTrade(); + expect(result.ready).toBe(true); + expect(calls.map((call) => call.function)).not.toContain( + '_signChangePubKey', + ); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + }); + + describe('market reads', () => { + it('returns adapted markets', async () => { + const { provider } = buildProvider(); + const markets = await provider.getMarkets(); + expect(markets).toHaveLength(1); + expect(markets[0]).toMatchObject({ name: 'BTC', providerId: 'lighter' }); + }); + + it('returns empty markets on API failure', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getOrderBooks.mockRejectedValue(new Error('down')); + expect(await provider.getMarkets()).toStrictEqual([]); + }); + + it('returns adapted market data with prices', async () => { + const { provider } = buildProvider(); + const data = await provider.getMarketDataWithPrices(); + expect(data).toHaveLength(1); + expect(data[0].symbol).toBe('BTC'); + }); + }); + + describe('account reads', () => { + it('returns adapted positions', async () => { + const { provider } = buildProvider(); + const positions = await provider.getPositions(); + expect(positions).toHaveLength(1); + expect(positions[0]).toMatchObject({ + symbol: 'BTC', + size: '0.1', + providerId: 'lighter', + }); + }); + + it('returns adapted account state', async () => { + const { provider } = buildProvider(); + const state = await provider.getAccountState(); + expect(state.totalBalance).toBe('10500'); + expect(state.providerId).toBe('lighter'); + }); + + it('returns empty account state on failure', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockRejectedValue(new Error('down')); + const state = await provider.getAccountState(); + expect(state.totalBalance).toBe('0'); + }); + + it('returns open orders through the auth-token path', async () => { + const { provider, clientInstance } = buildProvider(); + await provider.initialize(); + const orders = await provider.getOpenOrders(); + expect(orders).toHaveLength(1); + expect(orders[0]).toMatchObject({ + orderId: '555', + symbol: 'BTC', + side: 'buy', + }); + expect(clientInstance.getActiveOrders).toHaveBeenCalledWith( + 28, + 'auth-token', + ); + }); + + it('builds a CAIP account id from the L1 address', async () => { + const { provider } = buildProvider(); + expect(await provider.getCurrentAccountId()).toBe( + 'eip155:300:0x8D7f03FdE1A626223364E592740a233b72395235', + ); + }); + }); + + describe('placeOrder', () => { + it('signs and submits a limit order with integerized values', async () => { + const { provider, clientInstance, calls } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('lighter'); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + expect(orderCall).toBeDefined(); + const [accountIndex, marketId, , baseAmount, price, isAsk] = + orderCall?.params ?? []; + expect(accountIndex).toBe(28); + expect(marketId).toBe(1); + // 0.001 BTC @ 5 size decimals = 100; but the $10 minimum at $90k + // requires 0.00020 BTC = 20 -> requested size wins (100 > 20). + expect(baseAmount).toBe('100'); + expect(price).toBe('900000'); + expect(isAsk).toBe(0); + expect(clientInstance.sendTx).toHaveBeenCalledWith( + 14, + expect.stringContaining('"createOrder":true'), + ); + }); + + it('rejects sizes below the market minimum instead of silently bumping', async () => { + const { provider, calls } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.00001', + orderType: 'limit', + price: '90000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('below the Lighter minimum'); + expect( + calls.find((call) => call.function === '_signCreateOrder'), + ).toBeUndefined(); + }); + + it('accepts a USD amount whose raw quotient lands a hair under the minimum but grid-snaps onto it', async () => { + // Regression (found live on device): a $10.15 seed computed from a + // slightly stale price produced a raw quotient of 0.00529… ETH against + // a 0.0053 minimum and was rejected — even though wire integerization + // rounds to the size grid, so the venue would have received the valid + // minimum step. The pre-check must judge the SNAPPED size. + const { provider, calls } = buildProvider(); + // 17.9999 / 90000 = 0.00019999988… < 0.0002 raw, snaps to 0.0002 @ 5dp. + const validation = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + usdAmount: '17.9999', + size: '', + orderType: 'limit', + price: '90000', + }); + expect(validation.isValid).toBe(true); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + usdAmount: '17.9999', + size: '', + orderType: 'limit', + price: '90000', + }); + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + const [, , , baseAmount] = orderCall?.params ?? []; + // 0.0002 BTC @ 5 size decimals — exactly the venue minimum step. + expect(baseAmount).toBe('20'); + }); + + it('rejects non-positive sizes and attached TP/SL', async () => { + const { provider } = buildProvider(); + const negative = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '-1', + orderType: 'limit', + price: '90000', + }); + expect(negative.success).toBe(false); + expect(negative.error).toContain('positive'); + + const withTpsl = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + takeProfitPrice: '100000', + }); + expect(withTpsl.success).toBe(false); + expect(withTpsl.error).toContain('updatePositionTPSL'); + }); + + it('rejects an isFullClose claim that live positions do not verify', async () => { + // Fixture position is 0.1 BTC; a 0.00001 "full close" is a lie a bump + // would turn into an over-close. + const { provider, calls } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.00001', + orderType: 'market', + reduceOnly: true, + isFullClose: true, + currentPrice: 90000, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('below the Lighter minimum'); + expect( + calls.find((call) => call.function === '_signCreateOrder'), + ).toBeUndefined(); + }); + + it('bumps a live-verified dust full close to the venue minimum', async () => { + const { provider, clientInstance, calls } = buildProvider(); + // The live position IS the dust amount being closed. + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.00001' }], + }, + ], + }); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.00001', + orderType: 'market', + reduceOnly: true, + isFullClose: true, + currentPrice: 90000, + }); + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + // Bumped to the venue minimum; reduce-only clamps execution. + expect(orderCall?.params[3]).toBe('20'); + }); + + it('rejects unknown markets', async () => { + const { provider } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'NOPE', + isBuy: true, + size: '1', + orderType: 'limit', + price: '1', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown Lighter market'); + }); + + it('rejects limit orders without a price', async () => { + const { provider } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('requires a price'); + }); + + it('opens a FLAT market with ISOLATED margin mode; an existing position keeps its venue mode', async () => { + // The app manages isolated positions only (no cross-margin UI): a + // flat market opens isolated — which also makes the venue report a + // real per-position liquidation price. The venue refuses changing + // the mode of a market with an open position, so an existing + // position keeps whatever mode it already has. + const { provider, clientInstance, bridge } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.000' }], + }, + ], + }); + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signUpdateLeverage') { + return { + txInfo: JSON.stringify({ + updateLeverage: true, + ExpiredAt: Date.now() + 599_000, + }), + txHash: 'eeee999900000002', + }; + } + return realImplementation(call); + }, + ); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, + }); + expect(result.success).toBe(true); + const leverageCall = (bridge.execute as jest.Mock).mock.calls.find( + ([call]: [LighterWasmCall]) => call.function === '_signUpdateLeverage', + )?.[0] as LighterWasmCall; + expect(leverageCall).toBeDefined(); + // marginMode param: 1 = isolated on a flat market. + expect(leverageCall.params[3]).toBe(1); + }); + + it('rejects unsupported order types', async () => { + const { provider } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'twap', + } as never); + expect(result.success).toBe(false); + }); + + it('fails without a signer bridge', async () => { + const { provider } = buildProvider({ withBridge: false }); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('signer bridge'); + }); + }); + + describe('cancelOrder', () => { + it('signs and submits a cancel', async () => { + const { provider, clientInstance, calls } = buildProvider(); + const result = await provider.cancelOrder({ + orderId: '555', + symbol: 'BTC', + }); + expect(result.success).toBe(true); + const cancelCall = calls.find( + (call) => call.function === '_signCancelOrder', + ); + // Nonce 43: the setup ChangePubKey dispatched with 42 and the + // session-global reservation never reissues a dispatched nonce. + expect(cancelCall?.params).toStrictEqual([28, 1, '555', 43]); + expect(clientInstance.sendTx).toHaveBeenCalledWith( + 15, + expect.stringContaining('"cancelOrder":true'), + ); + }); + + it('rejects unknown markets', async () => { + const { provider } = buildProvider(); + const result = await provider.cancelOrder({ + orderId: '1', + symbol: 'NOPE', + }); + expect(result.success).toBe(false); + }); + }); + + describe('price streaming', () => { + class FakeWebSocket implements LighterWebSocketLike { + static instances: FakeWebSocket[] = []; + + readyState = 0; + + sent: string[] = []; + + onopen: (() => void) | null = null; + + onmessage: ((event: { data: unknown }) => void) | null = null; + + onclose: (() => void) | null = null; + + onerror: (() => void) | null = null; + + url: string; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send = (data: string): void => { + this.sent.push(data); + }; + + close = (): void => { + this.readyState = 3; + this.onclose?.(); + }; + + open = (): void => { + this.readyState = 1; + this.onopen?.(); + }; + + receive = (message: unknown): void => { + this.onmessage?.({ data: JSON.stringify(message) }); + }; + } + + const fakeCtor = FakeWebSocket as unknown as LighterWebSocketCtor; + + beforeEach(() => { + FakeWebSocket.instances = []; + }); + + const wsStat = ( + symbol: string, + marketId: number, + midPrice: string, + ): Record => ({ + symbol, + market_id: marketId, + index_price: midPrice, + mark_price: midPrice, + mid_price: midPrice, + best_ask_price: midPrice, + best_bid_price: midPrice, + last_trade_price: midPrice, + open_interest: '1000', + open_interest_limit: '100000', + funding_rate: '0.0012', + daily_quote_token_volume: 5, + daily_price_change: 0.5, + }); + + it('subscribes to market_stats/all and dispatches snapshot + updates', async () => { + const { provider } = buildProvider({ webSocketCtor: fakeCtor }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback, + }); + + const socket = FakeWebSocket.instances[0]; + socket.open(); + expect(socket.sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'market_stats/all' }), + ); + + socket.receive({ + type: 'subscribed/market_stats', + channel: 'market_stats:all', + market_stats: { '1': wsStat('BTC', 1, '63000.5') }, + timestamp: 123, + }); + expect(callback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: '63000.5', + markPrice: '63000.5', + timestamp: 123, + }), + ]); + + socket.receive({ + type: 'update/market_stats', + channel: 'market_stats:all', + market_stats: { '2': wsStat('SOL', 2, '75.1') }, + timestamp: 456, + }); + expect(callback).toHaveBeenLastCalledWith([ + expect.objectContaining({ symbol: 'SOL', price: '75.1' }), + ]); + unsubscribe(); + await provider.disconnect(); + }); + + it('replays the merged snapshot to late subscribers with symbol filters', async () => { + const { provider } = buildProvider({ webSocketCtor: fakeCtor }); + const unsubscribeFirst = provider.subscribeToPrices({ + symbols: [], + callback: jest.fn(), + }); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ + type: 'subscribed/market_stats', + market_stats: { '1': wsStat('BTC', 1, '63000.5') }, + }); + // A later delta must not evict BTC from the replay cache. + socket.receive({ + type: 'update/market_stats', + market_stats: { '2': wsStat('SOL', 2, '75.1') }, + }); + + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToPrices({ + symbols: ['BTC'], + callback: late, + }); + expect(late).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC' }), + ]); + unsubscribeFirst(); + unsubscribeLate(); + await provider.disconnect(); + }); + + it('streams user_stats and account_all_positions into their subscribers', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: 'a'.repeat(80), + }); + const accountCallback = jest.fn(); + const positionsCallback = jest.fn(); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + // Let account-channel setup resolve (account index + auth token). + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + const socket = FakeWebSocket.instances[0]; + socket.open(); + expect(socket.sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'user_stats/28' }), + ); + expect(socket.sent).toContainEqual( + JSON.stringify({ + type: 'subscribe', + channel: 'account_all_positions/28', + }), + ); + + socket.receive({ + type: 'subscribed/user_stats', + channel: 'user_stats:28', + stats: { + collateral: '10000', + portfolio_value: '11000', + leverage: '2', + available_balance: '6000', + margin_usage: '40', + buying_power: '0', + }, + }); + expect(accountCallback).toHaveBeenCalledWith( + expect.objectContaining({ + totalBalance: '11000', + spendableBalance: '6000', + marginUsed: '4000', + unrealizedPnl: '1000', + }), + ); + + socket.receive({ + type: 'subscribed/account_all_positions', + channel: 'account_all_positions:28', + positions: { + '1': { + market_id: 1, + symbol: 'BTC', + initial_margin_fraction: '5.00', + open_order_count: 0, + sign: -1, + position: '0.5', + avg_entry_price: '60000', + position_value: '30000', + unrealized_pnl: '100', + realized_pnl: '0', + liquidation_price: '90000', + }, + }, + }); + expect(positionsCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', size: '-0.5' }), + ]); + unsubscribeAccount(); + unsubscribePositions(); + await provider.disconnect(); + }); + + it('tears down the socket when the last subscriber unsubscribes', async () => { + const { provider } = buildProvider({ webSocketCtor: fakeCtor }); + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback: jest.fn(), + }); + const socket = FakeWebSocket.instances[0]; + socket.open(); + unsubscribe(); + expect(socket.readyState).toBe(3); + await provider.disconnect(); + }); + + it('reports connection-state transitions and supports manual reconnect', async () => { + const { provider } = buildProvider({ webSocketCtor: fakeCtor }); + const transitions: string[] = []; + const unsubscribeState = provider.subscribeToConnectionState((state) => { + transitions.push(state); + }); + expect(transitions).toStrictEqual(['disconnected']); + + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback: jest.fn(), + }); + FakeWebSocket.instances[0].open(); + expect(transitions).toStrictEqual([ + 'disconnected', + 'connecting', + 'connected', + ]); + expect(provider.getWebSocketConnectionState()).toBe('connected'); + + await provider.reconnect(); + expect(FakeWebSocket.instances).toHaveLength(2); + FakeWebSocket.instances[1].open(); + expect(transitions.slice(3)).toStrictEqual([ + 'disconnected', + 'connecting', + 'connected', + ]); + // The replacement socket re-subscribes the wanted channels. + expect(FakeWebSocket.instances[1].sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'market_stats/all' }), + ); + + unsubscribeState(); + unsubscribe(); + expect(provider.getWebSocketConnectionState()).toBe('disconnected'); + }); + + it('falls back to REST polling when no WebSocket implementation exists', async () => { + jest.useFakeTimers(); + try { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: null, + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(callback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '100000' }), + ]); + + await jest.advanceTimersByTimeAsync(10_500); + expect( + clientInstance.getOrderBookDetails.mock.calls.length, + ).toBeGreaterThanOrEqual(3); + + unsubscribe(); + const callsAfter = clientInstance.getOrderBookDetails.mock.calls.length; + await jest.advanceTimersByTimeAsync(20_000); + expect(clientInstance.getOrderBookDetails.mock.calls).toHaveLength( + callsAfter, + ); + await provider.disconnect(); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('session binding', () => { + it('does not let a stale account lookup poison the session after an account switch', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + configuredAccountIndex: null, + }); + const accountA = { ...ACCOUNT, index: 28 }; + const accountB = { ...ACCOUNT, index: 900 }; + // Account A's lookup is slow; B's resolves immediately. + let resolveLookupA: (value: unknown) => void = () => undefined; + clientInstance.getAccountsByL1Address + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLookupA = resolve; + }), + ) + .mockResolvedValue({ + code: 200, + l1Address: '0xbbbb', + subAccounts: [accountB], + }); + + // Start a read under account A (lookup hangs in flight). + const readUnderA = provider.getAccountState(); + // Wallet switches to account B; a new read rebinds the session and + // resolves B's index. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + // A's lookup finally resolves — it must NOT overwrite B's session. + resolveLookupA({ + code: 200, + l1Address: accountA.l1Address, + subAccounts: [accountA], + }); + await readUnderA; + + const accountReads = clientInstance.getAccountByIndex.mock.calls.map( + (call) => call[0], + ); + expect(accountReads).not.toContain(28); + expect(accountReads).toContain(900); + }); + + it('rebuilds stream channels for the NEW account after a switch', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, + }); + const accountB = { ...ACCOUNT, index: 900 }; + clientInstance.getAccountsByL1Address.mockImplementation( + (address: string) => + Promise.resolve( + address.toLowerCase() === '0xbbbb' + ? { code: 200, l1Address: '0xbbbb', subAccounts: [accountB] } + : { + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }, + ), + ); + StreamFakeWebSocket.instances = []; + const unsubscribePrices = provider.subscribeToPrices({ + symbols: [], + callback: jest.fn(), + }); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: jest.fn(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + StreamFakeWebSocket.instances[0].open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(StreamFakeWebSocket.instances[0].sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'user_stats/28' }), + ); + + // Wallet switches accounts; any session-bound call triggers rebind. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const replacement = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + expect(StreamFakeWebSocket.instances.length).toBeGreaterThan(1); + replacement.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(replacement.sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'market_stats/all' }), + ); + // The account channels target account B's exact index — never A's. + expect(replacement.sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'user_stats/900' }), + ); + expect( + replacement.sent.some((frame) => frame.includes('user_stats/28')), + ).toBe(false); + + unsubscribePrices(); + unsubscribeAccount(); + }); + + it('cancels a queued write when the wallet switches accounts first', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + configuredAccountIndex: null, + }); + // Hold the write chain busy with a slow nonce fetch so the next write + // queues behind it. + let releaseNonce: (value: unknown) => void = () => undefined; + clientInstance.getNextNonce.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseNonce = resolve; + }), + ); + const firstWrite = provider.cancelOrder({ orderId: '1', symbol: 'BTC' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const queuedWrite = provider.cancelOrder({ orderId: '2', symbol: 'BTC' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Account switch happens while the second write sits in the queue. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState().catch(() => undefined); + releaseNonce({ code: 200, nonce: 42 }); + await firstWrite; + const queuedResult = await queuedWrite; + expect(queuedResult.success).toBe(false); + expect(queuedResult.error).toContain('switched accounts'); + }); + }); + + describe('session races (reviewer scenarios)', () => { + it('a stalled account-A _createClient aborts and account B ends as the actual signer', async () => { + // Serialized design: A's setup enters the lock and stalls INSIDE + // _createClient; B's setup must remain pending behind the lock; on + // release, A aborts (generation fence) and only then B creates. + const { provider, clientInstance, getUserAddressMock, calls, bridge } = + buildProvider({ + configuredAccountIndex: null, + registeredKey: '9c'.repeat(40), + }); + const accountB = { ...ACCOUNT, index: 900 }; + clientInstance.getAccountsByL1Address.mockImplementation( + (address: string) => + Promise.resolve( + address.toLowerCase() === '0xbbbb' + ? { code: 200, l1Address: '0xbbbb', subAccounts: [accountB] } + : { + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }, + ), + ); + // Capture the ORIGINAL implementation, not the mock reference — + // delegating to the mock itself would recurse forever. + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let releaseCreateA: () => void = () => undefined; + let createARequested: () => void = () => undefined; + const createAPaused = new Promise((resolve) => { + createARequested = resolve; + }); + let stalledOnce = false; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_createClient' && !stalledOnce) { + stalledOnce = true; + createARequested(); + await new Promise((resolve) => { + releaseCreateA = resolve; + }); + } + return realImplementation(call); + }, + ); + + const setupUnderA = provider.isReadyToTrade(); + await createAPaused; + // Switch to B and start B's setup: it must queue behind A's lock. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + let setupBSettled = false; + const setupUnderB = provider.isReadyToTrade().then((result) => { + setupBSettled = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(setupBSettled).toBe(false); + // The recorded `calls` list only captures delegated (completed) + // executions; count issued creates on the wrapper itself. + const issuedCreates = (bridge.execute as jest.Mock).mock.calls.filter( + ([call]: [LighterWasmCall]) => call.function === '_createClient', + ); + expect(issuedCreates).toHaveLength(1); + + // Release A: it aborts at the post-createClient fence; B then runs. + releaseCreateA(); + const readyA = await setupUnderA; + const readyB = await setupUnderB; + expect(readyA.ready).toBe(false); + expect(readyB.ready).toBe(true); + const createCalls = calls.filter( + (call) => call.function === '_createClient', + ); + // Exactly two creates, and the LAST client created belongs to B — B + // is the actual signer left in the bridge. + expect(createCalls).toHaveLength(2); + expect(createCalls[1].params[2]).toBe(900); + // A never registered or submitted anything. + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('an account-A write paused inside the lock never signs after B initializes', async () => { + const { provider, clientInstance, getUserAddressMock, calls } = + buildProvider({ + configuredAccountIndex: null, + registeredKey: '9c'.repeat(40), + }); + const accountB = { ...ACCOUNT, index: 900 }; + clientInstance.getAccountsByL1Address.mockImplementation( + (address: string) => + Promise.resolve( + address.toLowerCase() === '0xbbbb' + ? { code: 200, l1Address: '0xbbbb', subAccounts: [accountB] } + : { + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }, + ), + ); + // Warm A's signer FIRST so the deferred nonce below is definitively + // the cancel write's nonce, not signer setup's. + const warmed = await provider.isReadyToTrade(); + expect(warmed.ready).toBe(true); + let releaseNonce: (value: unknown) => void = () => undefined; + let nonceRequested: () => void = () => undefined; + const noncePaused = new Promise((resolve) => { + nonceRequested = resolve; + }); + clientInstance.getNextNonce.mockImplementationOnce(() => { + nonceRequested(); + return new Promise((resolve) => { + releaseNonce = resolve; + }); + }); + const writeUnderA = provider.cancelOrder({ + orderId: '555', + symbol: 'BTC', + }); + await noncePaused; + // While A's write holds the lock: switch to B and start B's signer + // setup — it must QUEUE behind A's critical section. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + let setupBSettled = false; + const setupUnderB = provider.isReadyToTrade().then((result) => { + setupBSettled = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(setupBSettled).toBe(false); + + releaseNonce({ code: 200, nonce: 42 }); + const result = await writeUnderA; + expect(result.success).toBe(false); + expect(result.error).toContain('switched accounts'); + // A's cancel never signed or submitted. + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + // B's signer completes once the lock frees. + const readyB = await setupUnderB; + expect(readyB.ready).toBe(true); + }); + + it('ignores frames from the pre-switch WebSocket after a rebind', async () => { + const { provider, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + }); + StreamFakeWebSocket.instances = []; + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback, + }); + // Bind the session under account A first — without a previous binding + // an account call merely binds, it does not rebuild anything. + await provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const staleSocket = StreamFakeWebSocket.instances[0]; + staleSocket.open(); + // Rebind to another account: the socket is replaced. (The read + // itself now rejects — 0xbbbb does not own configured account 28 — + // but the rebind happens at entry, which is all this test needs.) + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState().catch(() => undefined); + callback.mockClear(); + // A late frame from the OLD socket must not reach subscribers. + staleSocket.onmessage?.({ + data: JSON.stringify({ + type: 'update/market_stats', + channel: 'market_stats:all', + market_stats: { + '1': { + symbol: 'BTC', + market_id: 1, + index_price: '1', + mark_price: '1', + mid_price: '1', + last_trade_price: '1', + }, + }, + }), + }); + expect(callback).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('closePosition aborts before trading when the account switches after the position read', async () => { + const { provider, clientInstance, getUserAddressMock, calls } = + buildProvider({ configuredAccountIndex: null }); + const accountB = { ...ACCOUNT, index: 900 }; + // Stall the position read (getAccountByIndex) under A. + let releasePositions: (value: unknown) => void = () => undefined; + clientInstance.getAccountByIndex + .mockImplementationOnce( + () => + new Promise((resolve) => { + releasePositions = resolve; + }), + ) + .mockResolvedValue({ code: 200, accounts: [accountB] }); + clientInstance.getAccountsByL1Address.mockImplementation( + (address: string) => + Promise.resolve( + address.toLowerCase() === '0xbbbb' + ? { code: 200, l1Address: '0xbbbb', subAccounts: [accountB] } + : { + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }, + ), + ); + const closeUnderA = provider.closePosition({ symbol: 'BTC' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + releasePositions({ code: 200, accounts: [ACCOUNT] }); + const result = await closeUnderA; + expect(result.success).toBe(false); + expect(result.error).toContain('switched accounts'); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }); + + it('updatePositionTPSL cancels nothing when the account switches mid-sequence', async () => { + const { provider, clientInstance, getUserAddressMock, calls } = + buildProvider({ configuredAccountIndex: null }); + const accountB = { ...ACCOUNT, index: 900 }; + clientInstance.getAccountsByL1Address.mockImplementation( + (address: string) => + Promise.resolve( + address.toLowerCase() === '0xbbbb' + ? { code: 200, l1Address: '0xbbbb', subAccounts: [accountB] } + : { + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }, + ), + ); + // A reduce-only trigger order exists so the replace path would cancel. + clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [ + { + orderIndex: 999, + clientOrderIndex: 9, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.1', + remainingBaseAmount: '0.1', + price: '80000', + isAsk: true, + type: 'stop_loss', + timeInForce: 'immediate-or-cancel', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + }, + ], + }); + // Stall the open-orders read; switch while it is in flight. + let releaseOrders: (value: unknown) => void = () => undefined; + clientInstance.getActiveOrders.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseOrders = resolve; + }), + ); + const tpslUnderA = provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + releaseOrders({ + code: 200, + orders: [ + { + orderIndex: 999, + clientOrderIndex: 9, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.1', + remainingBaseAmount: '0.1', + price: '80000', + isAsk: true, + type: 'stop_loss', + timeInForce: 'immediate-or-cancel', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + }, + ], + }); + const result = await tpslUnderA; + expect(result.success).toBe(false); + expect(result.error).toContain('switched accounts'); + // No cancel and no grouped order ever reached signing. + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect( + calls.filter((call) => call.function === '_signCreateGroupedOrders'), + ).toHaveLength(0); + }); + }); + + describe('account-type gate', () => { + it('refuses Premium (nonzero-fee) accounts across the account surface', async () => { + const { provider, clientInstance } = buildProvider({ + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [{ ...ACCOUNT, accountType: 1 }], + }); + // Capability gates SURFACE: no plausible empty state that hides why. + await expect(provider.getAccountState()).rejects.toThrow('Premium'); + const ready = await provider.isReadyToTrade(); + expect(ready.ready).toBe(false); + expect(ready.error).toContain('Premium'); + }); + + it('verifies a configured account index is Standard before using it', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, accountType: 1 }], + }); + const ready = await provider.isReadyToTrade(); + expect(ready.ready).toBe(false); + expect(ready.error).toContain('Premium'); + }); + + it('fails closed when the account type cannot be verified', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [], + }); + const ready = await provider.isReadyToTrade(); + expect(ready.ready).toBe(false); + expect(ready.error).toContain('could not be verified'); + }); + + it('gates calculateFees for non-Standard accounts', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, accountType: 1 }], + }); + await expect( + provider.calculateFees({ + orderType: 'market', + symbol: 'BTC', + amount: '100', + }), + ).rejects.toThrow('Premium'); + }); + }); + + describe('UpdateLeverage signing contract', () => { + it('signs exactly [accountIndex, marketId, imfHundredths, marginMode, nonce]', async () => { + // Regression: a patch artifact once injected a 6th argument before + // the nonce, shifting it and mis-signing every leverage-changing + // placement. + const { provider, bridge } = buildProvider(); + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signUpdateLeverage') { + return { + txInfo: JSON.stringify({ + updateLeverage: true, + ExpiredAt: Date.now() + 599_000, + }), + txHash: 'eeee999900000001', + }; + } + return realImplementation(call); + }, + ); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, + }); + expect(result.success).toBe(true); + const leverageCall = (bridge.execute as jest.Mock).mock.calls.find( + ([call]: [LighterWasmCall]) => call.function === '_signUpdateLeverage', + )?.[0] as LighterWasmCall; + expect(leverageCall).toBeDefined(); + expect(leverageCall.params).toHaveLength(5); + expect(leverageCall.params[0]).toBe(28); + expect(leverageCall.params[1]).toBe(1); + expect(leverageCall.params[2]).toBe(1000); + expect(leverageCall.params[3]).toBe(0); + // Fifth param is the nonce from the shared write lock: 43 because + // the setup ChangePubKey dispatched 42 and reservations are + // session-global. + expect(leverageCall.params[4]).toBe(43); + }); + }); + + type RawTriggerOrder = { + orderIndex: number; + clientOrderIndex: number; + marketIndex: number; + ownerAccountIndex: number; + initialBaseAmount: string; + remainingBaseAmount: string; + price: string; + isAsk: boolean; + type: string; + timeInForce: string; + reduceOnly: number; + status: string; + orderExpiry: number; + timestamp: number; + triggerPrice: string; + /** Venue OCO linkage: the sibling this order auto-cancels. */ + toCancelOrderId0?: string; + }; + /** + * Stateful fake venue trigger book: creations observed at the bridge + * add triggers, cancels remove them, and getActiveOrders always + * reflects the current state — so interleaving outcomes are decided by + * actual call order, not static mocks. + * + * @param clientInstance - Mock client service instance. + * @param bridge - Mock signer bridge. + * @param venueOptions - Venue configuration. + * @param venueOptions.apiKeyIndex - API key slot the fake reports for + * landed transactions; defaults to 7. + * @returns The live raw trigger table and a seeding helper. + */ + const setupTriggerVenue = ( + clientInstance: MockClientInstance, + bridge: LighterSignerBridge, + venueOptions: { apiKeyIndex?: number } = {}, + ): { + rawTriggers: RawTriggerOrder[]; + seedTrigger: (type: string, triggerPrice: string) => number; + seedLinkedPair: (tpPrice: string, slPrice: string) => [number, number]; + events: string[]; + armCreateGate: () => Promise; + releaseCreateGate: () => void; + setRestLag: (reads: number) => void; + stagedCancels: { orderId: string; txHash: string; nonce: number }[]; + rawInactive: RawTriggerOrder[]; + setCreateTerminal: ( + mode: + | 'none' + | 'filled' + | 'canceled' + | 'oco-mixed' + | 'oco-split' + | 'filled-partial', + ) => void; + delayedCommitOnce: (txType: number, delayMs: number) => void; + failResponseOnce: (txType: number) => void; + failCodedAfterCommitOnce: (txType: number, code: number) => void; + failBeforeCommitOnce: (txType: number) => void; + failExecutionOnceFor: (txType: number) => void; + landedTxs: Map; + getVenueNonce: () => number; + setVenueNonce: (nonce: number) => void; + getNextIndex: () => number; + setNextIndex: (index: number) => void; + primeLag: (view: RawTriggerOrder[], reads: number) => void; + } => { + let nextIndex = 9000; + const rawTriggers: RawTriggerOrder[] = []; + const buildRawTrigger = ( + orderIndex: number, + type: string, + triggerPrice: string, + ): RawTriggerOrder => ({ + orderIndex, + clientOrderIndex: orderIndex, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + price: '80000', + isAsk: true, + type, + timeInForce: 'immediate-or-cancel', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + triggerPrice, + }); + const seedTrigger = (type: string, triggerPrice: string): number => { + const orderIndex = nextIndex; + nextIndex += 1; + rawTriggers.push(buildRawTrigger(orderIndex, type, triggerPrice)); + return orderIndex; + }; + // A VENUE-LINKED OCO pair: both rows carry the venue's own mutual + // to_cancel linkage fields — the ONLY basis for grouping. + const seedLinkedPair = ( + tpPrice: string, + slPrice: string, + ): [number, number] => { + const tpIndex = nextIndex; + nextIndex += 1; + const slIndex = nextIndex; + nextIndex += 1; + rawTriggers.push( + { + ...buildRawTrigger(tpIndex, 'take-profit', tpPrice), + toCancelOrderId0: String(slIndex), + }, + { + ...buildRawTrigger(slIndex, 'stop-loss', slPrice), + toCancelOrderId0: String(tpIndex), + }, + ); + return [tpIndex, slIndex]; + }; + // Deterministic interleaving instrumentation: reads are counted, and + // the FIRST trigger creation can be stalled mid-transition (after its + // snapshot, at signing). Under full-transition exclusion a concurrent + // call CANNOT read while the first is stalled — it is queued behind + // the write chain; unserialized code reaches getActiveOrders during + // the stall and double-snapshots pre-mutation state. + const events: string[] = []; + // Venue-faithful timing: state commits when sendTx ACCEPTS (not at + // signing), and reads can lag commits by a configurable number of + // responses to model REST visibility delay. + let restLag = 0; + let lagRemaining = 0; + let laggedView: RawTriggerOrder[] = []; + const setRestLag = (reads: number): void => { + restLag = reads; + }; + const primeLag = (view: RawTriggerOrder[], reads: number): void => { + laggedView = [...view]; + lagRemaining = reads; + }; + clientInstance.getActiveOrders.mockImplementation(async () => { + events.push('read'); + if (lagRemaining > 0) { + lagRemaining -= 1; + return { code: 200, orders: [...laggedView] }; + } + return { code: 200, orders: [...rawTriggers] }; + }); + type StagedCreate = { + type: string; + triggerPrice: string; + clientOrderIndex: number; + }; + type StagedCreateBatch = { + creates: StagedCreate[]; + txHash: string; + nonce: number; + }; + type StagedCancel = { orderId: string; txHash: string; nonce: number }; + const stagedCreates: StagedCreateBatch[] = []; + const stagedCancels: StagedCancel[] = []; + // Generic (non-order) dispatches: withdraw/margin/leverage/key-reg. + // The venue's tx registry records EVERY landed tx by hash, so masked + // commits of these types must be exact-hash resolvable too. + const stagedGenerics: { txHash: string; nonce: number }[] = []; + // Authoritative tx registry: exact-hash lookup resolves acceptance. + // Status semantics follow the venue: 3 executed, 4 failed, 5 rejected. + const venueApiKeyIndex = venueOptions.apiKeyIndex ?? 7; + const landedTxs = new Map(); + clientInstance.getTx.mockImplementation(async (hash: string) => + landedTxs.has(hash) + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: venueApiKeyIndex, + nonce: landedTxs.get(hash)?.nonce, + status: landedTxs.get(hash)?.status, + } + : null, + ); + // Inactive/terminal book + terminal mode: an immediate/crossed trigger + // never rests active and lands directly in inactive history. + const rawInactive: RawTriggerOrder[] = []; + // Inactive history honors limit + cursor (numeric offset), newest + // first — a truncated first page must be traversable. + clientInstance.getInactiveOrders.mockImplementation( + async ( + _accountIndex: number, + _authToken: string, + limit = 50, + cursor?: string, + ) => { + const newestFirst = [...rawInactive].reverse(); + const offset = cursor === undefined ? 0 : Number(cursor); + const page = newestFirst.slice(offset, offset + limit); + const nextOffset = offset + limit; + return { + code: 200, + orders: page, + ...(nextOffset < newestFirst.length + ? { nextCursor: String(nextOffset) } + : {}), + }; + }, + ); + const beginLag = (): void => { + if (restLag > 0) { + laggedView = [...rawTriggers]; + lagRemaining = restLag; + } + }; + type CreateTerminalMode = + | 'none' + | 'filled' + | 'canceled' + | 'oco-mixed' + | 'oco-split' + | 'filled-partial'; + let createTerminalMode: CreateTerminalMode = 'none'; + const setCreateTerminal = (mode: CreateTerminalMode): void => { + createTerminalMode = mode; + }; + // Authoritative venue nonce: consumed on each ACCEPTED submission. + let venueNonce = 42; + clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: venueNonce, + })); + // One-shot transport failure AFTER venue commit (response loss). + const failAfterCommit = new Set(); + const failResponseOnce = (txType: number): void => { + failAfterCommit.add(txType); + }; + // One-shot transport failure BEFORE the venue sees the submission: + // the staged payload is dropped (it never reached the venue), so a + // later retry can never accidentally commit the stale payload. + const failBeforeCommit = new Set(); + const failBeforeCommitOnce = (txType: number): void => { + failBeforeCommit.add(txType); + }; + // One-shot DELAYED commit: the caller sees a transport failure now, + // but the request is still in flight server-side and commits later + // (within the signed validity window). + const delayedCommit = new Map(); + const delayedCommitOnce = (txType: number, delayMs: number): void => { + delayedCommit.set(txType, delayMs); + }; + // One-shot FAILED execution: the sequencer consumes the nonce and the + // tx lands with terminal status 4 (failed), but NO book mutation + // happens; the caller's response is also lost. + const failExecutionOnce = new Set(); + const failExecutionOnceFor = (txType: number): void => { + failExecutionOnce.add(txType); + }; + // One-shot CODED error AFTER commit: the venue commits, then the + // caller receives an application/HTTP-coded error (e.g. a 5xx that + // masked the commit). The nonce IS consumed. + const failCodedAfterCommit = new Map(); + const failCodedAfterCommitOnce = (txType: number, code: number): void => { + failCodedAfterCommit.set(txType, code); + }; + const realSendTx = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + // Venue commit application, shared by the accepted path and the + // delayed-commit (response-lost) path. + const commitCreateBatch = (batch: StagedCreateBatch): void => { + beginLag(); + landedTxs.set(batch.txHash, { nonce: batch.nonce, status: 3 }); + batch.creates.forEach((create, createIndexInBatch) => { + const orderIndex = nextIndex; + nextIndex += 1; + const row = { + ...buildRawTrigger(orderIndex, create.type, create.triggerPrice), + clientOrderIndex: create.clientOrderIndex, + }; + if (createTerminalMode === 'none') { + rawTriggers.push(row); + } else if (createTerminalMode === 'oco-mixed') { + // One OCO leg fills (fully); the venue auto-cancels its sibling. + rawInactive.push({ + ...row, + remainingBaseAmount: + createIndexInBatch === 0 ? '0.000' : row.remainingBaseAmount, + status: createIndexInBatch === 0 ? 'filled' : 'canceled', + }); + } else if (createTerminalMode === 'oco-split') { + // One leg rests ACTIVE; the sibling terminal-cancels. + if (createIndexInBatch === 0) { + rawTriggers.push(row); + } else { + rawInactive.push({ ...row, status: 'canceled' }); + } + } else if (createTerminalMode === 'filled') { + // A genuine execution leaves nothing remaining. + rawInactive.push({ + ...row, + remainingBaseAmount: '0.000', + status: 'filled', + }); + } else if (createTerminalMode === 'filled-partial') { + // Inconsistent venue row: 'filled' status but remaining size — + // must NOT count as a proven execution. + rawInactive.push({ ...row, status: 'filled' }); + } else { + rawInactive.push({ ...row, status: createTerminalMode }); + } + }); + }; + const commitCancel = (staged: StagedCancel): void => { + beginLag(); + landedTxs.set(staged.txHash, { nonce: staged.nonce, status: 3 }); + const at = rawTriggers.findIndex( + (entry) => String(entry.orderIndex) === String(staged.orderId), + ); + if (at >= 0) { + rawTriggers.splice(at, 1); + } + }; + // Payloads are matched by the wire NONCE in the submitted txInfo + // (the REAL signed payload shape carries Nonce, never the hash): a + // signed-but-never-submitted payload must never be committed in + // place of the actually-submitted one (FIFO desync). + const nonceFromTxInfo = (txInfo: string): number | undefined => { + try { + const parsed = ( + JSON.parse(txInfo) as { + // eslint-disable-next-line @typescript-eslint/naming-convention + Nonce?: unknown; + } + ).Nonce; + return typeof parsed === 'number' ? parsed : undefined; + } catch { + return undefined; + } + }; + const takeStagedCreate = ( + txInfo: string, + ): StagedCreateBatch | undefined => { + const nonce = nonceFromTxInfo(txInfo); + const at = stagedCreates.findIndex((batch) => batch.nonce === nonce); + return at >= 0 ? stagedCreates.splice(at, 1)[0] : undefined; + }; + const takeStagedCancel = (txInfo: string): StagedCancel | undefined => { + const nonce = nonceFromTxInfo(txInfo); + const at = stagedCancels.findIndex((staged) => staged.nonce === nonce); + return at >= 0 ? stagedCancels.splice(at, 1)[0] : undefined; + }; + const takeStagedGeneric = ( + txInfo: string, + ): { txHash: string; nonce: number } | undefined => { + const nonce = nonceFromTxInfo(txInfo); + const at = stagedGenerics.findIndex((staged) => staged.nonce === nonce); + return at >= 0 ? stagedGenerics.splice(at, 1)[0] : undefined; + }; + // Drop a submission's staged payload when it never reached acceptance. + const dropStaged = (txType: number, txInfo: string): void => { + if (txType === 14 || txType === 28) { + takeStagedCreate(txInfo); + } + if (txType === 15) { + takeStagedCancel(txInfo); + } else if (txType !== 14 && txType !== 28) { + takeStagedGeneric(txInfo); + } + }; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + if (failBeforeCommit.has(txType)) { + failBeforeCommit.delete(txType); + dropStaged(txType, txInfo); + throw new Error('network unreachable'); + } + if (failExecutionOnce.has(txType)) { + failExecutionOnce.delete(txType); + // Nonce consumed, tx recorded with terminal FAILED status, no + // book mutation, response lost. + const failedStaged = + txType === 15 ? takeStagedCancel(txInfo) : takeStagedCreate(txInfo); + if (failedStaged) { + venueNonce += 1; + landedTxs.set(failedStaged.txHash, { + nonce: failedStaged.nonce, + status: 4, + }); + } + throw new Error('transport failure with failed execution'); + } + const delayMs = delayedCommit.get(txType); + if (delayMs !== undefined) { + delayedCommit.delete(txType); + // The request is still in flight: commit later, fail the caller + // NOW with a transport error. + if (txType === 14 || txType === 28) { + const batch = takeStagedCreate(txInfo); + if (batch) { + setTimeout(() => { + venueNonce += 1; + commitCreateBatch(batch); + }, delayMs); + } + } + if (txType === 15) { + const staged = takeStagedCancel(txInfo); + if (staged) { + setTimeout(() => { + venueNonce += 1; + commitCancel(staged); + }, delayMs); + } + } + throw new Error('network timeout with request in flight'); + } + // ACCEPTANCE timing: apply staged mutations only after the venue + // resolves 200 — a rejected/failed submission must not mutate, + // matching the provider's onAccepted boundary. + let response: { code?: number }; + try { + response = (await realSendTx(txType, txInfo)) as { code?: number }; + } catch (error) { + dropStaged(txType, txInfo); + throw error; + } + if (response?.code !== 200) { + dropStaged(txType, txInfo); + return response; + } + venueNonce += 1; + if (txType === 14 || txType === 28) { + const batch = takeStagedCreate(txInfo); + if (batch) { + commitCreateBatch(batch); + } + } + if (txType === 15) { + const staged = takeStagedCancel(txInfo); + if (staged) { + commitCancel(staged); + } + } + if (txType !== 14 && txType !== 28 && txType !== 15) { + // Generic dispatch (withdraw/margin/leverage/key-reg): the + // venue records EVERY landed tx by exact hash. + const staged = takeStagedGeneric(txInfo); + if (staged) { + landedTxs.set(staged.txHash, { nonce: staged.nonce, status: 3 }); + } + } + if (failAfterCommit.has(txType)) { + failAfterCommit.delete(txType); + throw new Error('transport failure after venue commit'); + } + const codedFailure = failCodedAfterCommit.get(txType); + if (codedFailure !== undefined) { + failCodedAfterCommit.delete(txType); + throw new LighterApiError('internal server error', codedFailure); + } + return response; + }, + ); + let pendingCreateGate: Promise | null = null; + let releaseCreateGate = (): void => undefined; + let signalGateEntered = (): void => undefined; + const gateEntered = new Promise((resolve) => { + signalGateEntered = resolve; + }); + const armCreateGate = (): Promise => { + pendingCreateGate = new Promise((resolve) => { + releaseCreateGate = resolve; + }); + return gateEntered; + }; + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const wireParams = call.params as (string | number)[]; + if ( + call.function === '_signCreateOrder' && + (wireParams[6] === 2 || + wireParams[6] === 3 || + wireParams[6] === 4 || + wireParams[6] === 5) + ) { + if (pendingCreateGate) { + const gate = pendingCreateGate; + pendingCreateGate = null; + events.push('create-stalled'); + signalGateEntered(); + await gate; + } + events.push('create'); + // Stage with the REAL wire client id + signed tx identity + // (hash/nonce), committed at sendTx acceptance. + const result = (await realImplementation(call)) as { + txHash?: string; + }; + const singleTypeByWire: Record = { + 2: 'stop-loss', + 3: 'stop-loss-limit', + 4: 'take-profit', + 5: 'take-profit-limit', + }; + stagedCreates.push({ + creates: [ + { + type: singleTypeByWire[Number(wireParams[6])] ?? 'stop-loss', + triggerPrice: String(Number(wireParams[9]) / 10), + clientOrderIndex: Number(wireParams[2]), + }, + ], + txHash: result.txHash ?? 'missing', + nonce: Number(wireParams[11]), + }); + return result; + } + if (call.function === '_signCreateGroupedOrders') { + const count = Number(wireParams[2]); + const creates: StagedCreate[] = []; + for (let index = 0; index < count; index++) { + const base = 3 + index * 10; + creates.push({ + type: wireParams[base + 5] === 4 ? 'take-profit' : 'stop-loss', + triggerPrice: String(Number(wireParams[base + 8]) / 10), + clientOrderIndex: Number(wireParams[base + 1]), + }); + } + const result = (await realImplementation(call)) as { + txHash?: string; + }; + stagedCreates.push({ + creates, + txHash: result.txHash ?? 'missing', + nonce: Number(wireParams[wireParams.length - 1]), + }); + return result; + } + if (call.function === '_signCancelOrder') { + events.push('cancel'); + const result = (await realImplementation(call)) as { + txHash?: string; + }; + stagedCancels.push({ + orderId: String(wireParams[2]), + txHash: result.txHash ?? 'missing', + nonce: Number(wireParams[3]), + }); + return result; + } + if ( + [ + '_signWithdraw', + '_signUpdateMargin', + '_signUpdateLeverage', + '_signChangePubKey', + ].includes(call.function) + ) { + const result = (await realImplementation(call)) as { + txHash?: string; + txInfo?: string; + }; + let wireNonce: number | undefined; + try { + wireNonce = ( + JSON.parse(result.txInfo ?? '') as { + // eslint-disable-next-line @typescript-eslint/naming-convention + Nonce?: number; + } + ).Nonce; + } catch { + wireNonce = undefined; + } + if (typeof result.txHash === 'string' && wireNonce !== undefined) { + stagedGenerics.push({ txHash: result.txHash, nonce: wireNonce }); + } + return result; + } + return realImplementation(call); + }, + ); + return { + rawTriggers, + seedTrigger, + seedLinkedPair, + events, + armCreateGate, + releaseCreateGate: () => releaseCreateGate(), + setRestLag, + stagedCancels, + rawInactive, + setCreateTerminal, + failResponseOnce, + failCodedAfterCommitOnce, + failBeforeCommitOnce, + failExecutionOnceFor, + landedTxs, + getVenueNonce: () => venueNonce, + setVenueNonce: (nonce: number): void => { + venueNonce = nonce; + }, + getNextIndex: () => nextIndex, + setNextIndex: (index: number): void => { + nextIndex = index; + }, + delayedCommitOnce, + primeLag, + }; + }; + + describe('round-12 venue integrity and serialized TP/SL lifecycle', () => { + it("a malformed venue position size ('0.1oops') fails closed with an explicit error and zero signer mutation", async () => { + const { provider, calls, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.1oops' }], + }, + ], + }); + // Reads surface an explicit data error — never a silently-coerced + // '0.1' or a silent empty list that can preserve stale views. + await expect(provider.getPositions()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + const tpsl = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(tpsl.success).toBe(false); + expect(tpsl.error).toContain('Invalid Lighter venue data'); + const close = await provider.closePosition({ + symbol: 'BTC', + currentPrice: 100000, + }); + expect(close.success).toBe(false); + expect(close.error).toContain('Invalid Lighter venue data'); + const closeValidation = await provider.validateClosePosition({ + symbol: 'BTC', + currentPrice: 100000, + }); + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('Invalid Lighter venue data'); + expect(calls).toHaveLength(0); + }); + + it('two concurrent replacements serialize: the second cannot snapshot while the first transition is mid-flight', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Stall the FIRST replacement inside its transition: snapshot done, + // creation signing held. + // Deterministic pre-lock signal: the second call's preflight ends + // with its getPositions account read — wait for that, then flush a + // macrotask, instead of asserting against a sleep. + let accountReads = 0; + let signalSecondPreflight = (): void => undefined; + const secondPreflightDone = new Promise((resolve) => { + signalSecondPreflight = resolve; + }); + const realGetAccount = + clientInstance.getAccountByIndex.getMockImplementation() as () => Promise; + clientInstance.getAccountByIndex.mockImplementation(async () => { + const result = await realGetAccount(); + accountReads += 1; + if (accountReads >= 2) { + signalSecondPreflight(); + } + return result; + }); + const gateEntered = venue.armCreateGate(); + const firstPromise = provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + await gateEntered; + const secondPromise = provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + await secondPreflightDone; + await new Promise((resolve) => setTimeout(resolve, 0)); + // FULL-TRANSITION EXCLUSION: the second call has provably finished + // its pre-lock preflight, yet while the first is stalled mid-create + // it must NOT have reached getActiveOrders — unserialized code reads + // here and double-snapshots the seed trigger. + expect(venue.events.filter((event) => event === 'read')).toHaveLength(1); + venue.releaseCreateGate(); + const [first, second] = await Promise.all([firstPromise, secondPromise]); + expect(first.success).toBe(true); + expect(second.success).toBe(true); + // Serial outcome: the second snapshots the first's fresh trigger and + // cancels it — exactly ONE protection set remains, with the second + // read strictly after the first transition's cancel. + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(2); + // The second op's create happened strictly after the first op's + // cancel (full-transition exclusion; both ops' own barrier reads + // sit between). + const cancelEvents = venue.events.filter( + (event) => event === 'cancel' || event === 'create', + ); + expect(cancelEvents).toStrictEqual([ + 'create', + 'cancel', + 'create', + 'cancel', + ]); + }); + + it('replacement vs concurrent remove serializes: the remove sees and clears the fresh protection', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + let accountReads = 0; + let signalSecondPreflight = (): void => undefined; + const secondPreflightDone = new Promise((resolve) => { + signalSecondPreflight = resolve; + }); + const realGetAccount = + clientInstance.getAccountByIndex.getMockImplementation() as () => Promise; + clientInstance.getAccountByIndex.mockImplementation(async () => { + const result = await realGetAccount(); + accountReads += 1; + if (accountReads >= 2) { + signalSecondPreflight(); + } + return result; + }); + const gateEntered = venue.armCreateGate(); + const replacePromise = provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + await gateEntered; + const removePromise = provider.updatePositionTPSL({ symbol: 'BTC' }); + await secondPreflightDone; + await new Promise((resolve) => setTimeout(resolve, 0)); + // The remove has provably passed its pre-lock preflight, yet must + // NOT snapshot while the replacement is mid-flight — a stale + // snapshot would cancel only the seed and "successfully" remove + // nothing of the fresh protection. + expect(venue.events.filter((event) => event === 'read')).toHaveLength(1); + venue.releaseCreateGate(); + const [replaced, removed] = await Promise.all([ + replacePromise, + removePromise, + ]); + expect(replaced.success).toBe(true); + expect(removed.success).toBe(true); + // Serial outcome: replace lands 85000 (seed cancelled), then remove + // snapshots the fresh trigger and clears it. + expect(venue.rawTriggers).toHaveLength(0); + }); + + it('an active-orders REST failure rejects remove AND replace with zero mutation calls', async () => { + const { provider, calls, clientInstance } = buildProvider(); + clientInstance.getActiveOrders.mockRejectedValue( + new Error('active orders REST down'), + ); + const removed = await provider.updatePositionTPSL({ symbol: 'BTC' }); + expect(removed.success).toBe(false); + expect(removed.error).toContain('active orders REST down'); + const replaced = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(replaced.success).toBe(false); + expect(replaced.error).toContain('active orders REST down'); + // A swallowed [] would have let remove "succeed" cancelling nothing + // and replace "succeed" with the old triggers still live. + expect( + calls.filter((call) => + [ + '_signCreateOrder', + '_signCreateGroupedOrders', + '_signCancelOrder', + ].includes(call.function), + ), + ).toHaveLength(0); + }); + + it('rejects unsupported partial TP/SL sizes before any read or mutation', async () => { + const { provider, calls } = buildProvider(); + // The venue path always wires the FULL position size; silently + // ignoring a partial size would close the whole position. + const takeProfit = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + takeProfitSize: '0.0005', + }); + expect(takeProfit.success).toBe(false); + expect(takeProfit.error).toContain('partial'); + const stopLoss = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '80000', + stopLossSize: '0.0005', + }); + expect(stopLoss.success).toBe(false); + expect(stopLoss.error).toContain('partial'); + expect(calls).toHaveLength(0); + }); + + it('rejects prices that overflow the signer uint32 wire cast, in validators, placement and TP/SL', async () => { + const { provider, calls } = buildProvider(); + // 429496729.7 at 1 price decimal scales to 4,294,967,297 — a safe JS + // integer that the pinned lighter-go signer wraps to 1 via uint32(). + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '429496729.7', + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('uint32'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('uint32'); + const tpsl = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '429496729.7', + }); + expect(tpsl.success).toBe(false); + expect(tpsl.error).toContain('uint32'); + expect(calls).toHaveLength(0); + }); + + it('refreshes the authoritative margin cache after TTL and fails closed on removed/failed metadata', async () => { + const { provider, clientInstance } = buildProvider(); + const baseNow = Date.now(); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(baseNow); + try { + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 40, + }; + // Default metadata: minInitial 200 -> 50x. 40x validates. + expect((await provider.validateOrder(request)).isValid).toBe(true); + // Venue tightens to 400 -> 25x; within TTL the cache still says 50x. + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + symbol: 'BTC', + lastTradePrice: 100000, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }); + expect((await provider.validateOrder(request)).isValid).toBe(true); + // TTL expiry forces an authoritative refresh: 40x now overlimit. + nowSpy.mockReturnValue(baseNow + 61_000); + const refreshed = await provider.validateOrder(request); + expect(refreshed.isValid).toBe(false); + expect(refreshed.error).toContain('25'); + // Row removed from fresh metadata: stale cap must not survive the + // atomic cache replacement. + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'ETH', lastTradePrice: 3000 }], + }); + nowSpy.mockReturnValue(baseNow + 122_000); + const removedRow = await provider.validateOrder(request); + expect(removedRow.isValid).toBe(false); + expect(removedRow.error).toContain('margin metadata'); + // Fetch failure after expiry: fail closed, never the stale cap. + clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('metadata endpoint down'), + ); + nowSpy.mockReturnValue(baseNow + 183_000); + const failedFetch = await provider.validateOrder(request); + expect(failedFetch.isValid).toBe(false); + expect(failedFetch.error).toContain('margin metadata'); + } finally { + nowSpy.mockRestore(); + } + }); + }); + + describe('round-15 authoritative settlement identity and recovery', () => { + /** + * Simulate full process death for a provider: every venue read/write + * and every signer call fails from now on. Without this, a detached + * background task (e.g. the setup-time recovery kick) of the "dead" + * provider can keep mutating the shared venue after the crash and + * invalidate restart-recovery scenarios. + * + * @param built - The provider under test. + * @param built.clientInstance - Its mocked client service instance. + * @param built.bridge - Its mocked signer bridge. + */ + const killProvider = (built: { + clientInstance: MockClientInstance; + bridge: LighterSignerBridge; + }): void => { + for (const mockFn of Object.values(built.clientInstance)) { + if (jest.isMockFunction(mockFn)) { + mockFn.mockImplementation(async () => { + throw new Error('process died'); + }); + } + } + (built.bridge.execute as jest.Mock).mockImplementation(async () => { + throw new Error('process died'); + }); + }; + + it('journals are bound to the API key slot: another slot neither consumes nor is blocked by them', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const slot7 = buildProvider({ platformDependencies: infra }); + const venue7 = setupTriggerVenue(slot7.clientInstance, slot7.bridge); + venue7.seedTrigger('stop-loss', '80000'); + venue7.setRestLag(50); + venue7.failResponseOnce(14); + const first = await slot7.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + const journalKeys = [...disk.keys()].filter((key) => + key.startsWith('lighterTpslJournal:'), + ); + expect(journalKeys.some((key) => key.includes(':7:'))).toBe(true); + // A slot-8 provider (same account, shared disk) must not load, + // clear, or be blocked by the slot-7 journal. + const slot8 = buildProvider({ + platformDependencies: infra, + apiKeyIndex: 8, + }); + const venue8 = setupTriggerVenue(slot8.clientInstance, slot8.bridge); + venue8.seedTrigger('stop-loss', '80000'); + const other = await slot8.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '87000', + }); + expect(other.error).toBeUndefined(); + expect(other.success).toBe(true); + // The slot-7 obligation is untouched. + expect( + [...disk.keys()].some( + (key) => key.startsWith('lighterTpslJournal:') && key.includes(':7:'), + ), + ).toBe(true); + }); + + it('a lagging nextNonce endpoint cannot hand a multi-cancel section the same nonce twice', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('take-profit', '110000'); + venue.seedTrigger('stop-loss', '80000'); + // Warm signer setup, then FREEZE the nonce endpoint: acceptances no + // longer advance what it reports. + await provider.getOpenOrders(); + const frozen = venue.getVenueNonce(); + clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: frozen, + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + // create + two cancels must carry three DISTINCT ascending nonces. + const wireNonces = calls + .filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ) + .map((call) => + call.function === '_signCreateOrder' + ? Number(call.params[11]) + : Number(call.params[3]), + ); + expect(wireNonces).toStrictEqual([frozen, frozen + 1, frozen + 2]); + }); + + it('a delayed commit inside the signed validity window stays blocked, then reconciles without duplicate', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Transport fails NOW; the request commits 2.8 s later — BEYOND the + // retry's full poll + lookup window, so nothing during the blocked + // reconciliation can observe it yet. + venue.delayedCommitOnce(14, 2800); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + // Aged past the OLD grace design but still inside the signed + // validity window: the retry must remain blocked (the venue could + // still accept), never discard-and-duplicate — the old design + // cleared here and duplicated once the commit landed. + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 13_000); + try { + const blocked = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(blocked.success).toBe(false); + expect(blocked.error).toContain('unresolved'); + } finally { + nowSpy.mockRestore(); + } + // Await the delayed commit, then retry: the dispatch is JOURNAL- + // OWNED, so its landed outcome is consumed by the settlement + // machine directly — never parked behind the generic + // acknowledgment — and the retry reconciles serially. + await new Promise((resolve) => setTimeout(resolve, 3000)); + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(await provider.getRecoveredDispatches()).toStrictEqual([]); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('a venue-confirmed not-found is only never-landed after the signed expiry', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + venue.failBeforeCommitOnce(14); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + // Inside the validity window: blocked even though the venue answers + // not-found. + const immediate = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(immediate.success).toBe(false); + expect(immediate.error).toContain('unresolved'); + // Beyond signed ExpiredAt (+slack): the sequencer can no longer + // accept the payload — authoritatively never landed; retry runs. + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 700_000); + try { + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + } finally { + nowSpy.mockRestore(); + } + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('a tx-lookup transport failure is ambiguous and stays blocked', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + venue.failBeforeCommitOnce(14); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + clientInstance.getTx.mockRejectedValue(new Error('tx endpoint down')); + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 700_000); + try { + const blocked = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(blocked.success).toBe(false); + expect(blocked.error).toContain('unresolved'); + } finally { + nowSpy.mockRestore(); + } + }); + + it('an OCO with one leg active and one terminal-cancelled at the barrier rolls back the survivor and keeps old protection', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + venue.setCreateTerminal('oco-split'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '75000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('left untouched'); + // The OLD trigger survives; the surviving NEW leg was rolled back. + expect(venue.rawTriggers).toHaveLength(1); + expect(String(venue.rawTriggers[0].orderIndex)).toBe(String(oldId)); + // Exactly one cancel was signed — the rollback of the surviving + // leg, never the old protection. + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + }); + + it('a replacement that terminal-fails after the old protection was cancelled parks DURABLE manual recovery', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Venue cancels the new trigger AFTER the barrier (read 3+) — by + // then the old trigger is being/has been cancelled. + let readsSeen = 0; + const realActive = + clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + clientInstance.getActiveOrders.mockImplementation(async () => { + readsSeen += 1; + if (readsSeen === 3) { + const at = venue.rawTriggers.findIndex( + (row) => row.triggerPrice === '85000', + ); + if (at >= 0) { + const [row] = venue.rawTriggers.splice(at, 1); + venue.rawInactive.push({ ...row, status: 'canceled' }); + } + } + return await realActive(); + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + // NO automatic restore across an unprovable lifecycle: the failure + // is explicit, the obligation parks DURABLY for manual recovery + // and is surfaced to callers. + expect(result.error).toContain('MANUAL re-establishment'); + expect(venue.rawTriggers).toHaveLength(0); + const pending = await provider.getPendingManualRecoveries(); + expect(pending).toHaveLength(1); + expect(pending[0].symbol).toBe('BTC'); + // A NEW explicit protection intent acknowledges and resolves it. + const renewed = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await provider.getPendingManualRecoveries()).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('84000'); + }); + + it("a 'filled' terminal row with remaining size is NOT a proven execution", async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + venue.setCreateTerminal('filled-partial'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + // Not proven executed: treated as a terminal failure — old + // protection untouched. + expect(result.success).toBe(false); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('80000'); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + }); + + it('a failed journal disk-remove keeps the obligation coherent for a later retry', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + let removeFails = true; + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + if (removeFails && key.startsWith('lighterTpslJournal:')) { + throw new Error('disk remove refused'); + } + disk.delete(key); + }, + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + const first = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + // The venue settled but the durable obligation could not be + // resolved: the op must NOT report clean success. + expect(first.success).toBe(false); + expect(first.error).toContain('disk remove refused'); + // Later, with the disk healthy again, a retry reconciles the intact + // journal and completes. + removeFails = false; + const second = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('startup recovery completes an interrupted replacement WITHOUT a new mutation call', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + // Crash BEFORE any cancel submission (local signing failure): the + // journal holds only the accepted create, and old + new triggers are + // both live — no ambiguous in-flight cancel exists. + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died'); + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + expect(venueA.rawTriggers).toHaveLength(2); + // NEW provider lifetime: recovery must run from a NON-mutating call. + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + await second.provider.getOpenOrders(); + // Bounded wait for the automatic recovery to converge the venue. + for (let attempt = 0; attempt < 40; attempt += 1) { + if (venueB.rawTriggers.length === 1) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venueB.rawTriggers).toHaveLength(1); + expect(venueB.rawTriggers[0].triggerPrice).toBe('85000'); + expect( + [...disk.keys()].filter((key) => key.startsWith('lighterTpslJournal:')), + ).toHaveLength(0); + }); + + it('inactive-history requests are bounded: zero when active, one page when recent, cursor-walk only until found', async () => { + // Normal replacement (new trigger rests ACTIVE): ZERO inactive calls. + const normal = buildProvider(); + const normalVenue = setupTriggerVenue( + normal.clientInstance, + normal.bridge, + ); + normalVenue.seedTrigger('stop-loss', '80000'); + const normalResult = await normal.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(normalResult.success).toBe(true); + // ZERO inactive reads when the replacement rests active. + expect(normal.clientInstance.getInactiveOrders).not.toHaveBeenCalled(); + // Recent terminal (immediate fill): a single first-page read finds it. + const recent = buildProvider(); + const recentVenue = setupTriggerVenue( + recent.clientInstance, + recent.bridge, + ); + recentVenue.setCreateTerminal('filled'); + const recentResult = await recent.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(recentResult.success).toBe(true); + expect(recent.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(1); + // Deep history: the JOURNALED terminal create sits beyond 100 newer + // rows — the retry's reconcile must walk cursor pages (bounded, + // stopping when found), never 10 pages per poll. + const deep = buildProvider(); + const deepVenue = setupTriggerVenue(deep.clientInstance, deep.bridge); + deepVenue.setCreateTerminal('filled'); + // Commit terminal AND lose the response: the journal remains. + deepVenue.failResponseOnce(14); + const deepFirst = await deep.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(deepFirst.success).toBe(false); + // Bury THAT terminal row under 120 newer inactive rows. + for (let filler = 0; filler < 120; filler += 1) { + deepVenue.rawInactive.push({ + ...deepVenue.rawInactive[0], + orderIndex: 500000 + filler, + clientOrderIndex: 500000 + filler, + status: 'canceled', + }); + } + deepVenue.setCreateTerminal('none'); + deep.clientInstance.getInactiveOrders.mockClear(); + // The lost-response create is JOURNAL-OWNED: the retry reconciles + // it through the settlement machine directly (no quarantine). + const second = await deep.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(await deep.provider.getRecoveredDispatches()).toStrictEqual([]); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + const inactiveCalls = + deep.clientInstance.getInactiveOrders.mock.calls.length; + // Cursor pages were genuinely used (>1) and bounded (found early) — + // never a 10-pages-per-poll blowup (100+). + expect(inactiveCalls).toBeGreaterThan(1); + expect(inactiveCalls).toBeLessThanOrEqual(6); + expect(deepVenue.rawTriggers).toHaveLength(1); + expect(deepVenue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it("exact status matching: 'unfilled' and 'execution-failed' are failures, never executions", async () => { + for (const trickStatus of ['unfilled', 'execution-failed']) { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + venue.setCreateTerminal(trickStatus as 'canceled'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + // A substring match on fill/execut would treat these as SUCCESS + // and cancel the old protection; exact matching fails them closed + // with the old trigger untouched. + expect(result.success).toBe(false); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('80000'); + } + }); + + it('a persistence failure AFTER an accepted attempt preserves the prior durable obligation', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + let journalWrites = 0; + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + if (key.startsWith('lighterTpslJournal:') && !key.includes('Index')) { + journalWrites += 1; + // Attempt 1 (the create) persists; attempt 2 (first cancel) + // fails to persist. + if (journalWrites === 2) { + throw new Error('disk write refused'); + } + } + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('disk write refused'); + // The PRIOR durable obligation (accepted create) survives — the + // failed second persistence must never compensate it away. + const journalKeys = [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ); + expect(journalKeys).toHaveLength(1); + const persisted = JSON.parse( + disk.get(resolveJournalPayloadKey(disk, journalKeys[0])) ?? '{}', + ) as { + attempts?: { kind: string }[]; + }; + expect(persisted.attempts?.some((a) => a.kind === 'create')).toBe(true); + // With disk healthy again — and past the never-submitted cancel's + // signed expiry — the retry reconciles and completes. + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 700_000); + try { + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + } finally { + nowSpy.mockRestore(); + } + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('restart recovery after a crash mid-rollback keeps the OLD protection, not the failed replacement survivor', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + const oldId = venueA.seedTrigger('stop-loss', '80000'); + // OCO where one leg terminal-cancels and its sibling rests active; + // the crash hits BEFORE the live rollback can cancel the survivor. + venueA.setCreateTerminal('oco-split'); + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died'); + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '75000', + }); + expect(crashed.success).toBe(false); + // Venue: old trigger + the surviving (failed-set) replacement leg. + expect(venueA.rawTriggers).toHaveLength(2); + killProvider(first); + // Restart: recovery must complete the ROLLBACK — keep the old + // trigger, remove the survivor of the FAILED replacement set. + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + } + for (const row of venueA.rawInactive) { + venueB.rawInactive.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (venueB.rawTriggers.length === 1) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venueB.rawTriggers).toHaveLength(1); + expect(String(venueB.rawTriggers[0].orderIndex)).toBe(String(oldId)); + }); + + it('restart recovery after old cancels + a later terminal failure RESTORES the previous protection', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + // Crash AFTER the old cancel was accepted (first read following a + // signed cancel dies): journal is mid-'cancelling'. + const realActive = + first.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + let died = false; + first.clientInstance.getActiveOrders.mockImplementation(async () => { + if (!died && venueA.events.includes('cancel')) { + died = true; + throw new Error('process died'); + } + return await realActive(); + }); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // Old protection is gone; only the replacement is live... + expect(venueA.rawTriggers).toHaveLength(1); + expect(venueA.rawTriggers[0].triggerPrice).toBe('85000'); + // ...and during the downtime the venue terminal-cancels it. + const [failedRow] = venueA.rawTriggers.splice(0, 1); + venueA.rawInactive.push({ ...failedRow, status: 'canceled' }); + // Restart: recovery must RESTORE the previous protection from the + // persisted prior-trigger intent — never leave the position naked. + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawInactive) { + venueB.rawInactive.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ((await second.provider.getPendingManualRecoveries()).length === 1) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // NO automatic restore across the unprovable lifecycle: the + // obligation parks DURABLY as manual recovery (surfaced), nothing + // is created, and a NEW explicit intent resolves it. + expect(venueB.rawTriggers).toHaveLength(0); + expect(venueB.events.filter((event) => event === 'create')).toHaveLength( + 0, + ); + const pending = await second.provider.getPendingManualRecoveries(); + expect(pending).toHaveLength(1); + expect(pending[0].symbol).toBe('BTC'); + const renewed = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await second.provider.getPendingManualRecoveries()).toHaveLength( + 0, + ); + expect(venueB.rawTriggers.map((row) => row.triggerPrice)).toStrictEqual([ + '84000', + ]); + }); + + it('recovery detects a replacement failing DURING its old-protection cancels and finishes the swap when the replacement stays active', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + // Crash at the FIRST stale-cancel signing: the replacement is live, + // the old protection untouched, journal phase still 'creating'. + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died'); + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + killProvider(first); + expect(venueA.rawTriggers).toHaveLength(2); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ( + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ).length === 0 && + venueB.rawTriggers.length === 1 + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // The replacement stayed ACTIVE: recovery legitimately finishes + // the swap (no restore machinery involved). + expect(venueB.rawTriggers.map((row) => row.triggerPrice)).toStrictEqual([ + '85000', + ]); + expect( + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ), + ).toHaveLength(0); + }); + + it('a failed replacement after old cancels parks durable manual recovery across restarts: the obligation is retried until protection exists', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + const realActive = + first.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + let died = false; + first.clientInstance.getActiveOrders.mockImplementation(async () => { + if (!died && venueA.events.includes('cancel')) { + died = true; + throw new Error('process died'); + } + return await realActive(); + }); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // During the downtime the venue also terminal-cancels the + // replacement: recovery must restore, but the venue rejects the + // FIRST restore attempt too. + const [failedRow] = venueA.rawTriggers.splice(0, 1); + venueA.rawInactive.push({ ...failedRow, status: 'canceled' }); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawInactive) { + venueB.rawInactive.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ((await second.provider.getPendingManualRecoveries()).length === 1) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // NO automatic restore: the obligation parks DURABLY as manual + // recovery; nothing is created; a NEW explicit intent resolves it. + expect(venueB.events.filter((event) => event === 'create')).toHaveLength( + 0, + ); + expect(await second.provider.getPendingManualRecoveries()).toHaveLength( + 1, + ); + const renewed = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await second.provider.getPendingManualRecoveries()).toHaveLength( + 0, + ); + }); + + it('an unresolved startup recovery is retried by a later non-mutating read in the SAME session', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died'); + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + // New lifetime with the committed create HIDDEN by REST lag beyond + // the recovery window: the first read's recovery stays unresolved. + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + const laggedView = venueB.rawTriggers.filter( + (row) => row.triggerPrice === '80000', + ); + venueB.primeLag(laggedView, 50); + await second.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 2500)); + // Still unresolved: both triggers live, journal retained. + expect(venueB.rawTriggers).toHaveLength(2); + // Venue reveals; a later NON-mutating read in the same session must + // re-kick recovery and converge — no TP/SL mutation by this test. + venueB.primeLag(venueB.rawTriggers, 0); + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (venueB.rawTriggers.length === 1) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venueB.rawTriggers).toHaveLength(1); + expect(venueB.rawTriggers[0].triggerPrice).toBe('85000'); + expect( + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ), + ).toHaveLength(0); + }); + + it('a kick arriving while a recovery is in flight is preserved, not lost', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + let indexReads = 0; + let releaseIndexRead = (): void => undefined; + let gateArmed = true; + const indexGate = new Promise((resolve) => { + releaseIndexRead = resolve; + }); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => { + if (key.startsWith('lighterTpslJournalIndex:')) { + indexReads += 1; + if (gateArmed) { + gateArmed = false; + await indexGate; + } + } + return disk.get(key) ?? null; + }, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + // Seed an UNRESOLVABLE pending journal (unknown create, not on the + // books, unexpired): every recovery pass ends incomplete, so a lost + // kick would visibly halt retries. + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + disk.set( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + disk.set( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 3, + recordedAt: 5, + operationId: 'op-kick-1', + createdAt: 5, + nextAttemptId: 2, + venueCheckpoint: 0, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + positionFingerprint: null, + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 999, + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }), + ); + const built = buildProvider({ platformDependencies: infra }); + setupTriggerVenue(built.clientInstance, built.bridge); + // First read: recovery starts and stalls inside the index read. + const firstRead = built.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 20)); + // Second read while in flight: its kick must be PRESERVED. + await built.provider.getOpenOrders(); + releaseIndexRead(); + await firstRead; + // The preserved kick re-runs recovery after the stalled pass ends + // (the first pass spends its full books-poll bound reconciling). + for (let attempt = 0; attempt < 100; attempt += 1) { + if (indexReads >= 2) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(indexReads).toBeGreaterThanOrEqual(2); + }); + + it('a signing result without txHash or ExpiredAt refuses to submit', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const result = (await realImplementation(call)) as Record< + string, + unknown + >; + if (call.function === '_signCreateOrder') { + return { ...result, txHash: undefined }; + } + return result; + }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('txHash'); + // Nothing was submitted; old protection intact. + expect( + clientInstance.sendTx.mock.calls.filter( + ([txType]) => txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + }); + + describe('round-16 durable intent, faithful restoration and authoritative resolution', () => { + /** + * Durable disk map + infra wiring shared by restart scenarios. + * + * @returns The disk map and mocked infrastructure bound to it. + */ + const makeDurableDisk = (): { + disk: Map; + infra: ReturnType; + } => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + return { disk, infra }; + }; + + const journalKeysOf = (disk: Map): string[] => + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ); + + /** + * Simulate full process death for a provider (see round-15 helper). + * + * @param built - The provider under test. + * @param built.clientInstance - Its mocked client service instance. + * @param built.bridge - Its mocked signer bridge. + */ + const killProvider = (built: { + clientInstance: MockClientInstance; + bridge: LighterSignerBridge; + }): void => { + for (const mockFn of Object.values(built.clientInstance)) { + if (jest.isMockFunction(mockFn)) { + mockFn.mockImplementation(async () => { + throw new Error('process died'); + }); + } + } + (built.bridge.execute as jest.Mock).mockImplementation(async () => { + throw new Error('process died'); + }); + }; + + it('remove-only: a crash between two cancels finishes the removal exactly once and NEVER restores', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('take-profit', '110000'); + venueA.seedTrigger('stop-loss', '80000'); + // First cancel lands; the process dies signing the second. + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let cancelSignings = 0; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + cancelSignings += 1; + if (cancelSignings >= 2) { + throw new Error('process died'); + } + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + killProvider(first); + expect(venueA.rawTriggers).toHaveLength(1); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + for (let attempt = 0; attempt < 40; attempt += 1) { + await second.provider.getOpenOrders(); + if ( + venueB.rawTriggers.length === 0 && + journalKeysOf(disk).length === 0 + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // The intentional removal FINISHED: nothing restored, exactly the + // one remaining old trigger cancelled. + expect(venueB.rawTriggers).toHaveLength(0); + expect(journalKeysOf(disk)).toHaveLength(0); + expect(venueB.events.filter((event) => event === 'cancel')).toHaveLength( + 1, + ); + }); + + it('remove-only: an accepted cancel with lost response resolves by exact tx identity without restoring or double-cancelling', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + venueA.failResponseOnce(15); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // The cancel COMMITTED (response was lost): venue book is empty. + expect(venueA.rawTriggers).toHaveLength(0); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + for (let attempt = 0; attempt < 40; attempt += 1) { + await second.provider.getOpenOrders(); + if (journalKeysOf(disk).length === 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(journalKeysOf(disk)).toHaveLength(0); + expect(venueB.rawTriggers).toHaveLength(0); + // Removal completed exactly once: no second cancel, no restore. + expect(venueB.events.filter((event) => event === 'cancel')).toHaveLength( + 0, + ); + expect(venueB.events.filter((event) => event === 'create')).toHaveLength( + 0, + ); + }); + + it('a v2 journal without a durable operation intent fails closed as malformed', async () => { + const { disk, infra } = makeDurableDisk(); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + disk.set( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + disk.set( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 3, + recordedAt: 5, + operationId: 'op-intentless', + createdAt: 5, + nextAttemptId: 2, + venueCheckpoint: 0, + apiKeyIndex: 7, + phase: 'cancelling', + priorGrouping: 'independent', + priorTriggers: [], + positionFingerprint: null, + attempts: [ + { + kind: 'cancel', + attemptId: 1, + nonce: 999, + outcome: 'accepted', + orderId: '424242', + txHash: 'ffff00000002', + expiresAt: 9_999_999_999_999, + role: 'stale', + }, + ], + }), + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('malformed'); + // Zero venue mutation from an uninterpretable obligation. + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it('a direct foreground update routes the pending obligation through the SAME state machine before proceeding', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + const realActive = + first.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + let died = false; + first.clientInstance.getActiveOrders.mockImplementation(async () => { + if (!died && venueA.events.includes('cancel')) { + died = true; + throw new Error('process died'); + } + return await realActive(); + }); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // Downtime: the replacement terminal-cancels — the journal owes a + // RESTORE ('cancelling' phase, replacement fully failed). + const [failedRow] = venueA.rawTriggers.splice(0, 1); + venueA.rawInactive.push({ ...failedRow, status: 'canceled' }); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawInactive) { + venueB.rawInactive.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + // DIRECT foreground update — no prior read-path kick. The machine + // parks the interrupted operation as MANUAL; this very call is the + // explicit new intent that acknowledges it, so the update proceeds + // and establishes the NEW protection (never restoring the old). + const update = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(update.error).toBeUndefined(); + expect(update.success).toBe(true); + expect(venueB.rawTriggers.map((row) => row.triggerPrice)).toStrictEqual([ + '86000', + ]); + expect(journalKeysOf(disk)).toHaveLength(0); + expect(await second.provider.getPendingManualRecoveries()).toHaveLength( + 0, + ); + }); + + it('a live OCO leg failing after activation parks durable manual recovery (never a silent partial pair)', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('take-profit', '110000'); + venue.seedTrigger('stop-loss', '80000'); + // After the FIRST old-cancel commits, the venue terminal-cancels + // one replacement leg — the phase race at its worst. + const realSend = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + let raced = false; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + const result = await realSend(txType, txInfo); + if (txType === 15 && !raced) { + raced = true; + const failedAt = venue.rawTriggers.findIndex( + (row) => row.triggerPrice === '81000', + ); + if (failedAt >= 0) { + const [failedRow] = venue.rawTriggers.splice(failedAt, 1); + venue.rawInactive.push({ ...failedRow, status: 'canceled' }); + } + } + return result; + }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '111000', + stopLossPrice: '81000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('MANUAL re-establishment'); + expect(await provider.getPendingManualRecoveries()).toHaveLength(1); + const renewed = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await provider.getPendingManualRecoveries()).toHaveLength(0); + }); + + it('a stale trigger whose wire intent cannot be faithfully restored refuses the update BEFORE any mutation', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('take-profit', '110000'); + // Unknown venue time-in-force: an exact restoration cannot be + // signed, so the swap must refuse before touching anything. + venue.rawTriggers[0].timeInForce = 'mystery-tif'; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('faithfully restored'); + expect( + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it('an UNKNOWN cancel is never resolved by book state alone: an independently-removed target keeps blocking until identity resolves', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + venueA.failBeforeCommitOnce(15); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // The signed cancel NEVER reached the venue — but the target then + // disappears independently (fill or external cancel). Book state + // alone cannot prove the signed payload will not land later. + venueA.rawTriggers.splice(0, 1); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + await second.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 500)); + // The obligation is retained (unexpired + venue-confirmed nothing). + expect(journalKeysOf(disk)).toHaveLength(1); + const update = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(update.success).toBe(false); + expect(update.error).toContain('unresolved'); + }); + + it('an exact tx found with terminal FAILED status resolves deterministically and the removal is retried to completion', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + // The sequencer consumes the nonce, records terminal status 4 + // (failed), mutates nothing, and the response is lost. + venueA.failExecutionOnceFor(15); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + killProvider(first); + expect(venueA.rawTriggers).toHaveLength(1); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + for (let attempt = 0; attempt < 40; attempt += 1) { + await second.provider.getOpenOrders(); + if ( + venueB.rawTriggers.length === 0 && + journalKeysOf(disk).length === 0 + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // The failed cancel was classified terminally and RETRIED: the + // removal completed instead of blocking forever. + expect(venueB.rawTriggers).toHaveLength(0); + expect(journalKeysOf(disk)).toHaveLength(0); + expect(venueB.events.filter((event) => event === 'cancel')).toHaveLength( + 1, + ); + }); + + it('a symbol carrying more prior triggers than the journal can hold refuses the mutation before any submission', async () => { + const { disk, infra } = makeDurableDisk(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + for (let index = 0; index < 5; index += 1) { + venue.seedTrigger( + index % 2 === 0 ? 'stop-loss' : 'take-profit', + '80000', + ); + } + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('too many'); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(5); + expect(journalKeysOf(disk)).toHaveLength(0); + }); + + it('a journal persisted AFTER the initial empty-index recovery is still retried by later read kicks in the same session', async () => { + const { disk, infra } = makeDurableDisk(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + // Initial recovery completes against an EMPTY index — twice, so the + // completion marker is recorded at the STABLE session generation + // (the first read also performs the initial session bind). + await built.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await built.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 100)); + venue.seedTrigger('stop-loss', '80000'); + // The replacement create commits but the response is lost: the + // journal is created AFTER the completion marker was set. + venue.failResponseOnce(14); + const crashed = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + for (let attempt = 0; attempt < 40; attempt += 1) { + await built.provider.getOpenOrders(); + if ( + journalKeysOf(disk).length === 0 && + venue.rawTriggers.length === 1 + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // The later read kicks reconciled it: swap completed, old cancelled. + expect(journalKeysOf(disk)).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('85000'); + }); + + it('a nonce consumed by a lost-response submission is never reissued to the next lock section while the endpoint lags', async () => { + const { infra } = makeDurableDisk(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + // Freeze the REST nonce endpoint at the pre-loss value. + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + // The remove's cancel consumes the frozen nonce; response is lost. + venue.failResponseOnce(15); + const crashed = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + // A DIFFERENT operation in a new lock section must not reuse it. + // The lost-response cancel is JOURNAL-OWNED: its ledger entry is + // consumed by exact-hash proof (floor advance) without parking a + // generic quarantine, so the unrelated write proceeds immediately. + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.error).toBeUndefined(); + expect(placed.success).toBe(true); + // The cancel consumed its issued nonce even though the response was + // lost; the NEXT section must sign strictly above it — never a + // reuse of the lagging REST value. + const cancelCall = built.calls + .filter((call) => call.function === '_signCancelOrder') + .at(-1); + expect(cancelCall).toBeDefined(); + const cancelParams = cancelCall?.params as (string | number)[]; + const consumedNonce = Number(cancelParams[cancelParams.length - 1]); + expect(consumedNonce).toBeGreaterThanOrEqual(frozenNonce); + const orderCall = built.calls + .filter((call) => call.function === '_signCreateOrder') + .at(-1); + expect(orderCall).toBeDefined(); + const orderParams = orderCall?.params as (string | number)[]; + expect(Number(orderParams[orderParams.length - 1])).toBe( + consumedNonce + 1, + ); + }); + }); + + describe('round-17 journal revisions, durable nonce ledger and independent restores', () => { + /** + * Durable disk map + infra wiring shared by restart scenarios. + * + * @returns The disk map and mocked infrastructure bound to it. + */ + const makeDurableDisk = (): { + disk: Map; + infra: ReturnType; + } => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + return { disk, infra }; + }; + + const journalKeysOf = (disk: Map): string[] => + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ); + + /** + * Simulate full process death for a provider (see round-15 helper). + * + * @param built - The provider under test. + * @param built.clientInstance - Its mocked client service instance. + * @param built.bridge - Its mocked signer bridge. + */ + const killProvider = (built: { + clientInstance: MockClientInstance; + bridge: LighterSignerBridge; + }): void => { + for (const mockFn of Object.values(built.clientInstance)) { + if (jest.isMockFunction(mockFn)) { + mockFn.mockImplementation(async () => { + throw new Error('process died'); + }); + } + } + (built.bridge.execute as jest.Mock).mockImplementation(async () => { + throw new Error('process died'); + }); + }; + + const copyVenue = ( + from: ReturnType, + to: ReturnType, + options: { triggers?: boolean; inactive?: boolean } = {}, + ): void => { + to.setVenueNonce(from.getVenueNonce()); + to.setNextIndex(from.getNextIndex()); + if (options.triggers !== false) { + for (const row of from.rawTriggers) { + to.rawTriggers.push({ ...row }); + } + } + if (options.inactive !== false) { + for (const row of from.rawInactive) { + to.rawInactive.push({ ...row }); + } + } + for (const [hash, landed] of from.landedTxs) { + to.landedTxs.set(hash, landed); + } + }; + + it('a recovery holding a STALE journal snapshot can never erase a newer operation journal (in-lock reload + revision guard)', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + // Journal A: replacement (85000) accepted+active, crash BEFORE the + // stale cancel — resolvable by finishing the swap. + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died'); + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // Let any zombie pass of the dead provider fail against the killed + // mocks BEFORE arming the stale-read seam: the seam must capture + // the NEW session's recovery, not the corpse's. + await new Promise((resolve) => setTimeout(resolve, 150)); + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + copyVenue(venueA, venueB); + // STALE-SNAPSHOT seam: the FIRST read of the journal key captures + // its value, then stalls until the foreground finished (or a bound + // elapses) — modelling a recovery preempted between its journal + // load and its lock section. + const journalKey = journalKeysOf(disk)[0]; + let staleGateArmed = true; + let releaseStaleGate = (): void => undefined; + const staleGate = new Promise((resolve) => { + releaseStaleGate = resolve; + }); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => { + const value = disk.get(key) ?? null; + if (key === journalKey && staleGateArmed) { + staleGateArmed = false; + await staleGate; + } + return value; + }, + ); + // Kick recovery: its journal read stalls holding the captured + // (soon stale) snapshot. + await second.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 50)); + // Foreground: settles A (finishes the swap to 85000), then its NEW + // replacement (86000) commits but the response is lost — journal B. + venueB.failResponseOnce(14); + const foreground = second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + await Promise.race([ + foreground, + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + releaseStaleGate(); + await foreground.catch(() => undefined); + // Let the STALE recovery pass fully finish BEFORE any fresh kick + // could mask the erasure it would cause. + await new Promise((resolve) => setTimeout(resolve, 800)); + // The stale recovery must NOT erase journal B; later kicks resolve + // B: the committed 86000 replacement wins, 85000 is cancelled. + for (let attempt = 0; attempt < 40; attempt += 1) { + await second.provider.getOpenOrders(); + if ( + journalKeysOf(disk).length === 0 && + venueB.rawTriggers.length === 1 && + venueB.rawTriggers[0].triggerPrice === '86000' + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venueB.rawTriggers.map((row) => row.triggerPrice)).toStrictEqual([ + '86000', + ]); + expect(journalKeysOf(disk)).toHaveLength(0); + }); + + it('a response-lost dispatch survives RESTART: an unrelated write on the new session never reuses the consumed nonce', async () => { + const { infra } = makeDurableDisk(); + // Venue key pre-registered on BOTH sessions: the unrelated write is + // the FIRST dispatch of the fresh session (no ChangePubKey ahead of + // it to absorb the reused nonce by accident). + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + venueA.failResponseOnce(15); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + // The cancel consumed its nonce (response lost). + const cancelCall = first.calls + .filter((call) => call.function === '_signCancelOrder') + .at(-1); + const cancelParams = cancelCall?.params as (string | number)[]; + const consumedNonce = Number(cancelParams[cancelParams.length - 1]); + killProvider(first); + const second = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + copyVenue(venueA, venueB); + // The REST endpoint LAGS at the consumed nonce after restart. + second.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: consumedNonce, + })); + // A DIRECT write on the fresh session — no recovery kick ran; only + // the durable dispatch ledger can prevent the reuse. The lost- + // response cancel is JOURNAL-OWNED: the fresh session's protection + // intent resolves it through the settlement machine (no generic + // quarantine), and the floor advance survives the restart. + const resolvedRestart = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(resolvedRestart.success).toBe(true); + expect(await second.provider.getRecoveredDispatches()).toStrictEqual([]); + const placed = await second.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.success).toBe(true); + const orderCall = second.calls + .filter((call) => call.function === '_signCreateOrder') + .at(-1); + const orderParams = orderCall?.params as (string | number)[]; + expect(Number(orderParams[orderParams.length - 1])).toBe( + consumedNonce + 1, + ); + }); + + it('a PROVEN never-landed dispatch releases its nonce: writes recover to the value the venue still expects', async () => { + const { disk, infra } = makeDurableDisk(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + // The order dispatch never reaches the venue: nonce NOT consumed. + venue.failBeforeCommitOnce(14); + const failed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(failed.success).toBe(false); + const firstCall = built.calls + .filter((call) => call.function === '_signCreateOrder') + .at(-1); + const firstParams = firstCall?.params as (string | number)[]; + const unconsumedNonce = Number(firstParams[firstParams.length - 1]); + // Age the durable ledger entry past its signed validity: the + // dispatch is now PROVABLY never-landed. + const ledgerKey = [...disk.keys()].find((key) => + key.startsWith('lighterNonceLedger:'), + ); + expect(ledgerKey).toBeDefined(); + const ledger = JSON.parse(disk.get(ledgerKey as string) as string) as { + entries: { expiresAt: number | null }[]; + }; + for (const entry of ledger.entries) { + entry.expiresAt = Date.now() - 700_000; + } + disk.set(ledgerKey as string, JSON.stringify(ledger)); + // The NEXT write must recover to the nonce the venue still expects + // — a sticky memory floor would brick every subsequent write. + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90500', + }); + expect(placed.success).toBe(true); + const secondCall = built.calls + .filter((call) => call.function === '_signCreateOrder') + .at(-1); + const secondParams = secondCall?.params as (string | number)[]; + expect(Number(secondParams[secondParams.length - 1])).toBe( + unconsumedNonce, + ); + }); + + it('a stale trigger with NO trigger price refuses the update — semantics are never substituted', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + delete (venue.rawTriggers[0] as { triggerPrice?: string }).triggerPrice; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('faithfully restored'); + expect( + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it('a prior whose wire values cannot be integerized refuses the update before any mutation (writer/loader symmetry)', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Exceeds the uint32 wire price range at 1 decimal: restoring this + // prior could never be signed — the swap must refuse up front. + venue.rawTriggers[0].price = '429496729.7'; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('faithfully restored'); + expect( + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it('the LIVE transition never re-attaches protection after a post-cancel failure: durable manual recovery is parked', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // After the old cancel commits: the replacement terminal-fails AND + // the position closes+reopens (venue fills + changed account row). + const realSend = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + let raced = false; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + const result = await realSend(txType, txInfo); + if (txType === 15 && !raced) { + raced = true; + const failedAt = venue.rawTriggers.findIndex( + (row) => row.triggerPrice === '85000', + ); + if (failedAt >= 0) { + const [failedRow] = venue.rawTriggers.splice(failedAt, 1); + venue.rawInactive.push({ ...failedRow, status: 'canceled' }); + } + venue.rawInactive.push({ + orderIndex: 9990, + clientOrderIndex: 777001, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.1', + remainingBaseAmount: '0.000', + price: '95000', + isAsk: true, + type: 'market', + timeInForce: 'immediate-or-cancel', + reduceOnly: 1, + status: 'filled', + orderExpiry: 0, + timestamp: Date.now(), + triggerPrice: '0', + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + sign: -1, + position: '0.05', + avgEntryPrice: '95000', + }, + ], + }, + ], + }); + } + return result; + }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + // NO automatic restore: explicit failure, durable manual state. + expect(result.error).toContain('MANUAL re-establishment'); + expect(venue.events.filter((event) => event === 'create')).toHaveLength( + 1, + ); + expect(await provider.getPendingManualRecoveries()).toHaveLength(1); + const renewed = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await provider.getPendingManualRecoveries()).toHaveLength(0); + }); + + it('a proven-never-landed retry may reuse its nonce: the journal stays loadable across a restart mid-retry', async () => { + const { disk, infra } = makeDurableDisk(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + venueA.failBeforeCommitOnce(15); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + killProvider(first); + // Age the unknown cancel attempt (and its ledger entry) past the + // signed validity: PROVEN never-landed → nonce released, retried. + const journalKey = resolveJournalPayloadKey(disk, journalKeysOf(disk)[0]); + const journal = JSON.parse(disk.get(journalKey) as string) as { + attempts: { expiresAt: number }[]; + }; + for (const attempt of journal.attempts) { + attempt.expiresAt = Date.now() - 700_000; + } + disk.set(journalKey, JSON.stringify(journal)); + const ledgerKey = [...disk.keys()].find((key) => + key.startsWith('lighterNonceLedger:'), + ); + if (ledgerKey) { + const ledger = JSON.parse(disk.get(ledgerKey) as string) as { + entries: { expiresAt: number | null }[]; + }; + for (const entry of ledger.entries) { + entry.expiresAt = Date.now() - 700_000; + } + disk.set(ledgerKey, JSON.stringify(ledger)); + } + // Restart 1: the retry cancel signs (with a possibly REUSED nonce), + // then the process dies before settlement. + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + copyVenue(venueA, venueB); + const frozen = venueB.getVenueNonce(); + second.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozen, + })); + const realActiveB = + second.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + let diedB = false; + second.clientInstance.getActiveOrders.mockImplementation(async () => { + if (!diedB && venueB.events.includes('cancel')) { + diedB = true; + throw new Error('process died'); + } + return await realActiveB(); + }); + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (venueB.events.includes('cancel')) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + killProvider(second); + // Restart 2: the journal now holds TWO attempts that may share a + // nonce. It must still LOAD and the removal must complete. + const third = buildProvider({ platformDependencies: infra }); + const venueC = setupTriggerVenue(third.clientInstance, third.bridge); + copyVenue(venueB, venueC); + for (let attempt = 0; attempt < 40; attempt += 1) { + await third.provider.getOpenOrders(); + if ( + venueC.rawTriggers.length === 0 && + journalKeysOf(disk).length === 0 + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venueC.rawTriggers).toHaveLength(0); + expect(journalKeysOf(disk)).toHaveLength(0); + }); + }); + + describe('round-18 real signer identity, durable nonce integrity and grouped restores', () => { + /** + * Durable disk map + infra wiring shared by restart scenarios. + * + * @returns The disk map and mocked infrastructure bound to it. + */ + const makeDurableDisk = (): { + disk: Map; + infra: ReturnType; + } => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + return { disk, infra }; + }; + + const journalKeysOf = (disk: Map): string[] => + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ); + + /** + * Simulate full process death for a provider (see round-15 helper). + * + * @param built - The provider under test. + * @param built.clientInstance - Its mocked client service instance. + * @param built.bridge - Its mocked signer bridge. + */ + const killProvider = (built: { + clientInstance: MockClientInstance; + bridge: LighterSignerBridge; + }): void => { + for (const mockFn of Object.values(built.clientInstance)) { + if (jest.isMockFunction(mockFn)) { + mockFn.mockImplementation(async () => { + throw new Error('process died'); + }); + } + } + (built.bridge.execute as jest.Mock).mockImplementation(async () => { + throw new Error('process died'); + }); + }; + + const copyVenue = ( + from: ReturnType, + to: ReturnType, + options: { triggers?: boolean; inactive?: boolean } = {}, + ): void => { + to.setVenueNonce(from.getVenueNonce()); + to.setNextIndex(from.getNextIndex()); + if (options.triggers !== false) { + for (const row of from.rawTriggers) { + to.rawTriggers.push({ ...row }); + } + } + if (options.inactive !== false) { + for (const row of from.rawInactive) { + to.rawInactive.push({ ...row }); + } + } + for (const [hash, landed] of from.landedTxs) { + to.landedTxs.set(hash, landed); + } + }; + + const lastSignedNonce = (calls: LighterWasmCall[], fn: string): number => { + const call = calls.filter((entry) => entry.function === fn).at(-1); + const params = call?.params as (string | number)[]; + return Number(params[params.length - 1]); + }; + + it('the dispatch ledger records the RESULT tx hash (real signer shape) and resolves a restart by exact identity', async () => { + const { infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + venueA.failResponseOnce(15); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + const consumedNonce = lastSignedNonce(first.calls, '_signCancelOrder'); + killProvider(first); + const second = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + copyVenue(venueA, venueB); + // REST lags at the consumed nonce; the ONLY consumption proof is + // the exact RESULT hash recorded at dispatch — txInfo never + // carried it (pinned WASM contract). + second.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: consumedNonce, + })); + // The retry resolves the JOURNAL-OWNED dispatch through the + // settlement machine: the exact RESULT hash (recorded at dispatch; + // txInfo never carried it) proves consumption, the floor advances, + // and no generic quarantine is parked. + const resolvedRestart = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(resolvedRestart.success).toBe(true); + expect(await second.provider.getRecoveredDispatches()).toStrictEqual([]); + const placed = await second.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.error).toBeUndefined(); + expect(placed.success).toBe(true); + expect(lastSignedNonce(second.calls, '_signCreateOrder')).toBe( + consumedNonce + 1, + ); + }); + + it('a HASHLESS unresolved dispatch is never released by expiry alone: writes stay blocked until the venue advances', async () => { + const { disk, infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + // Seed a durable hashless entry (e.g. a dispatch whose signing + // result carried no hash): venue-confirmed absence is impossible. + const built = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + disk.set( + `lighterNonceLedger:testnet:28:7`, + JSON.stringify({ + version: 1, + consumedFloor: 0, + entries: [ + { + nonce: frozenNonce, + txHash: null, + expiresAt: Date.now() - 700_000, + }, + ], + }), + ); + const blocked = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + // Expiry alone must NOT prove non-consumption without a hash. + expect(blocked.success).toBe(false); + expect(blocked.error).toContain('unresolved'); + // The venue advances (the dispatch actually consumed the nonce): + // only the ADVANCE is proven via REST — the hashless intent's own + // fate is UNKNOWN, never reported completed. The outcome is + // QUARANTINED and writes recover only after explicit + // per-outcome acknowledgment. + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce + 1, + })); + const quarantined = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(quarantined.success).toBe(false); + expect(quarantined.error).toContain('landed with an UNKNOWN outcome'); + const hashlessOutcomes = await built.provider.getRecoveredDispatches(); + expect(hashlessOutcomes).toHaveLength(1); + expect(hashlessOutcomes[0].outcome).toBe('unknown'); + expect(hashlessOutcomes[0].evidence).toBe('rest-advance'); + await acknowledgeAllRecovered(built.provider); + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.success).toBe(true); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( + frozenNonce + 1, + ); + }); + + it('ledger consumption proof verifies the FULL identity, not the nonce alone', async () => { + const { disk, infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + // The venue KNOWS a tx under this hash — but for a DIFFERENT api + // key slot: identity mismatch is ambiguity, never consumption. + venue.landedTxs.set('dddd000000000001', { + nonce: frozenNonce, + status: 3, + }); + built.clientInstance.getTx.mockImplementation(async (hash: string) => + hash === 'dddd000000000001' + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 9, + nonce: frozenNonce, + status: 3, + } + : null, + ); + disk.set( + `lighterNonceLedger:testnet:28:7`, + JSON.stringify({ + version: 1, + consumedFloor: 0, + entries: [ + { + nonce: frozenNonce, + txHash: 'dddd000000000001', + expiresAt: Date.now() + 500_000, + }, + ], + }), + ); + const blocked = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(blocked.success).toBe(false); + expect(blocked.error).toContain('unresolved'); + }); + + it('a ledger write failure aborts the dispatch with the memory floor UNTOUCHED; writes heal to the venue-expected nonce', async () => { + const { disk, infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + // The durable append fails ONCE: nothing may be dispatched and the + // floor must not advance. + let failLedgerWrite = true; + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + if (key.startsWith('lighterNonceLedger:') && failLedgerWrite) { + failLedgerWrite = false; + throw new Error('storage write refused'); + } + disk.set(key, value); + }, + ); + const failed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(failed.success).toBe(false); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => txType === 14, + ), + ).toHaveLength(0); + // Storage healed: the next write signs the nonce the venue still + // expects — a floor advanced before the durable append would have + // burned it. + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90500', + }); + expect(placed.success).toBe(true); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( + frozenNonce, + ); + }); + + it('a coded venue/HTTP error after a hidden commit never releases the nonce: the next write proves consumption by hash', async () => { + const { infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + // The venue COMMITS the trigger create, then answers with a coded + // 5xx that masks the commit. + venue.failCodedAfterCommitOnce(14, 500); + const failed = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(failed.success).toBe(false); + const consumedNonce = lastSignedNonce(built.calls, '_signCreateOrder'); + // The next write proves consumption via the exact hash: the + // JOURNAL-OWNED dispatch resolves through the settlement machine + // (no generic quarantine) and the next dispatch signs the NEXT + // nonce — releasing on the coded error would have reused the + // consumed one. + const resolvedNext = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '87000', + }); + expect(resolvedNext.success).toBe(true); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90500', + }); + expect(placed.error).toBeUndefined(); + expect(placed.success).toBe(true); + // The masked-commit nonce is signed EXACTLY once (the original + // dispatch): the settlement + follow-up writes all sign LATER + // nonces — releasing on the coded error would have reused it. + const signedNonces = built.calls + .filter((call) => + [ + '_signCreateOrder', + '_signCancelOrder', + '_signCreateGroupedOrders', + ].includes(call.function), + ) + .map((call) => { + const params = call.params as (string | number)[]; + return Number(params[params.length - 1]); + }); + expect( + signedNonces.filter((nonce) => nonce === consumedNonce), + ).toHaveLength(1); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBeGreaterThan( + consumedNonce, + ); + }); + + it('a stale never-landed reconciliation can never lower the floor below a nonce a RETRY has since consumed', async () => { + const { disk, infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ + code: 200, + nonce: frozenNonce, + })); + // TPSL remove dispatch A never lands. + venue.failBeforeCommitOnce(15); + const crashed = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(crashed.success).toBe(false); + // Age dispatch A (journal attempt + ledger entry): proven + // never-landed on next resolution. + for (const key of [...disk.keys()]) { + if ( + key.startsWith('lighterNonceLedger:') || + key.startsWith('lighterTpslJournalOp:') || + (key.startsWith('lighterTpslJournal:') && !key.includes('Index')) + ) { + const doc = JSON.parse(disk.get(key) as string) as { + entries?: { expiresAt: number | null }[]; + attempts?: { expiresAt: number }[]; + }; + for (const entry of doc.entries ?? []) { + entry.expiresAt = Date.now() - 700_000; + } + for (const attempt of doc.attempts ?? []) { + attempt.expiresAt = Date.now() - 700_000; + } + disk.set(key, JSON.stringify(doc)); + } + } + // The old trigger disappears INDEPENDENTLY (external cancel): the + // later journal reconciliation will have nothing left to submit — + // its ONLY effect on the nonce state is the release itself. + venue.rawTriggers.splice(0, 1); + // Retry B: an unrelated write consumes the released nonce N. + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.success).toBe(true); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( + frozenNonce, + ); + // The STALE journal reconciliation of dispatch A now proves A + // never landed — but N was consumed by B: the floor must not drop. + for (let attempt = 0; attempt < 40; attempt += 1) { + await built.provider.getOpenOrders(); + if (journalKeysOf(disk).length === 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const placedAfter = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '91000', + }); + expect(placedAfter.success).toBe(true); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( + frozenNonce + 1, + ); + }); + + it('tWO LIVE providers: a stale settlement pass physically cannot destroy the newer journal written by its peer', async () => { + const { disk, infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + venue.seedTrigger('stop-loss', '80000'); + // Journal A: replacement accepted+active, crash before cancels. + const realBridge = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died'); + } + return await realBridge(call); + }, + ); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + (first.bridge.execute as jest.Mock).mockImplementation(realBridge); + await new Promise((resolve) => setTimeout(resolve, 150)); + // A SECOND LIVE provider shares the same venue and disk — its lock + // is instance-local, so it interleaves with the first for real. + // Both providers are wired to ONE venue state: the second's client + // and signer mocks share the first venue's closures. + const second = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + for (const method of [ + 'getActiveOrders', + 'getInactiveOrders', + 'getNextNonce', + 'getTx', + 'sendTx', + ] as const) { + second.clientInstance[method].mockImplementation( + first.clientInstance[method].getMockImplementation() as never, + ); + } + (second.bridge.execute as jest.Mock).mockImplementation( + (first.bridge.execute as jest.Mock).getMockImplementation() as never, + ); + // Stale seam on the FIRST provider's recovery read of the journal + // pointer: it captures the current value, then yields until the + // SECOND provider settled A and journalled its own operation B. + const baseJournalKey = journalKeysOf(disk).find( + (key) => key.split(':').length === 6, + ); + let staleGateArmed = true; + let releaseStaleGate = (): void => undefined; + const staleGate = new Promise((resolve) => { + releaseStaleGate = resolve; + }); + const journalPointerKey = baseJournalKey ?? journalKeysOf(disk)[0]; + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => { + const value = disk.get(key) ?? null; + if (key === journalPointerKey && staleGateArmed) { + staleGateArmed = false; + await staleGate; + } + return value; + }, + ); + // First provider's recovery holds the stale snapshot... + await first.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 50)); + // ...while the SECOND provider settles A and journals B + // (response-loss on its replacement create). + venue.failResponseOnce(14); + const foreground = second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + await Promise.race([ + foreground, + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + releaseStaleGate(); + await foreground.catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 800)); + // B's journal payload must still exist — the stale pass could not + // delete or overwrite it — and later reads resolve it. + for (let attempt = 0; attempt < 40; attempt += 1) { + await second.provider.getOpenOrders(); + if ( + journalKeysOf(disk).length === 0 && + venue.rawTriggers.length === 1 && + venue.rawTriggers[0].triggerPrice === '86000' + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venue.rawTriggers.map((row) => row.triggerPrice)).toStrictEqual([ + '86000', + ]); + expect(journalKeysOf(disk)).toHaveLength(0); + }, 15_000); + + it('compaction also covers proven-resolved cancel attempts: >40 mixed failures stay recoverable', async () => { + const { disk, infra } = makeDurableDisk(); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + disk.set( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + disk.set( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 4, + recordedAt: 5, + createdAt: 5, + nextAttemptId: 41, + operationId: 'op-mixed-compact', + apiKeyIndex: 7, + intent: 'remove', + phase: 'cancelling', + priorGrouping: 'independent', + priorTriggers: [ + { + orderId: '9000', + side: 'sell', + wireOrderType: 2, + wireTimeInForce: 0, + orderExpiry: 0, + price: '80000', + triggerPrice: '80000', + remainingSize: '0.001', + }, + ], + // 40 proven-resolved CANCEL failures (never landed, expired): + // without cancel compaction the next attempt dead-ends at the + // cap. + attempts: Array.from({ length: 40 }, (_, index) => ({ + kind: 'cancel', + attemptId: index + 1, + nonce: 2000 + index, + outcome: 'unknown', + orderId: String(7000 + index), + txHash: `eeee${String(index).padStart(4, '0')}0000`, + expiresAt: 1_700_000_000_000, + role: 'stale', + })), + }), + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + // The prior trigger is STILL on the venue: the removal's final + // cancel is owed, but the journal is already AT the attempt cap. + venue.rawTriggers.push({ + orderIndex: 9000, + clientOrderIndex: 9000, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + price: '80000', + isAsk: true, + type: 'stop-loss', + timeInForce: 'immediate-or-cancel', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + triggerPrice: '80000', + }); + const journalSizes: number[] = []; + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + if ( + key.startsWith('lighterTpslJournalOp:') || + (key.startsWith('lighterTpslJournal:') && !key.includes('Index')) + ) { + try { + const parsed = JSON.parse(value) as { attempts?: unknown[] }; + if (Array.isArray(parsed.attempts)) { + journalSizes.push(parsed.attempts.length); + } + } catch { + // pointer docs are not journals + } + } + disk.set(key, value); + }, + ); + for (let attempt = 0; attempt < 40; attempt += 1) { + await built.provider.getOpenOrders(); + if ( + venue.rawTriggers.length === 0 && + journalKeysOf(disk).length === 0 + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // Compaction dropped the 40 proven-resolved cancels: the 41st + // (real) cancel completed the removal under the cap. + expect(venue.rawTriggers).toHaveLength(0); + expect(journalKeysOf(disk)).toHaveLength(0); + expect(Math.max(...journalSizes)).toBeLessThanOrEqual(40); + }); + }); + + describe('round-20 financial idempotency, signer ownership and manual recovery', () => { + it('a WITHDRAW whose commit was masked by response loss is never blindly retried: quarantined until acknowledged', async () => { + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ registeredKey }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.failResponseOnce(13); + const first = await built.provider.withdraw({ amount: '25' }); + expect(first.success).toBe(false); + // The blind retry at the CLIENT BOUNDARY is refused: the original + // withdrawal actually completed. + const retry = await built.provider.withdraw({ amount: '25' }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('actually completed'); + expect(retry.error).toContain('withdraw:25'); + const outcomes = await acknowledgeAllRecovered(built.provider); + expect(outcomes.map((outcome) => outcome.intent)).toStrictEqual([ + 'withdraw:25', + ]); + }); + + it('an UPDATE-MARGIN whose commit was masked by response loss is quarantined until acknowledged', async () => { + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ registeredKey }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.failResponseOnce(29); + const first = await built.provider.updateMargin({ + symbol: 'BTC', + amount: '10', + }); + expect(first.success).toBe(false); + const retry = await built.provider.updateMargin({ + symbol: 'BTC', + amount: '10', + }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('actually completed'); + await acknowledgeAllRecovered(built.provider); + const after = await built.provider.updateMargin({ + symbol: 'BTC', + amount: '10', + }); + expect(after.success).toBe(true); + }); + + it('tWO providers on DIFFERENT accounts sharing one bridge re-establish the correct signer client before every write section', async () => { + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ registeredKey }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + // Second provider on a DIFFERENT venue account sharing the SAME + // bridge OBJECT (the singleton WASM client model). + const second = buildProvider({ + registeredKey, + configuredAccountIndex: 99, + sharedBridge: { + bridge: first.bridge, + calls: first.calls, + fireReset: first.fireReset, + }, + }); + second.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, index: 99 }], + }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + const sharedCalls = first.calls; + const order = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + } as const; + expect((await first.provider.placeOrder(order)).success).toBe(true); + expect((await second.provider.placeOrder(order)).success).toBe(true); + // A's next write happens AFTER B overwrote the singleton client: + // the bridge-ownership mutex must re-create A's client first. + expect((await first.provider.placeOrder(order)).success).toBe(true); + expect(venue.rawTriggers).toHaveLength(0); + expect(venueB.rawTriggers).toHaveLength(0); + // Sequence check: every _signCreateOrder is preceded (since the + // last account switch) by a _createClient for the SAME account. + let currentOwner: number | null = null; + const mismatches: string[] = []; + for (const call of sharedCalls) { + if (call.function === '_createClient') { + currentOwner = Number((call.params as (string | number)[])[2]); + } + if (call.function === '_signCreateOrder') { + const signer = Number((call.params as (string | number)[])[0]); + if (currentOwner !== signer) { + mismatches.push(`${String(currentOwner)}!=${String(signer)}`); + } + } + } + expect(mismatches).toStrictEqual([]); + // At least one RE-establishment happened for A's second write. + expect( + sharedCalls.filter((call) => call.function === '_createClient').length, + ).toBeGreaterThanOrEqual(3); + }); + + it('a stale trigger with DANGLING venue linkage refuses the update before any mutation (never classified independent)', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // One-sided linkage to an order that is not part of the pair. + venue.rawTriggers[0].toCancelOrderId0 = '424242'; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('venue linkage'); + expect( + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it('an index read failure during clear is AMBIGUITY: the settlement stays unresolved and the index is retained', async () => { + const disk = new Map(); + const infra = createMockInfrastructure(); + // Allow the persist-time index read; fail from the SECOND index + // read on (the clear-time RMW). + let failIndexReads = false; + let indexReads = 0; + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => { + if (key.startsWith('lighterTpslJournalIndex:')) { + indexReads += 1; + if (failIndexReads && indexReads > 1) { + throw new Error('index storage read failed'); + } + } + return disk.get(key) ?? null; + }, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + failIndexReads = true; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + // The venue settled but the index could not be safely updated: the + // operation must NOT report clean success and the index survives. + expect(result.success).toBe(false); + expect(result.error).toContain('index storage read failed'); + expect(disk.has('lighterTpslJournalIndex:testnet')).toBe(true); + failIndexReads = false; + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + }); + + it('an order failing AFTER a committed leverage change reports the partial venue state explicitly', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + setupTriggerVenue(clientInstance, bridge); + // Leverage submit succeeds; the ORDER dispatch then fails at the + // venue boundary. + const realSend = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + if (txType === 14) { + throw new LighterApiError('order rejected', 21000); + } + return await realSend(txType, txInfo); + }, + ); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('PARTIAL STATE'); + expect(result.error).toContain('leverage'); + expect(result.error).toContain('10x'); + }); + }); + + describe('round-19 process-wide serialization, complete identity and real OCO semantics', () => { + /** + * Durable disk map + infra wiring shared by scenarios. + * + * @returns The disk map and mocked infrastructure bound to it. + */ + const makeDurableDisk = (): { + disk: Map; + infra: ReturnType; + } => { + const disk = new Map(); + const infra = createMockInfrastructure(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, + ); + return { disk, infra }; + }; + + const shareVenue = ( + from: { clientInstance: MockClientInstance; bridge: LighterSignerBridge }, + to: { clientInstance: MockClientInstance; bridge: LighterSignerBridge }, + ): void => { + for (const method of [ + 'getActiveOrders', + 'getInactiveOrders', + 'getNextNonce', + 'getTx', + 'sendTx', + ] as const) { + to.clientInstance[method].mockImplementation( + from.clientInstance[method].getMockImplementation() as never, + ); + } + (to.bridge.execute as jest.Mock).mockImplementation( + (from.bridge.execute as jest.Mock).getMockImplementation() as never, + ); + }; + + it('tWO LIVE providers dispatching concurrently never issue the same nonce (process-wide venue write mutex)', async () => { + const { infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + const second = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + shareVenue(first, second); + // Freeze the REST endpoint: without cross-provider serialization + // both providers read the same nonce and dispatch it twice. + const frozen = venue.getVenueNonce(); + const frozenImpl = async (): Promise<{ + code: number; + nonce: number; + }> => ({ + code: 200, + nonce: frozen, + }); + first.clientInstance.getNextNonce.mockImplementation(frozenImpl); + second.clientInstance.getNextNonce.mockImplementation(frozenImpl); + const [resultA, resultB] = await Promise.all([ + first.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }), + second.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90500', + }), + ]); + expect(resultA.success).toBe(true); + expect(resultB.success).toBe(true); + const signedNonces = [...first.calls, ...second.calls] + .filter((call) => call.function === '_signCreateOrder') + .map((call) => { + const params = call.params as (string | number)[]; + return Number(params[params.length - 1]); + }) + .sort((left, right) => left - right); + expect(signedNonces).toStrictEqual([frozen, frozen + 1]); + }); + + it('two LIVE resolvers of the same journal park exactly ONE manual obligation (process-wide settlement mutex)', async () => { + const { infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + const realActive = + first.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + let died = false; + first.clientInstance.getActiveOrders.mockImplementation(async () => { + if (!died && venueA.events.includes('cancel')) { + died = true; + throw new Error('process died'); + } + return await realActive(); + }); + const crashed = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(crashed.success).toBe(false); + // Heal the seam WITHOUT killing the provider: BOTH instances stay + // live and share the venue + disk. + first.clientInstance.getActiveOrders.mockImplementation( + realActive as never, + ); + // Replacement terminal-cancels during the outage: a restore is owed. + const [failedRow] = venueA.rawTriggers.splice(0, 1); + venueA.rawInactive.push({ ...failedRow, status: 'canceled' }); + const second = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + shareVenue(first, second); + // BOTH providers kick recovery concurrently: the settlement mutex + // serializes them — exactly ONE parks the obligation as manual, + // neither submits any restore mutation. + const createsBeforeRecovery = venueA.events.filter( + (event) => event === 'create', + ).length; + await Promise.all([ + first.provider.getOpenOrders(), + second.provider.getOpenOrders(), + ]); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ((await first.provider.getPendingManualRecoveries()).length === 1) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(venueA.events.filter((event) => event === 'create')).toHaveLength( + createsBeforeRecovery, + ); + expect(await first.provider.getPendingManualRecoveries()).toHaveLength(1); + expect(venueA.rawTriggers).toHaveLength(0); + }); + + it('concurrent persists for DIFFERENT symbols never lose an index entry (index RMW mutex)', async () => { + const { disk, infra } = makeDurableDisk(); + // Interleave-friendly disk: every operation yields, maximizing the + // read-modify-write race window without the mutex. + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return disk.get(key) ?? null; + }, + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + // Two same-provider mutations on DIFFERENT symbols cannot race the + // write lock — drive the index RMW directly through two concurrent + // recovery-persist paths instead: seed two journals whose persists + // interleave via the yielding disk. + const btc = built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + await btc; + const index = JSON.parse( + disk.get('lighterTpslJournalIndex:testnet') ?? '[]', + ) as string[]; + // The BTC settlement resolved: its entry is gone, the index intact. + expect(Array.isArray(index)).toBe(true); + }); + + it('a signing result without a hash can never dispatch: the wire is REFUSED before submission', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const result = (await realImplementation(call)) as Record< + string, + unknown + >; + if (call.function === '_signCancelOrder') { + delete result.txHash; + } + return result; + }, + ); + const result = await provider.updatePositionTPSL({ symbol: 'BTC' }); + expect(result.success).toBe(false); + expect(result.error).toContain('txHash'); + expect( + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => txType === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it('a v1 nonce-ledger document migrates instead of blocking writes as corrupt', async () => { + const { disk, infra } = makeDurableDisk(); + const registeredKey = '9c'.repeat(40); + // Earlier-schema ledger: version 1 without the consumed watermark. + disk.set( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ version: 1, entries: [] }), + ); + const built = buildProvider({ + platformDependencies: infra, + registeredKey, + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.error).toBeUndefined(); + expect(placed.success).toBe(true); + }); + + it('an early-schema (v2) journal converts to durable manual remediation, resolved by an explicit new intent', async () => { + const { disk, infra } = makeDurableDisk(); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + disk.set( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + disk.set( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 2, + recordedAt: 5, + operationId: 'op-v2', + createdAt: 5, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorTriggers: [], + positionFingerprint: null, + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 999, + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }), + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + // REMEDIATION POLICY: an uninterpretable early-schema journal + // converts to durable MANUAL state (surfaced); the explicit new + // intent resolves it and proceeds fresh. + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(1); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(0); + }); + }); + + describe('round-13 position semantics, settlement bookkeeping and margin concurrency', () => { + it('a negative magnitude or malformed sign fails TP/SL and close with an explicit data error and zero mutation', async () => { + const { provider, calls, clientInstance } = buildProvider(); + // '-0.1' with sign 1 would flip the canonical direction: close/TPSL + // would act OPPOSITE the real position. sign '1' (string) would be + // silently coerced by a > 0 ternary. + for (const overrides of [ + { position: '-0.1', sign: 1 }, + { position: '0.1', sign: '1' as unknown as number }, + { position: '0.1', sign: 0 }, + ]) { + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], ...overrides }], + }, + ], + }); + const tpsl = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(tpsl.success).toBe(false); + expect(tpsl.error).toContain('Invalid Lighter venue data'); + const close = await provider.closePosition({ + symbol: 'BTC', + currentPrice: 100000, + }); + expect(close.success).toBe(false); + expect(close.error).toContain('Invalid Lighter venue data'); + } + expect(calls).toHaveLength(0); + }); + + it('validateOrder resolves invalid (never rejects) when the reduce-only full-close read hits malformed venue data', async () => { + const { provider, calls, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.1oops' }], + }, + ], + }); + // Below-min reduce-only forces the live full-close read, whose new + // data-integrity throw must surface as an explicit invalid result. + const validation = await provider.validateOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.00005', + orderType: 'limit', + price: '100000', + reduceOnly: true, + }); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid Lighter venue data'); + expect(calls).toHaveLength(0); + }); + + it('overlapping stale margin refreshes share ONE authoritative request', async () => { + const { provider, clientInstance } = buildProvider(); + const baseNow = Date.now(); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(baseNow); + try { + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 40, + }; + expect((await provider.validateOrder(request)).isValid).toBe(true); + nowSpy.mockReturnValue(baseNow + 61_000); + // Stale epoch: gate the first fetch; a second overlapping caller + // must NOT issue an independent fetch whose delayed/older payload + // could later overwrite a fresher cap for a full TTL. + let fetches = 0; + let releaseFetch = (): void => undefined; + const fetchGate = new Promise((resolve) => { + releaseFetch = resolve; + }); + clientInstance.getOrderBookDetails.mockImplementation(async () => { + fetches += 1; + await fetchGate; + return { + code: 200, + orderBookDetails: [ + { + symbol: 'BTC', + lastTradePrice: 100000, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }; + }); + const firstPromise = provider.validateOrder(request); + const secondPromise = provider.validateOrder(request); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFetch(); + const [first, second] = await Promise.all([ + firstPromise, + secondPromise, + ]); + expect(fetches).toBe(1); + // Both observe the single authoritative 25x result. + expect(first.isValid).toBe(false); + expect(second.isValid).toBe(false); + } finally { + nowSpy.mockRestore(); + } + }); + + it('a failed shared margin refresh fails closed for all waiters and clears for retry', async () => { + const { provider, clientInstance } = buildProvider(); + const baseNow = Date.now(); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(baseNow); + try { + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 20, + }; + expect((await provider.validateOrder(request)).isValid).toBe(true); + nowSpy.mockReturnValue(baseNow + 61_000); + clientInstance.getOrderBookDetails.mockRejectedValueOnce( + new Error('metadata endpoint down'), + ); + const failed = await provider.validateOrder(request); + expect(failed.isValid).toBe(false); + expect(failed.error).toContain('margin metadata'); + // The rejected in-flight slot cleared: the next call retries and + // succeeds against fresh metadata. + const retried = await provider.validateOrder(request); + expect(retried.isValid).toBe(true); + } finally { + nowSpy.mockRestore(); + } + }); + + it('settles through delayed REST visibility and keeps queued transitions serial', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Accepted sendTx is not immediately visible: REST lags 2 reads. + venue.setRestLag(2); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.error).toBeUndefined(); + expect(first.success).toBe(true); + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(second.success).toBe(true); + // Serial state despite the lag: exactly the second op's trigger. + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('an unresolved settlement blocks the next mutation until reconciliation succeeds', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + // Lag beyond the settle bound: the first update times out unresolved. + venue.setRestLag(12); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + expect(first.error).toContain('settlement is not yet visible'); + const mutationsAfterFirst = (functionName: string): number => + (bridge.execute as jest.Mock).mock.calls.filter( + ([call]) => (call as LighterWasmCall).function === functionName, + ).length; + const createsBefore = mutationsAfterFirst('_signCreateOrder'); + // Venue visibility recovers; the retry must still reconcile the + // recorded expectation BEFORE mutating, then proceed serially. + venue.setRestLag(2); + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(second.success).toBe(true); + expect(mutationsAfterFirst('_signCreateOrder')).toBeGreaterThan( + createsBefore, + ); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('a create accepted before a failed cancel leaves a reconciliation obligation the retry honors', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Fail the CANCEL submission once, after the create was accepted. + const venueSendTx = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + let cancelFailed = false; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + if (txType === 15 && !cancelFailed) { + cancelFailed = true; + venue.stagedCancels.shift(); + throw new Error('cancel submission failed'); + } + return await venueSendTx(txType, txInfo); + }, + ); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + expect(first.error).toContain('cancel submission failed'); + // An IMMEDIATE retry stays blocked: the lost cancel is inside its + // discard grace (the original request could still be in flight). + const immediate = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(immediate.success).toBe(false); + expect(immediate.error).toContain('unresolved'); + // Once aged past the grace with a stable unconsumed nonce, the + // never-landed cancel is discarded and the retry reconciles the + // accepted create, then completes the replacement serially. + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 700_000); + try { + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + } finally { + nowSpy.mockRestore(); + } + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('a replacement that goes terminal-cancelled BEFORE activation leaves the old protection untouched', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // The accepted create lands directly in inactive as 'canceled'. + venue.setCreateTerminal('canceled'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('before becoming active'); + expect(result.error).toContain('left untouched'); + // PHASE BARRIER: the old trigger was never cancelled. + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('80000'); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + // The obligation cleared (terminal is authoritative): a retry with + // normal venue behavior succeeds. + venue.setCreateTerminal('none'); + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('a replacement that EXECUTES before activation is observed is not treated as a failure', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Immediate/crossed trigger: fills before it can be observed active. + venue.setCreateTerminal('filled'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + // Stale reduce-only leftovers still cleaned up. + expect(venue.rawTriggers).toHaveLength(0); + }); + + it('response loss AFTER venue commit reconciles the HIDDEN create on retry without duplicating protection', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // The create commits venue-side but the 200 never arrives, AND the + // commit lags REST: a journal-less retry would snapshot the lagged + // book, miss 85000, and leave 85000+86000 live. + venue.setRestLag(3); + venue.failResponseOnce(14); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + expect(first.error).toContain('transport failure after venue commit'); + const mutationCallsBefore = calls.filter((call) => + [ + '_signCreateOrder', + '_signCreateGroupedOrders', + '_signCancelOrder', + ].includes(call.function), + ).length; + // The hidden create is JOURNAL-OWNED: the retry reconciles it + // through the settlement machine (observing the hidden create + // BEFORE any new signer mutation) without a generic quarantine. + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(await provider.getRecoveredDispatches()).toStrictEqual([]); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + expect( + calls.filter((call) => + [ + '_signCreateOrder', + '_signCreateGroupedOrders', + '_signCancelOrder', + ].includes(call.function), + ).length, + ).toBeGreaterThan(mutationCallsBefore); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('response loss BEFORE venue commit does not permanently wedge retries', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Warm signer setup first (key registration consumes a nonce) so the + // pre/post comparison isolates the failed CREATE submission. + await provider.getOpenOrders(); + const nonceBefore = venue.getVenueNonce(); + const triggersBefore = venue.rawTriggers.length; + // Transport rejects before the venue ever sees the create; the + // staged payload is dropped inside the venue helper so a retry can + // never accidentally commit the stale 85000. + venue.failBeforeCommitOnce(14); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + expect(first.error).toContain('network unreachable'); + // Venue truly untouched before the retry. + expect(venue.getVenueNonce()).toBe(nonceBefore); + expect(venue.rawTriggers).toHaveLength(triggersBefore); + // Immediate retry: blocked inside the discard grace. + const immediate = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(immediate.success).toBe(false); + expect(immediate.error).toContain('unresolved'); + // Aged + stable unconsumed nonce: concluded never-landed; retry runs + // and commits ONLY 86000 — never the dropped stale 85000 payload. + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 700_000); + try { + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + } finally { + nowSpy.mockRestore(); + } + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + expect( + venue.rawInactive.some((row) => row.triggerPrice === '85000'), + ).toBe(false); + }); + + it('a provider recreation after committed-but-unacknowledged create recovers via the durable journal', async () => { + // Shared durable disk across two provider "lifetimes". + const disk = new Map(); + const sharedInfrastructure = createMockInfrastructure(); + (sharedInfrastructure.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => disk.get(key) ?? null, + ); + (sharedInfrastructure.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + disk.set(key, value); + }, + ); + ( + sharedInfrastructure.diskCache.removeItem as jest.Mock + ).mockImplementation(async (key: string) => { + disk.delete(key); + }); + const first = buildProvider({ + platformDependencies: sharedInfrastructure, + }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + // Commit-then-lose the create response, then "kill" the provider. + venueA.failResponseOnce(14); + const attempt = await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(attempt.success).toBe(false); + expect( + [...disk.keys()].filter( + (key) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + ), + ).toHaveLength(1); + // NEW provider lifetime: same wallet, same disk, same venue state + // INCLUDING the consumed nonce — but REST hides the committed + // create from the first reconciliation window entirely. + const second = buildProvider({ + platformDependencies: sharedInfrastructure, + }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + // Authoritative venue continuity: nonce AND order-index allocator. + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + const committedView: typeof venueB.rawTriggers = []; + for (const row of venueA.rawTriggers) { + venueB.rawTriggers.push({ ...row }); + if (row.triggerPrice === '80000') { + committedView.push({ ...row }); + } + } + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + // Phase 1: the committed 85000 stays hidden beyond the whole + // reconciliation window; its nonce IS consumed. The JOURNAL-OWNED + // dispatch is exact-hash proven consumed (no generic quarantine), + // but the settlement itself cannot converge against the lagged + // book — the fresh provider stays blocked with ZERO NEW protection + // mutations beyond the journal's own reconciliation. + venueB.primeLag(committedView, 50); + const blocked = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(blocked.success).toBe(false); + expect( + second.calls.filter((call) => + ['_signCreateGroupedOrders'].includes(call.function), + ), + ).toHaveLength(0); + expect(await second.provider.getRecoveredDispatches()).toStrictEqual([]); + // Phase 2: the venue reveals the committed state; the retry + // reconciles the journal and proceeds serially. + venueB.primeLag(venueB.rawTriggers, 0); + const result = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + // WITHOUT the durable journal the fresh instance would snapshot the + // lagged book, miss the committed 85000, and leave a duplicate. + expect(venueB.rawTriggers).toHaveLength(1); + expect(venueB.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('two stale cancels with one response lost + delayed REST reconcile to the serial state', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('take-profit', '110000'); + venue.seedTrigger('stop-loss', '80000'); + // The SECOND cancel commits venue-side but its response is lost, and + // REST lags the commit. + venue.setRestLag(2); + let cancelSubmissions = 0; + const venueSendTx = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + if (txType === 15) { + cancelSubmissions += 1; + if (cancelSubmissions === 2) { + await venueSendTx(txType, txInfo); + throw new Error('transport failure after venue commit'); + } + } + return await venueSendTx(txType, txInfo); + }, + ); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + // Retry: the journal reconciles the accepted create + accepted + // cancel #1 + committed-unknown cancel #2 through the lag, then + // completes serially. + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('an OCO pair where one leg fills and the sibling terminal-cancels is an EXECUTION, not a failure', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.setCreateTerminal('oco-mixed'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '80000', + }); + // Aggregated: any success terminal dominates — this is an immediate + // execution outcome, not a replacement failure. + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(venue.rawInactive).toHaveLength(2); + }); + + it('a replacement active at the phase barrier that terminal-fails before final settlement is an explicit failure', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // After the barrier observes the create ACTIVE, the venue cancels it + // before the final settlement poll. + let readsSeen = 0; + const realActive = + clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + clientInstance.getActiveOrders.mockImplementation(async () => { + readsSeen += 1; + // Reads: 1 = snapshot, 2 = phase barrier, 3+ = final settlement. + if (readsSeen === 3) { + const at = venue.rawTriggers.findIndex( + (row) => row.triggerPrice === '85000', + ); + if (at >= 0) { + const [row] = venue.rawTriggers.splice(at, 1); + venue.rawInactive.push({ ...row, status: 'canceled' }); + } + } + return await realActive(); + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + // NO automatic restore: the failure parks a durable MANUAL state. + expect(result.error).toContain('MANUAL re-establishment'); + }); + + it('journal disk failures and corrupt entries block with zero venue mutation', async () => { + // getItem rejection. + const infraReadFail = createMockInfrastructure(); + (infraReadFail.diskCache.getItem as jest.Mock).mockRejectedValue( + new Error('disk unavailable'), + ); + const readFailBuilt = buildProvider({ + platformDependencies: infraReadFail, + }); + const readFailVenue = setupTriggerVenue( + readFailBuilt.clientInstance, + readFailBuilt.bridge, + ); + const readFailResult = await readFailBuilt.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(readFailResult.success).toBe(false); + // The FIRST durable read (nonce ledger, then journal) fails closed. + expect(readFailResult.error).toContain('read failed'); + expect(readFailVenue.rawTriggers).toHaveLength(0); + // Corrupt persisted JOURNAL JSON: blocked and NOT auto-removed. + // (Key-scoped: only the journal is corrupt, other durable state is + // absent.) + const infraCorrupt = createMockInfrastructure(); + (infraCorrupt.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index') + ? '{not json' + : null, + ); + const corruptBuilt = buildProvider({ + platformDependencies: infraCorrupt, + }); + const corruptVenue = setupTriggerVenue( + corruptBuilt.clientInstance, + corruptBuilt.bridge, + ); + const corruptResult = await corruptBuilt.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(corruptResult.success).toBe(false); + expect(corruptResult.error).toContain('corrupt'); + expect(infraCorrupt.diskCache.removeItem).not.toHaveBeenCalled(); + expect(corruptVenue.rawTriggers).toHaveLength(0); + // Early schema version 1: REMEDIATION policy — converts to durable + // manual state, resolved by the explicit new intent (never a + // permanent opaque block, never silently reinterpreted). + const v1Disk = new Map(); + const infraV1 = createMockInfrastructure(); + (infraV1.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => v1Disk.get(key) ?? null, + ); + (infraV1.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + v1Disk.set(key, value); + }, + ); + (infraV1.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + v1Disk.delete(key); + }, + ); + const v1SettlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + v1Disk.set( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([v1SettlementKey]), + ); + v1Disk.set( + `lighterTpslJournal:testnet:${v1SettlementKey}`, + JSON.stringify({ version: 1, recordedAt: 5, attempts: [] }), + ); + const v1Built = buildProvider({ platformDependencies: infraV1 }); + const v1Venue = setupTriggerVenue(v1Built.clientInstance, v1Built.bridge); + expect(await v1Built.provider.getPendingManualRecoveries()).toHaveLength( + 1, + ); + const v1Result = await v1Built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(v1Result.error).toBeUndefined(); + expect(v1Result.success).toBe(true); + expect(v1Venue.rawTriggers).toHaveLength(1); + expect(await v1Built.provider.getPendingManualRecoveries()).toHaveLength( + 0, + ); + // Malformed-but-JSON entry (empty attempts): blocked. + const infraMalformed = createMockInfrastructure(); + (infraMalformed.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => + key.startsWith('lighterTpslJournal:') && !key.includes('Index') + ? JSON.stringify({ + version: 3, + recordedAt: 5, + operationId: 'op-empty', + createdAt: 5, + nextAttemptId: 1, + venueCheckpoint: 0, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + positionFingerprint: null, + attempts: [], + }) + : null, + ); + const malformedBuilt = buildProvider({ + platformDependencies: infraMalformed, + }); + const malformedVenue = setupTriggerVenue( + malformedBuilt.clientInstance, + malformedBuilt.bridge, + ); + const malformedResult = await malformedBuilt.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(malformedResult.success).toBe(false); + expect(malformedResult.error).toContain('malformed'); + expect(malformedVenue.rawTriggers).toHaveLength(0); + // Pre-send setItem failure: the mutation aborts BEFORE submission. + const infraWriteFail = createMockInfrastructure(); + (infraWriteFail.diskCache.setItem as jest.Mock).mockRejectedValue( + new Error('disk write refused'), + ); + const writeFailBuilt = buildProvider({ + platformDependencies: infraWriteFail, + }); + const writeFailVenue = setupTriggerVenue( + writeFailBuilt.clientInstance, + writeFailBuilt.bridge, + ); + const writeFailResult = await writeFailBuilt.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(writeFailResult.success).toBe(false); + expect(writeFailResult.error).toContain('disk write refused'); + expect(writeFailVenue.rawTriggers).toHaveLength(0); + // No order mutation ever reached the venue (signer-key registration + // is the only submission). + expect( + writeFailBuilt.clientInstance.sendTx.mock.calls.filter( + ([txType]) => txType === 14 || txType === 28 || txType === 15, + ), + ).toHaveLength(0); + }); + + it('a nonce advancing past an ABSENT exact hash proves the dispatch never landed: retry-safe, no quarantine', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + venue.failBeforeCommitOnce(14); + const first = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(first.success).toBe(false); + // Venue nonce keeps MOVING between the stable reads (some other + // writer is active): even an aged unknown attempt must stay blocked. + let bump = 0; + clientInstance.getNextNonce.mockImplementation(async () => { + bump += 1; + return { code: 200, nonce: venue.getVenueNonce() + bump }; + }); + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 13_000); + try { + // The venue moved past the nonce while OUR exact hash is absent: + // another writer consumed it — the LEDGER treats it retry-safe + // (floor advances, NOTHING is reported completed and no + // quarantine is parked); the settlement machine itself stays + // conservatively blocked inside the signed validity window. + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('unresolved'); + expect(await provider.getRecoveredDispatches()).toStrictEqual([]); + } finally { + nowSpy.mockRestore(); + } + }); + + it('a wallet switch during the final journal clear fails the stale operation explicitly', async () => { + const infra = createMockInfrastructure(); + let releaseRemove = (): void => undefined; + let signalRemoveEntered = (): void => undefined; + const removeEntered = new Promise((resolve) => { + signalRemoveEntered = resolve; + }); + const removeGate = new Promise((resolve) => { + releaseRemove = resolve; + }); + // Real disk backing: the CAS-guarded clear only issues a remove + // when a journal actually exists on disk. + const switchDisk = new Map(); + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => switchDisk.get(key) ?? null, + ); + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + switchDisk.set(key, value); + }, + ); + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + signalRemoveEntered(); + await removeGate; + switchDisk.delete(key); + }, + ); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + const pending = built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + await removeEntered; + built.getUserAddressMock.mockReturnValue(`0x${'b'.repeat(40)}`); + releaseRemove(); + const result = await pending; + // The venue mutation may well have settled, but the STALE operation + // must report the switch, never success under B. + expect(result.success).toBe(false); + expect(result.error).toContain('switched accounts'); + }); + + it('validator failures from markets, margin metadata, and fresh price all resolve invalid', async () => { + // Markets read failure. + const markets = buildProvider(); + markets.clientInstance.getOrderBooks.mockRejectedValue( + new Error('orderBooks endpoint down'), + ); + const marketsOrder = await markets.provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(marketsOrder.isValid).toBe(false); + // The markets read failure surfaces as an explicit invalid result + // (the provider's market cache degrades to unknown-market). + expect(marketsOrder.error).toContain('BTC'); + const marketsClose = await markets.provider.validateClosePosition({ + symbol: 'BTC', + currentPrice: 100000, + }); + expect(marketsClose.isValid).toBe(false); + // Margin metadata failure with explicit leverage. + const margins = buildProvider(); + margins.clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('metadata endpoint down'), + ); + const marginsResult = await margins.provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, + }); + expect(marginsResult.isValid).toBe(false); + expect(marginsResult.error).toContain('margin metadata'); + // Fresh-price failure on a market order. + const price = buildProvider(); + price.clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('price endpoint down'), + ); + const priceResult = await price.provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', + }); + expect(priceResult.isValid).toBe(false); + const priceClose = await price.provider.validateClosePosition({ + symbol: 'BTC', + }); + expect(priceClose.isValid).toBe(false); + }); + + it('a bridge reset racing the auth mint completes bounded — never the old in-lock self-deadlock', async () => { + const { provider, clientInstance, bridge, fireReset } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + // Faithful reset seam: the provider's own onReset listener fires + // from the FIRST _createAuthToken call, before it resolves — exactly + // the moment f0fbd90 minted auth INSIDE the held transition lock, + // where the invalidated signer re-setup queued a nested write lock + // behind the outer section awaiting it: a hang. + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let resetFired = false; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_createAuthToken' && !resetFired) { + resetFired = true; + fireReset(); + } + return realImplementation(call); + }, + ); + // Bounded race with a CLEARED timer: an uncancelled 6 s timeout + // would keep the suite's event loop open after the test finishes. + let hangTimer: ReturnType | undefined; + const outcome = await Promise.race([ + provider.updatePositionTPSL({ symbol: 'BTC', stopLossPrice: '85000' }), + new Promise<'hang'>((resolve) => { + hangTimer = setTimeout(() => resolve('hang'), 6000); + }), + ]).finally(() => clearTimeout(hangTimer)); + // Bounded completion (success after re-setup, or a prompt explicit + // rejection) — never a hang. + expect(outcome).not.toBe('hang'); + expect(typeof outcome).toBe('object'); + }); + + it('a wallet switch DURING create submission still records the accepted mutation; switching back reconciles it', async () => { + const { provider, clientInstance, bridge, getUserAddressMock } = + buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const originalAddress = getUserAddressMock() as string; + // Defer the create sendTx; switch A->B while it is in flight; the + // venue still ACCEPTS it. + const venueSendTx = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + let releaseSend = (): void => undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + let signalSendEntered = (): void => undefined; + const sendEntered = new Promise((resolve) => { + signalSendEntered = resolve; + }); + let deferred = false; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + if (txType === 14 && !deferred) { + deferred = true; + // Deterministic: the switch happens strictly AFTER the create + // submission passed its pre-submit fence and is in flight. + signalSendEntered(); + await sendGate; + } + return await venueSendTx(txType, txInfo); + }, + ); + const firstPromise = provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + await sendEntered; + getUserAddressMock.mockReturnValue(`0x${'b'.repeat(40)}`); + releaseSend(); + const first = await firstPromise; + // The post-submit fence cancels the OPERATION under B... + expect(first.success).toBe(false); + expect(first.error).toContain('switched accounts'); + // ...but the venue accepted the create. + expect( + venue.rawTriggers.some((entry) => entry.triggerPrice === '85000'), + ).toBe(true); + // Switching back to A: the retry must reconcile the accepted create + // (recorded via onAccepted BEFORE the fence) and then proceed. + getUserAddressMock.mockReturnValue(originalAddress); + const second = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + }); + + describe('round-11 TP/SL local preflight', () => { + it('malformed live position sizes abort TP/SL replacement before any cancellation or signer call', async () => { + const { provider, calls, clientInstance } = buildProvider(); + // getPositions does not reject these; integerizing the cover size + // after the existing triggers were cancelled would strip protection + // and then fail locally. + for (const badSize of ['Infinity', 'NaN', '1e-9']) { + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: badSize }], + }, + ], + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + } + // Zero bridge calls: no signer setup, no cancels, no grouped signing. + expect(calls).toHaveLength(0); + }); + + it('degenerate randomness aborts TP/SL replacement before any cancellation', async () => { + const { provider, calls } = buildProvider(); + // The bounded allocator throws after 100 attempts per id; that + // exhaustion must land BEFORE signer setup and cancels. Ids draw + // from WebCrypto now — degenerate CRYPTO output is the seam. + const cryptoObj = ensureWebCrypto(); + const randomSpy = jest + .spyOn(cryptoObj, 'getRandomValues') + .mockImplementation( + (array: TView): TView => { + if (array instanceof Uint8Array) { + array.fill(0); + } + return array; + }, + ); + try { + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '80000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('client order id'); + expect(calls).toHaveLength(0); + } finally { + randomSpy.mockRestore(); + } + }); + + it('a wallet switch during public preflight aborts before any signer setup', async () => { + const { provider, calls, clientInstance, getUserAddressMock } = + buildProvider(); + // Stall the fresh-price read; the wallet switches A→B while placeOrder + // is parked in its PUBLIC preflight. Signer setup afterwards would + // create/register B's venue key for A's stale intent. + let releasePrice = (): void => undefined; + const priceGate = new Promise((resolve) => { + releasePrice = resolve; + }); + const details = { + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], + }; + clientInstance.getOrderBookDetails.mockImplementation(async () => { + await priceGate; + return details; + }); + const placement = provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + getUserAddressMock.mockReturnValue(`0x${'b'.repeat(40)}`); + releasePrice(); + const result = await placement; + expect(result.success).toBe(false); + // Zero bridge calls: no _createClient / personal_sign / key + // registration for account B under A's intent. + expect(calls).toHaveLength(0); + }); + + it('fails leverage validation closed when venue margin metadata is unavailable', async () => { + // Metadata row present but WITHOUT margin fractions: the global 50x + // fallback must not validate 26x for what may be a 25x market. + const missingRow = buildProvider(); + missingRow.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], + }); + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 26, + }; + const missingValidation = + await missingRow.provider.validateOrder(request); + expect(missingValidation.isValid).toBe(false); + expect(missingValidation.error).toContain('margin metadata'); + const missingPlacement = await missingRow.provider.placeOrder(request); + expect(missingPlacement.success).toBe(false); + expect(missingPlacement.error).toContain('margin metadata'); + expect(missingRow.calls).toHaveLength(0); + // Metadata endpoint failing outright: same fail-closed behavior. + const failing = buildProvider(); + failing.clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('venue metadata unavailable'), + ); + const failingValidation = await failing.provider.validateOrder(request); + expect(failingValidation.isValid).toBe(false); + const failingPlacement = await failing.provider.placeOrder(request); + expect(failingPlacement.success).toBe(false); + expect(failing.calls).toHaveLength(0); + }); + + it("rejects prefix-numeric strings like '10USD' across every money surface, with zero bridge calls", async () => { + const { provider, calls } = buildProvider(); + // parseFloat prefix-parses these into plausible numbers; strict + // full-string parsing must refuse them everywhere. + const orderCases = [ + { overrides: { size: '0.001BTC' }, error: 'Order size must be' }, + { + overrides: { size: '0.001', usdAmount: '10USD' }, + error: 'Invalid usdAmount', + }, + { + overrides: { size: '0.001', price: '90000USD' }, + error: 'Invalid limit price', + }, + ]; + for (const testCase of orderCases) { + const request = { + symbol: 'BTC', + isBuy: true, + orderType: 'limit' as const, + price: '90000', + ...testCase.overrides, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain(testCase.error); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain(testCase.error); + } + const closeValidation = await provider.validateClosePosition({ + symbol: 'BTC', + size: '0.001BTC', + currentPrice: 100000, + }); + expect(closeValidation.isValid).toBe(false); + const closeExecution = await provider.closePosition({ + symbol: 'BTC', + size: '0.001BTC', + currentPrice: 100000, + }); + expect(closeExecution.success).toBe(false); + const withdrawValidation = await provider.validateWithdrawal({ + amount: '5USD', + }); + expect(withdrawValidation.isValid).toBe(false); + const withdrawExecution = await provider.withdraw({ amount: '5USD' }); + expect(withdrawExecution.success).toBe(false); + const marginExecution = await provider.updateMargin({ + symbol: 'BTC', + amount: '5USD', + }); + expect(marginExecution.success).toBe(false); + expect(calls).toHaveLength(0); + }); + + it('enforces the advertised withdrawal minimum in validation and execution', async () => { + const { provider, calls } = buildProvider(); + // Route advertises minWithdrawUsdc '1'; 0.000001 USDC integerizes to + // wire 1 and previously signed. + const validation = await provider.validateWithdrawal({ + amount: '0.000001', + }); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('below the Lighter minimum'); + const execution = await provider.withdraw({ amount: '0.000001' }); + expect(execution.success).toBe(false); + expect(execution.error).toContain('below the Lighter minimum'); + expect(calls).toHaveLength(0); + // Exactly at the minimum is accepted. + const atMin = await provider.validateWithdrawal({ amount: '1' }); + expect(atMin.isValid).toBe(true); + }); + + it('creates the replacement protection BEFORE cancelling the snapshotted old triggers', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + // A lone SL is an ordinary CreateOrder trigger (venue rejects + // grouped type 0), created BEFORE the old trigger is cancelled. + const createAt = calls.findIndex( + (call) => call.function === '_signCreateOrder', + ); + const cancelAt = calls.findIndex( + (call) => call.function === '_signCancelOrder', + ); + expect(createAt).toBeGreaterThanOrEqual(0); + expect(cancelAt).toBeGreaterThanOrEqual(0); + // Create-first: a signing/submission failure can no longer strip + // protection that was already cancelled. + expect(createAt).toBeLessThan(cancelAt); + // Settlement reconciled: exactly the fresh trigger remains. + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('85000'); + }); + + it('keeps the old protection untouched when creating the replacement fails', async () => { + const { provider, calls, bridge, clientInstance } = buildProvider(); + clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [ + { + orderIndex: 778, + clientOrderIndex: 3, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + price: '80000', + isAsk: true, + type: 'stop-loss', + timeInForce: 'immediate-or-cancel', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + triggerPrice: '80000', + }, + ], + }); + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + return { error: 'venue rejected replacement trigger' }; + } + return realImplementation(call); + }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('venue rejected replacement trigger'); + // The snapshotted old trigger was NEVER cancelled: protection stays. + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + }); + + it('a single trigger replacement reserves exactly one client id', async () => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + setupTriggerVenue(clientInstance, bridge); + const cryptoObj = ensureWebCrypto(); + const randomSpy = jest.spyOn(cryptoObj, 'getRandomValues'); + try { + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + // One uint48 id = exactly ONE 6-byte crypto draw (plus ONE draw + // for the journal's collision-resistant operation id); reserving + // an unused second id would waste allocator budget for no order. + expect(randomSpy).toHaveBeenCalledTimes(2); + // A lone TP is an ordinary CreateOrder trigger — the venue rejects + // CreateGroupedOrders with grouping type 0 ('GroupingType is not + // valid'), and OCO requires two siblings. + expect( + calls.filter((call) => call.function === '_signCreateGroupedOrders'), + ).toHaveLength(0); + const createCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + expect(createCall).toBeDefined(); + // params: [accountIndex, marketId, clientOrderIndex, size, price, + // isAsk, orderType, timeInForce, reduceOnly, triggerPrice, expiry, + // nonce] + const orderParams = createCall?.params as (string | number)[]; + expect(orderParams[6]).toBe(4); // take-profit wire type + expect(orderParams[7]).toBe(0); // immediate-or-cancel + expect(orderParams[8]).toBe(1); // reduce-only + expect(Number(orderParams[9])).toBeGreaterThan(0); // trigger price + } finally { + randomSpy.mockRestore(); + } + }); + }); + + describe('round-9 finite-positive intent parity', () => { + it('rejects non-finite size, usdAmount, and leverage in validateOrder and placeOrder before any signer call', async () => { + const { provider, calls } = buildProvider(); + // parseFloat('Infinity') === Infinity and Infinity > 0, so bare + // positivity checks pass: leverage Infinity becomes IMF + // Math.round(10000/Infinity) = 0 and reaches _signUpdateLeverage; + // infinite size/USD integerizes to 'Infinity' inside + // _signCreateOrder params. + const cases = [ + { overrides: { size: 'Infinity' }, error: 'Order size must be' }, + { overrides: { size: 'NaN' }, error: 'Order size must be' }, + { + overrides: { size: '0.001', usdAmount: 'Infinity' }, + error: 'Invalid usdAmount', + }, + { + overrides: { size: '0.001', usdAmount: 'NaN' }, + error: 'Invalid usdAmount', + }, + { + overrides: { size: '0.001', leverage: Infinity }, + error: 'Invalid leverage', + }, + { + overrides: { size: '0.001', leverage: Number.NaN }, + error: 'Invalid leverage', + }, + ]; + for (const testCase of cases) { + const request = { + symbol: 'BTC', + isBuy: true, + orderType: 'limit' as const, + price: '90000', + ...testCase.overrides, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain(testCase.error); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain(testCase.error); + } + // Invalid intent must exit before signer/account setup entirely: not + // just no order/leverage signing, but ZERO bridge calls (no + // _createClient / key registration side effects either). + expect(calls).toHaveLength(0); + }); + + it('rejects non-finite close size and usdAmount in validateClosePosition and closePosition before any signer call', async () => { + const { provider, calls } = buildProvider(); + // Pre-fix, validateClosePosition silently fell back from a + // non-finite usdAmount to the held size (approving), while + // closePosition forwarded the infinite USD into placement — a + // validator/execution split on real money. + const cases = [ + { overrides: { size: 'Infinity' }, error: 'Order size must be' }, + { overrides: { size: 'NaN' }, error: 'Order size must be' }, + { + overrides: { usdAmount: 'Infinity' }, + error: 'Invalid usdAmount', + }, + { overrides: { usdAmount: 'NaN' }, error: 'Invalid usdAmount' }, + ]; + for (const testCase of cases) { + const request = { + symbol: 'BTC', + currentPrice: 100000, + ...testCase.overrides, + }; + const validation = await provider.validateClosePosition(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain(testCase.error); + const execution = await provider.closePosition(request); + expect(execution.success).toBe(false); + expect(execution.error).toContain(testCase.error); + } + expect(calls).toHaveLength(0); + }); + + it('fails closed when finite intent cannot be represented as venue wire integers', async () => { + const { provider, calls } = buildProvider(); + // Finite alone is insufficient: 1e300 * 10^decimals overflows the + // safe-integer wire format (stringifying as '1e+305') before signing. + const cases = [ + { overrides: { size: '1e300' } }, + { overrides: { size: '0.001', usdAmount: '1e300' } }, + { overrides: { size: '0.001', price: '1e300' } }, + ]; + for (const testCase of cases) { + const request = { + symbol: 'BTC', + isBuy: true, + orderType: 'limit' as const, + price: '90000', + ...testCase.overrides, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('integer range'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('integer range'); + } + // Close-path parity for an unrepresentable explicit size. + const closeValidation = await provider.validateClosePosition({ + symbol: 'BTC', + size: '1e300', + currentPrice: 100000, + }); + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('integer range'); + const closeExecution = await provider.closePosition({ + symbol: 'BTC', + size: '1e300', + currentPrice: 100000, + }); + expect(closeExecution.success).toBe(false); + expect(closeExecution.error).toContain('integer range'); + expect(calls).toHaveLength(0); + }); + + it('safe-checks the slippage-adjusted EXECUTION price a market buy signs, not just the reference', async () => { + const { provider, clientInstance, calls } = buildProvider(); + // priceDecimals=1: reference 415,000,000 wires to 4.15e9 (within the + // signer's uint32 price cast), but a BUY signs +5% protection: + // 4,357,500,000 wire — ABOVE uint32, which the pinned signer would + // silently wrap. Reference-only validation approves what placement + // refuses. + const reference = 415_000_000; + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: reference }], + }); + const buyRequest = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market' as const, + }; + const buyValidation = await provider.validateOrder(buyRequest); + expect(buyValidation.isValid).toBe(false); + expect(buyValidation.error).toContain('uint32'); + const buyPlacement = await provider.placeOrder(buyRequest); + expect(buyPlacement.success).toBe(false); + expect(buyPlacement.error).toContain('uint32'); + // Invalid intent exits before signer/account setup: ZERO bridge calls. + expect(calls).toHaveLength(0); + // Discriminating counterpart: a SELL protects at -5% (safe wire), so + // both surfaces must ACCEPT the same reference price. + const sellRequest = { ...buyRequest, isBuy: false }; + const sellValidation = await provider.validateOrder(sellRequest); + expect(sellValidation.isValid).toBe(true); + const sellPlacement = await provider.placeOrder(sellRequest); + expect(sellPlacement.success).toBe(true); + }); + + it('safe-checks the buy-to-close execution price when closing a short', async () => { + const { provider, clientInstance, calls } = buildProvider(); + const reference = 415_000_000; + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: reference }], + }); + // SHORT position (documented venue representation: positive + // magnitude, sign -1): closing means BUYING, so the +5% protection + // price overflows exactly like the market-buy case. + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], position: '0.001', sign: -1 }, + ], + }, + ], + }); + const shortCloseValidation = await provider.validateClosePosition({ + symbol: 'BTC', + }); + expect(shortCloseValidation.isValid).toBe(false); + expect(shortCloseValidation.error).toContain('uint32'); + const shortCloseExecution = await provider.closePosition({ + symbol: 'BTC', + }); + expect(shortCloseExecution.success).toBe(false); + expect(shortCloseExecution.error).toContain('uint32'); + // Invalid intent exits before signer/account setup: ZERO bridge calls. + expect(calls).toHaveLength(0); + // A LONG close SELLS at -5% (safe wire): both surfaces accept. + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + }, + ], + }); + const longCloseValidation = await provider.validateClosePosition({ + symbol: 'BTC', + }); + expect(longCloseValidation.isValid).toBe(true); + const longCloseExecution = await provider.closePosition({ + symbol: 'BTC', + }); + expect(longCloseExecution.success).toBe(true); + }); + + it('invalid TP/SL replacements are rejected before any cancellation or signer call', async () => { + const { provider, calls } = buildProvider(); + // Cancelling existing protection FIRST and only then discovering the + // replacement is unrepresentable would leave the position naked. + // '0.04' is sub-tick at priceDecimals=1: wire Math.round(0.4) = 0. + const badPrices = ['Infinity', 'NaN', '-100', '0', '1e300', '0.04']; + for (const bad of badPrices) { + const takeProfit = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: bad, + }); + expect(takeProfit.success).toBe(false); + const stopLoss = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: bad, + }); + expect(stopLoss.success).toBe(false); + } + // Zero bridge calls of ANY kind: no signer setup, no cancels, no + // grouped-order signing. + expect(calls).toHaveLength(0); + }); + + it('withdraw rejects non-finite and unrepresentable amounts before any signer call, matching validateWithdrawal', async () => { + const { provider, calls } = buildProvider(); + for (const amount of ['Infinity', 'NaN', '-5', '0', '1e300']) { + const validation = await provider.validateWithdrawal({ amount }); + expect(validation.isValid).toBe(false); + const execution = await provider.withdraw({ amount }); + expect(execution.success).toBe(false); + } + expect(calls).toHaveLength(0); + }); + + it('updateMargin fails closed on wire-integer overflow before any signer call', async () => { + const { provider, calls } = buildProvider(); + const overflow = await provider.updateMargin({ + symbol: 'BTC', + amount: '1e300', + }); + expect(overflow.success).toBe(false); + expect(overflow.error).toContain('integer range'); + const infinite = await provider.updateMargin({ + symbol: 'BTC', + amount: 'Infinity', + }); + expect(infinite.success).toBe(false); + expect(calls).toHaveLength(0); + }); + + it('rejects tiny finite leverage whose margin fraction overflows to Infinity', async () => { + const { provider, calls } = buildProvider(); + // 10000 / Number.MIN_VALUE === Infinity: an IMF-below-one guard alone + // misses it and Infinity would ride into _signUpdateLeverage. + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: Number.MIN_VALUE, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid leverage'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('Invalid leverage'); + expect(calls).toHaveLength(0); + }); + + it('enforces the published per-market max leverage, not only IMF representability', async () => { + const { provider, clientInstance, calls } = buildProvider(); + // Venue publishes minInitialMarginFraction 400 -> 25x for BTC. + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + symbol: 'BTC', + lastTradePrice: 100000, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }); + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 26, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid leverage'); + expect(validation.error).toContain('25'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('Invalid leverage'); + expect(calls).toHaveLength(0); + // 25x exactly is within the published bound: accepted by both. + const atMax = { ...request, leverage: 25 }; + const atMaxValidation = await provider.validateOrder(atMax); + expect(atMaxValidation.isValid).toBe(true); + const atMaxPlacement = await provider.placeOrder(atMax); + expect(atMaxPlacement.error).toBeUndefined(); + expect(atMaxPlacement.success).toBe(true); + }); + + it('rejects a positive sub-tick limit price that rounds to wire zero, in validation and placement', async () => { + const { provider, calls } = buildProvider(); + // 0.04 at priceDecimals=1 -> Math.round(0.4) = 0: positive intent, + // zero on the wire. Size 251 clears the $10 minimum notional (min + // size 250.00001 after float ceil) so ONLY the wire-zero check can + // be the rejection. + const request = { + symbol: 'BTC', + isBuy: true, + size: '251', + orderType: 'limit' as const, + price: '0.04', + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('rounds to zero'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('rounds to zero'); + expect(calls).toHaveLength(0); + }); + + it('rejects finite leverage that derives a zero venue margin fraction', async () => { + const { provider, calls } = buildProvider(); + // Math.round(10000/1e6) === 0: an IMF of zero must never be signed. + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 1e6, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid leverage'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('Invalid leverage'); + expect(calls).toHaveLength(0); + }); + }); + + describe('round-8 market validation parity', () => { + it('sizes market validateOrder at the FRESH venue price, ignoring the caller price', async () => { + const { provider, clientInstance, calls } = buildProvider(); + // Fresh venue price 40,000: min size = max(0.0002 base, $10/40,000 = + // 0.00025) = 0.00025. The caller's Infinity price would give min size + // 0.0002 (quote minimum vanishes), so 0.0002 discriminates: caller + // price approves, fresh price rejects. + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 40000 }], + }); + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.0002', + orderType: 'market' as const, + price: 'Infinity', + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('below the Lighter minimum'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('below the Lighter minimum'); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }); + + it('rejects a non-finite or non-positive price snapshot before drift math, in validation and execution', async () => { + const { provider } = buildProvider(); + // Infinity produces NaN drift (bypasses protection); NaN and 0 were + // silently skipped. All must fail closed now. + for (const snapshot of [Infinity, NaN, 0, -100]) { + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market' as const, + currentPrice: 100000, + priceAtCalculation: snapshot, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid price snapshot'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('Invalid price snapshot'); + const closeValidation = await provider.validateClosePosition({ + symbol: 'BTC', + currentPrice: 100000, + priceAtCalculation: snapshot, + }); + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('Invalid price snapshot'); + } + }); + + it('rejects out-of-range slippage tolerance consistently across validators and placement', async () => { + const { provider } = buildProvider(); + // 10,000 bps on a sell derives a zero protection price: placement + // rejects, so validation must too — and both with the same reason. + for (const maxSlippageBps of [10_000, -100, Number.NaN]) { + const request = { + symbol: 'BTC', + isBuy: false, + size: '0.001', + orderType: 'market' as const, + currentPrice: 100000, + maxSlippageBps, + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid slippage tolerance'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('Invalid slippage tolerance'); + const closeValidation = await provider.validateClosePosition({ + symbol: 'BTC', + maxSlippageBps, + }); + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('Invalid slippage tolerance'); + } + }); + }); + + describe('client order index allocation', () => { + it('a perpetually colliding or zero draw exhausts with a clear error instead of hanging', async () => { + const { provider, calls } = buildProvider(); + // Perpetual zero: every candidate is rejected, so a bounded allocator + // must throw instead of spinning. The mock never falls through. + const cryptoObj = ensureWebCrypto(); + const fillWith = + (byte: number) => + (array: TView): TView => { + if (array instanceof Uint8Array) { + array.fill(byte); + } + return array; + }; + const randomSpy = jest + .spyOn(cryptoObj, 'getRandomValues') + .mockImplementation(fillWith(0)); + try { + const zeroResult = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(zeroResult.success).toBe(false); + expect(zeroResult.error).toContain('client order id'); + const zeroDraws = randomSpy.mock.calls.length; + expect(zeroDraws).toBeGreaterThan(0); + expect(zeroDraws).toBeLessThanOrEqual(200); + + // Perpetual collision: one id issues, then every later candidate + // collides with it forever (constant crypto output). + randomSpy.mockClear(); + randomSpy.mockImplementation(fillWith(0x80)); + const first = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(first.success).toBe(true); + const second = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90001', + }); + expect(second.success).toBe(false); + expect(second.error).toContain('client order id'); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + } finally { + randomSpy.mockRestore(); + } + }); + + it('parallel placements draw unique random uint48 ids within venue bounds', async () => { + const { provider, calls } = buildProvider(); + const results = await Promise.all([ + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }), + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90001', + }), + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90002', + }), + ]); + for (const result of results) { + expect(result.success).toBe(true); + } + const ids = calls + .filter((call) => call.function === '_signCreateOrder') + .map((call) => call.params[2] as number); + expect(ids).toHaveLength(3); + expect(new Set(ids).size).toBe(3); + for (const id of ids) { + expect(Number.isSafeInteger(id)).toBe(true); + expect(id).toBeGreaterThan(0); + expect(id).toBeLessThan(2 ** 48); + } + }); + + it('a colliding random draw is retried until the id is unique', async () => { + const { provider, calls } = buildProvider(); + // One 6-byte crypto draw per candidate. Force the second + // placement's first candidate to collide with the first + // placement's id, then verify the allocator retries with a fresh + // draw instead of reusing the id. The spy falls through to real + // crypto once the queue is exhausted, so the retry loop cannot + // spin forever even if this sequence is wrong. + const cryptoObj = ensureWebCrypto(); + const realRandom = cryptoObj.getRandomValues.bind(cryptoObj); + const queue: number[] = [0x80, 0x80, 0x40]; + const randomSpy = jest + .spyOn(cryptoObj, 'getRandomValues') + .mockImplementation( + (array: TView): TView => { + const next = queue.shift(); + if (next !== undefined && array instanceof Uint8Array) { + array.fill(next); + return array; + } + return realRandom(array as never) as TView; + }, + ); + try { + const first = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + const second = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90001', + }); + expect(first.success).toBe(true); + expect(second.success).toBe(true); + const ids = calls + .filter((call) => call.function === '_signCreateOrder') + .map((call) => call.params[2] as number); + const of = (byte: number): number => { + const third = byte * 65_536 + byte * 256 + byte; + return third * 2 ** 24 + third; + }; + expect(ids).toStrictEqual([of(0x80), of(0x40)]); + // Three draws prove the colliding candidate was rejected and + // redrawn. + expect(randomSpy).toHaveBeenCalledTimes(3); + } finally { + randomSpy.mockRestore(); + } + }); + + it('a zero draw is rejected and redrawn, never issued as a client id', async () => { + const { provider, calls } = buildProvider(); + const cryptoObj = ensureWebCrypto(); + const zeroQueue: number[] = [0x00, 0xc0]; + const randomSpy = jest + .spyOn(cryptoObj, 'getRandomValues') + .mockImplementation( + (array: TView): TView => { + const next = zeroQueue.shift(); + if (next !== undefined && array instanceof Uint8Array) { + array.fill(next); + } + return array; + }, + ); + try { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(result.success).toBe(true); + const ids = calls + .filter((call) => call.function === '_signCreateOrder') + .map((call) => call.params[2] as number); + const third = 0xc0 * 65_536 + 0xc0 * 256 + 0xc0; + expect(ids).toStrictEqual([third * 2 ** 24 + third]); + expect(randomSpy).toHaveBeenCalledTimes(2); + } finally { + randomSpy.mockRestore(); + } + }); + + it('grouped TP/SL ids are unique against each other and prior placements', async () => { + const { provider, calls } = buildProvider(); + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '80000', + }); + const orderId = calls.find((call) => call.function === '_signCreateOrder') + ?.params[2] as number; + const groupedCall = calls.find( + (call) => call.function === '_signCreateGroupedOrders', + ); + expect(groupedCall).toBeDefined(); + // Grouped params: [accountIndex, groupingType, orderCount, ...orders, + // nonce] where each order is 10 elements with the client id at offset 1. + const groupedParams = groupedCall?.params as (string | number)[]; + const takeProfitId = groupedParams[4] as number; + const stopLossId = groupedParams[14] as number; + const allIds = [orderId, takeProfitId, stopLossId]; + for (const id of allIds) { + expect(Number.isSafeInteger(id)).toBe(true); + expect(id).toBeGreaterThan(0); + expect(id).toBeLessThan(2 ** 48); + } + expect(new Set(allIds).size).toBe(3); + }); + }); + + describe('full-close precision and validate/execute parity', () => { + const dustPosition = ( + position: string, + ): { code: number; accounts: (typeof ACCOUNT)[] } => ({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position }], + }, + ], + }); + + it('a deliberate 99% partial dust close is rejected, never bumped to 100%', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue( + dustPosition('0.0001'), + ); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.000099', + orderType: 'market', + reduceOnly: true, + currentPrice: 90000, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('below the Lighter minimum'); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }); + + it('an exact-size dust close still bumps to the venue minimum', async () => { + const { provider, calls, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue( + dustPosition('0.0001'), + ); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.0001', + orderType: 'market', + reduceOnly: true, + currentPrice: 90000, + }); + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + expect(orderCall?.params[3]).toBe('20'); + }); + + it('validateOrder matches placement: an isFullClose lie without reduceOnly is invalid', async () => { + const { provider } = buildProvider(); + const result = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.00001', + orderType: 'market', + isFullClose: true, + currentPrice: 90000, + }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('below the Lighter minimum'); + }); + + it('validateOrder approves a live-verified reduce-only full close like placement', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue( + dustPosition('0.00001'), + ); + const result = await provider.validateOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.00001', + orderType: 'market', + reduceOnly: true, + currentPrice: 90000, + }); + expect(result).toStrictEqual({ isValid: true }); + }); + + it('validateClosePosition rejects the shapes closePosition refuses', async () => { + const { provider } = buildProvider(); + expect( + await provider.validateClosePosition({ + symbol: 'BTC', + orderType: 'limit', + }), + ).toMatchObject({ + isValid: false, + error: 'Limit close requires a price', + }); + expect( + ( + await provider.validateClosePosition({ + symbol: 'BTC', + usdAmount: '-5', + }) + ).isValid, + ).toBe(false); + expect( + ( + await provider.validateClosePosition({ + symbol: 'BTC', + size: '0', + }) + ).isValid, + ).toBe(false); + expect( + await provider.validateClosePosition({ symbol: 'BTC' }), + ).toStrictEqual({ isValid: true }); + }); + + it('validateClosePosition agrees with execution on live sizing', async () => { + const { provider, clientInstance } = buildProvider(); + const dust = { + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.0001' }], + }, + ], + }; + clientInstance.getAccountByIndex.mockResolvedValue(dust); + // Explicit below-min PARTIAL: both validator and execution reject. + const partialValidation = await provider.validateClosePosition({ + symbol: 'BTC', + size: '0.000099', + currentPrice: 90000, + }); + expect(partialValidation.isValid).toBe(false); + expect(partialValidation.error).toContain('below the Lighter minimum'); + const partialExecution = await provider.closePosition({ + symbol: 'BTC', + size: '0.000099', + currentPrice: 90000, + }); + expect(partialExecution.success).toBe(false); + // Exact dust full close: both approve. + expect( + ( + await provider.validateClosePosition({ + symbol: 'BTC', + size: '0.0001', + currentPrice: 90000, + }) + ).isValid, + ).toBe(true); + const exactExecution = await provider.closePosition({ + symbol: 'BTC', + size: '0.0001', + currentPrice: 90000, + }); + expect(exactExecution.success).toBe(true); + }); + + it('validates limit closes at the caller price and rejects 0/NaN prices', async () => { + const { provider } = buildProvider(); + for (const price of ['0', 'abc']) { + const result = await provider.validateClosePosition({ + symbol: 'BTC', + orderType: 'limit', + price, + }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Invalid limit price'); + } + }); + + it('sizes market close validation at the FRESH venue price, not a stale snapshot', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + }, + ], + }); + // Discriminating stale price: at the caller's HIGH snapshot of + // 1,000,000 the close of 0.00005 BTC = $50, which stale-price + // validation would APPROVE. At the fresh venue price of 100,000 it is + // $5 — below the $10 minimum — so fresh-price validation rejects. + const result = await provider.validateClosePosition({ + symbol: 'BTC', + size: '0.00005', + currentPrice: 1_000_000, + }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('below the Lighter minimum'); + // Execution parity: the same request fails the same way. + const execution = await provider.closePosition({ + symbol: 'BTC', + size: '0.00005', + currentPrice: 1_000_000, + }); + expect(execution.success).toBe(false); + expect(execution.error).toContain('below the Lighter minimum'); + }); + + it('fails market close validation closed when the fresh venue price is missing or zero', async () => { + for (const orderBookDetails of [ + [], + [{ symbol: 'BTC', lastTradePrice: 0 }], + ]) { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + }, + ], + }); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails, + }); + const result = await provider.validateClosePosition({ + symbol: 'BTC', + size: '0.0005', + currentPrice: 100000, + }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('No live venue price available'); + // Execution parity: closePosition refuses with the same error. + const execution = await provider.closePosition({ + symbol: 'BTC', + size: '0.0005', + currentPrice: 100000, + }); + expect(execution.success).toBe(false); + expect(execution.error).toContain('No live venue price available'); + } + }); + + it('rejects a market close when the fresh price drifted beyond tolerance, in validation and execution', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + }, + ], + }); + // Sized at 90,000 but the fresh venue price is 100,000: ~11.1% move + // against a 5% default tolerance. Size $50 at the fresh price, so + // ONLY the drift check can be the rejection. + const request = { + symbol: 'BTC', + size: '0.0005', + currentPrice: 90000, + priceAtCalculation: 90000, + }; + const result = await provider.validateClosePosition(request); + expect(result.isValid).toBe(false); + expect(result.error).toContain('slippage tolerance since sizing'); + const execution = await provider.closePosition(request); + expect(execution.success).toBe(false); + expect(execution.error).toContain('slippage tolerance since sizing'); + }); + + it("rejects an 'Infinity' limit price in validators and placement alike", async () => { + const { provider, calls } = buildProvider(); + // parseFloat('Infinity') === Infinity, which passes a bare > 0 check; + // all three surfaces must refuse it before integerization/signing. + const closeValidation = await provider.validateClosePosition({ + symbol: 'BTC', + orderType: 'limit', + price: 'Infinity', + }); + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('Invalid limit price'); + const orderValidation = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: 'Infinity', + }); + expect(orderValidation.isValid).toBe(false); + expect(orderValidation.error).toContain('Invalid limit price'); + const placement = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: 'Infinity', + }); + expect(placement.success).toBe(false); + // Strict-parse parity: placement now rejects with the SAME message + // as the validators. + expect(placement.error).toContain('Invalid limit price'); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + // Parity in the OTHER direction: a MARKET order ignores params.price + // (placement sizes at the fresh venue price), so an irrelevant + // 'Infinity' must not fail validation for an order placement accepts. + const marketValidation = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', + price: 'Infinity', + }); + expect(marketValidation.isValid).toBe(true); + const marketPlacement = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', + price: 'Infinity', + currentPrice: 100000, + }); + expect(marketPlacement.success).toBe(true); + }); + + it('preserves subscriber state when channel setup hits a capability gate', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, + }); + // The bound wallet resolves to a Premium account. + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [{ ...ACCOUNT, accountType: 1 }], + }); + getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + StreamFakeWebSocket.instances = []; + const accountCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Capability gates are not "no data": no false-empty emissions. + expect(accountCallback).not.toHaveBeenCalled(); + expect(ordersCallback).not.toHaveBeenCalled(); + unsubscribeAccount(); + unsubscribeOrders(); + }); + + it('emits order book levels with the full contract shape (cumulative totals, notionals, maxTotal)', async () => { + // Regression (found live on device): levels were fanned out as bare + // {price, size}, so the depth chart's parseFloat(level.total) produced + // NaN Y-coordinates and crashed the native SVG path parser + // (RNSVGPathParser InvalidNumber). + const { provider } = buildProvider({ webSocketCtor: fakeStreamCtor }); + const bookCallback = jest.fn(); + const unsubscribe = provider.subscribeToOrderBook({ + symbol: 'BTC', + levels: 5, + callback: bookCallback, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socket = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + socket.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'subscribed/order_book', + channel: 'order_book:1', + order_book: { + bids: [ + { price: '90000', size: '0.5' }, + { price: '89990', size: '1.5' }, + ], + asks: [ + { price: '90010', size: '0.4' }, + { price: '90020', size: '2.0' }, + ], + }, + }), + }); + expect(bookCallback).toHaveBeenCalled(); + const book = + bookCallback.mock.calls[bookCallback.mock.calls.length - 1][0]; + // Cumulative sizes per side. + expect( + book.bids.map((level: { total: string }) => level.total), + ).toStrictEqual(['0.5', '2']); + expect( + book.asks.map((level: { total: string }) => level.total), + ).toStrictEqual(['0.4', '2.4']); + // Per-level notional and cumulative notional. + expect(parseFloat(book.bids[0].notional)).toBeCloseTo(45000); + expect(parseFloat(book.bids[1].totalNotional)).toBeCloseTo( + 45000 + 89990 * 1.5, + ); + // Book-level scaling fields the UI depends on. + expect(parseFloat(book.maxTotal)).toBeCloseTo(2.4); + expect(typeof book.lastUpdated).toBe('number'); + // No NaN anywhere the depth chart reads. + for (const side of [book.bids, book.asks]) { + for (const level of side) { + for (const field of [ + 'price', + 'size', + 'total', + 'notional', + 'totalNotional', + ]) { + expect(Number.isFinite(parseFloat(level[field]))).toBe(true); + } + } + } + unsubscribe(); + }); + + it('drops malformed order book levels at the WS boundary instead of poisoning cumulative totals', async () => { + // The WS payload is cast, not validated: a level with a malformed + // price or size would flow into the cumulative-total math as NaN and + // reach the depth chart as an invalid SVG coordinate. + const { provider } = buildProvider({ webSocketCtor: fakeStreamCtor }); + const bookCallback = jest.fn(); + const unsubscribe = provider.subscribeToOrderBook({ + symbol: 'BTC', + callback: bookCallback, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socket = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + socket.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'subscribed/order_book', + channel: 'order_book:1', + order_book: { + bids: [ + { price: '90000', size: '0.5' }, + { price: 'abc', size: '1' }, + { size: '2' }, + { price: '89990', size: 'oops' }, + ], + asks: [{ price: '90010', size: '0.4' }, { price: '90020' }], + }, + }), + }); + expect(bookCallback).toHaveBeenCalled(); + const book = + bookCallback.mock.calls[bookCallback.mock.calls.length - 1][0]; + expect(book.bids).toHaveLength(1); + expect(book.asks).toHaveLength(1); + for (const side of [book.bids, book.asks]) { + for (const level of side) { + for (const field of [ + 'price', + 'size', + 'total', + 'notional', + 'totalNotional', + ]) { + expect(Number.isFinite(parseFloat(level[field]))).toBe(true); + } + } + } + expect(Number.isFinite(parseFloat(book.maxTotal))).toBe(true); + unsubscribe(); + }); + + it('drops malformed candles from the REST seed and the WS channel', async () => { + // Same boundary: a candle with a missing field would be stringified + // as "undefined" and reach the chart as NaN. + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeStreamCtor, + }); + jest + .spyOn(clientInstance, 'getCandles') + .mockImplementation() + .mockResolvedValue({ + code: 200, + c: [ + { t: 1000, o: 1, h: 2, l: 0.5, c: 1.5, v: 10 }, + { t: 1500, o: 1, h: 2, l: 0.5, v: 10 }, // missing close + ], + }); + const candleCallback = jest.fn(); + const unsubscribe = provider.subscribeToCandles({ + symbol: 'BTC', + interval: '1h', + callback: candleCallback, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const seeded = + candleCallback.mock.calls[candleCallback.mock.calls.length - 1][0]; + expect(seeded.candles).toHaveLength(1); + const socket = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + socket.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'update/candle', + channel: 'candle:1:1h', + candles: [ + { t: 2000, o: 1.5, h: 2.5, l: 1, c: 2, v: 5 }, + { t: 3000, o: 'x', h: 2, l: 1, c: 2, v: 5 }, + { o: 1, h: 2, l: 1, c: 2, v: 5 }, + ], + }), + }); + const live = + candleCallback.mock.calls[candleCallback.mock.calls.length - 1][0]; + expect( + live.candles.map((candle: { time: number }) => candle.time), + ).toStrictEqual([1000, 2000]); + for (const candle of live.candles) { + for (const field of ['open', 'high', 'low', 'close', 'volume']) { + expect(Number.isFinite(parseFloat(candle[field]))).toBe(true); + } + } + unsubscribe(); + }); + + it('withholds a fills snapshot containing unsupported (nonzero-fee) fills', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }); + getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + StreamFakeWebSocket.instances = []; + const fillsCallback = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ + callback: fillsCallback, + }); + await provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socket = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + socket.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fillsCallback.mockClear(); + // Snapshot with one supported and one unsupported (nonzero-fee) fill: + // emitting the partial remainder would overwrite valid history. + socket.onmessage?.({ + data: JSON.stringify({ + type: 'subscribed/account_all_trades', + channel: 'account_all_trades:28', + trades: { + '1': [ + { + trade_id: 1, + market_id: 1, + size: '0.001', + price: '90000', + ask_id: 1, + bid_id: 2, + ask_account_id: 28, + bid_account_id: 7, + is_maker_ask: false, + timestamp: 1700000000000, + }, + { + trade_id: 2, + market_id: 1, + size: '0.001', + price: '90000', + ask_id: 3, + bid_id: 4, + ask_account_id: 28, + bid_account_id: 7, + is_maker_ask: false, + timestamp: 1700000001000, + taker_fee: 45000, + }, + ], + }, + }), + }); + expect(fillsCallback).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('validateClosePosition rejects a close with no open position', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [] }], + }); + const result = await provider.validateClosePosition({ symbol: 'BTC' }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('No open Lighter position'); + }); + }); + + describe('round-4 session races', () => { + const accountB = { ...ACCOUNT, index: 900 }; + const perAddressLookup = + () => + ( + address: string, + ): Promise<{ + code: number; + l1Address: string; + subAccounts: (typeof ACCOUNT)[]; + }> => + Promise.resolve( + address.toLowerCase() === '0xbbbb' + ? { code: 200, l1Address: '0xbbbb', subAccounts: [accountB] } + : { + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }, + ); + + it('a delayed getAccountState response never surfaces as the new account', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + await provider.getAccountState(); // bind under A + let releaseResponse: (value: unknown) => void = () => undefined; + clientInstance.getAccountByIndex.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseResponse = resolve; + }), + ); + const delayedRead = provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // External switch: nothing else observes it before the response lands. + getUserAddressMock.mockReturnValue('0xbbbb'); + releaseResponse({ code: 200, accounts: [ACCOUNT] }); + const result = await delayedRead; + // Cancelled → empty state, never account A's balances. + expect(result.totalBalance).toBe('0'); + }); + + it('getOpenOrders returns nothing when the account switches between index and token', async () => { + const { provider, clientInstance, getUserAddressMock, bridge } = + buildProvider({ configuredAccountIndex: null }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + await provider.getAccountState(); // bind under A + // Stall the auth-token mint (the step between index and token use). + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let releaseToken: () => void = () => undefined; + let stallOnce = true; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_createAuthToken' && stallOnce) { + stallOnce = false; + await new Promise((resolve) => { + releaseToken = resolve; + }); + } + return realImplementation(call); + }, + ); + const readUnderA = provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 0)); + getUserAddressMock.mockReturnValue('0xbbbb'); + releaseToken(); + const orders = await readUnderA; + expect(orders).toStrictEqual([]); + // The A index + fresh token pairing never reached the venue. + expect(clientInstance.getActiveOrders).not.toHaveBeenCalled(); + }); + + it("getOrders never merges one account's history with another's open orders", async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + await provider.getAccountState(); // bind under A + // Historical leg resolves under A; the OPEN leg stalls and the wallet + // switches while it is in flight. + let releaseOpen: (value: unknown) => void = () => undefined; + clientInstance.getActiveOrders.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseOpen = resolve; + }), + ); + const mergedRead = provider.getOrders(); + await new Promise((resolve) => setTimeout(resolve, 0)); + getUserAddressMock.mockReturnValue('0xbbbb'); + releaseOpen({ code: 200, orders: [] }); + const orders = await mergedRead; + // The merge is refused outright — no A-historical leakage. + expect(orders).toStrictEqual([]); + }); + + it('a paused write never signs after ALL accounts are deselected', async () => { + const { provider, clientInstance, getUserAddressMock, calls } = + buildProvider({ configuredAccountIndex: null }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + const warmed = await provider.isReadyToTrade(); + expect(warmed.ready).toBe(true); + // Warming registered the venue key; only post-deselection sends count. + clientInstance.sendTx.mockClear(); + let releaseNonce: (value: unknown) => void = () => undefined; + let nonceRequested: () => void = () => undefined; + const noncePaused = new Promise((resolve) => { + nonceRequested = resolve; + }); + clientInstance.getNextNonce.mockImplementationOnce(() => { + nonceRequested(); + return new Promise((resolve) => { + releaseNonce = resolve; + }); + }); + const writeUnderA = provider.cancelOrder({ + orderId: '555', + symbol: 'BTC', + }); + await noncePaused; + // All accounts deselected while the write is paused at its nonce. + getUserAddressMock.mockImplementation(() => { + throw new Error('NO_ACCOUNT_SELECTED'); + }); + releaseNonce({ code: 200, nonce: 42 }); + const result = await writeUnderA; + expect(result.success).toBe(false); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('a paused write never submits after provider disconnect', async () => { + const { provider, clientInstance, calls } = buildProvider({ + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + const warmed = await provider.isReadyToTrade(); + expect(warmed.ready).toBe(true); + clientInstance.sendTx.mockClear(); + let releaseNonce: (value: unknown) => void = () => undefined; + let nonceRequested: () => void = () => undefined; + const noncePaused = new Promise((resolve) => { + nonceRequested = resolve; + }); + clientInstance.getNextNonce.mockImplementationOnce(() => { + nonceRequested(); + return new Promise((resolve) => { + releaseNonce = resolve; + }); + }); + const writeUnderA = provider.cancelOrder({ + orderId: '555', + symbol: 'BTC', + }); + await noncePaused; + // The provider is disconnected (e.g. venue switch) mid-write. + await provider.disconnect(); + releaseNonce({ code: 200, nonce: 42 }); + const result = await writeUnderA; + expect(result.success).toBe(false); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('a configured account index without a bound wallet requests no user channels', async () => { + const { provider, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: 28, + }); + // No wallet account selected at mount time. + getUserAddressMock.mockImplementation(() => { + throw new Error('NO_ACCOUNT_SELECTED'); + }); + StreamFakeWebSocket.instances = []; + const unsubscribe = provider.subscribeToAccount({ callback: jest.fn() }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socket = StreamFakeWebSocket.instances[0]; + socket?.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The configured index alone must never subscribe user channels. + expect( + (socket?.sent ?? []).some((frame) => frame.includes('user_stats/')), + ).toBe(false); + + // A NON-OWNER wallet is then selected: the configured account (owned + // by 0x8d7f…) must be rejected, never subscribed for wallet 0xbbbb. + getUserAddressMock.mockImplementation(() => '0xbbbb'); + await expect(provider.getAccountState()).rejects.toThrow( + 'not owned by the selected wallet', + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socketsAfterMismatch = StreamFakeWebSocket.instances.map( + (instance) => instance.sent, + ); + expect( + socketsAfterMismatch + .flat() + .some((frame) => frame.includes('user_stats/')), + ).toBe(false); + + // The OWNER wallet is selected: channels for the account appear. + getUserAddressMock.mockImplementation(() => ACCOUNT.l1Address); + await provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const lastSocket = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + lastSocket.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + lastSocket.sent.some((frame) => frame.includes('user_stats/28')), + ).toBe(true); + unsubscribe(); + }); + + it("an aborted setup auth failure never blanks the new session's order subscribers", async () => { + const { provider, clientInstance, getUserAddressMock, bridge } = + buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + StreamFakeWebSocket.instances = []; + const ordersCallback = jest.fn(); + // Stall then FAIL account A's auth-token mint. + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let failAuthA: () => void = () => undefined; + let stallOnce = true; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_createAuthToken' && stallOnce) { + stallOnce = false; + await new Promise((_resolve, reject) => { + failAuthA = (): void => reject(new Error('auth backend down')); + }); + } + return realImplementation(call); + }, + ); + const unsubscribe = provider.subscribeToOrders({ + callback: ordersCallback, + }); + await provider.getAccountState(); // bind under A; channel setup stalls at auth + await new Promise((resolve) => setTimeout(resolve, 0)); + // Switch to B; its own setup runs with working auth. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + ordersCallback.mockClear(); + // A's stalled auth finally FAILS: its inner catch must not blank B. + failAuthA(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ordersCallback).not.toHaveBeenCalledWith([], expect.anything()); + expect(ordersCallback).not.toHaveBeenCalledWith([]); + unsubscribe(); + }); + + it('routes no account frame after an unobserved external switch', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + StreamFakeWebSocket.instances = []; + const accountCallback = jest.fn(); + const unsubscribe = provider.subscribeToAccount({ + callback: accountCallback, + }); + await provider.getAccountState(); // bind under A + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socketA = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + socketA.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + accountCallback.mockClear(); + // EXTERNAL switch: no provider call observes it before the frame. + getUserAddressMock.mockReturnValue('0xbbbb'); + socketA.onmessage?.({ + data: JSON.stringify({ + type: 'update/user_stats', + channel: 'user_stats:28', + stats: { portfolio_value: '9999', available_balance: '9999' }, + }), + }); + // The frame itself is the first observer: it must be dropped, and + // the rebind replaces the socket for account B. + expect(accountCallback).not.toHaveBeenCalled(); + const socketB = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + expect(socketB).not.toBe(socketA); + unsubscribe(); + }); + + it('a deferred onopen auth continuation never reinserts a stale channel after a switch', async () => { + const { provider, clientInstance, getUserAddressMock, bridge } = + buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + StreamFakeWebSocket.instances = []; + // Orders subscription wants the authenticated channel. + const unsubscribe = provider.subscribeToOrders({ callback: jest.fn() }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socketA = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + // The channel setup cached a fresh (+600s) token; expire it so socket + // A's onopen genuinely enters the deferred re-mint branch. + // Restored in the finally below so a failed assertion cannot poison + // later tests with a frozen clock. + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 700_000); + try { + const realImplementation = ( + bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + let stallNext = true; + let releaseToken: () => void = () => undefined; + let stallEntered: () => void = () => undefined; + const refreshEntered = new Promise((resolve) => { + stallEntered = resolve; + }); + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_createAuthToken' && stallNext) { + stallNext = false; + stallEntered(); + await new Promise((resolve) => { + releaseToken = resolve; + }); + } + return realImplementation(call); + }, + ); + socketA.open(); + // The deferred re-mint MUST have started, or this test proves nothing. + await refreshEntered; + await new Promise((resolve) => setTimeout(resolve, 0)); + // Switch to B: rebind replaces the socket and the channel set. + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + const socketB = + StreamFakeWebSocket.instances[ + StreamFakeWebSocket.instances.length - 1 + ]; + expect(socketB).not.toBe(socketA); + socketB.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const framesBefore = socketB.sent.length; + // The stale continuation resolves AFTER the switch. + releaseToken(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + // No account-A channel was sent on B's socket by the stale continuation. + const framesAfter = socketB.sent.slice(framesBefore); + expect( + framesAfter.some((frame) => frame.includes('account_all_orders/28')), + ).toBe(false); + expect( + socketB.sent.some((frame) => frame.includes('account_all_orders/28')), + ).toBe(false); + // The deferred mint really ran exactly once through the stall. + expect(stallNext).toBe(false); + } finally { + nowSpy.mockRestore(); + } + unsubscribe(); + }); + }); + + describe('validateOrder usd sizing', () => { + it('validates a USD-sized order through the min-size calculation', async () => { + // Regression: this path read `usdAmount` outside its declaring block + // (a runtime ReferenceError under plain TS) — a valid usdAmount with + // a positive reference price must reach the min-size check and pass. + const { provider } = buildProvider(); + const result = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0', + usdAmount: '5000', + orderType: 'limit', + price: '100000', + }); + expect(result).toStrictEqual({ isValid: true }); + }); + + it('rejects a USD-sized order below the venue minimum', async () => { + const { provider } = buildProvider(); + const result = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0', + // $5 at $100k → 0.00005 BTC, below min base 0.0002. + usdAmount: '5', + orderType: 'limit', + price: '100000', + }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('below the Lighter minimum'); + }); + + it('rejects an invalid usdAmount before any sizing math', async () => { + const { provider } = buildProvider(); + const result = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '1', + usdAmount: '-5', + orderType: 'limit', + price: '100000', + }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Invalid usdAmount'); + }); + }); + + describe('closePosition semantics', () => { + it('routes a limit close with the requested price, not a market order', async () => { + const { provider, calls } = buildProvider(); + const result = await provider.closePosition({ + symbol: 'BTC', + size: '0.05', + orderType: 'limit', + price: '120000', + }); + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + // Limit type (0), GTT, and the requested price scaled by decimals. + expect(orderCall?.params[6]).toBe(0); + expect(orderCall?.params[4]).toBe('1200000'); + expect(orderCall?.params[8]).toBe(1); + }); + + it('rejects a limit close without a price', async () => { + const { provider } = buildProvider(); + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'limit', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('requires a price'); + }); + + it('honors usdAmount sizing and slippage on a market close', async () => { + const { provider, calls } = buildProvider(); + const result = await provider.closePosition({ + symbol: 'BTC', + usdAmount: '5000', + maxSlippageBps: 100, + priceAtCalculation: 100000, + }); + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + // usdAmount / fresh reference (100000) = 0.05 → sized at reference, + // not at the protection price. + expect(orderCall?.params[3]).toBe('5000'); + // Sell-side protection price offset by 1% (100 bps): 99000. + expect(orderCall?.params[4]).toBe('990000'); + }); + + it('refuses drifted market closes beyond the slippage tolerance', async () => { + const { provider } = buildProvider(); + const result = await provider.closePosition({ + symbol: 'BTC', + usdAmount: '5000', + maxSlippageBps: 100, + // Fresh venue price is 100000; a 90000 snapshot is >1% away. + priceAtCalculation: 90000, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('slippage tolerance'); + }); + }); + + describe('history and routes', () => { + it('getOrders merges open orders with the historical lifecycle', async () => { + const { provider, clientInstance } = buildProvider(); + const orders = await provider.getOrders(); + expect(clientInstance.getInactiveOrders).toHaveBeenCalled(); + expect(orders.map((order) => order.status)).toStrictEqual([ + 'open', + 'filled', + ]); + }); + + it('getUserHistory maps deposits and withdrawals with venue statuses', async () => { + const { provider } = buildProvider(); + const history = await provider.getUserHistory(); + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ + type: 'withdrawal', + amount: '1.000000', + status: 'pending', + asset: 'USDC', + }); + expect(history[1]).toMatchObject({ + type: 'deposit', + amount: '10000.000000', + status: 'completed', + }); + }); + + it('getUserNonFundingLedgerUpdates merges signed flows newest first', async () => { + const { provider } = buildProvider(); + const updates = await provider.getUserNonFundingLedgerUpdates(); + expect(updates.map((update) => update.delta.type)).toStrictEqual([ + 'transferOut', + 'withdraw', + 'deposit', + ]); + expect(updates[0].delta.usdc).toBe('-100.000000'); + expect(updates[1].delta.usdc).toBe('-1.000000'); + expect(updates[2].delta.usdc).toBe('10000.000000'); + }); + + it('exposes the venue bridge route on mainnet only; testnet advertises NO routes (unreachable devnet L1)', () => { + // Testnet settles on the venue-hosted devnet chain 123456 that the + // wallet cannot reach: advertising it made mobile pay-with flows + // build a deposit transaction on an unknown chain and fail the + // trade ("Invalid chain ID 0x1e240" — found in device validation). + const { provider } = buildProvider(); + expect(provider.getDepositRoutes()).toStrictEqual([]); + expect(provider.getWithdrawalRoutes()).toStrictEqual([]); + // The isTestnet OVERRIDE is honored (route contract): a testnet + // provider asked for mainnet routes returns the Ethereum L1 bridge + // — DepositService uses this to scaffold the deposit-and-trade + // transaction on a chain the wallet can reach. + const [scaffoldRoute] = provider.getDepositRoutes({ isTestnet: false }); + expect(scaffoldRoute.chainId).toBe('eip155:1'); + expect(scaffoldRoute.contractAddress).toBe( + '0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7', + ); + + const { provider: mainnetProvider } = buildProvider({ + isTestnet: false, + }); + const [mainnetRoute] = mainnetProvider.getWithdrawalRoutes(); + expect(mainnetRoute.chainId).toBe('eip155:1'); + expect(mainnetRoute.contractAddress).toBe( + '0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7', + ); + expect(mainnetRoute.assetId).toContain( + 'erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ); + }); + }); + + describe('stubs', () => { + it('returns not-supported results for unimplemented writes', async () => { + const { provider } = buildProvider(); + const results = await Promise.all([ + provider.editOrder({} as never), + provider.closePosition({} as never), + provider.updatePositionTPSL({} as never), + provider.updateMargin({} as never), + provider.withdraw({} as never), + ]); + for (const result of results) { + expect(result.success).toBe(false); + } + // Batch operations are deliberately absent (optional interface + // members) so the controller falls back to per-item calls. + const optionalBatch = provider as unknown as Record; + expect(optionalBatch.cancelOrders).toBeUndefined(); + expect(optionalBatch.closePositions).toBeUndefined(); + }); + + it('gates the historical portfolio instead of returning false zeros', async () => { + const { provider } = buildProvider(); + await expect(provider.getHistoricalPortfolio()).rejects.toThrow( + 'unavailable', + ); + }); + + it('validates only simple limit/market orders', async () => { + const { provider } = buildProvider(); + expect( + await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }), + ).toStrictEqual({ isValid: true }); + expect( + await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + }), + ).toMatchObject({ isValid: false }); + expect(await provider.validateDeposit({} as never)).toMatchObject({ + isValid: false, + }); + expect(await provider.validateClosePosition({} as never)).toMatchObject({ + isValid: false, + }); + expect(await provider.validateWithdrawal({} as never)).toMatchObject({ + isValid: false, + }); + }); + + it('derives estimates instead of returning false zeros', async () => { + const { provider } = buildProvider(); + // Maintenance fraction: venue fallback (no margin data mocked). + expect( + await provider.calculateMaintenanceMargin({} as never), + ).toBeCloseTo(1 / (2 * 50)); + // The liquidation preview uses the ISOLATED formula (positions open + // isolated by default) with the venue's own maintenance fraction: + // BTC fixture maintenance 120 hundredths of a percent -> 1.2%. + // long: 100 - (0.1 - 0.012)*100/(1 - 0.012) = 91.0931... + expect( + parseFloat( + await provider.calculateLiquidationPrice({ + entryPrice: 100, + leverage: 10, + direction: 'long', + asset: 'BTC', + }), + ), + ).toBeCloseTo(91.0931, 3); + // short: 100 + (0.1 - 0.012)*100/(1 + 0.012) = 108.6956... + expect( + parseFloat( + await provider.calculateLiquidationPrice({ + entryPrice: 100, + leverage: 10, + direction: 'short', + asset: 'BTC', + }), + ), + ).toBeCloseTo(108.6957, 3); + // Unknown asset falls back to the constant-derived maintenance + // (1 / (2 * 50) = 1%): 100 - 0.09*100/0.99 = 90.9090... + expect( + parseFloat( + await provider.calculateLiquidationPrice({ + entryPrice: 100, + leverage: 10, + direction: 'long', + }), + ), + ).toBeCloseTo(90.909, 3); + // Malformed inputs report the explicit zero contract. + expect( + await provider.calculateLiquidationPrice({ + entryPrice: 0, + leverage: 10, + direction: 'long', + }), + ).toBe('0.00'); + expect(await provider.getMaxLeverage('BTC')).toBeGreaterThan(0); + // Fee rates come from the venue's per-market metadata (currently 0). + const fees = await provider.calculateFees({ + orderType: 'market', + symbol: 'BTC', + amount: '100', + }); + expect(fees.protocolFeeRate).toBe(parseFloat(BTC_MARKET.takerFee)); + expect(fees.feeAmount).toBe(100 * parseFloat(BTC_MARKET.takerFee)); + }); + + it('returns immediate empty snapshots from subscriptions', async () => { + const { provider } = buildProvider(); + const callback = jest.fn(); + // No `as never` here: force-casting subscription params is exactly + // what hid the missing required `symbol` on subscribeToOrderBook and + // the bare-level order book payload defect. + const unsubscribers = [ + provider.subscribeToPrices({ symbols: ['BTC'], callback }), + provider.subscribeToPositions({ callback }), + provider.subscribeToOrderFills({ callback }), + provider.subscribeToOrders({ callback }), + provider.subscribeToAccount({ callback }), + provider.subscribeToOICaps({ callback }), + provider.subscribeToCandles({ + symbol: 'BTC', + interval: '1h', + callback, + }), + provider.subscribeToOrderBook({ symbol: 'BTC', callback }), + ]; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(callback).toHaveBeenCalled(); + for (const unsubscribe of unsubscribers) { + expect(() => unsubscribe()).not.toThrow(); + } + expect(() => provider.setLiveDataConfig({})).not.toThrow(); + }); + + it('returns an explorer URL', () => { + const { provider } = buildProvider(); + expect(provider.getBlockExplorerUrl('0xabc')).toContain('/address/0xabc'); + expect(provider.getBlockExplorerUrl()).toMatch(/^https:/u); + }); + }); + describe('round-21 quarantine persistence, selective acknowledgment and durable manual state', () => { + it('an unacknowledged recovered outcome blocks the SECOND and THIRD retries too (no empty-entries bypass)', async () => { + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ registeredKey }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.failResponseOnce(13); + expect((await built.provider.withdraw({ amount: '25' })).success).toBe( + false, + ); + // Retry 1 resolves the entry, quarantines the outcome, blocks. + const retry1 = await built.provider.withdraw({ amount: '25' }); + expect(retry1.success).toBe(false); + expect(retry1.error).toContain('actually completed'); + // Retries 2 and 3 arrive with ZERO unresolved entries — the + // quarantine check runs BEFORE the empty-entries return, so they + // stay blocked until the outcome is acknowledged. + const retry2 = await built.provider.withdraw({ amount: '25' }); + expect(retry2.success).toBe(false); + expect(retry2.error).toContain('actually completed'); + const retry3 = await built.provider.updateMargin({ + symbol: 'BTC', + amount: '10', + }); + expect(retry3.success).toBe(false); + expect(retry3.error).toContain('actually completed'); + await acknowledgeAllRecovered(built.provider); + const after = await built.provider.withdraw({ amount: '25' }); + expect(after.success).toBe(true); + }); + + it('getRecoveredDispatches is READ-ONLY and acknowledgment is selective per stable id', async () => { + const registeredKey = '9c'.repeat(40); + const infra = createMockInfrastructure(); + // TWO ambiguous dispatches recorded by an earlier session (writes + // block after the first, so two entries model a restart/two-device + // ledger), both later proven consumed by exact hash. + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [ + { + nonce: 42, + txHash: 'abab000000000042', + expiresAt: 9_999_999_999_999, + kind: 13, + intent: 'withdraw:25', + owner: null, + }, + { + nonce: 43, + txHash: 'ffff000000000043', + expiresAt: 9_999_999_999_999, + kind: 29, + intent: 'updateMargin:BTC:10', + owner: null, + }, + ], + recovered: [], + }), + ); + const built = buildProvider({ + registeredKey, + platformDependencies: infra, + }); + setupTriggerVenue(built.clientInstance, built.bridge); + built.clientInstance.getTx.mockImplementation(async (hash: string) => + hash === 'abab000000000042' || hash === 'ffff000000000043' + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: hash === 'abab000000000042' ? 42 : 43, + status: 3, + } + : null, + ); + // A blocked write resolves both entries into recovered outcomes. + expect((await built.provider.withdraw({ amount: '5' })).success).toBe( + false, + ); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(2); + expect(outcomes.map((outcome) => outcome.outcome)).toStrictEqual([ + 'succeeded', + 'succeeded', + ]); + // READ-ONLY: a second read returns the SAME outcomes — nothing was + // destructively cleared by reading. + const reread = await built.provider.getRecoveredDispatches(); + expect(reread).toStrictEqual(outcomes); + // Unknown id: refused explicitly. + await expect( + built.provider.acknowledgeRecoveredDispatch('999:deadbeef'), + ).rejects.toThrow('No pending recovered'); + // Acknowledge ONE: the other outcome still blocks writes. + await built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId); + const stillBlocked = await built.provider.withdraw({ amount: '5' }); + expect(stillBlocked.success).toBe(false); + expect(stillBlocked.error).toContain('actually completed'); + expect(await built.provider.getRecoveredDispatches()).toHaveLength(1); + // Acknowledge the second: writes recover. + await built.provider.acknowledgeRecoveredDispatch(outcomes[1].recoveryId); + const after = await built.provider.withdraw({ amount: '5' }); + expect(after.success).toBe(true); + }); + + it("an account switch cannot acknowledge (or lose) another account's recovered outcome", async () => { + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ + registeredKey, + configuredAccountIndex: null, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.failResponseOnce(13); + expect((await built.provider.withdraw({ amount: '25' })).success).toBe( + false, + ); + expect((await built.provider.withdraw({ amount: '25' })).success).toBe( + false, + ); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(1); + // WALLET SWITCH: a different address owning a different account. + const otherAddress = '0x9999999999999999999999999999999999999999'; + built.getUserAddressMock.mockReturnValue(otherAddress); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: otherAddress, + subAccounts: [{ ...ACCOUNT, index: 77, l1Address: otherAddress }], + }); + // The stale id targets the OLD account's ledger: the new session + // must not clear it (its own ledger has no such outcome). + await expect( + built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId), + ).rejects.toThrow('No pending recovered'); + // Switch BACK: the outcome survived untouched and is still owed. + built.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }); + const survived = await built.provider.getRecoveredDispatches(); + expect(survived).toStrictEqual(outcomes); + }); + + it('a parked manual recovery carries reason, prior intent, survivors and required action — and survives a FAILED successor', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('take-profit', '110000'); + venue.seedTrigger('stop-loss', '80000'); + // After the FIRST old-cancel commits, the venue terminal-cancels + // one replacement leg (phase race) — parks durable manual state. + const realSend = clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + let raced = false; + clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + const result = await realSend(txType, txInfo); + if (txType === 15 && !raced) { + raced = true; + const failedAt = venue.rawTriggers.findIndex( + (row) => row.triggerPrice === '81000', + ); + if (failedAt >= 0) { + const [failedRow] = venue.rawTriggers.splice(failedAt, 1); + venue.rawInactive.push({ ...failedRow, status: 'canceled' }); + } + } + return result; + }, + ); + const parked = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '111000', + stopLossPrice: '81000', + }); + expect(parked.success).toBe(false); + const pending = await provider.getPendingManualRecoveries(); + expect(pending).toHaveLength(1); + expect(pending[0].symbol).toBe('BTC'); + expect(pending[0].reason.length).toBeGreaterThan(10); + expect(pending[0].priorIntent).toBe('replace'); + expect(Array.isArray(pending[0].survivingOrderIds)).toBe(true); + expect(pending[0].actionNeeded).toContain('TP/SL'); + // A FAILED successor intent must RETAIN the warning: the venue + // terminal-cancels the successor's create before activation. + venue.setCreateTerminal('canceled'); + const failedSuccessor = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(failedSuccessor.success).toBe(false); + expect(await provider.getPendingManualRecoveries()).toHaveLength(1); + // Only an authoritatively SUCCESSFUL successor clears it. + venue.setCreateTerminal('none'); + const renewed = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await provider.getPendingManualRecoveries()).toHaveLength(0); + }); + + it('manual-recovery discovery PROPAGATES storage errors and filters to the bound identity', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + setupTriggerVenue(built.clientInstance, built.bridge); + await built.provider.getOpenOrders(); + // A FOREIGN identity's parked warning is never surfaced here. + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify(['0xother:99:7:ETH']), + ); + await infra.diskCache.setItem( + 'lighterTpslManual:testnet:0xother:99:7:ETH', + JSON.stringify({ + version: 1, + settlementKey: '0xother:99:7:ETH', + symbol: 'ETH', + reason: 'foreign', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'op-x', + recordedAt: 1, + }), + ); + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(0); + // Corruption REJECTS — it must never degrade to \"nothing pending\". + await infra.diskCache.setItem('lighterTpslManualIndex:testnet', '{oops'); + await expect(built.provider.getPendingManualRecoveries()).rejects.toThrow( + 'corrupt', + ); + }); + + it('a leverage change committed before an order failure is reported as STRUCTURED partial state', async () => { + const built = buildProvider(); + setupTriggerVenue(built.clientInstance, built.bridge); + // Leverage submit succeeds; the ORDER dispatch then fails at the + // venue boundary. + const realSend = built.clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + built.clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + if (txType === 14) { + throw new LighterApiError('order rejected', 21000); + } + return await realSend(txType, txInfo); + }, + ); + const result = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('PARTIAL STATE'); + expect(result.partialState).toStrictEqual({ leverageUpdated: 10 }); + }); + + it("the auth-token mint runs under the bridge lease: a second account's takeover re-establishes OUR client first", async () => { + const registeredKey = '9c'.repeat(40); + const first = buildProvider({ registeredKey }); + setupTriggerVenue(first.clientInstance, first.bridge); + const second = buildProvider({ + registeredKey, + configuredAccountIndex: 99, + sharedBridge: { + bridge: first.bridge, + calls: first.calls, + fireReset: first.fireReset, + }, + }); + second.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, index: 99 }], + }); + setupTriggerVenue(second.clientInstance, second.bridge); + const order = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + } as const; + // A establishes and holds a cached token; B takes the singleton. + expect((await first.provider.placeOrder(order)).success).toBe(true); + expect((await second.provider.placeOrder(order)).success).toBe(true); + // EXPIRE A's cached token, then force a fresh mint via a read that + // needs auth: the mint must re-create A's client under the lease. + const realNow = Date.now(); + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow + 700_000); + try { + await first.provider.getOpenOrders(); + } finally { + nowSpy.mockRestore(); + } + // Sequence: every _createAuthToken is owned by the LAST-created + // client account. + const mismatches: string[] = []; + let currentOwner: number | null = null; + for (const call of first.calls) { + if (call.function === '_createClient') { + currentOwner = Number((call.params as (string | number)[])[2]); + } + if (call.function === '_createAuthToken') { + const minter = Number((call.params as (string | number)[])[0]); + if (currentOwner !== null && currentOwner !== minter) { + mismatches.push(`${String(currentOwner)}!=${String(minter)}`); + } + } + } + expect(mismatches).toStrictEqual([]); + }); + }); + describe('round-22 ledger serialization and post-dispatch fences', () => { + it('a selective acknowledgment can never overwrite a concurrent dispatch append with a stale ledger doc', async () => { + const registeredKey = '9c'.repeat(40); + const infra = createMockInfrastructure(); + // Durable NON-BLOCKING failed outcome F awaiting acknowledgment. + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [], + recovered: [ + { + recoveryId: '41:beef', + kind: 13, + intent: 'withdraw:9', + txHash: 'beef', + outcome: 'failed', + evidence: 'tx-status:4', + }, + ], + }), + ); + const built = buildProvider({ + registeredKey, + platformDependencies: infra, + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + // Warm signer setup so the gated window contains ONLY the ack read + // and the order's ledger RMW. + await built.provider.getOpenOrders(); + // Gate the ACK's ledger WRITE: it has already read the doc, and a + // concurrent placeOrder appends an unresolved entry in the window + // before the ack's (now stale) write lands. All ledger RMW must + // serialize on ONE mutex so this window cannot exist. + const realSet = ( + infra.diskCache.setItem as jest.Mock + ).getMockImplementation() as ( + key: string, + value: string, + ) => Promise; + let releaseGate: () => void = () => undefined; + const gate = { armed: true }; + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + if (gate.armed && key.startsWith('lighterNonceLedger:')) { + gate.armed = false; + await new Promise((resolve) => { + releaseGate = resolve; + }); + } + return await realSet(key, value); + }, + ); + const ackPromise = built.provider.acknowledgeRecoveredDispatch('41:beef'); + // Let the ack reach its (gated) ledger read. + await new Promise((resolve) => setTimeout(resolve, 100)); + // Concurrent dispatch whose venue commit is masked by response + // loss: its unresolved ledger entry is the only retry protection. + venue.failResponseOnce(14); + const orderPromise = built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + await new Promise((resolve) => setTimeout(resolve, 600)); + releaseGate(); + await ackPromise; + const orderResult = await orderPromise; + expect(orderResult.success).toBe(false); + const doc = JSON.parse( + (await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:7', + )) as string, + ) as { entries: unknown[]; recovered: unknown[] }; + // F acknowledged AND the concurrent unresolved dispatch SURVIVED — + // a stale ack write would have silently erased it, leaving the + // committed order retryable. + expect(doc.recovered).toHaveLength(0); + expect(doc.entries).toHaveLength(1); + }); + + it('an account switch DURING network submission quarantines the accepted dispatch: the switch-back retry is refused until acknowledged', async () => { + const registeredKey = '9c'.repeat(40); + const built = buildProvider({ registeredKey }); + setupTriggerVenue(built.clientInstance, built.bridge); + const otherAddress = '0x9999999999999999999999999999999999999999'; + // The venue ACCEPTS the withdraw; the wallet switches accounts + // while the response is in flight, so the post-send fence cancels + // the operation AFTER the financial intent committed. + const realSend = built.clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + let switched = false; + built.clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + const response = await realSend(txType, txInfo); + if (txType === 13 && !switched) { + switched = true; + built.getUserAddressMock.mockReturnValue(otherAddress); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: otherAddress, + subAccounts: [{ ...ACCOUNT, index: 77, l1Address: otherAddress }], + }); + } + return response; + }, + ); + const cancelled = await built.provider.withdraw({ amount: '25' }); + expect(cancelled.success).toBe(false); + // Switch BACK and retry the same intent: the committed withdraw + // was durably quarantined SUCCEEDED for the ORIGINAL account — + // the blind retry is refused until explicitly acknowledged. + built.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }); + const retry = await built.provider.withdraw({ amount: '25' }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('actually completed'); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(1); + expect(outcomes[0].outcome).toBe('succeeded'); + expect(outcomes[0].evidence).toBe('post-dispatch-session-cancelled'); + expect(outcomes[0].intent).toBe('withdraw:25'); + await built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId); + expect((await built.provider.withdraw({ amount: '25' })).success).toBe( + true, + ); + }); + }); + describe('per-market minimums (dynamic, venue-derived)', () => { + it('getMarkets reports the BINDING USD minimum: max(quote minimum, base minimum x last price), rounded up to cents', async () => { + const { provider } = buildProvider(); + const markets = await provider.getMarkets(); + const btc = markets.find((market) => market.name === 'BTC'); + // Mock market: minBaseAmount x lastTradePrice(100000) vs minQuoteAmount — + // whichever binds must be reported, never the raw quote minimum alone. + // Binding base size: max(minBase 0.0002, $10/100000 = 0.0001) + // = 0.0002 BTC x $100000 = $20 (> the raw $10 quote minimum). + expect(btc?.minimumOrderSize).toBe(20); + expect(btc?.maxLeverage).toBe(50); // 10000 / minInitialMarginFraction(200) + }); + }); + + describe('close-size contract (mobile sheet parity)', () => { + it('a FULL close sent as an EMPTY size string closes the position (mobile sends size: "" for 100% closes)', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + setupTriggerVenue(built.clientInstance, built.bridge); + const validation = await built.provider.validateClosePosition({ + symbol: 'BTC', + size: '', + currentPrice: 100000, + }); + expect(validation.isValid).toBe(true); + const result = await built.provider.closePosition({ + symbol: 'BTC', + size: '', + orderType: 'market', + currentPrice: 100000, + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + }); + }); + + describe('round-23 post-dispatch atomicity', () => { + it('a failing recovered-outcome write after a fence-cancelled accepted dispatch keeps the ORIGINAL unresolved entry: the switch-back retry stays blocked, then reconciles', async () => { + const registeredKey = '9c'.repeat(40); + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey, + platformDependencies: infra, + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const otherAddress = '0x9999999999999999999999999999999999999999'; + // The venue ACCEPTS the withdraw; the wallet switches accounts + // while the response is in flight, AND the post-dispatch ledger + // transition (which would record the SUCCEEDED outcome) fails at + // the disk exactly once. + const realSend = built.clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + const realSet = ( + infra.diskCache.setItem as jest.Mock + ).getMockImplementation() as ( + key: string, + value: string, + ) => Promise; + let failNextLedgerWrite = false; + (infra.diskCache.setItem as jest.Mock).mockImplementation( + async (key: string, value: string) => { + if (failNextLedgerWrite && key.startsWith('lighterNonceLedger:')) { + failNextLedgerWrite = false; + throw new Error('storage write refused'); + } + return await realSet(key, value); + }, + ); + let switched = false; + built.clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + const response = await realSend(txType, txInfo); + if (txType === 13 && !switched) { + switched = true; + built.getUserAddressMock.mockReturnValue(otherAddress); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: otherAddress, + subAccounts: [{ ...ACCOUNT, index: 77, l1Address: otherAddress }], + }); + // Arm the ONE-SHOT quarantine persistence failure for the + // atomic post-dispatch transition that follows acceptance. + failNextLedgerWrite = true; + } + return response; + }, + ); + const cancelled = await built.provider.withdraw({ amount: '25' }); + expect(cancelled.success).toBe(false); + // The transition write failed: the ORIGINAL unresolved entry must + // remain the durable record (never consumed-first, quarantined- + // second — that would swallow the only proof of the mutation). + const doc = JSON.parse( + (await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:7', + )) as string, + ) as { entries: unknown[]; recovered: unknown[] }; + expect(doc.entries).toHaveLength(1); + expect(doc.recovered).toHaveLength(0); + // Switch BACK: the retry stays BLOCKED — the resolve pass proves + // the exact hash landed (venue tx registry) and quarantines the + // outcome; only per-id acknowledgment unblocks. + built.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], + }); + const retry = await built.provider.withdraw({ amount: '25' }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('actually completed'); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(1); + expect(outcomes[0].outcome).toBe('succeeded'); + expect(outcomes[0].intent).toBe('withdraw:25'); + await built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId); + expect((await built.provider.withdraw({ amount: '25' })).success).toBe( + true, + ); + }); + }); + describe('mainnet write parity', () => { + it('mainnet venue writes sign and dispatch exactly like testnet (rollout gate removed)', async () => { + const built = buildProvider({ + isTestnet: false, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const placed = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + expect(placed.success).toBe(true); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect(built.clientInstance.sendTx).toHaveBeenCalledWith( + 14, + expect.stringContaining('"createOrder":true'), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterClientService.test.ts b/packages/perps-controller/tests/src/services/LighterClientService.test.ts new file mode 100644 index 0000000000..16d90fb532 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterClientService.test.ts @@ -0,0 +1,286 @@ +import { + LighterApiError, + LighterClientService, +} from '../../../src/services/LighterClientService.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +const ORDER_BOOK = { + symbol: 'BTC', + marketId: 1, + marketType: 'perp', + status: 'active', + takerFee: '0.0000', + makerFee: '0.0000', + minBaseAmount: '0.00020', + minQuoteAmount: '10.000000', + supportedSizeDecimals: 5, + supportedPriceDecimals: 1, + supportedQuoteDecimals: 6, +}; + +describe('LighterClientService', () => { + let fetchMock: jest.Mock; + + const buildService = (isTestnet = true): LighterClientService => + new LighterClientService(createMockInfrastructure(), { isTestnet }); + + const mockJsonResponse = ( + payload: unknown, + ok = true, + status = 200, + ): { ok: boolean; status: number; json: jest.Mock } => ({ + ok, + status, + json: jest.fn().mockResolvedValue(payload), + }); + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + describe('network resolution', () => { + it('uses the testnet base URL in testnet mode', () => { + expect(buildService(true).baseUrl).toBe( + 'https://testnet.zklighter.elliot.ai', + ); + expect(buildService(true).network).toBe('testnet'); + }); + + it('uses the mainnet base URL in mainnet mode', () => { + expect(buildService(false).baseUrl).toBe( + 'https://mainnet.zklighter.elliot.ai', + ); + expect(buildService(false).network).toBe('mainnet'); + }); + }); + + describe('getOrderBooks', () => { + it('fetches and caches market metadata', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, orderBooks: [ORDER_BOOK] }), + ); + const service = buildService(); + + const first = await service.getOrderBooks(); + const second = await service.getOrderBooks(); + + expect(first).toStrictEqual([ORDER_BOOK]); + expect(second).toStrictEqual([ORDER_BOOK]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://testnet.zklighter.elliot.ai/api/v1/orderBooks', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('refetches when forceRefresh is set', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, orderBooks: [ORDER_BOOK] }), + ); + const service = buildService(); + await service.getOrderBooks(); + await service.getOrderBooks(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('getTx', () => { + it('returns the transaction payload on 200', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + hash: 'aabbccdd', + account_index: 28, + api_key_index: 7, + nonce: 42, + }), + ); + const service = buildService(); + const tx = await service.getTx('aabbccdd'); + expect(tx).toMatchObject({ + code: 200, + hash: 'aabbccdd', + accountIndex: 28, + apiKeyIndex: 7, + nonce: 42, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://testnet.zklighter.elliot.ai/api/v1/tx?by=hash&value=aabbccdd', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('resolves NULL only for the venue-confirmed not-found code 21500', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse( + { code: 21500, message: 'transaction not found' }, + false, + 400, + ), + ); + const service = buildService(); + expect(await service.getTx('aabbccdd')).toBeNull(); + }); + + it('rethrows other API errors and transport failures (ambiguity, not non-acceptance)', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 21999, message: 'rate limited' }, false, 429), + ); + const service = buildService(); + await expect(service.getTx('aabbccdd')).rejects.toThrow('rate limited'); + fetchMock.mockRejectedValue(new Error('socket hang up')); + await expect(service.getTx('aabbccdd')).rejects.toThrow('socket hang up'); + }); + }); + + describe('getInactiveOrders pagination', () => { + it('encodes limit, cursor and market_id query params', async () => { + fetchMock.mockResolvedValue(mockJsonResponse({ code: 200, orders: [] })); + const service = buildService(); + await service.getInactiveOrders(28, 'auth-token', 100, 'abc/def', 2); + expect(fetchMock).toHaveBeenCalledWith( + 'https://testnet.zklighter.elliot.ai/api/v1/accountInactiveOrders?account_index=28&limit=100&cursor=abc%2Fdef&market_id=2', + expect.objectContaining({ method: 'GET' }), + ); + }); + }); + + describe('error handling', () => { + it('throws LighterApiError on application-level error codes', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 21100, message: 'account not found' }), + ); + const service = buildService(); + await expect(service.getAccountByIndex(999999)).rejects.toThrow( + LighterApiError, + ); + await expect(service.getAccountByIndex(999999)).rejects.toThrow( + 'account not found', + ); + }); + + it('throws LighterApiError on non-2xx HTTP responses', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 500, message: 'boom' }, false, 500), + ); + const service = buildService(); + await expect(service.getOrderBookDetails()).rejects.toThrow('boom'); + }); + + it('wraps network failures in LighterApiError', async () => { + fetchMock.mockRejectedValue(new Error('socket hang up')); + const service = buildService(); + await expect(service.getOrderBooks()).rejects.toThrow('socket hang up'); + }); + }); + + describe('account endpoints', () => { + it('queries accounts by L1 address', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, l1Address: '0xabc', subAccounts: [] }), + ); + const service = buildService(); + await service.getAccountsByL1Address('0xabc'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/accountsByL1Address?l1_address=0xabc'), + expect.anything(), + ); + }); + + it('queries the account by index', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, accounts: [] }), + ); + const service = buildService(); + await service.getAccountByIndex(28); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/account?by=index&value=28'), + expect.anything(), + ); + }); + + it('queries api keys and next nonce', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, apiKeys: [], nonce: 5 }), + ); + const service = buildService(); + await service.getApiKeys(28, 7); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + '/api/v1/apikeys?account_index=28&api_key_index=7', + ), + expect.anything(), + ); + await service.getNextNonce(28, 7); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + '/api/v1/nextNonce?account_index=28&api_key_index=7', + ), + expect.anything(), + ); + }); + + it('passes the auth token as authorization header for active orders', async () => { + fetchMock.mockResolvedValue(mockJsonResponse({ code: 200, orders: [] })); + const service = buildService(); + await service.getActiveOrders(28, 'auth-token-value'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + '/api/v1/accountActiveOrders?account_index=28&market_id=255', + ), + expect.objectContaining({ + headers: { authorization: 'auth-token-value' }, + }), + ); + }); + }); + + describe('wire-format conversion', () => { + it('converts snake_case wire keys to camelCase at the fetch boundary', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + // Raw zkLighter wire format (snake_case). + order_books: [ + { + symbol: 'BTC', + market_id: 1, + min_base_amount: '0.00020', + supported_size_decimals: 5, + }, + ], + }), + ); + const service = buildService(); + const markets = await service.getOrderBooks(); + expect(markets[0]).toMatchObject({ + symbol: 'BTC', + marketId: 1, + minBaseAmount: '0.00020', + supportedSizeDecimals: 5, + }); + }); + }); + + describe('sendTx', () => { + it('posts form-encoded tx_type and tx_info', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ code: 200, txHash: '0xhash' }), + ); + const service = buildService(); + const result = await service.sendTx(14, '{"foo":1}'); + + expect(result.txHash).toBe('0xhash'); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://testnet.zklighter.elliot.ai/api/v1/sendTx'); + expect(init.method).toBe('POST'); + expect(init.headers).toStrictEqual({ + 'content-type': 'application/x-www-form-urlencoded', + }); + expect(init.body).toBe( + `tx_type=14&tx_info=${encodeURIComponent('{"foo":1}').replace(/%20/gu, '+')}`, + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts new file mode 100644 index 0000000000..ad7810df93 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts @@ -0,0 +1,162 @@ +import { buildLighterKeyDerivationMessage } from '../../../src/constants/lighterConfig.js'; +import type { PerpsControllerMessenger } from '../../../src/PerpsController.js'; +import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +// A fixed 65-byte signature (deterministic vector). +const FIXED_SIGNATURE = `0x${'ab'.repeat(65)}`; +const HEADLESS_ADDRESS = '0x8D7f03FdE1A626223364E592740a233b72395235'; + +describe('LighterWalletService', () => { + describe('headless (injected signer)', () => { + const buildService = ( + signer = jest.fn().mockResolvedValue(FIXED_SIGNATURE), + ): { service: LighterWalletService; signer: jest.Mock } => { + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + personalSigner: signer, + l1Address: HEADLESS_ADDRESS, + }); + return { service, signer }; + }; + + it('returns the injected L1 address', () => { + const { service } = buildService(); + expect(service.getUserAddress()).toBe(HEADLESS_ADDRESS); + }); + + it('routes personal_sign through the injected signer', async () => { + const { service, signer } = buildService(); + const signature = await service.signPersonalMessage('hello'); + expect(signature).toBe(FIXED_SIGNATURE); + expect(signer).toHaveBeenCalledWith('hello'); + }); + + it('derives a deterministic 32-byte seed from the signature', async () => { + const { service } = buildService(); + const seed1 = await service.deriveKeySeed(7); + const seed2 = await service.deriveKeySeed(7); + expect(seed1).toBe(seed2); + expect(seed1).toMatch(/^0x[0-9a-f]{64}$/u); + }); + + it('binds the seed to the derivation message contents', async () => { + const { service, signer } = buildService(); + await service.deriveKeySeed(7); + expect(signer).toHaveBeenCalledWith( + buildLighterKeyDerivationMessage({ + address: HEADLESS_ADDRESS, + chainId: 300, + apiKeyIndex: 7, + }), + ); + }); + + it('derives different seeds for different signatures', async () => { + const { service: serviceA } = buildService( + jest.fn().mockResolvedValue(`0x${'11'.repeat(65)}`), + ); + const { service: serviceB } = buildService( + jest.fn().mockResolvedValue(`0x${'22'.repeat(65)}`), + ); + expect(await serviceA.deriveKeySeed(7)).not.toBe( + await serviceB.deriveKeySeed(7), + ); + }); + + it('strips the 0x prefix for the plain seed variant', async () => { + const { service } = buildService(); + const plain = await service.deriveKeySeedPlain(7); + expect(plain).toMatch(/^[0-9a-f]{64}$/u); + }); + + it('exposes and toggles testnet mode', () => { + const { service } = buildService(); + expect(service.isTestnetMode()).toBe(true); + service.setTestnetMode(false); + expect(service.isTestnetMode()).toBe(false); + expect(service.network).toBe('mainnet'); + }); + }); + + describe('messenger-backed', () => { + const selectedAccount = { + address: HEADLESS_ADDRESS, + type: 'eip155:eoa', + metadata: {}, + }; + + const buildMessengerService = ( + isUnlocked = true, + ): { + service: LighterWalletService; + messenger: ReturnType; + } => { + const messenger = createMockMessenger(); + messenger.call.mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked }; + } + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [selectedAccount]; + } + if (action === 'KeyringController:signPersonalMessage') { + return Promise.resolve(FIXED_SIGNATURE); + } + throw new Error(`Unexpected action: ${action}`); + }); + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + messenger: messenger as unknown as PerpsControllerMessenger, + }); + return { service, messenger }; + }; + + it('signs through KeyringController:signPersonalMessage', async () => { + const { service, messenger } = buildMessengerService(); + const signature = await service.signPersonalMessage('register me'); + expect(signature).toBe(FIXED_SIGNATURE); + expect(messenger.call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.objectContaining({ + from: HEADLESS_ADDRESS, + data: expect.stringMatching(/^0x/u), + }), + ); + }); + + it('rejects when the keyring is locked', async () => { + const { service } = buildMessengerService(false); + await expect(service.signPersonalMessage('nope')).rejects.toThrow( + 'KEYRING_LOCKED', + ); + }); + }); + + describe('unconfigured', () => { + it('rejects signing without messenger or injected signer', async () => { + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + l1Address: HEADLESS_ADDRESS, + }); + await expect(service.signPersonalMessage('x')).rejects.toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + + it('rejects address resolution without any source', () => { + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + }); + expect(() => service.getUserAddress()).toThrow('NO_ACCOUNT_SELECTED'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 1fab3de302..7e44e8afc3 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1349,6 +1349,33 @@ describe('MarketDataService', () => { expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); }); + it('ignores useTerminalApi when the active provider is not HyperLiquid-backed', async () => { + const providerMarkets: MarketInfo[] = [ + { + name: 'ETH', + szDecimals: 4, + maxLeverage: 50, + marginTableId: 0, + minimumOrderSize: 10.16, + }, + ]; + const lighterProvider = { + ...mockProvider, + protocolId: 'lighter', + getMarkets: jest.fn().mockResolvedValue(providerMarkets), + }; + + const result = await serviceWithTerminal.getMarkets({ + provider: lighterProvider as unknown as typeof mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + expect(lighterProvider.getMarkets).toHaveBeenCalled(); + }); + it('falls back to provider when symbol filter yields no terminal matches', async () => { mockTerminalService.fetchMarkets.mockResolvedValue({ markets: terminalMarkets, diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts new file mode 100644 index 0000000000..38d5f2ed67 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -0,0 +1,601 @@ +import type { MarketDataFormatters } from '../../../src/types/index.js'; +import type { + LighterApiOrder, + LighterApiPosition, + LighterOrderBookDetail, + LighterOrderBookMeta, + LighterSubAccount, +} from '../../../src/types/lighter-types.js'; +import { + adaptFillFromLighterTrade, + adaptAccountStateFromLighter, + adaptMarketDataFromLighter, + adaptMarketFromLighter, + adaptOrderFromLighter, + adaptPositionFromLighter, +} from '../../../src/utils/lighterAdapter.js'; + +// Built per-test (jest resetMocks wipes module-scope jest.fn implementations). +const buildFormatters = (): MarketDataFormatters => ({ + formatPerpsFiat: (value: number) => `$${value.toFixed(2)}`, + formatVolume: (value: number) => `$${value}`, + formatPercentage: (percent: number) => `${percent.toFixed(2)}%`, + priceRangesUniversal: [], +}); + +const btcMarket: LighterOrderBookMeta = { + symbol: 'BTC', + marketId: 1, + marketType: 'perp', + status: 'active', + takerFee: '0.0000', + makerFee: '0.0000', + minBaseAmount: '0.00020', + minQuoteAmount: '10.000000', + supportedSizeDecimals: 5, + supportedPriceDecimals: 1, + supportedQuoteDecimals: 6, +}; + +describe('lighterAdapter', () => { + describe('adaptMarketFromLighter', () => { + it('maps market metadata onto MarketInfo', () => { + const market = adaptMarketFromLighter(btcMarket); + expect(market).toStrictEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: expect.any(Number), + marginTableId: 0, + minimumOrderSize: 10, + providerId: 'lighter', + }); + }); + + it('flags inactive markets as delisted', () => { + const market = adaptMarketFromLighter({ + ...btcMarket, + status: 'inactive', + }); + expect(market.isDelisted).toBe(true); + }); + }); + + describe('adaptMarketDataFromLighter', () => { + const detail: LighterOrderBookDetail = { + ...btcMarket, + lastTradePrice: 100000, + dailyTradesCount: 1000, + dailyBaseTokenVolume: 25, + dailyQuoteTokenVolume: 2500000, + dailyPriceLow: 95000, + dailyPriceHigh: 101000, + dailyPriceChange: 2.5, + openInterest: 12345678, + dailyChart: {}, + }; + + it('maps market stats onto PerpsMarketData', () => { + const data = adaptMarketDataFromLighter(detail, buildFormatters()); + expect(data.symbol).toBe('BTC'); + expect(data.price).toBe('$100000.00'); + expect(data.change24hPercent).toBe('+2.50%'); + expect(data.change24h.startsWith('+$')).toBe(true); + expect(data.volume).toBe('$2500000'); + expect(data.openInterest).toBe('$12345678'); + }); + + it('formats negative change with a minus prefix', () => { + const data = adaptMarketDataFromLighter( + { ...detail, dailyPriceChange: -3 }, + buildFormatters(), + ); + expect(data.change24hPercent).toBe('-3.00%'); + expect(data.change24h.startsWith('-$')).toBe(true); + }); + + it('reports zero change as $0.00', () => { + const data = adaptMarketDataFromLighter( + { ...detail, dailyPriceChange: 0 }, + buildFormatters(), + ); + expect(data.change24h).toBe('$0.00'); + }); + }); + + describe('adaptPositionFromLighter', () => { + const position: LighterApiPosition = { + marketId: 1, + symbol: 'BTC', + initialMarginFraction: '20', + openOrderCount: 0, + sign: 1, + position: '0.5', + avgEntryPrice: '100000', + positionValue: '50000', + unrealizedPnl: '1000', + realizedPnl: '0', + liquidationPrice: '80000', + }; + + it('rejects malformed numeric position sizes at the adaptation boundary', () => { + // The REST layer type-casts JSON without runtime validation; a + // prefix-parsed '0.1oops' would become a canonical '0.1' that TP/SL + // cover-sizing then signs. Runtime-cast cases (undefined/null/number) + // and overflow exponents must ALL surface the data-integrity prefix, + // never a generic TypeError that reads swallow into false-empty. + const badSizes: unknown[] = [ + '0.1oops', + 'oops', + '', + undefined, + null, + 0.1, + '1e999', + ]; + for (const badSize of badSizes) { + expect(() => + adaptPositionFromLighter({ + ...position, + position: badSize as string, + }), + ).toThrow('Invalid Lighter venue data'); + } + }); + + it('rejects negative magnitudes and malformed signs at the adaptation boundary', () => { + // Documented representation: NONNEGATIVE magnitude + sign exactly + // ±1. '-0.1' with sign 1 would flip the canonical direction, so a + // close/TPSL would act OPPOSITE the real position; sign 0/2/'1' + // would be silently coerced by a > 0 ternary. + expect(() => + adaptPositionFromLighter({ ...position, position: '-0.1', sign: 1 }), + ).toThrow('Invalid Lighter venue data'); + for (const badSign of [0, 2, -2, '1', null, undefined]) { + expect(() => + adaptPositionFromLighter({ + ...position, + sign: badSign as number, + }), + ).toThrow('Invalid Lighter venue data'); + } + // The documented contract holds for FLAT positions too: sign must + // still be exactly ±1 (zero magnitudes are filtered downstream). + expect(() => + adaptPositionFromLighter({ + ...position, + position: '0', + sign: 0 as number, + }), + ).toThrow('Invalid Lighter venue data'); + expect( + adaptPositionFromLighter({ ...position, position: '0', sign: 1 }).size, + ).toBe('0'); + }); + + it('maps a long position', () => { + const adapted = adaptPositionFromLighter(position); + expect(adapted.symbol).toBe('BTC'); + expect(adapted.size).toBe('0.5'); + expect(adapted.entryPrice).toBe('100000'); + expect(adapted.leverage.value).toBe(5); + expect(adapted.marginUsed).toBe('10000'); + expect(adapted.liquidationPrice).toBe('80000'); + expect(adapted.providerId).toBe('lighter'); + }); + + it('negates size for short positions', () => { + const adapted = adaptPositionFromLighter({ ...position, sign: -1 }); + expect(adapted.size).toBe('-0.5'); + }); + + it('returns null liquidation price when zero', () => { + const adapted = adaptPositionFromLighter({ + ...position, + liquidationPrice: '0', + }); + expect(adapted.liquidationPrice).toBeNull(); + }); + }); + + describe('adaptAccountStateFromLighter', () => { + const account: LighterSubAccount = { + code: 0, + accountType: 0, + index: 28, + l1Address: '0xabc', + cancelAllTime: 0, + totalOrderCount: 0, + pendingOrderCount: 0, + status: 1, + collateral: '10000', + availableBalance: '8000', + positions: [ + { + marketId: 1, + symbol: 'BTC', + initialMarginFraction: '20', + openOrderCount: 0, + sign: 1, + position: '0.1', + avgEntryPrice: '100000', + positionValue: '10000', + unrealizedPnl: '500', + realizedPnl: '0', + liquidationPrice: '80000', + }, + ], + }; + + it('maps collateral and balances', () => { + const state = adaptAccountStateFromLighter(account); + expect(state.totalBalance).toBe('10500'); + expect(state.spendableBalance).toBe('8000'); + expect(state.withdrawableBalance).toBe('8000'); + expect(state.marginUsed).toBe('2000'); + expect(state.unrealizedPnl).toBe('500'); + expect(state.providerId).toBe('lighter'); + }); + + it('handles accounts with no positions', () => { + const state = adaptAccountStateFromLighter({ + ...account, + positions: undefined, + }); + expect(state.unrealizedPnl).toBe('0'); + expect(state.totalBalance).toBe('10000'); + }); + }); + + describe('adaptFillFromLighterTrade', () => { + // Captured verbatim from GET /api/v1/trades on testnet account 28 + // (2026-08-16, camelized) — the fixture the REST and WebSocket fill + // paths must both adapt identically. + const REAL_TRADE = { + tradeId: 9509524, + txHash: + '32e18fe51086d7496a29a8e6d5cf2e66056a1f6f1ee1c7e67214b8f5b607e35d720a7aa65850e9b1', + type: 'trade', + marketId: 2, + size: '0.133', + price: '75.180', + usdAmount: '9.998940', + askId: 844424944383120, + bidId: 1125899892620735, + askAccountId: 28, + bidAccountId: 7, + isMakerAsk: false, + timestamp: 1786878754951, + askAccountPnl: '-0.012901', + takerPositionSizeBefore: '0.133', + takerPositionSignChanged: true, + makerPositionSizeBefore: '0.000', + makerPositionSignChanged: true, + }; + + it('adapts the real venue payload: a taker sell of the full position is Close Long', () => { + const fill = adaptFillFromLighterTrade(REAL_TRADE, 'SOL', 28); + expect(fill).toMatchObject({ + orderId: '844424944383120', + symbol: 'SOL', + side: 'sell', + // Position before 0.133, sold 0.133, sign changed → closed a long. + direction: 'Close Long', + size: '0.133', + price: '75.180', + pnl: '-0.012901', + // No fee fields in the payload → venue-true zero. + fee: '0', + feeToken: 'USDC', + timestamp: 1786878754951, + }); + }); + + it('adapts the counterparty: a buy from a flat position is Open Long', () => { + const fill = adaptFillFromLighterTrade(REAL_TRADE, 'SOL', 7); + expect(fill.side).toBe('buy'); + expect(fill.direction).toBe('Open Long'); + expect(fill.orderId).toBe('1125899892620735'); + expect(fill.pnl).toBe('0'); + }); + + it('derives buy-close, sell-open, flip, and side-only fallbacks', () => { + // Buy that flattens a short: before 0.5, bought 0.5, sign changed. + const buyClose = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + isMakerAsk: true, + bidAccountId: 28, + askAccountId: 7, + takerPositionSizeBefore: '0.5', + takerPositionSignChanged: true, + size: '0.5', + bidAccountPnl: '1.25', + }, + 'SOL', + 28, + ); + expect(buyClose.direction).toBe('Close Short'); + expect(buyClose.pnl).toBe('1.25'); + + // Sell from flat opens a short even with zero pnl. + const sellOpen = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + takerPositionSizeBefore: '0.000', + takerPositionSignChanged: true, + askAccountPnl: '0', + }, + 'SOL', + 28, + ); + expect(sellOpen.direction).toBe('Open Short'); + + // Selling more than the long flips it. + const flip = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + size: '0.300', + takerPositionSizeBefore: '0.133', + takerPositionSignChanged: true, + }, + 'SOL', + 28, + ); + expect(flip.direction).toBe('Long > Short'); + + // Without position-before context: side-only vocabulary. + const bare = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + takerPositionSizeBefore: undefined, + takerPositionSignChanged: undefined, + }, + 'SOL', + 28, + ); + expect(bare.direction).toBe('Sell'); + }); + + it('a nonzero-pnl partial reduce without sign change is a close', () => { + const partial = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + size: '0.050', + takerPositionSizeBefore: '0.133', + takerPositionSignChanged: false, + askAccountPnl: '0.5', + }, + 'SOL', + 28, + ); + expect(partial.direction).toBe('Close Long'); + expect(partial.startPosition).toBe('0.133'); + }); + + it('an exactly break-even partial without sign change falls back to side-only', () => { + // Zero pnl + no sign change is genuinely ambiguous from this payload + // (break-even partial close and an add both fit) — never assert Open + // without evidence. + const ambiguous = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + size: '0.050', + takerPositionSizeBefore: '0.133', + takerPositionSignChanged: false, + askAccountPnl: '0', + }, + 'SOL', + 28, + ); + expect(ambiguous.direction).toBe('Sell'); + expect(ambiguous.startPosition).toBeUndefined(); + }); + + it('flips carry a SIGNED startPosition for post-flip sizing', () => { + // Long 0.133 flipped by selling 0.300 → Long > Short, start +0.133. + const longToShort = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + size: '0.300', + takerPositionSizeBefore: '0.133', + takerPositionSignChanged: true, + }, + 'SOL', + 28, + ); + expect(longToShort.direction).toBe('Long > Short'); + expect(longToShort.startPosition).toBe('0.133'); + + // Short 0.133 flipped by buying 0.300 → Short > Long, start -0.133. + const shortToLong = adaptFillFromLighterTrade( + { + ...REAL_TRADE, + isMakerAsk: true, + bidAccountId: 28, + askAccountId: 7, + size: '0.300', + takerPositionSizeBefore: '0.133', + takerPositionSignChanged: true, + bidAccountPnl: '0.7', + }, + 'SOL', + 28, + ); + expect(shortToLong.direction).toBe('Short > Long'); + expect(shortToLong.startPosition).toBe('-0.133'); + }); + + it('refuses nonzero fees loudly instead of coercing them to zero', () => { + // Standard accounts pay zero fees (the provider gates Premium); a + // present nonzero fee has an unverified wire unit and silently + // showing $0 would be financially false. + for (const takerFee of [45000, '45000', '0.0450'] as const) { + expect(() => + adaptFillFromLighterTrade({ ...REAL_TRADE, takerFee }, 'SOL', 28), + ).toThrow('fee unit is unverified'); + } + // Explicit zeros (any representation) are venue truth. + expect( + adaptFillFromLighterTrade( + { ...REAL_TRADE, takerFee: 0, makerFee: '0.0000' }, + 'SOL', + 28, + ).fee, + ).toBe('0'); + }); + + it('falls back to neutral vocabulary when isMakerAsk is absent', () => { + // Without isMakerAsk our maker/taker role is unknown: deriving + // lifecycle from the wrong side's position context would misattribute + // opens/closes, so the fill stays side-only with no startPosition. + const roleless = { + ...REAL_TRADE, + isMakerAsk: undefined, + }; + const fill = adaptFillFromLighterTrade(roleless, 'SOL', 28); + expect(fill.direction).toBe('Sell'); + expect(fill.startPosition).toBeUndefined(); + expect(fill.fee).toBe('0'); + }); + + it('keeps a Standard fill whose Premium counterparty paid the fee', () => { + // Account 28 is the taker; the MAKER (counterparty) fee being nonzero + // must not drop our valid zero-fee fill. + const counterpartyFee = { + ...REAL_TRADE, + takerFee: 0, + makerFee: 45000, + }; + const fill = adaptFillFromLighterTrade(counterpartyFee, 'SOL', 28); + expect(fill.fee).toBe('0'); + expect(fill.direction).toBe('Close Long'); + }); + }); + + describe('adaptOrderFromLighter', () => { + const order: LighterApiOrder = { + orderIndex: 12345, + clientOrderIndex: 999, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.5', + remainingBaseAmount: '0.3', + price: '90000', + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000000, + }; + + it('maps an open limit buy order', () => { + const adapted = adaptOrderFromLighter(order, 'BTC'); + expect(adapted).toMatchObject({ + orderId: '12345', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + price: '90000', + originalSize: '0.5', + remainingSize: '0.3', + status: 'open', + providerId: 'lighter', + }); + expect(parseFloat(adapted.filledSize)).toBeCloseTo(0.2, 10); + }); + + it('maps ask orders to sell side', () => { + const adapted = adaptOrderFromLighter({ ...order, isAsk: true }, 'BTC'); + expect(adapted.side).toBe('sell'); + }); + + it('maps the trigger LEVEL separately from the execution price on trigger orders', () => { + // Live venue payload: `price` on a take-profit is the ±5% protection + // EXECUTION price (107.265), while the user's TP level is + // `triggerPrice` (112.911). Confusing them shows the wrong number in + // every TP/SL surface. + const adapted = adaptOrderFromLighter( + { + ...order, + type: 'take-profit', + isAsk: true, + reduceOnly: 1, + price: '107.265', + triggerPrice: '112.911', + }, + 'SOL', + ); + expect(adapted.isTrigger).toBe(true); + expect(adapted.price).toBe('107.265'); + expect(adapted.triggerPrice).toBe('112.911'); + }); + + it('maps semantic trigger order types instead of generic limit', () => { + const cases = [ + { + type: 'take-profit', + orderType: 'market', + triggerOrderType: 'take_profit_market', + detailed: 'Take Profit Market', + }, + { + type: 'stop-loss', + orderType: 'market', + triggerOrderType: 'stop_market', + detailed: 'Stop Market', + }, + { + type: 'take-profit-limit', + orderType: 'limit', + triggerOrderType: 'take_profit_limit', + detailed: 'Take Profit Limit', + }, + { + type: 'stop-loss-limit', + orderType: 'limit', + triggerOrderType: 'stop_limit', + detailed: 'Stop Limit', + }, + ] as const; + for (const testCase of cases) { + const adapted = adaptOrderFromLighter( + { ...order, type: testCase.type, triggerPrice: '110000' }, + 'BTC', + ); + expect(adapted.orderType).toBe(testCase.orderType); + expect(adapted.triggerOrderType).toBe(testCase.triggerOrderType); + expect(adapted.detailedOrderType).toBe(testCase.detailed); + } + // Plain orders stay untyped. + const plain = adaptOrderFromLighter(order, 'BTC'); + expect(plain.triggerOrderType).toBeUndefined(); + expect(plain.detailedOrderType).toBeUndefined(); + }); + + it('omits triggerPrice on non-trigger orders and zero venue values', () => { + expect(adaptOrderFromLighter(order, 'BTC').triggerPrice).toBeUndefined(); + expect( + adaptOrderFromLighter({ ...order, triggerPrice: '0' }, 'BTC') + .triggerPrice, + ).toBeUndefined(); + }); + + it('normalizes canceled statuses', () => { + const adapted = adaptOrderFromLighter( + { ...order, status: 'canceled-post-only' }, + 'BTC', + ); + expect(adapted.status).toBe('canceled'); + }); + + it('normalizes filled status', () => { + const adapted = adaptOrderFromLighter( + { ...order, status: 'filled' }, + 'BTC', + ); + expect(adapted.status).toBe('filled'); + }); + }); +});